on 2026.07.13.0032 help sign in
usage-menubar/CLAUDE.md
57 lines 3.6 kB history raw
1# UsageMenubar
3## Architecture
4macOS menu bar app (SwiftUI, macOS 14+) showing Hyper (Charm) credit balance, plus
5Claude Code's Pro/Max usage limits when the CLI is signed in on the machine.
6- `UsageMenubarApp.swift` — @main App, NSStatusBar, timer, sleep/wake, popover
7- `ViewModel.swift` — Observable state: balance, Claude usage, refresh, notifications, history
8- `MenuView.swift` — SwiftUI popover view (balance, sparkline, Claude limits, settings)
9- `CreditsChecker.swift` — API client for `GET https://hyper.charm.land/v1/credits`
10- `ClaudeUsageClient.swift` — read-only Claude OAuth client + models for `GET https://api.anthropic.com/api/oauth/usage`
11- `KeychainHelper.swift` — Keychain wrapper (KeychainStore + KeychainHelper)
12- `VersionFormatter.swift` — Version string formatting
14The status bar title stays `⚡{balance}` (Hyper only); Claude usage lives in the popover.
15The two fetches are independent — `ViewModel.refresh()` runs them with `async let`, and
16either can fail or be unconfigured without touching the other.
18## Claude credentials (strictly read-only)
19`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
20Keychain item (via `/usr/bin/security`), falling back to `~/.claude/.credentials.json`.
21- **Never write them. Never refresh the token.** `ClaudeCredentialStoring` has no `save`,
22 and that is load-bearing, not an oversight. The app reads the current access token and
23 spends it as-is.
24- Why: the token endpoint **rotates the refresh token**, and the CLI refreshes on its own
25 schedule. An app that also refreshed would race it — whoever went second would present a
26 refresh token the server had already retired, signing the user out of their own terminal.
27 This app *did* do that, and it was the top bug. Do not reintroduce it, however tempting a
28 "just refresh it for the user" convenience looks.
29- A spent or revoked token (401/403) is therefore terminal, not retried: it surfaces as
30 `.invalidCredentials`, whose message tells the user to run `claude`. The CLI mints a new
31 token; the next fetch (every 1–5 min, credentials re-read each time) picks it up for free.
32- Consequently `ClaudeOAuthCredentials` models neither `refreshToken` nor `expiresAt` —
33 nothing here has any business knowing them.
34- Retries still apply to *transport* failures (timeouts, 5xx, 429). That's not auth.
35- Missing credentials are **not an error** — the popover just omits the section.
36- **The app cannot be sandboxed**: spawning `security` and reading `~/.claude` require it
37 to be off. Distribution is ad-hoc signed via Homebrew, not the App Store.
39## Build
40- Uses [xcodegen](https://github.com/yonaskolb/XcodeGen) — `project.yml` is source of truth
41- `xcodegen generate` creates the .xcodeproj (not committed)
42- Build: `xcodebuild -scheme UsageMenubar -configuration Debug build`
43- Test: `xcodebuild test -scheme UsageMenubar -configuration Debug -destination "platform=macOS"`
44- Swift 5.9, macOS 14.0 deployment target
46## Conventions
47- Clean minimal UI — big hero number, generous spacing, restrained color, no chrome
48- No card-heavy/multi-material/gradient-rich designs
49- Rounded font family at one scale (hero/subhero/section/control/caption/footnote)
50- Monospaced digits for numbers that change
51- Tests use StubURLProtocol for network mocking, FakeKeychain for keychain mocking,
52 FakeClaudeCredentialStore for Claude credentials — never touch the real ones in a test
53- The app is an agent app (LSUIElement=true) — no dock icon, no main window
55## VCS
56- jj colocated with git
57- Push directly to master — no PRs for this repo