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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ There are no tests or linters configured.
- **App entry point**: `ClaudeCodeStatsApp.swift` — `MenuBarExtra` with chart icon, red dot badge overlay for updates
- **Main view**: `ContentView.swift` — contains the `UsageViewModel` (handles usage data + status polling) and all view components
- **Services** (singletons, async/await):
- `OAuthUsageService` — fetches usage data from Anthropic API using OAuth credentials (reads `~/.claude/.credentials.json` first, falls back to macOS Keychain `Claude Code-credentials`), parsing rate limit response headers
- `OAuthUsageService` — fetches usage data from the Anthropic `GET /api/oauth/usage` endpoint using OAuth credentials (reads `~/.claude/.credentials.json` first, falls back to macOS Keychain `Claude Code-credentials`), decoding session, weekly all-models, and per-model scoped weekly limits (e.g. Fable) from the JSON `limits` array
- `StatusService` — fetches health status from status.claude.com
- `VersionService` — checks installed CLI version (`claude --version` via Process) and latest release from GitHub API; includes `UpdateChecker` ObservableObject for state management

Expand Down
17 changes: 11 additions & 6 deletions ClaudeCodeStats/ClaudeCodeStats/ClaudeCodeStatsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ struct ClaudeCodeStatsApp: App {
@StateObject private var viewModel = UsageViewModel()
@AppStorage("showSessionInMenuBar") private var showSession = false
@AppStorage("showWeeklyInMenuBar") private var showWeekly = false
@AppStorage("showFableInMenuBar") private var showFable = false

private var showRings: Bool {
showSession || showWeekly
showSession || showWeekly || showFable
}

var body: some Scene {
Expand All @@ -22,9 +23,12 @@ struct ClaudeCodeStatsApp: App {
if showRings {
let sessionPct = viewModel.webUsage?.sessionUsage ?? 0
let weeklyPct = viewModel.webUsage?.weeklyUsage ?? 0
let fablePct = viewModel.webUsage?.scopedLimits
.first(where: { $0.name == "Fable" })?.usage ?? 0
Image(nsImage: renderRings(
session: showSession ? sessionPct : nil,
weekly: showWeekly ? weeklyPct : nil
weekly: showWeekly ? weeklyPct : nil,
fable: showFable ? fablePct : nil
))
} else {
Image(systemName: "chart.bar.fill")
Expand All @@ -47,7 +51,7 @@ struct ClaudeCodeStatsApp: App {
.menuBarExtraStyle(.window)
}

private func renderRings(session: Double?, weekly: Double?) -> NSImage {
private func renderRings(session: Double?, weekly: Double?, fable: Double?) -> NSImage {
let height: CGFloat = 18
let ringSize: CGFloat = 14
let ringLineWidth: CGFloat = 2.5
Expand All @@ -59,16 +63,17 @@ struct ClaudeCodeStatsApp: App {
var segments: [(String, Double)] = []
if let session { segments.append(("S", session)) }
if let weekly { segments.append(("W", weekly)) }
if let fable { segments.append(("F", fable)) }

// Measure total width
let separatorWidth: CGFloat = segments.count > 1
? (" | " as NSString).size(withAttributes: textAttrs).width : 0
let separatorWidth: CGFloat = (" | " as NSString).size(withAttributes: textAttrs).width
var totalWidth: CGFloat = 0
for (label, _) in segments {
let labelSize = (label as NSString).size(withAttributes: textAttrs)
totalWidth += labelSize.width + 2 + ringSize // label + gap + ring
}
totalWidth += separatorWidth
// One separator between each pair of segments (count - 1 total)
totalWidth += separatorWidth * CGFloat(max(0, segments.count - 1))

let image = NSImage(size: NSSize(width: totalWidth, height: height), flipped: false) { _ in
guard let ctx = NSGraphicsContext.current?.cgContext else { return false }
Expand Down
9 changes: 8 additions & 1 deletion ClaudeCodeStats/ClaudeCodeStats/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ struct ContentView: View {
resetsAt: usage.weeklyResetsAt
)

}
ForEach(usage.scopedLimits) { limit in
UsageCardView(
title: "Weekly Limit (\(limit.name))",
usage: limit.usage,
resetsAt: limit.resetsAt
)
}
}
.padding(12)
}

Expand Down
12 changes: 12 additions & 0 deletions ClaudeCodeStats/ClaudeCodeStats/Models.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import Foundation

// A weekly limit scoped to a specific model (e.g. "Fable"), shown alongside
// the overall session and all-models limits.
struct ScopedUsageLimit: Identifiable {
let name: String
let usage: Double
let resetsAt: Date

var id: String { name }
}

struct WebUsageData {
let sessionUsage: Double
let sessionResetsAt: Date
let weeklyUsage: Double
let weeklyResetsAt: Date
let scopedLimits: [ScopedUsageLimit]
let lastUpdated: Date

static var empty: WebUsageData {
Expand All @@ -13,6 +24,7 @@ struct WebUsageData {
sessionResetsAt: Date(),
weeklyUsage: 0,
weeklyResetsAt: Date(),
scopedLimits: [],
lastUpdated: Date()
)
}
Expand Down
128 changes: 91 additions & 37 deletions ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Security
class OAuthUsageService {
static let shared = OAuthUsageService()

private let apiURL = "https://api.anthropic.com/v1/messages"
private let usageURL = "https://api.anthropic.com/api/oauth/usage"
private let credentialsPath: String = {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return "\(home)/.claude/.credentials.json"
Expand Down Expand Up @@ -33,27 +33,19 @@ class OAuthUsageService {
throw UsageError.noCredentials
}

guard let url = URL(string: apiURL) else {
guard let url = URL(string: usageURL) else {
throw UsageError.invalidResponse
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpMethod = "GET"
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "content-type")

let body: [String: Any] = [
"model": "claude-haiku-4-5-20251001",
"max_tokens": 1,
"messages": [["role": "user", "content": "hi"]]
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)

let (_, response): (Data, URLResponse)
let (data, response): (Data, URLResponse)
do {
(_, response) = try await withRetry {
(data, response) = try await withRetry {
try await session.data(for: request)
}
} catch {
Expand All @@ -69,12 +61,11 @@ class OAuthUsageService {
throw UsageError.tokenExpired
}

// 2xx and 429 both carry valid rate-limit headers
guard (200...299).contains(httpResponse.statusCode) || httpResponse.statusCode == 429 else {
guard (200...299).contains(httpResponse.statusCode) else {
throw UsageError.invalidResponse
}

return parseHeaders(httpResponse)
return try parseUsage(data)
}

private func readAccessToken() -> String? {
Expand Down Expand Up @@ -192,35 +183,98 @@ class OAuthUsageService {
return token
}

private func parseHeaders(_ response: HTTPURLResponse) -> WebUsageData {
let headers = response.allHeaderFields
// Shape of the /api/oauth/usage JSON response (only the fields we consume).
private struct UsageResponse: Decodable {
let fiveHour: Window?
let sevenDay: Window?
let limits: [Limit]?

enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case limits
}

struct Window: Decodable {
let utilization: Double?
let resetsAt: String?

enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}

let sessionUtilization = headerDouble(headers, key: "anthropic-ratelimit-unified-5h-utilization")
let sessionReset = headerDate(headers, key: "anthropic-ratelimit-unified-5h-reset")
let weeklyUtilization = headerDouble(headers, key: "anthropic-ratelimit-unified-7d-utilization")
let weeklyReset = headerDate(headers, key: "anthropic-ratelimit-unified-7d-reset")
struct Limit: Decodable {
let kind: String
let percent: Double?
let resetsAt: String?
let scope: Scope?

enum CodingKeys: String, CodingKey {
case kind, percent, scope
case resetsAt = "resets_at"
}

struct Scope: Decodable {
let model: Model?

struct Model: Decodable {
let displayName: String?

enum CodingKeys: String, CodingKey {
case displayName = "display_name"
}
}
}
}
}

private func parseUsage(_ data: Data) throws -> WebUsageData {
guard let decoded = try? JSONDecoder().decode(UsageResponse.self, from: data) else {
throw UsageError.invalidResponse
}

// Weekly limits scoped to a specific model (e.g. Fable) live only in the
// `limits` array — render each as its own card.
let scopedLimits: [ScopedUsageLimit] = (decoded.limits ?? []).compactMap { limit in
guard limit.kind == "weekly_scoped",
let name = limit.scope?.model?.displayName, !name.isEmpty else {
return nil
}
return ScopedUsageLimit(
name: name,
usage: limit.percent ?? 0,
resetsAt: parseDate(limit.resetsAt) ?? Date()
)
}

return WebUsageData(
sessionUsage: sessionUtilization * 100,
sessionResetsAt: sessionReset ?? Date(),
weeklyUsage: weeklyUtilization * 100,
weeklyResetsAt: weeklyReset ?? Date(),
sessionUsage: decoded.fiveHour?.utilization ?? 0,
sessionResetsAt: parseDate(decoded.fiveHour?.resetsAt) ?? Date(),
weeklyUsage: decoded.sevenDay?.utilization ?? 0,
weeklyResetsAt: parseDate(decoded.sevenDay?.resetsAt) ?? Date(),
Comment on lines 252 to +256
scopedLimits: scopedLimits,
lastUpdated: Date()
)
}

private func headerDouble(_ headers: [AnyHashable: Any], key: String) -> Double {
if let value = headers[key] as? String, let num = Double(value) {
return num
}
return 0
}
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()

private func headerDate(_ headers: [AnyHashable: Any], key: String) -> Date? {
if let value = headers[key] as? String, let timestamp = TimeInterval(value) {
return Date(timeIntervalSince1970: timestamp)
}
return nil
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()

private func parseDate(_ string: String?) -> Date? {
guard let string else { return nil }
return Self.isoFormatter.date(from: string)
?? Self.isoFormatterNoFraction.date(from: string)
}

private func withRetry<T>(
Expand Down
7 changes: 7 additions & 0 deletions ClaudeCodeStats/ClaudeCodeStats/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ struct SettingsView: View {
@Binding var isPresented: Bool
@AppStorage("showSessionInMenuBar") private var showSession = false
@AppStorage("showWeeklyInMenuBar") private var showWeekly = false
@AppStorage("showFableInMenuBar") private var showFable = false

var body: some View {
VStack(spacing: 0) {
Expand Down Expand Up @@ -93,6 +94,12 @@ struct SettingsView: View {
.foregroundColor(Theme.textSecondary)
.toggleStyle(.switch)
.controlSize(.mini)

Toggle("Show Fable usage", isOn: $showFable)
.font(.system(size: 11))
.foregroundColor(Theme.textSecondary)
.toggleStyle(.switch)
.controlSize(.mini)
}
.padding(12)
.background(Theme.cardBackground)
Expand Down