From 46c4efc5cabe8141360e20c8dc2a8e8501fd3679 Mon Sep 17 00:00:00 2001 From: Ryan George Date: Sat, 11 Jul 2026 16:54:42 +0200 Subject: [PATCH 1/6] feat: add generic password services for multi-account support --- .../OpenUsage/Services/SystemClients.swift | 30 +++++++++++++++++++ Tests/OpenUsageTests/TestSupport.swift | 4 +++ 2 files changed, 34 insertions(+) diff --git a/Sources/OpenUsage/Services/SystemClients.swift b/Sources/OpenUsage/Services/SystemClients.swift index 74fa53826..368444ca3 100644 --- a/Sources/OpenUsage/Services/SystemClients.swift +++ b/Sources/OpenUsage/Services/SystemClients.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import Security protocol EnvironmentReading: Sendable { func value(for name: String) -> String? @@ -211,6 +212,10 @@ protocol KeychainAccessing: Sendable { /// item under a known account name (e.g. Antigravity's `agy` token under service `gemini`, /// account `antigravity`) rather than the current user. func readGenericPassword(service: String, account: String) throws -> String? + /// Service names of every generic-password item whose service begins with `prefix`. Attributes-only + /// (never returns the secret data), so it enumerates without triggering an ACL/unlock prompt. Used by + /// Claude multi-account discovery to find keychain-stored logins under `Claude Code-credentials*`. + func genericPasswordServices(withPrefix prefix: String) throws -> [String] } extension KeychainAccessing { @@ -227,6 +232,9 @@ extension KeychainAccessing { func readGenericPassword(service: String, account: String) throws -> String? { try readGenericPassword(service: service) } + + /// Default for mocks that don't model enumeration: nothing to enumerate. + func genericPasswordServices(withPrefix prefix: String) throws -> [String] { [] } } struct SecurityKeychainAccessor: KeychainAccessing { @@ -297,6 +305,28 @@ struct SecurityKeychainAccessor: KeychainAccessing { ProcessInfo.processInfo.environment["USER"]?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? NSUserName() } + + // Attributes-only enumeration goes straight through the Security framework (not the `security` CLI, + // whose `dump-keychain` prompts): `kSecReturnAttributes` with no `kSecReturnData` returns each item's + // metadata without unlocking the secret, so no ACL prompt fires. + func genericPasswordServices(withPrefix prefix: String) throws -> [String] { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecMatchLimit: kSecMatchLimitAll, + kSecReturnAttributes: true + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess else { + AppLog.warn(.keychain, "service enumeration failed (status \(status))") + throw KeychainError.readFailed("keychain enumeration failed (status \(status))") + } + guard let items = result as? [[String: Any]] else { return [] } + return items + .compactMap { $0[kSecAttrService as String] as? String } + .filter { $0.hasPrefix(prefix) } + } } enum KeychainError: Error, LocalizedError { diff --git a/Tests/OpenUsageTests/TestSupport.swift b/Tests/OpenUsageTests/TestSupport.swift index 58fc44962..28a36a2d2 100644 --- a/Tests/OpenUsageTests/TestSupport.swift +++ b/Tests/OpenUsageTests/TestSupport.swift @@ -272,6 +272,10 @@ final class ServiceKeychain: KeychainAccessing, @unchecked Sendable { func writeGenericPasswordForCurrentUser(service: String, value: String) throws { currentUserValues[service] = value } + + func genericPasswordServices(withPrefix prefix: String) throws -> [String] { + Set(values.keys).union(currentUserValues.keys).filter { $0.hasPrefix(prefix) }.sorted() + } } final class FakeHTTPClient: HTTPClient, @unchecked Sendable { From 0fa76fbd5b79ab86dae4ace6af2c0b79da958e31 Mon Sep 17 00:00:00 2001 From: Ryan George Date: Sat, 11 Jul 2026 16:57:24 +0200 Subject: [PATCH 2/6] feat: implement ClaudeAccountScope for multi-account credential management --- .../Providers/Claude/ClaudeAuthStore.swift | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index 35cd5bc20..ac2aae8de 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -141,6 +141,15 @@ struct ClaudeOAuthConfig: Hashable, Sendable { var clientID: String } +/// Pins a `ClaudeAuthStore` to ONE additional account's credential sources (a specific config-dir file +/// and/or a specific keychain service), with no cross-account fallback and no env-token fallback. The +/// default instance leaves this `nil` and behaves exactly as before (env `CLAUDE_CONFIG_DIR`, the bare +/// keychain service, and the `CLAUDE_CODE_OAUTH_TOKEN` fallback). +struct ClaudeAccountScope: Hashable, Sendable { + var configDir: String? + var keychainService: String? +} + struct ClaudeAuthStore: Sendable { private static let defaultClaudeHome = "~/.claude" private static let credentialFileName = ".credentials.json" @@ -154,17 +163,22 @@ struct ClaudeAuthStore: Sendable { var files: TextFileAccessing var keychain: KeychainAccessing var now: @Sendable () -> Date + /// When set, this store reads/writes ONLY the pinned account's sources (see `ClaudeAccountScope`). + /// `nil` is the default instance — unchanged env-driven behavior. + var account: ClaudeAccountScope? init( environment: EnvironmentReading = ProcessEnvironmentReader(), files: TextFileAccessing = LocalTextFileAccessor(), keychain: KeychainAccessing = SecurityKeychainAccessor(), - now: @escaping @Sendable () -> Date = Date.init + now: @escaping @Sendable () -> Date = Date.init, + account: ClaudeAccountScope? = nil ) { self.environment = environment self.files = files self.keychain = keychain self.now = now + self.account = account } /// All credential sources currently on disk/keychain, in fixed keychain-before-file order, for the @@ -174,7 +188,9 @@ struct ClaudeAuthStore: Sendable { /// sits in another. Re-read on every refresh; nothing is cached in memory. func loadCredentialCandidates() -> [ClaudeCredentialState] { let stored = orderedStoredCandidates() - guard let envAccessToken = envText("CLAUDE_CODE_OAUTH_TOKEN") else { + // The ambient `CLAUDE_CODE_OAUTH_TOKEN` fallback belongs only to the default instance: an + // account-scoped store reads exactly its own file/keychain sources, never a shared env token. + guard account == nil, let envAccessToken = envText("CLAUDE_CODE_OAUTH_TOKEN") else { return stored } // An explicit `CLAUDE_CODE_OAUTH_TOKEN` is inference-only (typically a `claude setup-token` @@ -240,7 +256,8 @@ struct ClaudeAuthStore: Sendable { switch state.source { case .file: - try files.writeText(credentialsPath(), text) + guard let path = credentialsPath() else { return false } + try files.writeText(path, text) case .keychainCurrentUser(let service): try keychain.writeGenericPasswordForCurrentUser(service: service, value: text) case .keychainLegacy(let service): @@ -347,15 +364,34 @@ struct ClaudeAuthStore: Sendable { } func keychainServiceCandidates() -> [String] { + // An account-scoped store reads only its own pinned keychain service (or none for a file-only + // account) — never the bare service or the env-derived hash. + if let account { + return account.keychainService.map { [$0] } ?? [] + } + return keychainServiceCandidates(forConfigDir: claudeHomeOverride()) + } + + /// The keychain service names Claude Code would use for a given config dir, most specific first: + /// the base `Claude Code-credentials`, hash-suffixed by the config dir when one is given. + /// The single home of this derivation — Claude multi-account discovery reuses it to map a config + /// dir to its keychain service rather than re-deriving the hash. + func keychainServiceCandidates(forConfigDir configDir: String?) -> [String] { // Only needs the file suffix, which never fails — keep this off the throwing URL path so // credential loading stays forgiving even when a custom OAuth URL is malformed. let base = "\(Self.keychainServicePrefix)\(resolveOAuthEndpoints().suffix)-credentials" - if let configDir = claudeHomeOverride() { + if let configDir { return ["\(base)-\(hashSuffix(configDir))", base] } return [base] } + /// The bare (default-account) keychain service — `Claude Code-credentials` with no config-dir + /// hash. Discovery pairs this with `~/.claude` as the one default account. + func baseKeychainService() -> String { + "\(Self.keychainServicePrefix)\(resolveOAuthEndpoints().suffix)-credentials" + } + static func parseCredentials(_ text: String) -> ClaudeCredentialsFile? { ProviderParse.decodeJSONWithHexFallback(text, as: ClaudeCredentialsFile.self) } @@ -383,7 +419,7 @@ struct ClaudeAuthStore: Sendable { } private func loadFileCredentials() -> ClaudeCredentialState? { - let path = credentialsPath() + guard let path = credentialsPath() else { return nil } guard files.exists(path), let text = try? files.readText(path), let parsed = Self.parseCredentials(text), @@ -434,8 +470,13 @@ struct ClaudeAuthStore: Sendable { return ClaudeCredentialState(oauth: oauth, source: source, fullData: parsed, inferenceOnly: false) } - private func credentialsPath() -> String { - "\(envText("CLAUDE_CONFIG_DIR") ?? Self.defaultClaudeHome)/\(Self.credentialFileName)" + /// The credentials file to read/write, or `nil` when this store has no file source (a keychain-only + /// scoped account). The default instance always resolves a path (env override or `~/.claude`). + private func credentialsPath() -> String? { + if let account { + return account.configDir.map { "\($0)/\(Self.credentialFileName)" } + } + return "\(envText("CLAUDE_CONFIG_DIR") ?? Self.defaultClaudeHome)/\(Self.credentialFileName)" } private func envText(_ name: String) -> String? { From bc2eb690e8e6bf4fed97979ba409ae888f741f83 Mon Sep 17 00:00:00 2001 From: Ryan George Date: Sat, 11 Jul 2026 16:58:06 +0200 Subject: [PATCH 3/6] feat: add Claude account discovery and storage for multi-account --- .../Claude/ClaudeAccountDiscovery.swift | 173 +++++++++++++++ .../Claude/ClaudeAccountsStore.swift | 156 +++++++++++++ .../Providers/Claude/ClaudeProvider.swift | 62 +++++- .../ClaudeAccountDiscoveryTests.swift | 205 ++++++++++++++++++ 4 files changed, 584 insertions(+), 12 deletions(-) create mode 100644 Sources/OpenUsage/Providers/Claude/ClaudeAccountDiscovery.swift create mode 100644 Sources/OpenUsage/Providers/Claude/ClaudeAccountsStore.swift create mode 100644 Tests/OpenUsageTests/ClaudeAccountDiscoveryTests.swift diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAccountDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAccountDiscovery.swift new file mode 100644 index 000000000..6a29f50d6 --- /dev/null +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAccountDiscovery.swift @@ -0,0 +1,173 @@ +import Foundation + +/// One Claude account found on the machine: a config-dir file source, a keychain service source, or +/// both. `isDefault` marks the primary account (`~/.claude` / the bare keychain service), which is +/// always provider id `"claude"` and never gets a stored record. +struct DiscoveredClaudeAccount: Hashable, Sendable { + var configDir: String? + var keychainService: String? + var isDefault: Bool +} + +/// Finds Claude accounts already present on the machine, with no user configuration. Checks are one +/// level deep only (never a disk-wide search): the default `~/.claude`, sibling `~/.claude*` dirs with +/// credentials, `$XDG_CONFIG_HOME/claude`, every `CLAUDE_CONFIG_DIR` entry, and generic-password +/// keychain items under the `Claude Code-credentials` service prefix. +/// +/// A config dir and a keychain service that hash to the same account are collapsed into one entry +/// (reusing `ClaudeAuthStore`'s own service derivation, never a second copy of the hash). A keychain +/// service with no matching dir stands alone as a keychain-only account. +struct ClaudeAccountDiscovery { + private static let credentialFileName = ".credentials.json" + + var environment: EnvironmentReading + var files: TextFileAccessing + var keychain: KeychainAccessing + var homeDirectory: @Sendable () -> URL + /// Names of the immediate children of a directory. Injected so discovery's `~/.claude*` sibling scan + /// is testable without touching the real home directory. + var contentsOfDirectory: @Sendable (String) -> [String] + + init( + environment: EnvironmentReading = ProcessEnvironmentReader(), + files: TextFileAccessing = LocalTextFileAccessor(), + keychain: KeychainAccessing = SecurityKeychainAccessor(), + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, + contentsOfDirectory: @escaping @Sendable (String) -> [String] = { path in + (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? [] + } + ) { + self.environment = environment + self.files = files + self.keychain = keychain + self.homeDirectory = homeDirectory + self.contentsOfDirectory = contentsOfDirectory + } + + func discover() -> [DiscoveredClaudeAccount] { + let authStore = ClaudeAuthStore(environment: environment, files: files, keychain: keychain) + + // The default instance reads env `CLAUDE_CONFIG_DIR` when set, else `~/.claude`; its keychain + // services (bare + the env dir's hash) are reserved for the default account. The dir is + // canonicalized (symlinks resolved, trailing slash stripped) so a trailing-slash or symlinked + // `CLAUDE_CONFIG_DIR` pointing at the default dir can't reappear as a phantom second account. + // ponytail: `CLAUDE_CONFIG_DIR` is read raw here (not comma-split) to match the auth store's own + // single-path read; a rare comma-separated value won't match a real dir, so its entries fall + // through to the extra-account scan below — acceptable for v1. + let rawDefaultDir = environment.value(for: "CLAUDE_CONFIG_DIR")?.nilIfEmpty.map { expandHome($0) } + ?? "\(homeDirectory().path)/.claude" + let defaultDir = Self.canonicalize(rawDefaultDir) + // Ordered so the default account's chosen service is deterministic; the Set is only for + // membership tests and the `consumed` bookkeeping. + let defaultServiceCandidates = authStore.keychainServiceCandidates() + let defaultServices = Set(defaultServiceCandidates) + + let enumerated = enumeratedServices(prefix: authStore.baseKeychainService()) + + var accounts: [DiscoveredClaudeAccount] = [] + var consumed = Set() + + accounts.append(DiscoveredClaudeAccount( + configDir: defaultDir, + keychainService: defaultServiceCandidates.first { enumerated.contains($0) }, + isDefault: true + )) + consumed.formUnion(defaultServices) + + for candidate in candidateConfigDirs() where candidate.canonical != defaultDir { + // Claude Code hashes the raw configured dir string, so match keychain services against the + // raw AND canonical forms; identity/dedupe/persistence use the canonical path only. + let hashCandidates = orderedUnique([candidate.raw, candidate.canonical].compactMap { + authStore.keychainServiceCandidates(forConfigDir: $0).first + }) + let matched = hashCandidates.first { enumerated.contains($0) && !consumed.contains($0) } + let hasUsableFile = hasUsableFileCredentials(inConfigDir: candidate.canonical) + guard hasUsableFile || matched != nil else { continue } + if let matched { consumed.insert(matched) } + accounts.append(DiscoveredClaudeAccount(configDir: candidate.canonical, keychainService: matched, isDefault: false)) + } + + for service in enumerated.subtracting(consumed).sorted() { + accounts.append(DiscoveredClaudeAccount(configDir: nil, keychainService: service, isDefault: false)) + } + + return accounts + } + + /// Whether a config dir holds a genuinely usable login — a parseable OAuth blob with a non-empty + /// access token — not merely a `.credentials.json` that exists. Reuses the auth store's own file + /// loader (a config-dir-scoped, keychain-free `ClaudeAuthStore`) rather than a second credential + /// read, so discovery's "is this an account" bar matches `refresh()`'s. Cheap: a scoped store with no + /// keychain service reads only the one file, never the keychain or the env token. + private func hasUsableFileCredentials(inConfigDir dir: String) -> Bool { + let scoped = ClaudeAuthStore( + environment: environment, + files: files, + keychain: keychain, + account: ClaudeAccountScope(configDir: dir, keychainService: nil) + ) + return scoped.loadCredentialCandidates().contains { $0.hasUsableAccessToken } + } + + /// Resolve symlinks and strip a trailing slash so two spellings of one dir compare and dedupe as the + /// same account. Input is expected already `~`-expanded. + private static func canonicalize(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + } + + private func orderedUnique(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } + + private func enumeratedServices(prefix: String) -> Set { + do { + return Set(try keychain.genericPasswordServices(withPrefix: prefix)) + } catch { + AppLog.warn(.keychain, "Claude account keychain enumeration failed: \(error.localizedDescription)") + return [] + } + } + + /// A config dir worth probing, carrying both its canonical path (for identity/dedupe/persistence) and + /// the raw configured string (to reproduce Claude Code's keychain-service hash, which is taken over + /// the raw value). + private struct CandidateDir { + var canonical: String + var raw: String + } + + /// Every config dir worth probing, expanded and deduped by canonical path: the default, + /// credential-bearing `~/.claude*` siblings, `$XDG_CONFIG_HOME/claude`, and each `CLAUDE_CONFIG_DIR` + /// entry (comma-separated, like the log scanner). + private func candidateConfigDirs() -> [CandidateDir] { + let home = homeDirectory().path + var dirs: [CandidateDir] = [] + var seen = Set() + func add(_ path: String) { + let raw = expandHome(path) + let canonical = Self.canonicalize(raw) + if seen.insert(canonical).inserted { dirs.append(CandidateDir(canonical: canonical, raw: raw)) } + } + + add("\(home)/.claude") + + for name in contentsOfDirectory(home) where name.hasPrefix(".claude") { + let path = "\(home)/\(name)" + if files.exists("\(path)/\(Self.credentialFileName)") { add(path) } + } + + let xdgBase = environment.value(for: "XDG_CONFIG_HOME")?.nilIfEmpty.map { expandHome($0) } ?? "\(home)/.config" + let xdgClaude = "\(xdgBase)/claude" + if files.exists("\(xdgClaude)/\(Self.credentialFileName)") { add(xdgClaude) } + + if let raw = environment.value(for: "CLAUDE_CONFIG_DIR")?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty { + for part in raw.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) where !part.isEmpty { + add(part) + } + } + + return dirs + } +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAccountsStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAccountsStore.swift new file mode 100644 index 000000000..ef44c726c --- /dev/null +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAccountsStore.swift @@ -0,0 +1,156 @@ +import Foundation +import Observation + +/// One persisted additional Claude account. The default account (`~/.claude` / the bare keychain +/// service) is NEVER stored here — it is always provider id `"claude"`. Only extra accounts get a +/// record, a stable UUID, and provider id `"claude."`. +struct ClaudeAccountRecord: Codable, Hashable, Sendable, Identifiable { + var id: UUID + var configDirPath: String? + var keychainService: String? + /// User-supplied display name. `nil` falls back to a short id-derived name. + var customName: String? + + var scope: ClaudeAccountScope { + ClaudeAccountScope(configDir: configDirPath, keychainService: keychainService) + } + + /// Provider id for this account's `ClaudeProvider` instance and its widgets. + var providerID: String { "claude.\(id.uuidString.lowercased())" } + + /// Shown name: the custom name when set, else "Claude ()". + var displayName: String { + if let trimmed = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + return trimmed + } + return "Claude (\(String(id.uuidString.lowercased().prefix(8))))" + } + + func makeProvider() -> Provider { + Provider( + id: providerID, + displayName: displayName, + icon: .providerMark("claude"), + links: [ + .init(label: "Status", url: "https://status.anthropic.com/"), + .init(label: "Dashboard", url: "https://claude.ai/settings/usage") + ] + ) + } + + /// Whether a freshly discovered account refers to this same record (shared config dir or keychain + /// service). Used at launch to keep a record's UUID stable so its layout/enablement survives. + func matches(_ account: DiscoveredClaudeAccount) -> Bool { + if let dir = account.configDir, dir == configDirPath { return true } + if let service = account.keychainService, service == keychainService { return true } + return false + } + + /// Whether every credential source this record carries is also carried by `other` (which must share + /// at least one of them), so `other` now fully represents this account and this record is a stale + /// duplicate. Used to collapse a file-only + keychain-only pair once discovery links them into one. + func isSubsumed(by other: ClaudeAccountRecord) -> Bool { + guard id != other.id else { return false } + var sharesSource = false + if let dir = configDirPath { + guard dir == other.configDirPath else { return false } + sharesSource = true + } + if let service = keychainService { + guard service == other.keychainService else { return false } + sharesSource = true + } + return sharesSource + } +} + +/// Persists the set of additional Claude accounts and reconciles it against on-machine discovery at +/// launch. Records whose sources have vanished are kept (so a user's layout/rename isn't silently +/// dropped) and still instantiated — their provider simply surfaces "not signed in" on refresh. +@MainActor +@Observable +final class ClaudeAccountsStore { + static let storageKey = "openusage.claudeAccounts.v1" + + private let defaults: UserDefaults + private(set) var records: [ClaudeAccountRecord] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.records = Self.load(from: defaults) + } + + /// Merge discovery into the stored set: matched accounts keep their UUID (refreshing their source + /// fields in case a keychain service appeared for a previously file-only dir, or vice versa); newly + /// discovered accounts get a fresh UUID. Existing records with no match are kept as-is. + @discardableResult + func reconcile(with discovered: [DiscoveredClaudeAccount]) -> [ClaudeAccountRecord] { + var merged = records + for account in discovered { + if let index = merged.firstIndex(where: { $0.matches(account) }) { + if let dir = account.configDir { merged[index].configDirPath = dir } + if let service = account.keychainService { merged[index].keychainService = service } + Self.collapseSubsumedRecords(into: index, in: &merged) + } else { + merged.append(ClaudeAccountRecord( + id: UUID(), + configDirPath: account.configDir, + keychainService: account.keychainService, + customName: nil + )) + } + } + records = merged + persist() + return merged + } + + /// After a matched record absorbed a newly discovered source, drop any other record whose sources are + /// now fully represented by it — the file-only + keychain-only split that discovery has resolved into + /// one account. `firstIndex` matches the oldest record, so the survivor already keeps the older UUID; + /// if it has no custom name it adopts one from a collapsed record so a user's rename isn't lost. + private static func collapseSubsumedRecords(into index: Int, in records: inout [ClaudeAccountRecord]) { + let survivor = records[index] + var adoptedName = survivor.customName + records.removeAll { record in + guard record.isSubsumed(by: survivor) else { return false } + if adoptedName == nil { adoptedName = record.customName } + return true + } + if let survivorIndex = records.firstIndex(where: { $0.id == survivor.id }) { + records[survivorIndex].customName = adoptedName + } + } + + func record(forProviderID providerID: String) -> ClaudeAccountRecord? { + records.first { $0.providerID == providerID } + } + + /// Set (or clear) a custom name. This store is `@Observable`, and `AppContainer` wires a name-override + /// closure into `LayoutStore` that reads it on every render, so the change applies live wherever the + /// account name shows — no relaunch. See `AppContainer.renameClaudeAccount`. + func setCustomName(_ name: String?, forID id: UUID) { + guard let index = records.firstIndex(where: { $0.id == id }) else { return } + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) + records[index].customName = (trimmed?.isEmpty == false) ? trimmed : nil + persist() + } + + private func persist() { + do { + defaults.set(try JSONEncoder().encode(records), forKey: Self.storageKey) + } catch { + AppLog.error(.config, "failed to persist Claude accounts: \(error.localizedDescription)") + } + } + + private static func load(from defaults: UserDefaults) -> [ClaudeAccountRecord] { + guard let data = defaults.data(forKey: storageKey) else { return [] } + do { + return try JSONDecoder().decode([ClaudeAccountRecord].self, from: data) + } catch { + AppLog.error(.config, "failed to load Claude accounts: \(error.localizedDescription)") + return [] + } + } +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index 2466180b5..fa2842701 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -3,7 +3,9 @@ import Foundation @MainActor final class ClaudeProvider: ProviderRuntime { - let provider = Provider( + /// The default (primary) Claude account — the singleton `ClaudeProvider()` uses this, id `"claude"`. + /// Additional accounts get their own `Provider` (id `"claude."`) built by `ClaudeAccountRecord`. + static let defaultProvider = Provider( id: "claude", displayName: "Claude", icon: .providerMark("claude"), @@ -13,6 +15,12 @@ final class ClaudeProvider: ProviderRuntime { ] ) + let provider: Provider + /// Spend tiles (Today / Yesterday / Last 30 Days / Usage Trend) come from local session logs, which + /// the scanner merges across ALL config dirs and can't attribute per account — so only the default + /// instance shows them. Extra accounts show live-usage metrics only. + private let includeSpendTiles: Bool + let authStore: ClaudeAuthStore let usageClient: ClaudeUsageClient let logUsageScanner: ClaudeLogUsageScanner @@ -34,24 +42,52 @@ final class ClaudeProvider: ProviderRuntime { usageClient: ClaudeUsageClient = ClaudeUsageClient(), logUsageScanner: ClaudeLogUsageScanner = ClaudeLogUsageScanner(), now: @escaping @Sendable () -> Date = Date.init, - pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() } + pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() }, + provider: Provider = ClaudeProvider.defaultProvider, + includeSpendTiles: Bool = true ) { self.authStore = authStore self.usageClient = usageClient self.logUsageScanner = logUsageScanner self.now = now self.pricing = pricing + self.provider = provider + self.includeSpendTiles = includeSpendTiles + } + + /// An additional (non-default) Claude account: id `"claude."`, its own credential sources, and + /// live-usage metrics only (no local-log spend tiles — those can't be attributed per account). + convenience init( + account: ClaudeAccountRecord, + usageClient: ClaudeUsageClient = ClaudeUsageClient(), + logUsageScanner: ClaudeLogUsageScanner = ClaudeLogUsageScanner(), + now: @escaping @Sendable () -> Date = Date.init, + pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() } + ) { + self.init( + authStore: ClaudeAuthStore(account: account.scope), + usageClient: usageClient, + logUsageScanner: logUsageScanner, + now: now, + pricing: pricing, + provider: account.makeProvider(), + includeSpendTiles: false + ) } var widgetDescriptors: [WidgetDescriptor] { - [ - .percent(id: "claude.session", provider: provider, title: "Session", isSessionWindow: true), - .percent(id: "claude.weekly", provider: provider, title: "Weekly"), - .percent(id: "claude.sonnet", provider: provider, title: "Sonnet"), - .percent(id: "claude.fable", provider: provider, title: "Fable"), - .boundedDollars(id: "claude.extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent", limit: 100, valueWord: "spent"), - .usageTrend(provider: provider) - ] + WidgetDescriptor.spendTiles(provider: provider) + var descriptors: [WidgetDescriptor] = [ + .percent(id: "\(provider.id).session", provider: provider, title: "Session", isSessionWindow: true), + .percent(id: "\(provider.id).weekly", provider: provider, title: "Weekly"), + .percent(id: "\(provider.id).sonnet", provider: provider, title: "Sonnet"), + .percent(id: "\(provider.id).fable", provider: provider, title: "Fable"), + .boundedDollars(id: "\(provider.id).extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent", limit: 100, valueWord: "spent") + ] + if includeSpendTiles { + descriptors.append(.usageTrend(provider: provider)) + descriptors += WidgetDescriptor.spendTiles(provider: provider) + } + return descriptors } func hasLocalCredentials() async -> Bool { @@ -158,8 +194,10 @@ final class ClaudeProvider: ProviderRuntime { } // Local spend tiles, scanned natively from Claude Code's session logs and priced through the - // shared pricing store. `scan` runs on the scanner actor, off the main actor. - if let scan = await logUsageScanner.scan(now: now(), pricing: pricing()) { + // shared pricing store. `scan` runs on the scanner actor, off the main actor. Only the default + // instance carries spend tiles — the scanner merges logs across all config dirs, so extra + // accounts can't be attributed a share and skip it entirely. + if includeSpendTiles, let scan = await logUsageScanner.scan(now: now(), pricing: pricing()) { SpendTileMapper.appendTokenUsage( scan.series, to: &mapped.lines, now: now(), unknownModelsByDay: scan.unknownModelsByDay, diff --git a/Tests/OpenUsageTests/ClaudeAccountDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeAccountDiscoveryTests.swift new file mode 100644 index 000000000..6f4bb3de2 --- /dev/null +++ b/Tests/OpenUsageTests/ClaudeAccountDiscoveryTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import OpenUsage + +@MainActor +final class ClaudeAccountDiscoveryTests: XCTestCase { + private let home = "/home/test" + + // MARK: - Discovery + + func testConfigDirAndMatchingKeychainCollapseToOneAccount() { + let files = FakeFiles(["\(home)/.claude-work/.credentials.json": creds("work")]) + let keychain = ServiceKeychain() + let workService = authStore(files: files, keychain: keychain) + .keychainServiceCandidates(forConfigDir: "\(home)/.claude-work").first! + keychain.currentUserValues[workService] = creds("work") + + let extras = discover(files: files, keychain: keychain, entries: [".claude", ".claude-work"]) + .filter { !$0.isDefault } + + XCTAssertEqual(extras.count, 1) + XCTAssertEqual(extras.first?.configDir, "\(home)/.claude-work") + XCTAssertEqual(extras.first?.keychainService, workService) + } + + func testSuffixedKeychainWithNoDirIsKeychainOnlyAccount() { + let keychain = ServiceKeychain() + keychain.currentUserValues["Claude Code-credentials-deadbeef"] = creds("orphan") + + let extras = discover(files: FakeFiles(), keychain: keychain, entries: [".claude"]) + .filter { !$0.isDefault } + + XCTAssertEqual(extras.count, 1) + XCTAssertNil(extras.first?.configDir) + XCTAssertEqual(extras.first?.keychainService, "Claude Code-credentials-deadbeef") + } + + func testDefaultAccountIsNotDuplicated() { + let files = FakeFiles(["\(home)/.claude/.credentials.json": creds("default")]) + let keychain = ServiceKeychain() + keychain.currentUserValues["Claude Code-credentials"] = creds("default") + + let accounts = discover(files: files, keychain: keychain, entries: [".claude"]) + + XCTAssertEqual(accounts.filter(\.isDefault).count, 1) + XCTAssertTrue(accounts.filter { !$0.isDefault }.isEmpty) + XCTAssertEqual(accounts.first(where: \.isDefault)?.keychainService, "Claude Code-credentials") + } + + func testTrailingSlashConfigDirPointingAtDefaultIsNotAnExtraAccount() { + let files = FakeFiles(["\(home)/.claude/.credentials.json": creds("default")]) + let keychain = ServiceKeychain() + + let accounts = ClaudeAccountDiscovery( + environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "\(home)/.claude/"]), + files: files, + keychain: keychain, + homeDirectory: { [home] in URL(fileURLWithPath: home) }, + contentsOfDirectory: { _ in [".claude"] } + ).discover() + + XCTAssertEqual(accounts.filter(\.isDefault).count, 1) + XCTAssertTrue(accounts.filter { !$0.isDefault }.isEmpty, "trailing-slash CLAUDE_CONFIG_DIR must not clone the default account") + } + + func testJunkCredentialFileIsNotCountedAsAccount() { + let files = FakeFiles(["\(home)/.claude-junk/.credentials.json": "not json"]) + let keychain = ServiceKeychain() + + let extras = discover(files: files, keychain: keychain, entries: [".claude", ".claude-junk"]) + .filter { !$0.isDefault } + + XCTAssertTrue(extras.isEmpty, "an unparseable .credentials.json is not a usable account") + } + + // MARK: - Persistence / reconcile + + func testReconcileCollapsesFileOnlyAndKeychainOnlyRecordsOnceLinked() { + let store = ClaudeAccountsStore(defaults: freshDefaults()) + let dir = "\(home)/.claude-work" + let service = "Claude Code-credentials-deadbeef" + + // Two prior runs each saw only one side of the same account. + let fileOnly = store.reconcile(with: [DiscoveredClaudeAccount(configDir: dir, keychainService: nil, isDefault: false)]) + let fileOnlyID = fileOnly[0].id + store.reconcile(with: [DiscoveredClaudeAccount(configDir: nil, keychainService: service, isDefault: false)]) + XCTAssertEqual(store.records.count, 2) + // The rename lives on the newer keychain-only record. + let keychainOnlyID = store.records.first { $0.id != fileOnlyID }!.id + store.setCustomName("Work", forID: keychainOnlyID) + + // Discovery now links both sources into one account. + let merged = store.reconcile(with: [DiscoveredClaudeAccount(configDir: dir, keychainService: service, isDefault: false)]) + + XCTAssertEqual(merged.count, 1, "the stranded orphan must be collapsed, not left as a second card") + XCTAssertEqual(merged[0].id, fileOnlyID, "the older record's UUID survives") + XCTAssertEqual(merged[0].configDirPath, dir) + XCTAssertEqual(merged[0].keychainService, service) + XCTAssertEqual(merged[0].customName, "Work", "a custom name from the collapsed record is preserved") + } + + func testReconcileKeepsUUIDAndCustomNameAcrossRuns() { + let store = ClaudeAccountsStore(defaults: freshDefaults()) + let discovered = [DiscoveredClaudeAccount(configDir: "\(home)/.claude-work", keychainService: nil, isDefault: false)] + + let first = store.reconcile(with: discovered) + XCTAssertEqual(first.count, 1) + let id = first[0].id + store.setCustomName("Work", forID: id) + + let second = store.reconcile(with: discovered) + XCTAssertEqual(second.count, 1) + XCTAssertEqual(second[0].id, id, "matched account must keep its UUID") + XCTAssertEqual(second[0].customName, "Work") + XCTAssertEqual(second[0].displayName, "Work") + } + + // MARK: - Account-scoped auth store + + func testScopedAuthStoreReadsOnlyItsOwnSources() { + let files = FakeFiles([ + "/tmp/acctA/.credentials.json": creds("A"), + "/tmp/acctB/.credentials.json": creds("B") + ]) + let keychain = ServiceKeychain(currentUserValues: ["Claude Code-credentials": creds("bare")]) + let store = ClaudeAuthStore( + environment: FakeEnvironment([ + "CLAUDE_CONFIG_DIR": "/tmp/other", + "CLAUDE_CODE_OAUTH_TOKEN": "env-token" + ]), + files: files, + keychain: keychain, + account: ClaudeAccountScope(configDir: "/tmp/acctA", keychainService: nil) + ) + + let tokens = store.loadCredentialCandidates().compactMap(\.oauth.accessToken) + XCTAssertEqual(tokens, ["A"], "scoped store must ignore other dirs, the keychain, and the env token") + } + + // MARK: - Namespaced descriptors + + func testExtraInstanceDescriptorsAreNamespacedAndSpendFree() { + let uuid = UUID(uuidString: "3F9A2B1C-0000-0000-0000-000000000000")! + let record = ClaudeAccountRecord(id: uuid, configDirPath: "/tmp/acctA", keychainService: nil, customName: nil) + let provider = ClaudeProvider(account: record) + + XCTAssertEqual(provider.provider.id, "claude.3f9a2b1c-0000-0000-0000-000000000000") + XCTAssertEqual(provider.provider.displayName, "Claude (3f9a2b1c)") + let ids = provider.widgetDescriptors.map(\.id) + XCTAssertEqual(ids, [ + "\(provider.provider.id).session", + "\(provider.provider.id).weekly", + "\(provider.provider.id).sonnet", + "\(provider.provider.id).fable", + "\(provider.provider.id).extra" + ]) + XCTAssertTrue(provider.widgetDescriptors.allSatisfy { $0.providerID == provider.provider.id }) + XCTAssertFalse(provider.widgetDescriptors.contains { $0.isSpendTile || $0.id.hasSuffix(".trend") }) + } + + /// An extra account's provider id is namespaced (`claude.`), but its icon must still resolve to + /// the shared Claude brand mark — every id-keyed icon/branding lookup (dashboard, Customize, and the + /// menu-bar strip glyph) reads `provider.icon`, so the extra instance has to carry `.providerMark("claude")`. + func testExtraInstanceIconResolvesToClaudeBrandMark() { + let record = ClaudeAccountRecord(id: UUID(), configDirPath: "/tmp/acctA", keychainService: nil, customName: nil) + + XCTAssertEqual(record.makeProvider().icon, .providerMark("claude")) + XCTAssertEqual(ClaudeProvider(account: record).provider.icon, .providerMark("claude")) + } + + func testDefaultInstanceKeepsClaudeIDsAndSpendTiles() { + let provider = ClaudeProvider() + let ids = provider.widgetDescriptors.map(\.id) + XCTAssertEqual(provider.provider.id, "claude") + XCTAssertTrue(ids.contains("claude.session")) + XCTAssertTrue(ids.contains("claude.trend")) + XCTAssertTrue(provider.widgetDescriptors.contains { $0.isSpendTile }) + } + + // MARK: - Helpers + + private func authStore(files: FakeFiles, keychain: ServiceKeychain) -> ClaudeAuthStore { + ClaudeAuthStore(environment: FakeEnvironment(), files: files, keychain: keychain) + } + + private func discover(files: FakeFiles, keychain: ServiceKeychain, entries: [String]) -> [DiscoveredClaudeAccount] { + ClaudeAccountDiscovery( + environment: FakeEnvironment(), + files: files, + keychain: keychain, + homeDirectory: { [home] in URL(fileURLWithPath: home) }, + contentsOfDirectory: { _ in entries } + ).discover() + } + + private func creds(_ token: String) -> String { + #"{"claudeAiOauth":{"accessToken":"\#(token)","refreshToken":"r","scopes":["user:profile"]}}"# + } + + private func freshDefaults() -> UserDefaults { + let suite = "test.claudeAccounts.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } +} From 7696d12ca78adba26bc8908b8db4973f26fc825a Mon Sep 17 00:00:00 2001 From: Ryan George Date: Sat, 11 Jul 2026 16:59:45 +0200 Subject: [PATCH 4/6] feat: implement name override for Claude accounts --- .../Stores/LayoutStore+Customization.swift | 15 ++++++++--- Sources/OpenUsage/Stores/LayoutStore.swift | 9 ++++++- Tests/OpenUsageTests/LayoutStoreTests.swift | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Sources/OpenUsage/Stores/LayoutStore+Customization.swift b/Sources/OpenUsage/Stores/LayoutStore+Customization.swift index 15017e851..e352995b0 100644 --- a/Sources/OpenUsage/Stores/LayoutStore+Customization.swift +++ b/Sources/OpenUsage/Stores/LayoutStore+Customization.swift @@ -1,5 +1,14 @@ extension LayoutStore { - func provider(id: String) -> Provider? { registry.provider(id: id) } + func provider(id: String) -> Provider? { registry.provider(id: id).map(applyingNameOverride) } + + /// Swap in a live display-name override (Claude multi-account rename) when one exists, else return the + /// registry's baked provider unchanged. The single seam every provider-returning accessor funnels + /// through, so the whole UI — dashboard cards, Customize list/detail, menu-bar strip — reads the + /// renamed account without a relaunch. + private func applyingNameOverride(_ provider: Provider) -> Provider { + guard let name = providerNameOverride(provider.id) else { return provider } + return Provider(id: provider.id, displayName: name, icon: provider.icon, links: provider.links) + } func descriptor(for widget: PlacedWidget) -> WidgetDescriptor? { registry.descriptor(id: widget.descriptorID) @@ -49,7 +58,7 @@ extension LayoutStore { } private func orderedProviders() -> [Provider] { - orderedProviderIDs().compactMap { registry.provider(id: $0) } + orderedProviderIDs().compactMap { registry.provider(id: $0).map(applyingNameOverride) } } /// Enabled (and provider-enabled) widgets grouped by provider, in the user's provider order, each @@ -116,7 +125,7 @@ extension LayoutStore { /// provider is disabled so L2 can render dimmed-but-editable. nil for an unknown provider or one /// with no metrics — the per-provider slice of `customizeGroups` without the enablement guard. func customizeDetail(for providerID: String) -> ProviderMetrics? { - guard let provider = registry.provider(id: providerID) else { return nil } + guard let provider = registry.provider(id: providerID).map(applyingNameOverride) else { return nil } let metrics = orderedSupportedMetrics(for: providerID) guard !metrics.isEmpty else { return nil } return ProviderMetrics( diff --git a/Sources/OpenUsage/Stores/LayoutStore.swift b/Sources/OpenUsage/Stores/LayoutStore.swift index fb02999c9..d11fb2374 100644 --- a/Sources/OpenUsage/Stores/LayoutStore.swift +++ b/Sources/OpenUsage/Stores/LayoutStore.swift @@ -109,6 +109,11 @@ final class LayoutStore { private let defaultExpandedMetricIDs: [String] var defaultExpandedOnEnableIDs: Set let isProviderEnabled: @MainActor (String) -> Bool + /// Live display-name override for a provider id, or `nil` to keep the registry's baked name. Backs + /// the Claude multi-account rename: the extra-account name is resolved on every render (reading the + /// `@Observable` accounts store) so a rename shows immediately without rebuilding the registry or + /// relaunching. Defaults to "no override" for tests and previews. + let providerNameOverride: @MainActor (String) -> String? init( registry: WidgetRegistry, @@ -118,7 +123,8 @@ final class LayoutStore { migrationBaselineMetricIDs: [String] = DefaultLayout.migrationBaselineMetricIDs, defaultPinnedMetricIDs: [String] = DefaultLayout.pinnedMetricIDs, defaultExpandedMetricIDs: [String] = DefaultLayout.expandedMetricIDs, - isProviderEnabled: @escaping @MainActor (String) -> Bool = { _ in true } + isProviderEnabled: @escaping @MainActor (String) -> Bool = { _ in true }, + providerNameOverride: @escaping @MainActor (String) -> String? = { _ in nil } ) { self.registry = registry let persistence = LayoutPersistence(defaults: defaults, storageKey: storageKey) @@ -127,6 +133,7 @@ final class LayoutStore { self.defaultPinnedMetricIDs = defaultPinnedMetricIDs self.defaultExpandedMetricIDs = defaultExpandedMetricIDs self.isProviderEnabled = isProviderEnabled + self.providerNameOverride = providerNameOverride let initial = LayoutBootstrap.load( registry: registry, diff --git a/Tests/OpenUsageTests/LayoutStoreTests.swift b/Tests/OpenUsageTests/LayoutStoreTests.swift index 8a292140f..407dcc7a1 100644 --- a/Tests/OpenUsageTests/LayoutStoreTests.swift +++ b/Tests/OpenUsageTests/LayoutStoreTests.swift @@ -20,6 +20,32 @@ final class LayoutStoreTests: XCTestCase { XCTAssertTrue(store.placed.isEmpty) } + // MARK: - Provider name override (live Claude account rename) + + func testProviderNameOverrideAppliesToAllProviderAccessors() { + var overrides: [String: String] = ["claude": "Work Account"] + let store = LayoutStore( + registry: .mock, + defaults: makeDefaults("NameOverride"), + storageKey: "layout", + providerNameOverride: { overrides[$0] } + ) + + // Direct lookup, the L1 Customize list, and the L2 detail all resolve the override live. + XCTAssertEqual(store.provider(id: "claude")?.displayName, "Work Account") + XCTAssertEqual(store.customizeDetail(for: "claude")?.provider.displayName, "Work Account") + XCTAssertEqual( + store.customizeProviderRows.first { $0.provider.id == "claude" }?.provider.displayName, + "Work Account" + ) + // A provider with no override keeps the registry's baked name. + XCTAssertEqual(store.provider(id: "codex")?.displayName, "Codex") + + // A later rename is picked up on the next read — no rebuild, no relaunch. + overrides["claude"] = "Personal" + XCTAssertEqual(store.provider(id: "claude")?.displayName, "Personal") + } + // MARK: - Undo (#603) func testUndoRestoresRemovedMetricToSamePosition() { From cb9fea40994ba383061cef80b548dc5ea844e33f Mon Sep 17 00:00:00 2001 From: Ryan George Date: Sat, 11 Jul 2026 17:00:45 +0200 Subject: [PATCH 5/6] feat: add support for renaming extra Claude accounts in Customize detail view --- Sources/OpenUsage/App/AppContainer.swift | 44 +++++++++++++++++-- .../Views/CustomizeProviderDetailView.swift | 38 ++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index c6be13384..796125f4a 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -29,6 +29,9 @@ final class AppContainer { /// One-time onboarding state (the first-run Customize hint card). Only ever marked pending by /// `FirstRunSeeder` on a fresh install, so existing installs never see the card. let onboarding: OnboardingStore + /// Additional Claude accounts detected on the machine (beyond the default `~/.claude` login). Exposed + /// so the Customize detail can rename an extra account. See the Claude multi-account note below. + let claudeAccounts: ClaudeAccountsStore /// The provider runtimes, kept so on-demand credential detection (the Customize "Reset All" reseed) /// can re-probe `hasLocalCredentials()` the same way first-run seeding does. private let providers: [ProviderRuntime] @@ -55,8 +58,18 @@ final class AppContainer { // order is the default provider order (`LayoutStore.orderedProviderIDs` falls back to it, and // `resetToDefault` seeds it), so the dashboard, Customize sections, and the per-provider reset // menu all read this way. - let providers: [ProviderRuntime] = [ - ClaudeProvider(), + // Claude multi-account: the default `~/.claude` login is always provider id "claude"; any extra + // accounts detected on the machine slot in right after it, each its own `ClaudeProvider` instance + // (id "claude."). Discovery runs synchronously here — a file stat plus one attributes-only + // keychain query, both fast and prompt-free — so the provider array is ready at init. + // ponytail: discovery runs at launch only; a newly-added account appears on the next relaunch. + let claudeAccounts = ClaudeAccountsStore() + let claudeAccountRecords = claudeAccounts.reconcile( + with: ClaudeAccountDiscovery().discover().filter { !$0.isDefault } + ) + let extraClaudeProviders = claudeAccountRecords.map { ClaudeProvider(account: $0) } + + let providers: [ProviderRuntime] = [ClaudeProvider()] + extraClaudeProviders + [ CodexProvider(), CursorProvider(), AntigravityProvider(), @@ -70,9 +83,26 @@ final class AppContainer { let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } let enablement = ProviderEnablementStore() let notificationSettings = NotificationSettingsStore() + // Extra Claude accounts mirror the default Claude instance's metric placement: Session / Weekly / + // Extra Usage enabled and always visible; Sonnet / Fable disabled by default (absent from the + // enabled set), pre-placed in the On Demand section only so they land there if a user enables + // them later. They are NOT pinned to the menu bar by default (only the primary account is). No + // spend tiles exist for extra accounts. + let extraClaudeMetricIDs = claudeAccountRecords.flatMap { record in + ["\(record.providerID).session", "\(record.providerID).weekly", "\(record.providerID).extra"] + } + let extraClaudeExpandedIDs = claudeAccountRecords.flatMap { record in + ["\(record.providerID).sonnet", "\(record.providerID).fable"] + } let layout = LayoutStore( registry: registry, - isProviderEnabled: { [enablement] in enablement.isEnabled($0) } + defaultMetricIDs: DefaultLayout.metricIDs + extraClaudeMetricIDs, + defaultExpandedMetricIDs: DefaultLayout.expandedMetricIDs + extraClaudeExpandedIDs, + isProviderEnabled: { [enablement] in enablement.isEnabled($0) }, + // Resolve an extra Claude account's name on every render so a rename applies live everywhere the + // provider name shows. Reading the `@Observable` accounts store here makes SwiftUI (and the + // menu-bar strip's observation tracking) re-render on rename — no registry rebuild, no relaunch. + providerNameOverride: { [claudeAccounts] in claudeAccounts.record(forProviderID: $0)?.displayName } ) let dataStore = WidgetDataStore( registry: registry, @@ -102,6 +132,7 @@ final class AppContainer { ) self.providers = providers self.onboarding = onboarding + self.claudeAccounts = claudeAccounts self.registry = registry self.enablement = enablement self.apiKeyProviders = apiKeyProviders @@ -168,6 +199,13 @@ final class AppContainer { FirstRunSeeder.reseed(providers: providers, enablement: enablement) } + /// Rename an extra Claude account. The new name is persisted immediately and applied live: the + /// `providerNameOverride` wired into `LayoutStore` resolves it on every render, so the dashboard card, + /// Customize lists, and menu-bar strip pick it up without a relaunch. + func renameClaudeAccount(id: UUID, to name: String) { + claudeAccounts.setCustomName(name, forID: id) + } + /// Drives live updates: refresh on launch, then again every refresh interval. Each pass honors the /// cache, so it only hits the network once a snapshot has actually expired. `@Observable` propagates /// the resulting snapshot changes to the menu-bar label and any open widgets, so the UI refreshes on diff --git a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift index aead1fcb3..d87cd4d7f 100644 --- a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift +++ b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift @@ -28,6 +28,11 @@ struct CustomizeProviderDetailView: View { var body: some View { if let group = layout.customizeDetail(for: providerID) { VStack(alignment: .leading, spacing: density.sectionSpacing) { + if let account = container.claudeAccounts.record(forProviderID: providerID) { + ClaudeAccountNameSection(account: account) { newName in + container.renameClaudeAccount(id: account.id, to: newName) + } + } metricSections(group) .simultaneousGesture(metricDragGesture()) if let keyProvider = container.apiKeyProviders.first(where: { $0.provider.id == providerID }) { @@ -170,6 +175,39 @@ struct CustomizeProviderDetailView: View { } } +/// Rename field for an extra Claude account. The placeholder shows the auto-generated name (e.g. +/// "Claude (3f9a2b1c)"); typing a name and committing it persists a custom name. The registry is built +/// at launch, so the new name shows on the next relaunch. No tooltip (house rule). +private struct ClaudeAccountNameSection: View { + let account: ClaudeAccountRecord + let onRename: (String) -> Void + @State private var name: String + @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + + init(account: ClaudeAccountRecord, onRename: @escaping (String) -> Void) { + self.account = account + self.onRename = onRename + _name = State(initialValue: account.customName ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: density.headerToCardSpacing) { + Text("Account Name") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + TextField(account.displayName, text: $name) + .textFieldStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .onSubmit { onRename(name) } + .cardSurface() + } + // Commit on leave too, so a rename isn't lost when the user navigates back without pressing Return. + .onDisappear { onRename(name) } + } +} + /// The star (menu-bar pin) control on a metric row — always visible: an outline star when not /// starred, a filled accent star when starred. Tapping it pops a transient confirmation pill (green /// "Starred for menu bar" / "Removed from menu bar"); a denied tap over the per-provider cap shakes From 5f96b5a1199ce70a47873a8523dceb568ef7f5a5 Mon Sep 17 00:00:00 2001 From: Ryan George Date: Sat, 11 Jul 2026 17:03:07 +0200 Subject: [PATCH 6/6] docs: update docs for multiple Claude accounts --- docs/provider-enablement.md | 2 ++ docs/providers/claude.md | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/provider-enablement.md b/docs/provider-enablement.md index ff1bb79db..d5c27b770 100644 --- a/docs/provider-enablement.md +++ b/docs/provider-enablement.md @@ -15,6 +15,8 @@ The same detection runs for providers that arrive later. On the first launch aft This check happens **once per provider**. After that, the provider is yours to manage: if you turn it off, no update will ever turn it back on, and installing the tool later won't flip it on behind your back either — head to Customize when you want it. +An extra Claude account (see [Claude § Multiple accounts](providers/claude.md#multiple-accounts)) rides the exact same path: each one is a provider OpenUsage has never seen, so it turns on the first time it's detected with credentials, and is yours to manage from then on. + ## Your choices always stick Everything you set in Customize — providers on or off, metric layout, menu-bar stars — carries across updates untouched. The only thing an update may ever change is turning **on** a provider you have never seen before, and only when you actually have that tool installed. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 04a09cca2..70cf0ac6e 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -31,6 +31,14 @@ If one source holds an expired or "locked out" token, OpenUsage falls back to th Today / Yesterday / Last 30 Days are computed **locally**: OpenUsage reads the Claude Code session logs under `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR`) itself — no external tools needed. Cowork (the Claude desktop app's agent mode) counts too: it writes the same logs into per-session folders under `~/Library/Application Support/Claude/local-agent-mode-sessions/`, and OpenUsage scans those as well, so desktop agent sessions show up in the tiles alongside terminal ones. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); the token counts themselves are measured. No log data leaves your Mac. +## Multiple accounts + +If you sign in to more than one Claude account on the same Mac, OpenUsage shows each as its own card. It finds the extra accounts automatically at launch — no setup — by looking one level deep: your default `~/.claude`, any sibling `~/.claude*` folder that has credentials, `$XDG_CONFIG_HOME/claude`, anything in `CLAUDE_CONFIG_DIR`, and the Claude Code entries in your keychain. Nothing on disk is searched beyond those spots. + +- Your primary account is the usual **Claude** card. Each additional account gets its own card, named `Claude (########)` until you rename it (open Customize, tap the account, and set an **Account Name**). Renames apply right away, everywhere the account name shows. +- Extra accounts show the live meters only — Session, Weekly, Sonnet, Fable, and Extra Usage. They don't show the local spend tiles (Today / Yesterday / Last 30 Days) or the usage trend, because those come from local logs that can't be split per account; the spend tiles stay on the primary Claude card and still cover all your usage. +- A new account you add is picked up the next time OpenUsage launches. Removing an account's credentials leaves its card in place showing "Not logged in" rather than silently dropping your layout. + ## Troubleshooting - **"Not logged in"** — run `claude` and sign in, then refresh.