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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ as you cut a tag.

## [Unreleased]

## [0.3.0] - 2026-07-23

### Changed
- **Claude accounts are now just what you sign into — no more path scanning.** The app no longer
auto-discovers Claude "profiles" by reading `CLAUDE_CONFIG_DIR` from your shell config. Each account
is one you add via **Add account** (browser sign-in), and you can give it your own **name** in
Settings → Claude.

### Added
- **Per-account cost, opt-in.** Type a logs-folder path (e.g. `~/.claude`) into an account's **Cost
logs** field to show its $ equivalent-cost, token totals, and plan; leave it blank for live limits
only. There is no cost API — cost is derived from local logs, so each account points at its own
folder (which keeps cost attributed to the right account).

### Fixed
- Renaming an account or changing its cost folder no longer risks **rate-limiting** the usage endpoint:
a per-account usage cache means those changes re-render without re-calling the endpoint, and the name
/ path fields commit on blur, not on every keystroke.
- Removed a stale "Copy sign-in command" hint left over from the pre-OAuth design.

## [0.2.0] - 2026-07-16

### Changed
Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ Scripts/install-hooks.sh # activate the pre-commit personal-data guard
serializes refreshes); `invalid_grant` on refresh is terminal → re-auth. Keychain gotcha:
`kSecReturnData` + `kSecMatchLimitAll` in one query is `errSecParam` (-50) — list attributes, then
read each item. Headers otherwise: `Authorization: Bearer`, `anthropic-beta: oauth-2025-04-20`.
Cache ≥ 180s. Config-dir profiles are still auto-discovered; live tokens are matched to them by
account email/UUID, and a signed-in account with no local profile gets its own live-only card.
Cache ≥ 180s (per-account usage cache in `ClaudeTokenProvider.cachedUsage`, so re-rendering after a
config change doesn't re-hit the endpoint). **No path auto-discovery**: accounts are exactly the
OAuth sign-ins, each with a user config (`ClaudeAccountConfig`: name + optional logs dir). Cost +
plan are opt-in per account — shown only when its `logsDir` points at a config dir (`ClaudeCostReader
.summary(configDir:)`; `.claude.json` is at `<dir>/.claude.json` except the default `~/.claude` →
`~/.claude.json`). `ClaudeProfileDiscovery` survives for the `usageprobe` CLI only.
- **Cost** — subscriptions have no per-token bill, so cost is an **equivalent API cost**:
local JSONL token counts × per-model pricing (`Pricing.swift`).
- **Gemini** — detection only; no local live quota exists.
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Everything is local — no proxy, no telemetry, no account of ours.
| Provider | Source | Fidelity |
|---|---|---|
| **Codex** | Newest `token_count` event under the configured Codex root (defaults to `$CODEX_HOME`, then `~/.codex`) — real 5h/weekly %, resets, plan, credits, tokens. Zero-auth. | ✅ Full |
| **Claude** | `GET api.anthropic.com/api/oauth/usage` per account, using a token the app mints via its **own** OAuth (**Add account**) and keeps in its **own** Keychain item — no prompt, refreshed in place. Before signing in (or if the endpoint is unavailable) shows **local 5H / 7D** token activity + equivalent cost from `~/.claude` logs. | ✅ Full % |
| **Claude** | `GET api.anthropic.com/api/oauth/usage` per account, using a token the app mints via its **own** OAuth (**Add account**) and keeps in its **own** Keychain item — no prompt, refreshed in place. Each account is user-named; point it at a logs folder to also show **$ cost** + tokens + plan (from local `~/.claude` logs — there's no cost API). | ✅ Full % |
| **Gemini** | Detects `gemini-cli` and reads the selected configuration root (defaults to `~/.gemini`); shows the plan cap or "not detected". gemini-cli persists no live quota. | ⚠️ Best-effort |
| **Custom** | Any folder of `.jsonl` logs + the dot-paths you configure (e.g. `rate_limit.used_percent`, `rate_limit.resets_at`). Newest file, last matching line. | ✅ Whatever the tool writes |

Expand Down Expand Up @@ -119,8 +119,10 @@ Nothing prompts for credentials at launch. To see **live** Claude limits, open t
click **Add account** (or Settings → Claude) — this signs in with Claude in your browser and mints
the app's **own** token, stored in its **own** Keychain item. Because the app created that item, it
reads it back with **no macOS prompt**, and refreshes it in place, so you sign in once and it sticks
across restarts. Add more than one account (Personal + Work) to track each separately. Your account,
cost, and **last 5H / 7D local usage** (from your `~/.claude` logs) show without signing in.
across restarts. Add more than one account (Personal + Work) and **name** each in Settings → Claude.
To also see **$ cost** for an account, type its logs-folder path (e.g. `~/.claude`) into that account's
**Cost logs** field — cost is derived from local logs (there's no cost API), so each account points at
its own folder; leave it blank for live limits only.
(Building locally auto-signs with an installed Developer ID for a stable signature; released builds
are notarized, so the Keychain item stays readable across launches.)

Expand Down
21 changes: 21 additions & 0 deletions Sources/AIUsageBarCore/Claude/ClaudeAccountConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// User-controlled settings for one signed-in Claude account, keyed by the account's
/// Keychain key (`ClaudeTokenStore.accountKey`). Persisted outside the Keychain (it
/// holds no secrets). `logsDir` is opt-in: when set, the reader computes the $ /
/// token breakdown from that config dir's logs; when nil, the account shows live
/// limits only (there is no cost API — it can only come from local logs).
public struct ClaudeAccountConfig: Codable, Sendable, Hashable {
/// User-assigned display name; falls back to the account email in the UI.
public var name: String?
/// Absolute path to the Claude config dir (containing `projects/`) whose logs
/// this account's cost is read from. Nil = cost off.
public var logsDir: String?

public init(name: String? = nil, logsDir: String? = nil) {
self.name = name
self.logsDir = logsDir
}

public var logsURL: URL? { logsDir.map { URL(fileURLWithPath: $0) } }
}
12 changes: 12 additions & 0 deletions Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import Foundation
/// logs: totals for today / last 30 days, split by model and by repo, plus
/// cache-efficiency. Pure and Sendable so it can run off the main actor.
public enum ClaudeCostReader {
/// Cost from a Claude **config dir** (the folder containing `projects/`), for the
/// account-driven model where the user points an account at its logs. Only
/// `projects/` is read; the dir's identity/`.claude.json` is not used.
public static func summary(configDir: URL,
now: Date = Date(),
maxFiles: Int = 400,
maxFileBytes: Int = 40_000_000,
cacheDirectory: URL? = nil) -> CostSummary? {
summary(for: ClaudeProfile(name: configDir.lastPathComponent, configDir: configDir, isDefault: false),
now: now, maxFiles: maxFiles, maxFileBytes: maxFileBytes, cacheDirectory: cacheDirectory)
}

public static func summary(for profile: ClaudeProfile,
now: Date = Date(),
maxFiles: Int = 400,
Expand Down
7 changes: 7 additions & 0 deletions Sources/AIUsageBarCore/Claude/ClaudeJSONLReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ public struct ClaudeTokenActivity: Sendable, Hashable {
/// Sums `message.usage` tokens over trailing 5h / 7d wall-clock windows from
/// `<configDir>/projects/**/*.jsonl`, deduped by message id + request id.
public enum ClaudeJSONLReader {
/// Local activity from a Claude **config dir** (reads its `projects/`).
public static func activity(configDir: URL, now: Date = Date(),
maxFiles: Int = 40) -> ClaudeTokenActivity? {
activity(for: ClaudeProfile(name: configDir.lastPathComponent, configDir: configDir, isDefault: false),
now: now, maxFiles: maxFiles)
}

public static func activity(for profile: ClaudeProfile,
now: Date = Date(),
maxFiles: Int = 40) -> ClaudeTokenActivity? {
Expand Down
21 changes: 20 additions & 1 deletion Sources/AIUsageBarCore/Claude/ClaudeProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,26 @@ public struct ClaudeAccount: Codable, Sendable, Hashable {
public enum ClaudeAccountLoader {
/// Reads the `oauthAccount` block from a profile's `.claude.json`.
public static func load(_ profile: ClaudeProfile) -> ClaudeAccount? {
guard let data = try? Data(contentsOf: profile.dotClaudeJSON),
load(dotClaudeJSON: profile.dotClaudeJSON)
}

/// Reads identity/plan from a Claude **config dir** — used by the account-driven
/// model when the user points an account at its logs. `.claude.json` lives inside
/// a custom config dir but at `~/.claude.json` for the default `~/.claude`, so try
/// both.
public static func load(configDir: URL) -> ClaudeAccount? {
if let a = load(dotClaudeJSON: configDir.appendingPathComponent(".claude.json")) { return a }
// Only the default ~/.claude keeps its .claude.json at the home root — don't
// apply that fallback to other dirs, or an account whose logs dir lacks a
// .claude.json would inherit the default account's plan (a cross-account mislabel).
let home = FileManager.default.homeDirectoryForCurrentUser
guard configDir.standardizedFileURL == home.appendingPathComponent(".claude").standardizedFileURL
else { return nil }
return load(dotClaudeJSON: home.appendingPathComponent(".claude.json"))
}

public static func load(dotClaudeJSON url: URL) -> ClaudeAccount? {
guard let data = try? Data(contentsOf: url),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let oa = root["oauthAccount"] as? [String: Any]
else { return nil }
Expand Down
Loading
Loading