Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ A macOS menu bar app that displays your [Claude Code](https://docs.anthropic.com
## Features

- **Real-time usage tracking** - Monitor your Claude Code session and weekly usage limits
- **Account display** - Shows the currently logged-in email address in the menu
- **Switch accounts** - Quickly switch Claude accounts via `claude auth login` from the menu (make sure the right browser profile is focused if you use 2 profiles that are signed into 2 different claude accounts)
- **Multiple display modes**:
- Text: Shows percentages directly (e.g., `CC: 45% (32% weekly)`)
- Pie Charts: Visual representation with two pie charts
Expand Down Expand Up @@ -60,16 +62,18 @@ brew upgrade clive

Once running, Clive appears in your menu bar showing your Claude Code usage. Click the icon to see:

- **Logged-in email** - The email address of the current Claude account
- **Session usage** - Current session percentage and reset time
- **Weekly usage** - Current week's percentage
- **Switch Account** - Run `claude auth login` to switch to a different account

Access Settings (⌘,) to configure:
- Display mode (Text, Pie Charts, or Bar Charts)
- Refresh interval (1-30 minutes)

## How It Works

Clive periodically runs `claude /usage` to fetch your current usage statistics and displays them in the menu bar. The app parses the output to extract session and weekly usage percentages.
Clive periodically runs `claude /usage` to fetch your current usage statistics and displays them in the menu bar. The app parses the output to extract session and weekly usage percentages. It also runs `claude auth status` to display the currently logged-in account email.
No hacking of session tokens etc required :).

## License
Expand Down
41 changes: 41 additions & 0 deletions clive/CliveApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
onError: { [weak self] error in
self?.currentError = error
self?.updateMenuBarForError(error)
},
onEmailUpdate: { [weak self] email in
self?.updateEmailMenuItem(email)
}
)
usageManager?.startPolling()
Expand Down Expand Up @@ -61,6 +64,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private func setupMenu() {
let menu = NSMenu()

let emailItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
emailItem.isEnabled = false
emailItem.tag = 102
emailItem.isHidden = true
menu.addItem(emailItem)

let errorItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
errorItem.isEnabled = false
errorItem.tag = 99
Expand All @@ -79,6 +88,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r"))
menu.addItem(NSMenuItem(title: "Switch Account...", action: #selector(switchAccount), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ","))
menu.addItem(NSMenuItem.separator())
Expand Down Expand Up @@ -108,6 +118,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

private func updateEmailMenuItem(_ email: String?) {
guard let menu = statusItem?.menu, let emailItem = menu.item(withTag: 102) else { return }
if let email = email {
emailItem.title = email
emailItem.isHidden = false
} else {
emailItem.isHidden = true
}
}

private func updateMenuBar(with usage: UsageInfo?) {
DispatchQueue.main.async { [weak self] in
guard let self = self, let button = self.statusItem?.button else { return }
Expand Down Expand Up @@ -243,6 +263,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
}

@objc func switchAccount() {
let claudePath = SettingsManager.shared.claudePath
guard FileManager.default.isExecutableFile(atPath: claudePath) else { return }

let process = Process()
process.executableURL = URL(fileURLWithPath: claudePath)
process.arguments = ["auth", "login"]

process.terminationHandler = { [weak self] _ in
DispatchQueue.main.async {
self?.usageManager?.refreshNow()
}
}

do {
try process.run()
} catch {
// Failed to launch
}
}

@objc func refreshNow() {
usageManager?.refreshNow()
}
Expand Down
36 changes: 35 additions & 1 deletion clive/UsageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class UsageManager {
private var refreshTimer: Timer?
private let onUpdate: (UsageInfo?) -> Void
private let onError: (UsageError?) -> Void
private let onEmailUpdate: (String?) -> Void
private var childPid: pid_t = 0
private var masterFd: Int32 = -1
private var timeoutTimer: Timer?
Expand All @@ -41,9 +42,10 @@ class UsageManager {

private let timeout: TimeInterval = 30

init(onUpdate: @escaping (UsageInfo?) -> Void, onError: @escaping (UsageError?) -> Void) {
init(onUpdate: @escaping (UsageInfo?) -> Void, onError: @escaping (UsageError?) -> Void, onEmailUpdate: @escaping (String?) -> Void = { _ in }) {
self.onUpdate = onUpdate
self.onError = onError
self.onEmailUpdate = onEmailUpdate

// Listen for refresh interval changes
SettingsManager.shared.$refreshInterval.sink { [weak self] _ in
Expand All @@ -63,6 +65,7 @@ class UsageManager {

func startPolling() {
refreshNow()
fetchEmail()
startTimer()
}

Expand All @@ -76,6 +79,7 @@ class UsageManager {

func refreshNow() {
guard !isRefreshing else { return }
fetchEmail()
performRefresh()
}

Expand All @@ -93,6 +97,36 @@ class UsageManager {
}
}

func fetchEmail() {
let claudePath = SettingsManager.shared.claudePath
guard checkExecutableExists() else { return }

DispatchQueue.global(qos: .utility).async { [weak self] in
let process = Process()
process.executableURL = URL(fileURLWithPath: claudePath)
process.arguments = ["auth", "status"]

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice

do {
try process.run()
process.waitUntilExit()

let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let email = json["email"] as? String {
DispatchQueue.main.async {
self?.onEmailUpdate(email)
}
}
} catch {
// Silently fail - email display is non-critical
}
}
}

private func performRefresh() {
isRefreshing = true

Expand Down
12 changes: 12 additions & 0 deletions run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash
set -e

xcodebuild -scheme clive -configuration Debug build \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \
2>&1 | tail -3

pkill -x Clive 2>/dev/null || true
sleep 1

open "$(xcodebuild -scheme clive -configuration Debug -showBuildSettings 2>/dev/null \
| grep -m1 'BUILT_PRODUCTS_DIR' | awk '{print $3}')/Clive.app"