diff --git a/README.md b/README.md index b7add2a..ae85101 100644 --- a/README.md +++ b/README.md @@ -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 @@ -60,8 +62,10 @@ 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) @@ -69,7 +73,7 @@ Access Settings (⌘,) to configure: ## 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 diff --git a/clive/CliveApp.swift b/clive/CliveApp.swift index 65baa61..007c20f 100644 --- a/clive/CliveApp.swift +++ b/clive/CliveApp.swift @@ -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() @@ -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 @@ -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()) @@ -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 } @@ -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() } diff --git a/clive/UsageManager.swift b/clive/UsageManager.swift index e45da12..e6bcb13 100644 --- a/clive/UsageManager.swift +++ b/clive/UsageManager.swift @@ -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? @@ -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 @@ -63,6 +65,7 @@ class UsageManager { func startPolling() { refreshNow() + fetchEmail() startTimer() } @@ -76,6 +79,7 @@ class UsageManager { func refreshNow() { guard !isRefreshing else { return } + fetchEmail() performRefresh() } @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..f7bca46 --- /dev/null +++ b/run.sh @@ -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"