From bdcc76e31f4da33155f8d2a9a0850f46dec5770a Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Sun, 19 Jul 2026 08:11:30 +0400 Subject: [PATCH 01/24] Claude config-dir discovery: extra account cards from custom homes - ClaudeConfigDirDiscovery: bounded dot-dir/.config scan; identity-extraction-is-validation, credential shape (file or attributes-only keychain probe), token-free support trail - ProviderAccountAssembly: folds same-account dirs onto the default card as extra log roots, mints records+cards for distinct accounts; bare family id now reserved for default-home holders - ClaudeAuthStore: .configDir scope (own keychain item + credentials file only; no Desktop/env fallback); default card drops unpinned Desktop fallback while extra cards exist - ClaudeProvider/ProviderCatalog: injectable Provider identity, per-card descriptor ids, scoped scanner roots with partitioned parse cache - Layout: account cards seed their family's default metric set (translatedForAccountCards); unknown saved layout state is retained as invisible tombstones so an absent card's customization returns with it Co-authored-by: Cursor --- Sources/OpenUsage/App/AppContainer.swift | 11 +- .../Providers/Claude/ClaudeAuthStore.swift | 111 +++++++-- .../Claude/ClaudeConfigDirDiscovery.swift | 215 ++++++++++++++++++ .../Claude/ClaudeLogUsageScanner.swift | 27 ++- .../Providers/Claude/ClaudeProvider.swift | 43 ++-- .../OpenUsage/Providers/ProviderCatalog.swift | 47 +++- .../Services/ProviderAccountAssembly.swift | 159 +++++++++++-- Sources/OpenUsage/Services/UsageReader.swift | 17 +- Sources/OpenUsage/Stores/DefaultLayout.swift | 19 ++ .../OpenUsage/Stores/LayoutBootstrap.swift | 57 +++-- .../Stores/LayoutStore+Customization.swift | 24 +- Sources/OpenUsage/Stores/LayoutStore.swift | 19 +- .../Stores/ProviderAccountsStore.swift | 46 +++- Sources/OpenUsage/Stores/WidgetRegistry.swift | 16 +- .../AntigravityLayoutTests.swift | 17 +- Tests/OpenUsageTests/LayoutStoreTests.swift | 7 +- 16 files changed, 745 insertions(+), 90 deletions(-) create mode 100644 Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 86859deab..86396baeb 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -67,11 +67,16 @@ final class AppContainer { // Once the capture lands, persist its identity-relevant facts so the NEXT launch has them // even if that launch's own capture is slow (see `ShellEnvironmentSnapshot`). self.shellEnvironmentSnapshotTask = ShellEnvironmentSnapshotStore(defaults: .standard).startRefreshTask() - // The launch account pass: which account is signed in at each family's default home. Feeds - // the snapshot cache's account stamp and reconciles the account registry. + // The launch account pass: which account is signed in at each family's default home, plus + // the config-dir scan for extra Claude logins. Feeds the snapshot cache's account stamp, + // reconciles the account registry, and hands the catalog its extra-card build plan. let accountAssembly = ProviderAccountAssembly.make(waitsForLoginShell: true) - let providers = ProviderCatalog.make() + let providers = ProviderCatalog.make( + claudeCards: accountAssembly.claudeCards, + defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, + defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName + ) let registry = WidgetRegistry.from(providers) let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } let enablement = ProviderEnablementStore() diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index 72c0b65ee..8dcb0c52c 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -155,6 +155,17 @@ struct ClaudeOAuthConfig: Hashable, Sendable { var clientID: String } +/// Which login a `ClaudeAuthStore` is allowed to see. `.standard` is the default card — +/// byte-identical to the store's historical behavior. `.configDir` backs an extra account card and +/// deliberately has no cross-account, environment-token, or Desktop fallback: the card can only ever +/// read the one login it was created for. +enum ClaudeCredentialScope: Hashable, Sendable { + case standard + /// One extra `CLAUDE_CONFIG_DIR` home. `keychainLiteral` is the literal string whose hash names + /// the keychain item (Claude Code hashes the env value as typed — `~/…` vs absolute differ). + case configDir(path: String, keychainLiteral: String) +} + struct ClaudeAuthStore: Sendable { private static let defaultClaudeHome = "~/.claude" private static let credentialFileName = ".credentials.json" @@ -169,18 +180,28 @@ struct ClaudeAuthStore: Sendable { var keychain: KeychainAccessing var desktop: ClaudeDesktopAuthStore var now: @Sendable () -> Date + let scope: ClaudeCredentialScope + /// Whether the `.standard` store may fall back to Claude Desktop's credentials. On by default + /// (the historical behavior); the catalog turns it OFF once extra Claude account cards exist, + /// because the Desktop login could belong to any of them — borrowing it unpinned could fetch one + /// account's usage onto another account's card. Desktop-backed cards return properly in Phase 3. + let allowsDesktopFallback: Bool init( environment: EnvironmentReading = ProcessEnvironmentReader(), files: TextFileAccessing = LocalTextFileAccessor(), keychain: KeychainAccessing = SecurityKeychainAccessor(), desktop: ClaudeDesktopAuthStore? = nil, + scope: ClaudeCredentialScope = .standard, + allowsDesktopFallback: Bool = true, now: @escaping @Sendable () -> Date = Date.init ) { self.environment = environment self.files = files self.keychain = keychain self.desktop = desktop ?? ClaudeDesktopAuthStore(files: files, now: now) + self.scope = scope + self.allowsDesktopFallback = allowsDesktopFallback self.now = now } @@ -197,11 +218,18 @@ struct ClaudeAuthStore: Sendable { var desktopStatus: ClaudeDesktopCredentialStatus = .notChecked // A working CLI login remains the source of truth and avoids a second Keychain prompt. Desktop // is a fallback for people who only use the native app (or whose stored CLI login lacks profile - // scope), never a competing account source. + // scope), never a competing account source. A `.configDir` card never consults Desktop at all — + // that login belongs to another card. + let desktopAllowed = scope == .standard && allowsDesktopFallback + if forceDesktopFallback, !desktopAllowed { + // Tell the provider there is no safe Desktop candidate so it preserves the original CLI + // auth error instead of converting it to a generic "not logged in" result. + desktopStatus = .notFound + } let hasUsableCLILogin = stored.contains { $0.hasUsableAccessToken && liveUsageAvailability($0) == .available } - if forceDesktopFallback || !hasUsableCLILogin { + if desktopAllowed, forceDesktopFallback || !hasUsableCLILogin { let result = desktop.load(allowInteraction: allowDesktopInteraction) desktopStatus = result.status if let oauth = result.oauth { @@ -222,7 +250,26 @@ struct ClaudeAuthStore: Sendable { loadCredentialSet().candidates } + /// Whether this scoped card's login leaves any local footprint, checked without ever reading a + /// keychain secret — safe for the every-launch seeding probe (`NewProviderSeeder`), which must + /// never raise a permission dialog. The `.standard` card keeps its richer + /// `loadCredentialSet`-based probe in `ClaudeProvider.hasLocalCredentials`. + func hasCredentialFootprint() -> Bool { + switch scope { + case .standard: + return !loadCredentialSet().candidates.isEmpty + case .configDir: + if files.exists(credentialsPath()) { return true } + return keychainServiceCandidates().contains { + keychain.genericPasswordExists(service: $0) == true + } + } + } + private func applyingEnvironmentToken(to stored: [ClaudeCredentialState]) -> [ClaudeCredentialState] { + // An ambient env token describes the DEFAULT login's environment; a scoped card must never + // inherit it (that would leak one account's token into another account's card). + guard case .standard = scope else { return stored } guard let envAccessToken = envText("CLAUDE_CODE_OAUTH_TOKEN") else { return stored } @@ -330,38 +377,54 @@ struct ClaudeAuthStore: Sendable { } private func resolveOAuthEndpoints() -> ResolvedOAuthEndpoints { + Self.resolveOAuthEndpoints(environment: environment) + } + + private static func resolveOAuthEndpoints(environment: EnvironmentReading) -> ResolvedOAuthEndpoints { var baseAPI = Self.prodBaseAPIURL var refreshURL = Self.prodRefreshURL var clientID = Self.prodClientID var suffix = "" - let isAntUser = envText("USER_TYPE") == "ant" - if isAntUser, envFlag("USE_LOCAL_OAUTH") { - let base = (envText("CLAUDE_LOCAL_OAUTH_API_BASE") ?? "http://localhost:8000").trimmingTrailingSlashes + let isAntUser = envText(environment, "USER_TYPE") == "ant" + if isAntUser, envFlag(environment, "USE_LOCAL_OAUTH") { + let base = (envText(environment, "CLAUDE_LOCAL_OAUTH_API_BASE") ?? "http://localhost:8000").trimmingTrailingSlashes baseAPI = base refreshURL = "\(base)/v1/oauth/token" clientID = Self.nonProdClientID suffix = "-local-oauth" - } else if isAntUser, envFlag("USE_STAGING_OAUTH") { + } else if isAntUser, envFlag(environment, "USE_STAGING_OAUTH") { baseAPI = "https://api-staging.anthropic.com" refreshURL = "https://platform.staging.ant.dev/v1/oauth/token" clientID = Self.nonProdClientID suffix = "-staging-oauth" } - if let custom = envText("CLAUDE_CODE_CUSTOM_OAUTH_URL") { + if let custom = envText(environment, "CLAUDE_CODE_CUSTOM_OAUTH_URL") { let base = custom.trimmingTrailingSlashes baseAPI = base refreshURL = "\(base)/v1/oauth/token" suffix = "-custom-oauth" } - if let override = envText("CLAUDE_CODE_OAUTH_CLIENT_ID") { + if let override = envText(environment, "CLAUDE_CODE_OAUTH_CLIENT_ID") { clientID = override } return ResolvedOAuthEndpoints(baseAPI: baseAPI, refreshURL: refreshURL, clientID: clientID, suffix: suffix) } + /// The keychain service names as this environment's Claude Code writes them — the single source + /// both the scoped store and config-dir DISCOVERY build from, so a non-prod OAuth setup (local/ + /// staging/custom, which suffixes the service) can never make discovery probe one name while + /// refresh reads another. + static func baseKeychainServiceName(environment: EnvironmentReading) -> String { + "\(keychainServicePrefix)\(resolveOAuthEndpoints(environment: environment).suffix)-credentials" + } + + static func scopedKeychainServiceName(forConfigDirLiteral literal: String, environment: EnvironmentReading) -> String { + "\(baseKeychainServiceName(environment: environment))-\(hashSuffix(literal))" + } + // baseAPI/refreshURL can derive from user-set env vars (CLAUDE_CODE_CUSTOM_OAUTH_URL, // CLAUDE_LOCAL_OAUTH_API_BASE). A malformed value is a system-boundary input that must fail // loudly — never force-unwrap (crashes the app) and never silently fall back to prod (that hides @@ -386,10 +449,17 @@ struct ClaudeAuthStore: Sendable { // 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() { - return ["\(base)-\(hashSuffix(configDir))", base] + switch scope { + case .configDir(_, let keychainLiteral): + // Exactly this card's item — never the bare default service, which is another account's + // login. + return ["\(base)-\(hashSuffix(keychainLiteral))"] + case .standard: + if let configDir = claudeHomeOverride() { + return ["\(base)-\(hashSuffix(configDir))", base] + } + return [base] } - return [base] } static func parseCredentials(_ text: String) -> ClaudeCredentialsFile? { @@ -471,10 +541,17 @@ struct ClaudeAuthStore: Sendable { } private func credentialsPath() -> String { - "\(envText("CLAUDE_CONFIG_DIR") ?? Self.defaultClaudeHome)/\(Self.credentialFileName)" + if case .configDir(let path, _) = scope { + return "\(path)/\(Self.credentialFileName)" + } + return "\(envText("CLAUDE_CONFIG_DIR") ?? Self.defaultClaudeHome)/\(Self.credentialFileName)" } private func envText(_ name: String) -> String? { + Self.envText(environment, name) + } + + private static func envText(_ environment: EnvironmentReading, _ name: String) -> String? { guard let value = environment.value(for: name)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { @@ -484,11 +561,19 @@ struct ClaudeAuthStore: Sendable { } private func envFlag(_ name: String) -> Bool { - guard let value = envText(name)?.lowercased() else { return false } + Self.envFlag(environment, name) + } + + private static func envFlag(_ environment: EnvironmentReading, _ name: String) -> Bool { + guard let value = envText(environment, name)?.lowercased() else { return false } return !["0", "false", "no", "off"].contains(value) } private func hashSuffix(_ value: String) -> String { + Self.hashSuffix(value) + } + + private static func hashSuffix(_ value: String) -> String { let normalized = value.precomposedStringWithCanonicalMapping let digest = SHA256.hash(data: Data(normalized.utf8)) return String(digest.map { String(format: "%02x", $0) }.joined().prefix(8)) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift new file mode 100644 index 000000000..384db769b --- /dev/null +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -0,0 +1,215 @@ +import Foundation + +/// Launch-time scan for EXTRA Claude logins in custom config dirs — the homes a user points +/// `CLAUDE_CONFIG_DIR` at besides the default (`~/.claude` / `$XDG_CONFIG_HOME/claude`). +/// +/// Runs synchronously inside the launch account pass under a small time budget, and reads **no +/// keychain secrets** — credential presence is checked from file existence and attributes-only +/// keychain probes, so discovery can never raise a macOS permission dialog or block launch. +/// +/// Shape rules: candidates are dot-dirs at `~` and dirs under `~/.config` — bounded, never temp dirs +/// or project trees. A candidate only counts when it carries Claude's exact credential shape AND +/// names its account (identity read from the home itself). Identity-extraction-is-validation: that +/// routing, not name matching, is what keeps toys, forks, and sandbox homes out. +struct ClaudeConfigDirDiscovery { + /// One accepted custom-config-dir login. Whether it becomes its own card or attaches to an + /// existing account's record is the assembly's call, not discovery's. + struct Finding: Equatable, Sendable { + var identityKey: String + var label: String? + /// The expanded config-dir path (the card's credential home and its spend-log root). + var anchorPath: String + /// The literal string whose hash names the dir's keychain item (see `ClaudeCredentialScope`). + var keychainLiteral: String + } + + struct Result: Sendable { + var findings: [Finding] = [] + /// The support trail: one line per notable decision (near-miss rejections, folds), emitted to + /// the log so a "my account didn't show up" report is diagnosable from a default log. + /// Token-free and email-free by construction — identity hashes, kinds, and paths only. + var notes: [String] = [] + } + + var environment: EnvironmentReading + var files: TextFileAccessing + var keychain: KeychainAccessing + var homeDirectory: @Sendable () -> URL + var listSubdirectories: @Sendable (URL) -> [URL] + /// Wall-clock budget; on overrun the scan returns what it has (and the next launch resumes). + var timeBudget: TimeInterval + var now: @Sendable () -> Date + + init( + environment: EnvironmentReading = ProcessEnvironmentReader(), + files: TextFileAccessing = LocalTextFileAccessor(), + keychain: KeychainAccessing = SecurityKeychainAccessor(), + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, + listSubdirectories: @escaping @Sendable (URL) -> [URL] = Self.filesystemSubdirectories, + timeBudget: TimeInterval = 0.4, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.environment = environment + self.files = files + self.keychain = keychain + self.homeDirectory = homeDirectory + self.listSubdirectories = listSubdirectories + self.timeBudget = timeBudget + self.now = now + } + + func run() -> Result { + let started = now() + var result = Result() + let excluded = Set(defaultClaudeConfigDirs().map(canonical)) + + for candidate in candidateDirectories() { + if now().timeIntervalSince(started) > timeBudget { + result.notes.append("claude config-dir scan hit its \(Int(timeBudget * 1000))ms budget; finishing with partial results") + break + } + guard !excluded.contains(canonical(candidate.path)) else { continue } + if let finding = claudeCandidate(at: candidate, notes: &result.notes) { + result.findings.append(finding) + } + } + return result + } + + // MARK: - Candidates + + /// Dot-dirs at `~` plus dirs under `~/.config`, in stable path order. + private func candidateDirectories() -> [URL] { + let home = homeDirectory() + var candidates = listSubdirectories(home).filter { $0.lastPathComponent.hasPrefix(".") } + candidates += listSubdirectories(home.appendingPathComponent(".config")) + return candidates.sorted { $0.path < $1.path } + } + + private static func filesystemSubdirectories(of url: URL) -> [URL] { + let contents = (try? FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [] + )) ?? [] + return contents.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } + } + + private func claudeCandidate(at url: URL, notes: inout [String]) -> Finding? { + // Pre-gate: only dirs that carry an identity file at all enter the trail — everything else + // is a random dot-dir and stays out of the log. (A custom config dir keeps its state INSIDE + // the dir; only the default `~/.claude` keeps it next door at `~/.claude.json`, and the + // default homes are excluded before this runs.) + guard let identityText = try? files.readTextIfPresent(url.path + "/.claude.json") else { + return nil + } + guard let parsed = try? JSONDecoder().decode( + DefaultAccountObserver.ClaudeStateFile.self, from: Data(identityText.utf8) + ), + let account = parsed.oauthAccount, + let key = DefaultAccountObserver.claudeIdentityKey(account) + else { + notes.append("claude candidate \(logPath(url.path)): identity file present but names no account → skipped") + return nil + } + + // Credential shape: the dir's own `.credentials.json`, or its *computed* keychain item. + // Claude Code hashes the literal CLAUDE_CONFIG_DIR string, so every plausible spelling of + // this path is probed (attributes only — no secret, no prompt). + let fileBacked = (try? files.readTextIfPresent(url.path + "/.credentials.json")) + .flatMap { $0 } + .flatMap { ClaudeAuthStore.parseCredentials($0) }? + .claudeAiOauth?.accessToken?.nilIfEmpty != nil + + var matchedLiteral: String? + let literals = keychainLiterals(for: url) + for literal in literals { + let service = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: literal, + environment: environment + ) + if keychain.genericPasswordExists(service: service) == true { + matchedLiteral = literal + break + } + } + guard fileBacked || matchedLiteral != nil else { + notes.append("claude candidate \(logPath(url.path)): identity \(hash8(key)) but no credential (no .credentials.json, no keychain item for \(literals.count) path spellings) → skipped") + return nil + } + + notes.append("claude candidate \(logPath(url.path)): accepted (\(hash8(key)), \(fileBacked ? "file" : "keychain") credential)") + return Finding( + identityKey: key, + label: DefaultAccountObserver.claudeIdentityLabel(account), + anchorPath: url.path, + keychainLiteral: matchedLiteral ?? url.path + ) + } + + /// Every plausible spelling Claude Code might have hashed for this dir's keychain item: the path + /// as listed, symlink-resolved, and each with the home prefix swapped for `~` (users export + /// `CLAUDE_CONFIG_DIR=~/x` and `=/Users/me/x` interchangeably). + private func keychainLiterals(for url: URL) -> [String] { + let home = homeDirectory() + let homePaths = Array(Set([home.path, home.resolvingSymlinksInPath().path])) + var candidates = [url.path, url.resolvingSymlinksInPath().path] + for candidate in candidates { + for homePath in homePaths where candidate.hasPrefix(homePath + "/") { + let suffix = candidate.dropFirst(homePath.count) + candidates += homePaths.map { $0 + suffix } + } + } + var literals: [String] = [] + for candidate in candidates { + literals.append(candidate) + for homePath in homePaths where candidate.hasPrefix(homePath + "/") { + literals.append("~" + candidate.dropFirst(homePath.count)) + } + } + var seen = Set() + return literals.filter { seen.insert($0).inserted } + } + + // MARK: - Default homes (the exclusion set) + + /// The default card's config dirs: every `CLAUDE_CONFIG_DIR` entry when set, else the scanner's + /// standard resolution (`$XDG_CONFIG_HOME/claude`, then `~/.claude`). + private func defaultClaudeConfigDirs() -> [String] { + if let raw = environment.value(for: "CLAUDE_CONFIG_DIR")? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { + let dirs = raw.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + if !dirs.isEmpty { return dirs.map(expandTilde) } + } + let home = homeDirectory() + let xdg = environment.value(for: "XDG_CONFIG_HOME")?.nilIfEmpty.map(expandTilde) + ?? home.appendingPathComponent(".config").path + return [xdg + "/claude", home.appendingPathComponent(".claude").path] + } + + // MARK: - Path helpers + + private func expandTilde(_ path: String) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + return homeDirectory().path + String(path.dropFirst(1)) + } + + private func canonical(_ path: String) -> String { + URL(fileURLWithPath: expandTilde(path)).resolvingSymlinksInPath().standardizedFileURL.path + } + + /// Log-safe path: the home prefix is folded to `~` so support logs don't carry the username. + private func logPath(_ path: String) -> String { + let home = homeDirectory().path + guard path.hasPrefix(home + "/") else { return path } + return "~" + path.dropFirst(home.count) + } + + private func hash8(_ identityKey: String) -> String { + String(ProviderAccountID.make(family: "claude", identityKey: identityKey).dropFirst("claude@".count)) + } +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift b/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift index 2479099a1..c1b113df1 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeLogUsageScanner.swift @@ -26,6 +26,13 @@ actor ClaudeLogUsageScanner { /// Scoped provider instances pass their stable parse-source identity here. Account or time filters /// over the same physical roots deliberately pass the same value and share whole-file records. private let cacheIdentityOverride: String? + /// Extra account cards pin the scan to exactly their config dir(s), replacing the standard + /// resolution (env override, XDG, `~/.claude`, Cowork sandboxes) entirely — another account's + /// logs must never bleed into a scoped card. `nil` keeps the standard walk byte-identical. + private let rootsOverride: [URL]? + /// Same-account custom config dirs appended to the DEFAULT card's standard roots, so spend the + /// user's own login produced in a side home still counts on its card. + private let additionalRoots: [URL] /// One parsed usage line. Token buckets are pre-normalized into `TokenBreakdown`; dedup fields /// ride along so the global dedup pass can run over cached entries. @@ -57,13 +64,20 @@ actor ClaudeLogUsageScanner { environment: EnvironmentReading = ProcessEnvironmentReader(), homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, incrementalScanner: IncrementalJSONLScanner? = nil, - cacheIdentityOverride: String? = nil + cacheIdentityOverride: String? = nil, + rootsOverride: [URL]? = nil, + additionalRoots: [URL] = [] ) { precondition(cacheIdentityOverride?.isEmpty != true) + // A scoped root set must carry its own parse-source identity, or its cache records would + // collide with the default card's under the standard identity. + precondition(rootsOverride == nil || cacheIdentityOverride != nil) self.environment = environment self.homeDirectory = homeDirectory self.scanner = incrementalScanner ?? Self.sharedScanner self.cacheIdentityOverride = cacheIdentityOverride + self.rootsOverride = rootsOverride + self.additionalRoots = additionalRoots } /// Scan the last `daysBack` days of Claude logs. Returns `nil` when no Claude data directory or @@ -150,6 +164,13 @@ actor ClaudeLogUsageScanner { roots.append(url) } + // A scoped card scans exactly its own config dir(s) — no env resolution, no Cowork walk + // (sandboxes belong to the default login until Phase 3 attributes them per account). + if let rootsOverride { + for root in rootsOverride { addIfValid(root) } + return roots + } + 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 { @@ -174,6 +195,10 @@ actor ClaudeLogUsageScanner { for sandbox in Self.coworkClaudeDirs(home: homeDirectory()) { addIfValid(sandbox) } + // Same-account custom config dirs (default card only): the user's own spend in a side home. + for root in additionalRoots { + addIfValid(root) + } return roots } diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index c295db33d..457d0f583 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -3,15 +3,22 @@ import Foundation @MainActor final class ClaudeProvider: ProviderRuntime { - let provider = Provider( - id: "claude", - displayName: "Claude", - icon: .providerMark("claude"), - links: [ - .init(label: "Status", url: "https://status.anthropic.com/"), - .init(label: "Dashboard", url: "https://claude.ai/settings/usage") - ] - ) + /// The default card's identity. Extra account cards inject their own `Provider` with an + /// `@`-suffixed id and an account-derived display name; everything else about the runtime is + /// identical. + static func makeProvider(id: String = "claude", displayName: String = "Claude") -> Provider { + Provider( + id: id, + displayName: displayName, + icon: .providerMark("claude"), + links: [ + .init(label: "Status", url: "https://status.anthropic.com/"), + .init(label: "Dashboard", url: "https://claude.ai/settings/usage") + ] + ) + } + + let provider: Provider let authStore: ClaudeAuthStore let usageClient: ClaudeUsageClient @@ -30,12 +37,14 @@ final class ClaudeProvider: ProviderRuntime { private static let rateLimitCooldown: TimeInterval = 5 * 60 init( + provider: Provider = ClaudeProvider.makeProvider(), authStore: ClaudeAuthStore = ClaudeAuthStore(), usageClient: ClaudeUsageClient = ClaudeUsageClient(), logUsageScanner: ClaudeLogUsageScanner = ClaudeLogUsageScanner(), now: @escaping @Sendable () -> Date = Date.init, pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() } ) { + self.provider = provider self.authStore = authStore self.usageClient = usageClient self.logUsageScanner = logUsageScanner @@ -45,15 +54,15 @@ final class ClaudeProvider: ProviderRuntime { var widgetDescriptors: [WidgetDescriptor] { [ - .percent(id: "claude.session", provider: provider, title: "Session", isSessionWindow: true) + .percent(id: "\(provider.id).session", provider: provider, title: "Session", isSessionWindow: true) .exportingLimit("session", unit: "percent"), - .percent(id: "claude.weekly", provider: provider, title: "Weekly") + .percent(id: "\(provider.id).weekly", provider: provider, title: "Weekly") .exportingLimit("weekly", unit: "percent"), - .percent(id: "claude.fable", provider: provider, title: "Fable") + .percent(id: "\(provider.id).fable", provider: provider, title: "Fable") .exportingLimit("fable", unit: "percent"), - .percent(id: "claude.sonnet", provider: provider, title: "Sonnet") + .percent(id: "\(provider.id).sonnet", provider: provider, title: "Sonnet") .exportingLimit("sonnet", unit: "percent"), - .boundedDollars(id: "claude.extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent", limit: 100, valueWord: "spent") + .boundedDollars(id: "\(provider.id).extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent", limit: 100, valueWord: "spent") .exportingLimit("extraUsage", unit: "usd", source: .progressOrValue(kind: .dollars)), .usageTrend(provider: provider) .exportingHistory( @@ -65,6 +74,12 @@ final class ClaudeProvider: ProviderRuntime { } func hasLocalCredentials() async -> Bool { + // Scoped cards answer from footprints only (file existence / keychain attributes) so the + // every-launch seeding probe can never raise a keychain dialog for an account the user + // hasn't granted yet. The secret read happens on the first refresh. + if authStore.scope != .standard { + return await loadOffMainActor { [authStore] in authStore.hasCredentialFootprint() } + } // Never trigger another app's Keychain prompt during first-run detection. Encrypted Desktop // material still counts as a local login; the first manual refresh requests access if needed. let load = await loadOffMainActor { [authStore] in authStore.loadCredentialSet() } diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 715250ac9..fd3bb4614 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -4,11 +4,33 @@ import Foundation /// their runtimes here so credentials, refresh behavior, pricing, and normalization can never drift. @MainActor enum ProviderCatalog { - static func make(defaults: UserDefaults = .standard) -> [ProviderRuntime] { + /// `claudeCards` carries the extra Claude account cards found by the launch account pass + /// (`ProviderAccountAssembly`). Each becomes an ordinary runtime inserted right after the default + /// Claude card, with credentials and usage logs pinned to exactly its own config dir. The empty + /// default keeps the historical single-card set for focused tests and callers that intentionally + /// skip the account pass. + static func make( + defaults: UserDefaults = .standard, + claudeCards: [ClaudeAccountCard] = [], + defaultClaudeExtraLogRoots: [URL] = [], + defaultClaudeDisplayName: String? = nil + ) -> [ProviderRuntime] { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, - // then every other provider alphabetically by display name. - [ - ClaudeProvider(), + // then every other provider alphabetically by display name. Account cards slot in right after + // their family's default card. + var runtimes: [ProviderRuntime] = [] + runtimes.append(ClaudeProvider( + provider: ClaudeProvider.makeProvider(displayName: defaultClaudeDisplayName ?? "Claude"), + // Once extra Claude cards exist, an unpinned Desktop fallback could borrow a login that + // belongs to one of them — fetching that account's usage onto the default card. Desktop + // returns as its own properly-pinned source kind in Phase 3. + authStore: ClaudeAuthStore(allowsDesktopFallback: claudeCards.isEmpty), + logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) + )) + for card in claudeCards { + runtimes.append(claudeAccountRuntime(card: card)) + } + runtimes += [ CodexProvider(), CursorProvider(), AntigravityProvider(), @@ -19,5 +41,22 @@ enum ProviderCatalog { OpenRouterProvider(), ZAIProvider() ] + return runtimes + } + + /// An extra Claude account card: same provider machinery, credentials and logs pinned to one + /// login. The scanner's parse cache is partitioned per card so distinct homes never share + /// records. + private static func claudeAccountRuntime(card: ClaudeAccountCard) -> ClaudeProvider { + ClaudeProvider( + provider: ClaudeProvider.makeProvider(id: card.id, displayName: card.displayName), + authStore: ClaudeAuthStore( + scope: .configDir(path: card.configDirPath, keychainLiteral: card.keychainLiteral) + ), + logUsageScanner: ClaudeLogUsageScanner( + cacheIdentityOverride: "claude-account:\(card.id)", + rootsOverride: [URL(fileURLWithPath: card.configDirPath)] + card.extraLogRoots + ) + ) } } diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index f027b6a17..d4db949f4 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -1,14 +1,52 @@ import Foundation +/// One extra Claude account card to build this launch: a custom-config-dir login found on this +/// computer whose account is distinct from the default card's. Cards render only while their source +/// is found (owner decision 4) — a record with no finding this launch simply builds no card. +struct ClaudeAccountCard: Equatable, Sendable { + /// The account's stable record id (`claude@ab12cd34`) — the card id everywhere: layout, cache, + /// CLI/API matching. + var id: String + var displayName: String + /// The config dir the card's credentials and spend logs are pinned to. + var configDirPath: String + /// The literal string whose hash names the dir's keychain item (see `ClaudeCredentialScope`). + var keychainLiteral: String + /// Same-account additional config dirs (rare): extra spend-log roots, never extra credentials. + var extraLogRoots: [URL] = [] + + /// The card name: the user's rename, else "Claude — " from the account label, + /// else the record id itself (owner decision 2: short-hash fallback, one rename away from good). + static func displayName(customLabel: String?, label: String?, id: String) -> String { + if let customLabel = customLabel?.nilIfEmpty { return customLabel } + guard let label = label?.nilIfEmpty else { return id } + // Labels are our own "email (Org Name)" format — prefer the org for a short card title. + if label.hasSuffix(")"), let open = label.lastIndex(of: "(") { + let org = label[label.index(after: open).. = ProviderAccountID.families + families: Set = ProviderAccountID.families, + claudeDiscovery: ClaudeConfigDirDiscovery? = nil ) -> ProviderAccountAssembly { var identityKeys: [String: String] = [:] var observations: [ProviderAccountsStore.Observation] = [] @@ -91,7 +132,99 @@ struct ProviderAccountAssembly { } } - accountsStore.reconcile(with: observations) - return ProviderAccountAssembly(identityKeysByCard: identityKeys) + // Extra Claude logins in custom config dirs. Guarded on the default read: when a default + // login clearly EXISTS but can't be named (`unresolved`), accepting candidates could render + // the very account the default card shows as a second card — skip them this launch instead. + // A machine with no default login at all keeps accepting: there is nothing to duplicate, + // and a custom-dir-only login should still get its card. + var foundClaudeAccounts: [(identityKey: String, label: String?, dirs: [ClaudeConfigDirDiscovery.Finding])] = [] + var defaultClaudeExtraLogRoots: [URL] = [] + let claudeOutcome = outcomes.first { $0.family == "claude" }?.outcome + if let claudeDiscovery, let claudeOutcome { + if case .unresolved = claudeOutcome { + AppLog.info(.config, "discovery: claude default login present but its identity is unreadable → skipping extra-account candidates this launch") + } else { + let defaultKey = identityKeys["claude"] + let scan = claudeDiscovery.run() + for note in scan.notes { + AppLog.info(.config, "discovery: \(note)") + } + var order: [String] = [] + var grouped: [String: [ClaudeConfigDirDiscovery.Finding]] = [:] + for finding in scan.findings { + if grouped[finding.identityKey] == nil { order.append(finding.identityKey) } + grouped[finding.identityKey, default: []].append(finding) + } + for identityKey in order { + let findings = grouped[identityKey] ?? [] + let sources = findings.map { + ProviderAccountSource( + kind: .configDir, + anchor: $0.anchorPath, + holdsDefaultSource: false, + keychainLiteral: $0.keychainLiteral + ) + } + if identityKey == defaultKey { + // Same account as the default card: its dirs are extra spend-log roots on + // that card, never a second card — duplicate cards are structurally + // impossible because identity routes the source to the existing record. + defaultClaudeExtraLogRoots += findings.map { URL(fileURLWithPath: $0.anchorPath) } + if let index = observations.firstIndex(where: { $0.family == "claude" && $0.identityKey == identityKey }) { + observations[index].sources += sources + } + AppLog.info(.config, "discovery: \(findings.count) config dir(s) fold onto the default claude card (same account)") + } else { + observations.append(ProviderAccountsStore.Observation( + family: "claude", + identityKey: identityKey, + label: findings.first?.label, + sources: sources + )) + foundClaudeAccounts.append((identityKey, findings.first?.label, findings)) + } + } + } + } + + let records = accountsStore.reconcile(with: observations) + + // The extra-card build plan: one card per distinct account found this launch, under its + // reconciled record id. + var claudeCards: [ClaudeAccountCard] = [] + for account in foundClaudeAccounts { + guard let record = records.first(where: { $0.family == "claude" && $0.identityKey == account.identityKey }) else { + continue + } + guard record.id != "claude" else { + // The bare record's account has moved out of the default home into a config dir + // while another login occupies the default. The bare CARD is the default home's + // runtime, so this record can't render under its own id this launch. Proper swap + // support re-points this in Phase 4; until then the parked account stays hidden. + AppLog.warn(.config, "discovery: the claude record's account now lives in a config dir; its card is unavailable until swap support lands") + continue + } + guard let primary = account.dirs.first else { continue } + claudeCards.append(ClaudeAccountCard( + id: record.id, + displayName: ClaudeAccountCard.displayName( + customLabel: record.customLabel, label: record.label, id: record.id + ), + configDirPath: primary.anchorPath, + keychainLiteral: primary.keychainLiteral, + extraLogRoots: account.dirs.dropFirst().map { URL(fileURLWithPath: $0.anchorPath) } + )) + identityKeys[record.id] = account.identityKey + AppLog.info(.config, "accounts: extra claude card \(record.id) from \(account.dirs.count) config dir(s)") + } + claudeCards.sort { $0.id < $1.id } + + let defaultClaudeRename = records.first { $0.id == "claude" }?.customLabel?.nilIfEmpty + return ProviderAccountAssembly( + identityKeysByCard: identityKeys, + claudeCards: claudeCards, + defaultClaudeExtraLogRoots: defaultClaudeExtraLogRoots, + defaultClaudeDisplayName: defaultClaudeRename + ) } } diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 2e19d93f7..030707b8b 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -37,13 +37,11 @@ public struct UsageReader { } public func read(providerID requestedProviderID: String? = nil, force: Bool = false) async throws -> UsageReadResult { - let providers = providersOverride ?? ProviderCatalog.make(defaults: defaults) - let registry = WidgetRegistry.from(providers) - let knownIDs = Set(registry.providers.map(\.id)) - let enablement = ProviderEnablementStore(defaults: defaults) // The launch account pass (see `ProviderAccountAssembly`): resolves each family's default // account so cached snapshots are guarded — and refreshed ones stamped — with the correct - // account. Skipped when a test injects its own providers — they have no real homes to read. + // account, and finds the extra Claude cards the catalog must build (the CLI must know the + // same card set as the app, or family matching would answer differently between the two). + // Skipped when a test injects its own providers — they have no real homes to read. // // Warm the login-shell capture FIRST (off-main, one bounded subprocess). Identity-relevant // keys are pinned to the persisted shell-environment snapshot, but a CLI spawned without the @@ -59,6 +57,15 @@ public struct UsageReader { let accountAssembly = providersOverride == nil ? ProviderAccountAssembly.make(defaults: defaults, waitsForLoginShell: false) : ProviderAccountAssembly(identityKeysByCard: [:]) + let providers = providersOverride ?? ProviderCatalog.make( + defaults: defaults, + claudeCards: accountAssembly.claudeCards, + defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, + defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName + ) + let registry = WidgetRegistry.from(providers) + let knownIDs = Set(registry.providers.map(\.id)) + let enablement = ProviderEnablementStore(defaults: defaults) // A requested id names cards by plain string matching — an exact card id, or a family id // naming all of that family's cards — mirroring the local HTTP API exactly (see // `LocalUsageAPI.State.matchingCardIDs`). Never resolved from runtime state: the same diff --git a/Sources/OpenUsage/Stores/DefaultLayout.swift b/Sources/OpenUsage/Stores/DefaultLayout.swift index fa13439cf..94d6c4526 100644 --- a/Sources/OpenUsage/Stores/DefaultLayout.swift +++ b/Sources/OpenUsage/Stores/DefaultLayout.swift @@ -69,6 +69,25 @@ enum DefaultLayout { "zai.session", "zai.weekly" ] + /// Account-card-aware default list: for every extra account card in the registry + /// (`claude@ab12cd34`), the family's entries are re-prefixed onto the card and appended, so a + /// newly discovered account seeds the same metric set (and caret split) as its family's default + /// card. Pins are deliberately NOT translated — an extra account never claims menu-bar space by + /// default. `migrationBaselineMetricIDs` is deliberately NOT translated either: account-card ids + /// must always read as never-offered so their defaults seed the first time the card appears. + static func translatedForAccountCards(_ ids: [String], providerIDs: [String]) -> [String] { + let accountCardIDs = providerIDs.filter(ProviderAccountID.isAccountCard) + guard !accountCardIDs.isEmpty else { return ids } + var result = ids + for cardID in accountCardIDs { + let prefix = ProviderAccountID.family(of: cardID) + "." + for id in ids where id.hasPrefix(prefix) { + result.append("\(cardID).\(id.dropFirst(prefix.count))") + } + } + return result + } + /// Metrics placed in the per-provider On Demand section on a fresh install. This is /// membership, not enablement: optional disabled rows like Sonnet or Cursor Requests/Credits are /// listed here so if the user enables them later they appear below the caret by default. diff --git a/Sources/OpenUsage/Stores/LayoutBootstrap.swift b/Sources/OpenUsage/Stores/LayoutBootstrap.swift index 88ae1da91..d279f72ae 100644 --- a/Sources/OpenUsage/Stores/LayoutBootstrap.swift +++ b/Sources/OpenUsage/Stores/LayoutBootstrap.swift @@ -36,7 +36,11 @@ enum LayoutBootstrap { defaults: LayoutDefaultSet ) -> LayoutInitialState { let hasStoredLayout = persistence.hasStoredLayout - let savedPlaced = persistence.loadPlaced()?.filter { registry.descriptor(id: $0.descriptorID) != nil } + // Keep widgets whose provider is absent from this launch's registry (an account card whose + // login wasn't found this launch). They remain invisible because rendering resolves through + // the live registry, but carrying the tombstones through unrelated layout writes lets the + // card recover its enabled state when its account returns. + let savedPlaced = persistence.loadPlaced() let startingPlaced = savedPlaced ?? defaults.metricIDs .filter { registry.descriptor(id: $0) != nil } .map { PlacedWidget(descriptorID: $0) } @@ -54,17 +58,20 @@ enum LayoutBootstrap { } ?? LayoutOrdering.defaultMetricOrder(registry: registry) // An existing value — including an empty array from a user who unpinned everything — wins. - let pinnedMetricIDs = Set( - (persistence.loadPins() ?? defaults.pinnedMetricIDs) - .filter { registry.descriptor(id: $0) != nil } - ) + // Unknown saved ids are retained as invisible tombstones for temporarily absent account cards. + let pinnedMetricIDs: Set + if let savedPins = persistence.loadPins() { + pinnedMetricIDs = Set(savedPins) + } else { + pinnedMetricIDs = Set(defaults.pinnedMetricIDs.filter { registry.descriptor(id: $0) != nil }) + } // Expanded membership is a fresh-install default only. Existing layouts that predate the feature // keep every familiar metric above the caret unless the user later moves one. var shouldPersistExpanded = false var expandedMetricIDs: Set if let savedExpanded = persistence.loadExpandedMetrics() { - expandedMetricIDs = Set(savedExpanded.filter { registry.descriptor(id: $0) != nil }) + expandedMetricIDs = Set(savedExpanded) } else if hasStoredLayout { expandedMetricIDs = [] } else { @@ -72,9 +79,7 @@ enum LayoutBootstrap { shouldPersistExpanded = true } - let expandedProviderIDs = Set( - (persistence.loadExpandedProviders() ?? []).filter { registry.provider(id: $0) != nil } - ) + let expandedProviderIDs = Set(persistence.loadExpandedProviders() ?? []) // A newly-shipped default metric is new to an existing user, so it may safely start below the // caret when that is its declared default. Metrics they already had are never silently hidden. @@ -103,9 +108,16 @@ enum LayoutBootstrap { registry.descriptor(id: id) != nil && !expandedNow.contains(id) && !placedIDs.contains(id) } let savedOnEnable = persistence.loadExpandOnEnable() - let defaultExpandedOnEnableIDs = Set( - (savedOnEnable ?? defaults.expandedMetricIDs).filter(isExpandOnEnableCandidate) - ) + let defaultExpandedOnEnableIDs: Set + if let savedOnEnable { + // Known metrics still have to be valid candidates, but an unknown id may belong to a + // temporarily absent account card and must survive until its descriptor returns. + defaultExpandedOnEnableIDs = Set(savedOnEnable.filter { id in + registry.descriptor(id: id) == nil || isExpandOnEnableCandidate(id) + }) + } else { + defaultExpandedOnEnableIDs = Set(defaults.expandedMetricIDs.filter(isExpandOnEnableCandidate)) + } let promotedQueuedIDs = Set(savedOnEnable ?? []).intersection(newlyAlwaysShown) return LayoutInitialState( @@ -148,8 +160,12 @@ enum LayoutBootstrap { let seededDefaults: Set var shouldPersistSeededDefaults = false if let saved = persistence.loadSeededDefaults() { - seededDefaults = Set(LayoutOrdering.knownMetricIDs(saved, registry: registry)) - shouldPersistSeededDefaults = seededDefaults != Set(saved) + // Keep markers for metrics whose provider is absent from this launch's registry (an + // account card whose login wasn't found). Pruning them would make a default metric the + // user disabled look newly introduced when the card returns, so startup would turn it + // back on. Permanently removed metric ids are harmless tombstones and can stay here. + seededDefaults = Set(saved) + shouldPersistSeededDefaults = seededDefaults.count != saved.count } else if hasStoredLayout { seededDefaults = Set(LayoutOrdering.knownMetricIDs(defaults.migrationBaselineMetricIDs, registry: registry)) shouldPersistSeededDefaults = true @@ -198,11 +214,20 @@ enum LayoutOrdering { _ saved: [String: [String]], registry: WidgetRegistry ) -> [String: [String]] { - var fallback = defaultMetricOrder(registry: registry) + // Start with every saved provider so a temporarily absent account card keeps its ordering + // entry. For providers present now, deduplicate the saved sequence (including unknown metric + // tombstones) and append newly introduced live metrics; `LayoutStore` filters this persisted + // superset through the live registry before rendering. + var fallback = saved for provider in registry.providers { let valid = registry.descriptors(for: provider.id).map(\.id) if let savedIDs = saved[provider.id] { - fallback[provider.id] = normalizedMetricIDs(savedIDs, validIDs: valid) + var seen = Set() + var retained = savedIDs.filter { seen.insert($0).inserted } + retained.append(contentsOf: valid.filter { seen.insert($0).inserted }) + fallback[provider.id] = retained + } else { + fallback[provider.id] = valid } } return fallback diff --git a/Sources/OpenUsage/Stores/LayoutStore+Customization.swift b/Sources/OpenUsage/Stores/LayoutStore+Customization.swift index c3574cb28..5c378c233 100644 --- a/Sources/OpenUsage/Stores/LayoutStore+Customization.swift +++ b/Sources/OpenUsage/Stores/LayoutStore+Customization.swift @@ -17,7 +17,8 @@ extension LayoutStore { } func isMetricEnabled(_ descriptorID: String) -> Bool { - placed.contains { $0.descriptorID == descriptorID } + registry.descriptor(id: descriptorID) != nil + && placed.contains { $0.descriptorID == descriptorID } } /// Whether any enabled provider ships the local spend tiles — the capability gate for the @@ -159,8 +160,25 @@ extension LayoutStore { recordingUndoStep { let shown = customizeGroups.map(\.provider.id) guard let next = Self.reordered(shown, dragged: dragged, target: target) else { return false } - let rest = orderedProviderIDs().filter { !next.contains($0) } - providerOrder = next + rest + // Reorder only the visible slots in the raw persisted sequence. Unknown ids may be + // account cards absent from this launch's registry, and disabled providers are hidden + // from `customizeGroups`; both keep their exact positions while the visible ids move + // around them. + let shownSet = Set(shown) + var replacements = next.makeIterator() + var rebuilt: [String] = [] + for providerID in providerOrder { + if shownSet.contains(providerID) { + if let replacement = replacements.next() { rebuilt.append(replacement) } + } else { + rebuilt.append(providerID) + } + } + while let replacement = replacements.next() { rebuilt.append(replacement) } + for providerID in orderedProviderIDs() where !rebuilt.contains(providerID) { + rebuilt.append(providerID) + } + providerOrder = rebuilt persistProviderOrder() syncPlacedOrder() return true diff --git a/Sources/OpenUsage/Stores/LayoutStore.swift b/Sources/OpenUsage/Stores/LayoutStore.swift index fb02999c9..92cf5b6aa 100644 --- a/Sources/OpenUsage/Stores/LayoutStore.swift +++ b/Sources/OpenUsage/Stores/LayoutStore.swift @@ -123,19 +123,24 @@ final class LayoutStore { self.registry = registry let persistence = LayoutPersistence(defaults: defaults, storageKey: storageKey) self.persistence = persistence - self.defaultMetricIDs = defaultMetricIDs + // Extra account cards seed their family's default metric set (and caret split); pins and the + // migration baseline are deliberately never translated (see `translatedForAccountCards`). + let registryProviderIDs = registry.providers.map(\.id) + let translatedMetricIDs = DefaultLayout.translatedForAccountCards(defaultMetricIDs, providerIDs: registryProviderIDs) + let translatedExpandedIDs = DefaultLayout.translatedForAccountCards(defaultExpandedMetricIDs, providerIDs: registryProviderIDs) + self.defaultMetricIDs = translatedMetricIDs self.defaultPinnedMetricIDs = defaultPinnedMetricIDs - self.defaultExpandedMetricIDs = defaultExpandedMetricIDs + self.defaultExpandedMetricIDs = translatedExpandedIDs self.isProviderEnabled = isProviderEnabled let initial = LayoutBootstrap.load( registry: registry, persistence: persistence, defaults: LayoutDefaultSet( - metricIDs: defaultMetricIDs, + metricIDs: translatedMetricIDs, migrationBaselineMetricIDs: migrationBaselineMetricIDs, pinnedMetricIDs: defaultPinnedMetricIDs, - expandedMetricIDs: defaultExpandedMetricIDs + expandedMetricIDs: translatedExpandedIDs ) ) placed = initial.placed @@ -154,7 +159,7 @@ final class LayoutStore { } func isProviderExpanded(_ providerID: String) -> Bool { - expandedProviderIDs.contains(providerID) + registry.provider(id: providerID) != nil && expandedProviderIDs.contains(providerID) } @discardableResult @@ -263,7 +268,9 @@ final class LayoutStore { /// column, so a third would not fit the menu bar height. static let maxPinsPerProvider = 2 - func isPinned(_ descriptorID: String) -> Bool { pinnedMetricIDs.contains(descriptorID) } + func isPinned(_ descriptorID: String) -> Bool { + registry.descriptor(id: descriptorID) != nil && pinnedMetricIDs.contains(descriptorID) + } func pinnedCount(forProvider providerID: String) -> Int { pinnedMetricIDs.count { registry.descriptor(id: $0)?.providerID == providerID } diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index 79b44068a..a34b25262 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -20,6 +20,12 @@ enum ProviderAccountID { static func family(of cardID: String) -> String { cardID.firstIndex(of: "@").map { String(cardID[..<$0]) } ?? cardID } + + /// Whether a card id names an extra account card (`claude@ab12cd34`) rather than a bare + /// provider id. + static func isAccountCard(_ cardID: String) -> Bool { + cardID.contains("@") + } } /// One place an account is signed in. "Default" is a badge on a source (`holdsDefaultSource`), never @@ -30,12 +36,24 @@ struct ProviderAccountSource: Codable, Equatable, Sendable { enum Kind: String, Codable, Sendable { /// The provider's standard home for this machine (`~/.claude`, `~/.codex`, env override). case defaultHome + /// A custom Claude config dir (a `CLAUDE_CONFIG_DIR` home kept besides the default). + case configDir } var kind: Kind /// Canonical home path the source was observed at. var anchor: String? var holdsDefaultSource: Bool + /// `configDir` only: the literal string whose hash names the source's keychain item (Claude Code + /// hashes `CLAUDE_CONFIG_DIR` exactly as typed, so `~/x` and its absolute spelling differ). + var keychainLiteral: String? + + init(kind: Kind, anchor: String?, holdsDefaultSource: Bool, keychainLiteral: String? = nil) { + self.kind = kind + self.anchor = anchor + self.holdsDefaultSource = holdsDefaultSource + self.keychainLiteral = keychainLiteral + } } /// An account as the account-first model sees it: opaque identity key, stable record id minted at @@ -47,9 +65,18 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { var family: String var identityKey: String var label: String? + /// A user-chosen card name (Rename in the card's context menu / Customize). Wins over `label` + /// and the id-derived fallback; never touched by reconciliation. + var customLabel: String? var sources: [ProviderAccountSource] /// Set by a future "Remove Account…". A tombstoned account is never resurrected by rescans. var removedTombstone: Bool = false + + /// The name the card renders under: the user's rename, else the account's own label + /// ("email (Org Name)"), else the record id itself (`claude@ab12cd34` — owner decision 2). + var displayLabel: String { + customLabel?.nilIfEmpty ?? label?.nilIfEmpty ?? id + } } /// The account-first registry (`openusage.providerAccounts.v1`). Reconciled at every launch from the @@ -144,6 +171,15 @@ final class ProviderAccountsStore { return records } + /// Stores a user rename for a card; `nil` or blank clears it back to the derived name. + func rename(cardID: String, to name: String?) { + guard let index = records.firstIndex(where: { $0.id == cardID }) else { return } + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + guard records[index].customLabel != trimmed else { return } + records[index].customLabel = trimmed + persist() + } + /// The record currently holding a family's default badge, if any. func defaultBadgeHolder(family: String) -> ProviderAccountRecord? { records.first { record in @@ -154,9 +190,15 @@ final class ProviderAccountsStore { } /// The bare family id when free (the migration-killing rule: the first account observed at the - /// default home IS the existing card), else an identity-derived `family@` id. + /// default home IS the existing card), else an identity-derived `family@` id. Only an + /// account observed at the family's DEFAULT home may claim the bare id — that id's runtime reads + /// the default home, so handing it to a custom-config-dir account would point the existing card + /// at a login it can't read. private static func availableID(for observation: Observation, in records: [ProviderAccountRecord]) -> String { - if !records.contains(where: { $0.id == observation.family }) { return observation.family } + let observedAtDefaultHome = observation.sources.contains { $0.kind == .defaultHome } + if observedAtDefaultHome, !records.contains(where: { $0.id == observation.family }) { + return observation.family + } let derived = ProviderAccountID.make(family: observation.family, identityKey: observation.identityKey) guard records.contains(where: { $0.id == derived }) else { return derived } // A hash-prefix collision between two distinct identities of one family; salt until free. diff --git a/Sources/OpenUsage/Stores/WidgetRegistry.swift b/Sources/OpenUsage/Stores/WidgetRegistry.swift index b0cfceb89..2cc340810 100644 --- a/Sources/OpenUsage/Stores/WidgetRegistry.swift +++ b/Sources/OpenUsage/Stores/WidgetRegistry.swift @@ -45,13 +45,25 @@ struct WidgetRegistry: Sendable { } /// Saved order filtered to installed providers, with newly introduced providers appended in the - /// canonical registry order. Shared by the dashboard, local API, and one-shot CLI. + /// canonical registry order — except account cards (`claude@ab12cd34`), which slot in right + /// after their family's group so a newly discovered account appears next to its siblings + /// instead of at the end of the dashboard. Shared by the dashboard, local API, and one-shot CLI. func orderedProviderIDs(savedOrder: [String]) -> [String] { let defaults = providers.map(\.id) let known = Set(defaults) let saved = savedOrder.filter { known.contains($0) } let savedIDs = Set(saved) - return saved + defaults.filter { !savedIDs.contains($0) } + var result = saved + for id in defaults where !savedIDs.contains(id) { + let family = ProviderAccountID.family(of: id) + if ProviderAccountID.isAccountCard(id), + let anchor = result.lastIndex(where: { ProviderAccountID.family(of: $0) == family }) { + result.insert(id, at: result.index(after: anchor)) + } else { + result.append(id) + } + } + return result } var limitDescriptorsByProvider: [String: [WidgetDescriptor]] { diff --git a/Tests/OpenUsageTests/AntigravityLayoutTests.swift b/Tests/OpenUsageTests/AntigravityLayoutTests.swift index 7cd7892cf..1c0192bc1 100644 --- a/Tests/OpenUsageTests/AntigravityLayoutTests.swift +++ b/Tests/OpenUsageTests/AntigravityLayoutTests.swift @@ -60,10 +60,10 @@ final class AntigravityLayoutTests: XCTestCase { "a metric the user already lived with is never silently tucked away") } - func testSavedGeminiFlashStateIsFilteredEverywhere() { - // `antigravity.geminiFlash` no longer exists (owner-approved: its layout state drops with no - // migration). Every load path filters unknown IDs against the registry, so stale saved state - // self-heals. + func testSavedGeminiFlashStateStaysInvisibleWhileItsTombstonesAreRetained() { + // `antigravity.geminiFlash` no longer exists, so live registry lookups keep it out of the UI. + // Its saved state remains as a harmless tombstone because an unknown descriptor can also be a + // temporarily absent account card, whose customization must return on the next launch. let defaults = makeDefaults("FlashFilter") saveStored([ PlacedWidget(descriptorID: "antigravity.geminiPro"), @@ -80,8 +80,13 @@ final class AntigravityLayoutTests: XCTestCase { XCTAssertFalse(store.isMetricEnabled("antigravity.geminiFlash")) XCTAssertFalse(store.orderedSupportedMetrics(for: "antigravity").map(\.id).contains("antigravity.geminiFlash")) - // The saved pin set is respected exactly (dead ID dropped, no weekly pin auto-added). - XCTAssertEqual(store.pinnedMetricIDs, ["antigravity.geminiPro"]) + XCTAssertFalse(store.isPinned("antigravity.geminiFlash"), "the dead pin stays invisible") + XCTAssertTrue( + store.pinnedMetricIDs.contains("antigravity.geminiFlash"), + "…but its tombstone is retained for a possible return" + ) + XCTAssertTrue(store.isPinned("antigravity.geminiPro")) + XCTAssertFalse(store.isPinned("antigravity.geminiWeekly"), "an existing pin set gains no new defaults") } func testAbsentPinsKeyAdoptsGeminiWeeklyPinOnUpgrade() { diff --git a/Tests/OpenUsageTests/LayoutStoreTests.swift b/Tests/OpenUsageTests/LayoutStoreTests.swift index 7dd004886..6b6c4ce21 100644 --- a/Tests/OpenUsageTests/LayoutStoreTests.swift +++ b/Tests/OpenUsageTests/LayoutStoreTests.swift @@ -1010,14 +1010,17 @@ final class LayoutStoreTests: XCTestCase { XCTAssertTrue(store.expandedMetricIDs.contains("claude.weekly")) } - func testInvalidPersistedExpandedIDsAreDropped() { + func testUnknownPersistedExpandedIDsAreRetainedAsInvisibleTombstones() { let defaults = makeDefaults("InvalidExpand") saveStored([PlacedWidget(descriptorID: "claude.session")], forKey: "layout", in: defaults) defaults.set(["claude.session", "missing.metric"], forKey: "layout.expandedMetrics") let store = LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout") XCTAssertTrue(store.expandedMetricIDs.contains("claude.session")) - XCTAssertFalse(store.expandedMetricIDs.contains("missing.metric")) + XCTAssertTrue( + store.expandedMetricIDs.contains("missing.metric"), + "unknown state stays persisted so a temporarily absent account card can recover it" + ) } func testDisplayGroupsPartitionEnabledMetrics() { From 72789dd799e9c23195a3e95d35f5329d62918728 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Sun, 19 Jul 2026 08:17:24 +0400 Subject: [PATCH 02/24] tests: config-dir discovery, assembly card plan, scoped auth, layout seeding/tombstones Co-authored-by: Cursor --- .../ClaudeConfigDirDiscoveryTests.swift | 110 +++++++++++ .../ClaudeScopedAuthStoreTests.swift | 92 +++++++++ Tests/OpenUsageTests/LayoutStoreTests.swift | 65 +++++++ .../ProviderAccountAssemblyTests.swift | 177 ++++++++++++++++++ .../OpenUsageTests/WidgetRegistryTests.swift | 19 ++ 5 files changed, 463 insertions(+) create mode 100644 Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift create mode 100644 Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift diff --git a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift new file mode 100644 index 000000000..c867ea32a --- /dev/null +++ b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import OpenUsage + +/// The config-dir candidate rules: identity-extraction-is-validation plus the exact credential +/// shape, with the default homes excluded. Everything runs on fakes — no real filesystem/keychain. +final class ClaudeConfigDirDiscoveryTests: XCTestCase { + private let home = URL(fileURLWithPath: "/Users/dev") + + private func makeDiscovery( + environment: [String: String] = [:], + files: [String: String], + keychainServices: [String: String] = [:], + subdirectories: [String] = [] + ) -> ClaudeConfigDirDiscovery { + ClaudeConfigDirDiscovery( + environment: FakeEnvironment(environment), + files: FakeFiles(files), + keychain: ServiceKeychain(values: keychainServices), + homeDirectory: { [home] in home }, + listSubdirectories: { [home] url in + subdirectories + .map { URL(fileURLWithPath: $0) } + .filter { $0.deletingLastPathComponent().path == url.path } + .filter { _ in url.path.hasPrefix(home.path) } + } + ) + } + + func testAcceptsADirWithIdentityAndFileCredential() throws { + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-work/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-2", "emailAddress": "work@example.com", "organizationName": "Sunstory"}}"#, + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-2"}}"#, + ], + subdirectories: ["/Users/dev/.claude-work"] + ) + + let result = discovery.run() + + let finding = try XCTUnwrap(result.findings.first) + XCTAssertEqual(result.findings.count, 1) + XCTAssertEqual(finding.identityKey, "acct-2") + XCTAssertEqual(finding.label, "work@example.com (Sunstory)") + XCTAssertEqual(finding.anchorPath, "/Users/dev/.claude-work") + } + + func testAcceptsAKeychainBackedDirThroughItsScopedServiceName() throws { + // Claude Code hashes the literal CLAUDE_CONFIG_DIR string; the `~` spelling must be probed + // alongside the absolute one, and the matched literal is what the scoped store reuses. + let literal = "~/.claude-alt" + let service = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: literal, environment: FakeEnvironment([:]) + ) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-alt/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-3"}}"#, + ], + keychainServices: [service: "present"], + subdirectories: ["/Users/dev/.claude-alt"] + ) + + let result = discovery.run() + + let finding = try XCTUnwrap(result.findings.first) + XCTAssertEqual(finding.identityKey, "acct-3") + XCTAssertEqual(finding.keychainLiteral, literal) + } + + func testRejectsIdentityWithoutCredentialAndCredentialWithoutIdentity() { + let discovery = makeDiscovery( + files: [ + // Identity but no credential shape: a toy/fork state file. + "/Users/dev/.claude-toy/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-4"}}"#, + // Credential but no identity: can't be routed to an account, must not become a card. + "/Users/dev/.claude-anon/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-5"}}"#, + ], + subdirectories: ["/Users/dev/.claude-toy", "/Users/dev/.claude-anon"] + ) + + let result = discovery.run() + + XCTAssertTrue(result.findings.isEmpty) + XCTAssertEqual(result.notes.count, 1, "the near-miss with an identity enters the support trail") + } + + func testExcludesTheDefaultHomesIncludingTheEnvOverride() { + let files = [ + "/Users/dev/.claude-main/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-MAIN"}}"#, + "/Users/dev/.claude-main/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at"}}"#, + "/Users/dev/.config/claude/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-XDG"}}"#, + "/Users/dev/.config/claude/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at"}}"#, + ] + let discovery = makeDiscovery( + environment: ["CLAUDE_CONFIG_DIR": "~/.claude-main"], + files: files, + subdirectories: ["/Users/dev/.claude-main", "/Users/dev/.config/claude"] + ) + + // The env-named home is the default card's and is excluded; the XDG dir is then a genuinely + // separate home and a legitimate candidate. + XCTAssertEqual(discovery.run().findings.map(\.identityKey), ["acct-xdg"]) + + // Without the override, XDG is a default home again and the env-named dir is the candidate. + let withoutOverride = makeDiscovery( + files: files, + subdirectories: ["/Users/dev/.claude-main", "/Users/dev/.config/claude"] + ) + XCTAssertEqual(withoutOverride.run().findings.map(\.identityKey), ["acct-main"]) + } +} diff --git a/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift b/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift new file mode 100644 index 000000000..fa5a73c0e --- /dev/null +++ b/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import OpenUsage + +/// The `.configDir` credential scope: an extra account card may only ever see its own login — +/// its own credentials file and its own computed keychain item, with no Desktop, environment-token, +/// or default-service fallback. +final class ClaudeScopedAuthStoreTests: XCTestCase { + private let scope = ClaudeCredentialScope.configDir( + path: "/Users/dev/.claude-work", + keychainLiteral: "~/.claude-work" + ) + + func testScopedStoreReadsOnlyItsOwnCredentialSources() throws { + let scopedService = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: "~/.claude-work", environment: FakeEnvironment([:]) + ) + let store = ClaudeAuthStore( + environment: FakeEnvironment([:]), + files: FakeFiles([ + // Another account's default-home file must stay invisible to the scoped card. + "~/.claude/.credentials.json": #"{"claudeAiOauth": {"accessToken": "default-at"}}"#, + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "work-at"}}"#, + ]), + keychain: ServiceKeychain(), + scope: scope + ) + + XCTAssertEqual(store.keychainServiceCandidates(), [scopedService], "never the bare default service") + let load = store.loadCredentialSet() + XCTAssertEqual(load.candidates.map(\.oauth.accessToken), ["work-at"]) + XCTAssertEqual(load.desktopStatus, .notChecked, "a config-dir card never consults Desktop") + } + + func testScopedStoreNeverInheritsTheAmbientEnvironmentToken() { + let store = ClaudeAuthStore( + environment: FakeEnvironment(["CLAUDE_CODE_OAUTH_TOKEN": "ambient-token"]), + files: FakeFiles([ + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "work-at"}}"#, + ]), + keychain: ServiceKeychain(), + scope: scope + ) + + let candidates = store.loadCredentialSet().candidates + XCTAssertEqual(candidates.map(\.oauth.accessToken), ["work-at"]) + XCTAssertFalse(candidates.contains { $0.source == .environment }) + } + + func testFootprintProbeSeesFileAndKeychainShapesWithoutReadingSecrets() { + let scopedService = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: "~/.claude-work", environment: FakeEnvironment([:]) + ) + let fileBacked = ClaudeAuthStore( + environment: FakeEnvironment([:]), + files: FakeFiles(["/Users/dev/.claude-work/.credentials.json": "{}"]), + keychain: ServiceKeychain(), + scope: scope + ) + XCTAssertTrue(fileBacked.hasCredentialFootprint()) + + let keychainBacked = ClaudeAuthStore( + environment: FakeEnvironment([:]), + files: FakeFiles([:]), + keychain: ServiceKeychain(values: [scopedService: "present"]), + scope: scope + ) + XCTAssertTrue(keychainBacked.hasCredentialFootprint()) + + let bare = ClaudeAuthStore( + environment: FakeEnvironment([:]), + files: FakeFiles([:]), + keychain: ServiceKeychain(), + scope: scope + ) + XCTAssertFalse(bare.hasCredentialFootprint()) + } + + func testStandardStoreDropsDesktopFallbackWhileExtraCardsExist() { + // With no CLI login and Desktop disallowed (extra Claude cards exist), the load reports + // `.notFound` instead of consulting Desktop — the caller keeps the honest CLI error. + let store = ClaudeAuthStore( + environment: FakeEnvironment([:]), + files: FakeFiles([:]), + keychain: ServiceKeychain(), + allowsDesktopFallback: false + ) + + let load = store.loadCredentialSet(forceDesktopFallback: true) + XCTAssertEqual(load.desktopStatus, .notFound) + XCTAssertTrue(load.candidates.isEmpty) + } +} diff --git a/Tests/OpenUsageTests/LayoutStoreTests.swift b/Tests/OpenUsageTests/LayoutStoreTests.swift index 6b6c4ce21..7c9d8cc3a 100644 --- a/Tests/OpenUsageTests/LayoutStoreTests.swift +++ b/Tests/OpenUsageTests/LayoutStoreTests.swift @@ -780,6 +780,71 @@ final class LayoutStoreTests: XCTestCase { XCTAssertEqual(store.pinnedMetricIDs, expected) } + func testProviderReorderPreservesAnAbsentAccountCardSlot() { + let defaults = makeDefaults("ReorderAbsentAccountCard") + let storageKey = "layout" + let hidden = "claude@hidden" + LayoutPersistence(defaults: defaults, storageKey: storageKey).saveProviderOrder([ + "claude", hidden, "cursor", + ]) + let store = LayoutStore(registry: .mock, defaults: defaults, storageKey: storageKey) + + XCTAssertTrue(store.reorderProvider(dragged: "cursor", target: "claude")) + + XCTAssertEqual(Array(store.providerOrder.prefix(3)), ["cursor", hidden, "claude"]) + XCTAssertEqual( + LayoutPersistence(defaults: defaults, storageKey: storageKey).loadProviderOrder()?.contains(hidden), + true, + "the absent card keeps its slot for the launch it returns on" + ) + } + + func testTranslatedDefaultsSeedAnAccountCardTheFirstTimeItAppears() { + // An existing user's layout predates the account card entirely; when the card first enters + // the registry, its family's default metrics seed for it (enabled), because translated ids + // are never part of the seeded-defaults baseline. + let claude = Provider(id: "claude", displayName: "Claude", icon: .providerMark("claude")) + let work = Provider(id: "claude@work", displayName: "Claude — Work", icon: .providerMark("claude")) + func descriptor(_ id: String, _ provider: Provider) -> WidgetDescriptor { + WidgetDescriptor( + id: id, + providerID: provider.id, + metricLabel: id, + sample: WidgetData(title: id, icon: provider.icon, kind: .percent, used: 0, limit: 100) + ) + } + let registry = WidgetRegistry( + providers: [claude, work], + descriptors: [ + descriptor("claude.session", claude), + descriptor("claude.sonnet", claude), + descriptor("claude@work.session", work), + descriptor("claude@work.sonnet", work), + ] + ) + let defaults = makeDefaults("AccountCardSeeding") + saveStored([PlacedWidget(descriptorID: "claude.session")], forKey: "layout", in: defaults) + defaults.set(["claude.session", "claude.sonnet"], forKey: "layout.seededDefaults") + + let store = LayoutStore( + registry: registry, + defaults: defaults, + storageKey: "layout", + defaultMetricIDs: ["claude.session"], + defaultExpandedMetricIDs: ["claude.sonnet"] + ) + + XCTAssertTrue(store.isMetricEnabled("claude@work.session")) + XCTAssertFalse( + store.isMetricEnabled("claude.sonnet"), + "the family's own disabled default stays exactly as the user left it" + ) + XCTAssertTrue( + store.defaultExpandedOnEnableIDs.contains("claude@work.sonnet"), + "the caret split translates with the metric set" + ) + } + func testResetToDefaultRestoresProviderOrderAndMarksDefaultsSeeded() { let defaults = makeDefaults("ResetSeeded") let store = LayoutStore( diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 1e381d8e6..7cdcf52b8 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -61,6 +61,183 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertNil(store.defaultBadgeHolder(family: "claude"), "an out-of-pass family must not be reconciled") } + private func makeDiscovery( + files: [String: String], + subdirectories: [String] + ) -> ClaudeConfigDirDiscovery { + ClaudeConfigDirDiscovery( + environment: FakeEnvironment([:]), + files: FakeFiles(files), + keychain: ServiceKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { url in + subdirectories + .map { URL(fileURLWithPath: $0) } + .filter { $0.deletingLastPathComponent().path == url.path } + } + ) + } + + func testADistinctConfigDirAccountMintsAHashedRecordAndAnExtraCard() throws { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + environment: FakeEnvironment([:]), + files: FakeFiles([ + "/Users/dev/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-1", "emailAddress": "dev@example.com"}}"#, + ]), + keychain: FakeKeychain(nil), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-work/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-2", "emailAddress": "work@example.com", "organizationName": "Sunstory"}}"#, + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-2"}}"#, + ], + subdirectories: ["/Users/dev/.claude-work"] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(assembly.claudeCards.count, 1) + XCTAssertTrue(card.id.hasPrefix("claude@"), "a config-dir account never claims the bare id") + XCTAssertEqual(card.displayName, "Claude — Sunstory") + XCTAssertEqual(card.configDirPath, "/Users/dev/.claude-work") + XCTAssertEqual(assembly.identityKeysByCard["claude"], "acct-1") + XCTAssertEqual(assembly.identityKeysByCard[card.id], "acct-2") + // The registry recorded both: the default holder under the bare id, the extra account with + // its config-dir source. + let record = try XCTUnwrap(store.records.first { $0.id == card.id }) + XCTAssertEqual(record.sources.map(\.kind), [.configDir]) + XCTAssertEqual(record.label, "work@example.com (Sunstory)") + XCTAssertTrue(assembly.defaultClaudeExtraLogRoots.isEmpty) + } + + func testASameAccountConfigDirFoldsOntoTheDefaultCardAsALogRoot() throws { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + environment: FakeEnvironment([:]), + files: FakeFiles([ + "/Users/dev/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-1"}}"#, + ]), + keychain: FakeKeychain(nil), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-side/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-1"}}"#, + "/Users/dev/.claude-side/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-1"}}"#, + ], + subdirectories: ["/Users/dev/.claude-side"] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + + XCTAssertTrue(assembly.claudeCards.isEmpty, "one account never renders as two cards") + XCTAssertEqual(assembly.defaultClaudeExtraLogRoots.map(\.path), ["/Users/dev/.claude-side"]) + let record = try XCTUnwrap(store.defaultBadgeHolder(family: "claude")) + XCTAssertEqual(record.id, "claude") + XCTAssertEqual(Set(record.sources.map(\.kind)), [.defaultHome, .configDir]) + } + + func testAnUnresolvedDefaultLoginSkipsCandidatesThisLaunch() { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + environment: FakeEnvironment([:]), + files: FakeFiles([ + // Credentials exist but the state file names no account → unresolved, footprint present. + "/Users/dev/.claude/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-1"}}"#, + ]), + keychain: FakeKeychain(nil), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-work/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-2"}}"#, + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-2"}}"#, + ], + subdirectories: ["/Users/dev/.claude-work"] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + + XCTAssertTrue( + assembly.claudeCards.isEmpty, + "with a nameless default login, an accepted candidate could be that very account — skip" + ) + XCTAssertTrue(store.records.isEmpty) + } + + func testNoDefaultLoginStillAcceptsAConfigDirOnlyAccount() throws { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + environment: FakeEnvironment([:]), + files: FakeFiles([:]), + keychain: FakeKeychain(nil), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-work/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-2"}}"#, + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-2"}}"#, + ], + subdirectories: ["/Users/dev/.claude-work"] + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertTrue( + card.id.hasPrefix("claude@"), + "the bare id stays reserved for a future default-home login even when it is free" + ) + } + + func testARenamedRecordDrivesTheCardDisplayName() throws { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + environment: FakeEnvironment([:]), + files: FakeFiles([:]), + keychain: FakeKeychain(nil), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-work/.claude.json": #"{"oauthAccount": {"accountUuid": "ACCT-2"}}"#, + "/Users/dev/.claude-work/.credentials.json": #"{"claudeAiOauth": {"accessToken": "at-2"}}"#, + ], + subdirectories: ["/Users/dev/.claude-work"] + ) + + // First pass creates the record; the user then renames it. + let first = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + let cardID = try XCTUnwrap(first.claudeCards.first?.id) + XCTAssertEqual(first.claudeCards.first?.displayName, cardID, "no label → the short-hash id fallback") + store.rename(cardID: cardID, to: "Work Max") + + let second = ProviderAccountAssembly.make( + observer: observer, + accountsStore: ProviderAccountsStore(defaults: defaults), + claudeDiscovery: discovery + ) + XCTAssertEqual(second.claudeCards.first?.displayName, "Work Max") + } + func testNothingObservedLeavesRegistryAndKeysEmpty() { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) diff --git a/Tests/OpenUsageTests/WidgetRegistryTests.swift b/Tests/OpenUsageTests/WidgetRegistryTests.swift index 7bc8374b3..e4e0c5547 100644 --- a/Tests/OpenUsageTests/WidgetRegistryTests.swift +++ b/Tests/OpenUsageTests/WidgetRegistryTests.swift @@ -15,6 +15,25 @@ final class WidgetRegistryTests: XCTestCase { ) } + func testNewAccountCardsSlotInAfterTheirFamilyGroup() { + let registry = WidgetRegistry( + providers: [provider("claude"), provider("claude@ab12cd34"), provider("codex"), provider("cursor")], + descriptors: [] + ) + + // The saved order predates the account card: it appears right after the claude group, not + // at the end of the dashboard; a genuinely new provider still appends. + XCTAssertEqual( + registry.orderedProviderIDs(savedOrder: ["codex", "claude"]), + ["codex", "claude", "claude@ab12cd34", "cursor"] + ) + // With no family sibling in the saved order, the card appends like any new provider. + XCTAssertEqual( + registry.orderedProviderIDs(savedOrder: ["codex"]), + ["codex", "claude", "claude@ab12cd34", "cursor"] + ) + } + func testLookupsReturnExpectedEntries() { let claude = provider("claude") let codex = provider("codex") From cc0c0197e26171d46a52d2d2ee4a92a85c11e17f Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 15:39:58 +0400 Subject: [PATCH 03/24] fix(claude): bind swapped default accounts to their permanent cards --- Sources/OpenUsage/App/AppContainer.swift | 3 +- .../Providers/Claude/ClaudeProvider.swift | 7 ++- .../OpenUsage/Providers/ProviderCatalog.swift | 9 +++- .../Services/ProviderAccountAssembly.swift | 24 +++++---- Sources/OpenUsage/Services/UsageReader.swift | 3 +- .../ProviderAccountAssemblyTests.swift | 52 +++++++++++++++++++ 6 files changed, 83 insertions(+), 15 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 86396baeb..0616c86f5 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -75,7 +75,8 @@ final class AppContainer { let providers = ProviderCatalog.make( claudeCards: accountAssembly.claudeCards, defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, - defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName + defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, + defaultClaudeCardID: accountAssembly.defaultClaudeCardID ) let registry = WidgetRegistry.from(providers) let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index 457d0f583..3f177c47d 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -289,7 +289,12 @@ final class ClaudeProvider: ProviderRuntime { // Both scans run on their scanner actors, off the main actor, and do not require an OAuth login. let pricing = await pricing() let nativeScan = await logUsageScanner.scan(now: now(), pricing: pricing) - let piScan = await PiUsageScanner.shared.scan(cardID: provider.id, now: now(), pricing: pricing) + let piScan: LogUsageScan? + if authStore.scope == .standard { + piScan = await PiUsageScanner.shared.scan(cardID: "claude", now: now(), pricing: pricing) + } else { + piScan = nil + } var usageHistory: ProviderUsageHistory? // Cancellation can land between the native and pi scans. Treat the pair as one unit so a // partial result cannot replace the last-good combined history in WidgetDataStore. diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index fd3bb4614..43c0438b4 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -13,14 +13,18 @@ enum ProviderCatalog { defaults: UserDefaults = .standard, claudeCards: [ClaudeAccountCard] = [], defaultClaudeExtraLogRoots: [URL] = [], - defaultClaudeDisplayName: String? = nil + defaultClaudeDisplayName: String? = nil, + defaultClaudeCardID: String = "claude" ) -> [ProviderRuntime] { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, // then every other provider alphabetically by display name. Account cards slot in right after // their family's default card. var runtimes: [ProviderRuntime] = [] runtimes.append(ClaudeProvider( - provider: ClaudeProvider.makeProvider(displayName: defaultClaudeDisplayName ?? "Claude"), + provider: ClaudeProvider.makeProvider( + id: defaultClaudeCardID, + displayName: defaultClaudeDisplayName ?? "Claude" + ), // Once extra Claude cards exist, an unpinned Desktop fallback could borrow a login that // belongs to one of them — fetching that account's usage onto the default card. Desktop // returns as its own properly-pinned source kind in Phase 3. @@ -30,6 +34,7 @@ enum ProviderCatalog { for card in claudeCards { runtimes.append(claudeAccountRuntime(card: card)) } + runtimes.sort { $0.provider.id == "claude" && $1.provider.id != "claude" } runtimes += [ CodexProvider(), CursorProvider(), diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index d4db949f4..ce6e6cd05 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -47,6 +47,8 @@ struct ProviderAccountAssembly { var defaultClaudeExtraLogRoots: [URL] = [] /// The default Claude card's rename, when the badge-holder record carries one. var defaultClaudeDisplayName: String? + /// The default runtime follows its account record, even when another account keeps `claude`. + var defaultClaudeCardID = "claude" /// `waitsForLoginShell`: true for the menu-bar app (a Finder/Dock launch inherits no shell /// exports, so the pass leans on the login-shell layers), false for the one-shot CLI (a terminal @@ -188,6 +190,11 @@ struct ProviderAccountAssembly { } let records = accountsStore.reconcile(with: observations) + let defaultClaudeRecord = accountsStore.defaultBadgeHolder(family: "claude") + if let defaultClaudeRecord, defaultClaudeRecord.id != "claude" { + identityKeys.removeValue(forKey: "claude") + identityKeys[defaultClaudeRecord.id] = defaultClaudeRecord.identityKey + } // The extra-card build plan: one card per distinct account found this launch, under its // reconciled record id. @@ -196,14 +203,6 @@ struct ProviderAccountAssembly { guard let record = records.first(where: { $0.family == "claude" && $0.identityKey == account.identityKey }) else { continue } - guard record.id != "claude" else { - // The bare record's account has moved out of the default home into a config dir - // while another login occupies the default. The bare CARD is the default home's - // runtime, so this record can't render under its own id this launch. Proper swap - // support re-points this in Phase 4; until then the parked account stays hidden. - AppLog.warn(.config, "discovery: the claude record's account now lives in a config dir; its card is unavailable until swap support lands") - continue - } guard let primary = account.dirs.first else { continue } claudeCards.append(ClaudeAccountCard( id: record.id, @@ -219,12 +218,17 @@ struct ProviderAccountAssembly { } claudeCards.sort { $0.id < $1.id } - let defaultClaudeRename = records.first { $0.id == "claude" }?.customLabel?.nilIfEmpty + let defaultClaudeName = defaultClaudeRecord.flatMap { record in + if let customLabel = record.customLabel?.nilIfEmpty { return customLabel } + guard record.id != "claude" else { return nil } + return ClaudeAccountCard.displayName(customLabel: nil, label: record.label, id: record.id) + } return ProviderAccountAssembly( identityKeysByCard: identityKeys, claudeCards: claudeCards, defaultClaudeExtraLogRoots: defaultClaudeExtraLogRoots, - defaultClaudeDisplayName: defaultClaudeRename + defaultClaudeDisplayName: defaultClaudeName, + defaultClaudeCardID: defaultClaudeRecord?.id ?? "claude" ) } } diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 030707b8b..bccd61db3 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -61,7 +61,8 @@ public struct UsageReader { defaults: defaults, claudeCards: accountAssembly.claudeCards, defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, - defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName + defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, + defaultClaudeCardID: accountAssembly.defaultClaudeCardID ) let registry = WidgetRegistry.from(providers) let knownIDs = Set(registry.providers.map(\.id)) diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 7cdcf52b8..3c35fa1d2 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -238,6 +238,58 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(second.claudeCards.first?.displayName, "Work Max") } + func testAccountSwapKeepsTheOriginalCardBoundToItsOwnConfigDirectory() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let original = DefaultAccountObserver( + environment: FakeEnvironment(), + files: FakeFiles(["/Users/dev/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCOUNT-A"}}"#]), + keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + _ = ProviderAccountAssembly.make(observer: original, accountsStore: store) + store.rename(cardID: "claude", to: "Personal") + + let replacement = DefaultAccountObserver( + environment: FakeEnvironment(), + files: FakeFiles(["/Users/dev/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCOUNT-B"}}"#]), + keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let path = "/Users/dev/.claude-personal" + let discovery = makeDiscovery( + files: [ + path + "/.claude.json": #"{"oauthAccount":{"accountUuid":"ACCOUNT-A"}}"#, + path + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"personal"}}"#, + ], + subdirectories: [path] + ) + + let assembly = ProviderAccountAssembly.make( + observer: replacement, accountsStore: store, claudeDiscovery: discovery + ) + let originalCard = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(originalCard.id, "claude") + XCTAssertEqual(originalCard.configDirPath, path) + XCTAssertEqual(originalCard.displayName, "Personal") + XCTAssertNotEqual(assembly.defaultClaudeCardID, "claude") + XCTAssertEqual(assembly.identityKeysByCard["claude"], "account-a") + XCTAssertEqual(assembly.identityKeysByCard[assembly.defaultClaudeCardID], "account-b") + + let runtimes = ProviderCatalog.make( + claudeCards: assembly.claudeCards, + defaultClaudeExtraLogRoots: assembly.defaultClaudeExtraLogRoots, + defaultClaudeDisplayName: assembly.defaultClaudeDisplayName, + defaultClaudeCardID: assembly.defaultClaudeCardID + ).compactMap { $0 as? ClaudeProvider } + XCTAssertEqual(runtimes.first?.provider.id, "claude") + XCTAssertEqual(runtimes.first?.authStore.scope, + .configDir(path: path, keychainLiteral: path)) + XCTAssertEqual(runtimes.last?.provider.id, assembly.defaultClaudeCardID) + XCTAssertEqual(runtimes.last?.authStore.scope, .standard) + } + func testNothingObservedLeavesRegistryAndKeysEmpty() { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) From 893d55f3133f91802639c04f8e26a9d20a75138f Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 16:56:17 +0400 Subject: [PATCH 04/24] Make account foundations safe for ownership, downgrade, and privacy --- Sources/OpenUsage/App/AppContainer.swift | 3 +- .../Providers/Claude/ClaudeAuthStore.swift | 193 +++--------------- .../Claude/ClaudeCredentialModels.swift | 166 +++++++++++++++ .../Providers/Claude/ClaudeProvider.swift | 16 +- .../OpenUsage/Providers/ProviderCatalog.swift | 11 +- .../Services/ProviderAccountAssembly.swift | 2 + Sources/OpenUsage/Services/UsageReader.swift | 3 +- .../Stores/ProviderAccountsStore.swift | 9 +- .../OpenUsage/Stores/TelemetryRecorder.swift | 28 ++- .../ClaudeAccountIsolationTests.swift | 24 +++ .../ProviderAccountsStoreTests.swift | 21 ++ .../TelemetryRecorderTests.swift | 47 +++++ 12 files changed, 338 insertions(+), 185 deletions(-) create mode 100644 Sources/OpenUsage/Providers/Claude/ClaudeCredentialModels.swift diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 0616c86f5..2b6a2bacd 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -76,7 +76,8 @@ final class AppContainer { claudeCards: accountAssembly.claudeCards, defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, - defaultClaudeCardID: accountAssembly.defaultClaudeCardID + defaultClaudeCardID: accountAssembly.defaultClaudeCardID, + claudeIdentityKeys: accountAssembly.identityKeysByCard ) let registry = WidgetRegistry.from(providers) let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index 8dcb0c52c..8838238f7 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -1,171 +1,6 @@ import CryptoKit import Foundation -struct ClaudeOAuth: Codable, Hashable, Sendable { - var accessToken: String? - var refreshToken: String? - var expiresAt: Double? - var subscriptionType: String? - var rateLimitTier: String? - var scopes: [String]? -} - -struct ClaudeCredentialsFile: Codable, Hashable, Sendable { - var claudeAiOauth: ClaudeOAuth? -} - -struct ClaudeCredentialState: Hashable, Sendable { - enum Source: Hashable, Sendable { - case file - case keychainCurrentUser(service: String) - case keychainLegacy(service: String) - case desktop - case environment - - /// Log-safe source kind — NEVER the keychain service name or any token. - var label: String { - switch self { - case .file: "file" - case .keychainCurrentUser: "keychainCurrentUser" - case .keychainLegacy: "keychainLegacy" - case .desktop: "desktop" - case .environment: "environment" - } - } - } - - var oauth: ClaudeOAuth - var source: Source - var fullData: ClaudeCredentialsFile? - var inferenceOnly: Bool - - /// Whether this candidate carries a non-blank access token — the single definition of "usable" - /// shared by `refresh()`'s candidate filter and `hasLocalCredentials()`'s first-run detection, so - /// the two can never drift. - var hasUsableAccessToken: Bool { - oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - } - - /// A token-free, log-safe one-line descriptor for diagnosing auth failures from a default-level - /// (info) log: the source kind plus booleans for whether this candidate carries a refresh token and - /// whether its access token is already expired (`expiresAt`, epoch ms, vs `now`). NEVER includes any - /// token value or the credential blob — only the source kind and the two booleans. Why these two - /// booleans: a candidate with `refresh=no` can never self-heal an expiry (the #738 root cause), and - /// `expired=yes` explains why a refresh was needed at all. - func diagnosticsLabel(now: Date) -> String { - let refresh = (oauth.refreshToken?.isEmpty == false) ? "yes" : "no" - let expired: String - if let expiresAt = oauth.expiresAt { - expired = expiresAt <= now.timeIntervalSince1970 * 1000 ? "yes" : "no" - } else { - expired = "unknown" - } - return "\(source.label) refresh=\(refresh) expired=\(expired)" - } -} - -/// Token-bearing credential candidates in their effective probe order. Environment-only inference -/// tokens are excluded because they never fetch live usage; every stored candidate that can affect -/// selection remains, including an earlier source that the current refresh already tried and rejected. -struct ClaudeCredentialGeneration: Equatable, Sendable { - struct Candidate: Equatable, Sendable { - let oauth: ClaudeOAuth - let source: ClaudeCredentialState.Source - - init(_ state: ClaudeCredentialState) { - oauth = state.oauth - source = state.source - } - } - - var candidates: [Candidate] - - init(_ states: [ClaudeCredentialState]) { - candidates = states - .filter { $0.hasUsableAccessToken && !$0.inferenceOnly } - .map(Candidate.init) - } - - func replacing(_ state: ClaudeCredentialState) -> Self { - var updated = self - guard let index = updated.candidates.firstIndex(where: { $0.source == state.source }) else { - fatalError("live usage source missing from Claude credential generation") - } - updated.candidates[index] = Candidate(state) - return updated - } -} - -struct ClaudeCredentialLoad: Sendable { - var candidates: [ClaudeCredentialState] - var desktopStatus: ClaudeDesktopCredentialStatus -} - -enum ClaudeAuthError: Error, LocalizedError, Equatable { - case notLoggedIn - case desktopPermissionRequired - case desktopTokenExpired - case desktopCredentialsUnavailable - case sessionExpired - case tokenExpired - case credentialsChanged - case invalidOAuthURL(String) - - var errorDescription: String? { - switch self { - case .notLoggedIn: - return "Not logged in. Run `claude` to authenticate." - case .desktopPermissionRequired: - return "Claude Desktop login found. Refresh once and choose Always Allow to connect it." - case .desktopTokenExpired: - return "Claude Desktop login is stale. Open Claude Desktop, then refresh OpenUsage." - case .desktopCredentialsUnavailable: - return "Claude Desktop login couldn't be read. Open Claude Desktop, then try again." - case .sessionExpired: - return "Session expired. Run `claude` to log in again." - case .tokenExpired: - return "Token expired. Run `claude` to log in again." - case .credentialsChanged: - return "Claude login changed during refresh. Refresh again." - case .invalidOAuthURL(let value): - return "Invalid Claude OAuth URL: \(value). Check CLAUDE_CODE_CUSTOM_OAUTH_URL / CLAUDE_LOCAL_OAUTH_API_BASE." - } - } - - /// Whether a failure on one credential source should fall through to the next one rather than - /// failing the whole refresh. An expired/revoked token in the preferred source (a stale keychain - /// entry from a prior login that later "locked out") must not shadow a fresh token an external - /// `claude` re-login wrote to a different source — so the token-is-bad cases allow a fallback, - /// while "no credentials at all" does not (there is nothing better to try). Mirrors - /// `CodexAuthError.allowsAuthFallback`. - var allowsAuthFallback: Bool { - switch self { - case .sessionExpired, .tokenExpired, .desktopTokenExpired: - return true - case .notLoggedIn, .desktopPermissionRequired, .desktopCredentialsUnavailable, - .credentialsChanged, .invalidOAuthURL: - return false - } - } -} - -struct ClaudeOAuthConfig: Hashable, Sendable { - var usageURL: URL - var refreshURL: URL - var clientID: String -} - -/// Which login a `ClaudeAuthStore` is allowed to see. `.standard` is the default card — -/// byte-identical to the store's historical behavior. `.configDir` backs an extra account card and -/// deliberately has no cross-account, environment-token, or Desktop fallback: the card can only ever -/// read the one login it was created for. -enum ClaudeCredentialScope: Hashable, Sendable { - case standard - /// One extra `CLAUDE_CONFIG_DIR` home. `keychainLiteral` is the literal string whose hash names - /// the keychain item (Claude Code hashes the env value as typed — `~/…` vs absolute differ). - case configDir(path: String, keychainLiteral: String) -} - struct ClaudeAuthStore: Sendable { private static let defaultClaudeHome = "~/.claude" private static let credentialFileName = ".credentials.json" @@ -181,6 +16,7 @@ struct ClaudeAuthStore: Sendable { var desktop: ClaudeDesktopAuthStore var now: @Sendable () -> Date let scope: ClaudeCredentialScope + let expectedIdentityKey: String? /// Whether the `.standard` store may fall back to Claude Desktop's credentials. On by default /// (the historical behavior); the catalog turns it OFF once extra Claude account cards exist, /// because the Desktop login could belong to any of them — borrowing it unpinned could fetch one @@ -194,6 +30,7 @@ struct ClaudeAuthStore: Sendable { desktop: ClaudeDesktopAuthStore? = nil, scope: ClaudeCredentialScope = .standard, allowsDesktopFallback: Bool = true, + expectedIdentityKey: String? = nil, now: @escaping @Sendable () -> Date = Date.init ) { self.environment = environment @@ -202,6 +39,7 @@ struct ClaudeAuthStore: Sendable { self.desktop = desktop ?? ClaudeDesktopAuthStore(files: files, now: now) self.scope = scope self.allowsDesktopFallback = allowsDesktopFallback + self.expectedIdentityKey = expectedIdentityKey self.now = now } @@ -266,6 +104,31 @@ struct ClaudeAuthStore: Sendable { } } + func belongsToExpectedAccount() -> Bool { + guard let expectedIdentityKey else { return true } + let identityPath: String + switch scope { + case .configDir(let path, _): + identityPath = "\(path)/.claude.json" + case .standard: + let home = claudeHomeOverride() ?? Self.defaultClaudeHome + guard !home.contains(",") else { return false } + let expandedHome = (home as NSString).expandingTildeInPath + let expandedDefault = (Self.defaultClaudeHome as NSString).expandingTildeInPath + identityPath = URL(fileURLWithPath: expandedHome).standardizedFileURL.path + == URL(fileURLWithPath: expandedDefault).standardizedFileURL.path + ? "\(home).json" : "\(home)/.claude.json" + } + guard let text = try? files.readTextIfPresent(identityPath), + let state = try? JSONDecoder().decode( + DefaultAccountObserver.ClaudeStateFile.self, from: Data(text.utf8) + ), + let account = state.oauthAccount, + let observed = DefaultAccountObserver.claudeIdentityKey(account) + else { return false } + return observed.lowercased() == expectedIdentityKey.lowercased() + } + private func applyingEnvironmentToken(to stored: [ClaudeCredentialState]) -> [ClaudeCredentialState] { // An ambient env token describes the DEFAULT login's environment; a scoped card must never // inherit it (that would leak one account's token into another account's card). diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeCredentialModels.swift b/Sources/OpenUsage/Providers/Claude/ClaudeCredentialModels.swift new file mode 100644 index 000000000..6c717fdef --- /dev/null +++ b/Sources/OpenUsage/Providers/Claude/ClaudeCredentialModels.swift @@ -0,0 +1,166 @@ +import Foundation + +struct ClaudeOAuth: Codable, Hashable, Sendable { + var accessToken: String? + var refreshToken: String? + var expiresAt: Double? + var subscriptionType: String? + var rateLimitTier: String? + var scopes: [String]? +} + +struct ClaudeCredentialsFile: Codable, Hashable, Sendable { + var claudeAiOauth: ClaudeOAuth? +} + +struct ClaudeCredentialState: Hashable, Sendable { + enum Source: Hashable, Sendable { + case file + case keychainCurrentUser(service: String) + case keychainLegacy(service: String) + case desktop + case environment + + /// Log-safe source kind — NEVER the keychain service name or any token. + var label: String { + switch self { + case .file: "file" + case .keychainCurrentUser: "keychainCurrentUser" + case .keychainLegacy: "keychainLegacy" + case .desktop: "desktop" + case .environment: "environment" + } + } + } + + var oauth: ClaudeOAuth + var source: Source + var fullData: ClaudeCredentialsFile? + var inferenceOnly: Bool + + /// Whether this candidate carries a non-blank access token — the single definition of "usable" + /// shared by `refresh()`'s candidate filter and `hasLocalCredentials()`'s first-run detection, so + /// the two can never drift. + var hasUsableAccessToken: Bool { + oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + /// A token-free, log-safe one-line descriptor for diagnosing auth failures from a default-level + /// (info) log: the source kind plus booleans for whether this candidate carries a refresh token and + /// whether its access token is already expired (`expiresAt`, epoch ms, vs `now`). NEVER includes any + /// token value or the credential blob — only the source kind and the two booleans. Why these two + /// booleans: a candidate with `refresh=no` can never self-heal an expiry (the #738 root cause), and + /// `expired=yes` explains why a refresh was needed at all. + func diagnosticsLabel(now: Date) -> String { + let refresh = (oauth.refreshToken?.isEmpty == false) ? "yes" : "no" + let expired: String + if let expiresAt = oauth.expiresAt { + expired = expiresAt <= now.timeIntervalSince1970 * 1000 ? "yes" : "no" + } else { + expired = "unknown" + } + return "\(source.label) refresh=\(refresh) expired=\(expired)" + } +} + +/// Token-bearing credential candidates in their effective probe order. Environment-only inference +/// tokens are excluded because they never fetch live usage; every stored candidate that can affect +/// selection remains, including an earlier source that the current refresh already tried and rejected. +struct ClaudeCredentialGeneration: Equatable, Sendable { + struct Candidate: Equatable, Sendable { + let oauth: ClaudeOAuth + let source: ClaudeCredentialState.Source + + init(_ state: ClaudeCredentialState) { + oauth = state.oauth + source = state.source + } + } + + var candidates: [Candidate] + + init(_ states: [ClaudeCredentialState]) { + candidates = states + .filter { $0.hasUsableAccessToken && !$0.inferenceOnly } + .map(Candidate.init) + } + + func replacing(_ state: ClaudeCredentialState) -> Self { + var updated = self + guard let index = updated.candidates.firstIndex(where: { $0.source == state.source }) else { + fatalError("live usage source missing from Claude credential generation") + } + updated.candidates[index] = Candidate(state) + return updated + } +} + +struct ClaudeCredentialLoad: Sendable { + var candidates: [ClaudeCredentialState] + var desktopStatus: ClaudeDesktopCredentialStatus +} + +enum ClaudeAuthError: Error, LocalizedError, Equatable { + case notLoggedIn + case desktopPermissionRequired + case desktopTokenExpired + case desktopCredentialsUnavailable + case sessionExpired + case tokenExpired + case credentialsChanged + case invalidOAuthURL(String) + + var errorDescription: String? { + switch self { + case .notLoggedIn: + return "Not logged in. Run `claude` to authenticate." + case .desktopPermissionRequired: + return "Claude Desktop login found. Refresh once and choose Always Allow to connect it." + case .desktopTokenExpired: + return "Claude Desktop login is stale. Open Claude Desktop, then refresh OpenUsage." + case .desktopCredentialsUnavailable: + return "Claude Desktop login couldn't be read. Open Claude Desktop, then try again." + case .sessionExpired: + return "Session expired. Run `claude` to log in again." + case .tokenExpired: + return "Token expired. Run `claude` to log in again." + case .credentialsChanged: + return "Claude login changed during refresh. Refresh again." + case .invalidOAuthURL(let value): + return "Invalid Claude OAuth URL: \(value). Check CLAUDE_CODE_CUSTOM_OAUTH_URL / CLAUDE_LOCAL_OAUTH_API_BASE." + } + } + + /// Whether a failure on one credential source should fall through to the next one rather than + /// failing the whole refresh. An expired/revoked token in the preferred source (a stale keychain + /// entry from a prior login that later "locked out") must not shadow a fresh token an external + /// `claude` re-login wrote to a different source — so the token-is-bad cases allow a fallback, + /// while "no credentials at all" does not (there is nothing better to try). Mirrors + /// `CodexAuthError.allowsAuthFallback`. + var allowsAuthFallback: Bool { + switch self { + case .sessionExpired, .tokenExpired, .desktopTokenExpired: + return true + case .notLoggedIn, .desktopPermissionRequired, .desktopCredentialsUnavailable, + .credentialsChanged, .invalidOAuthURL: + return false + } + } +} + +struct ClaudeOAuthConfig: Hashable, Sendable { + var usageURL: URL + var refreshURL: URL + var clientID: String +} + +/// Which login a `ClaudeAuthStore` is allowed to see. `.standard` is the default card — +/// byte-identical to the store's historical behavior. `.configDir` backs an extra account card and +/// deliberately has no cross-account, environment-token, or Desktop fallback: the card can only ever +/// read the one login it was created for. +enum ClaudeCredentialScope: Hashable, Sendable { + case standard + /// One extra `CLAUDE_CONFIG_DIR` home. `keychainLiteral` is the literal string whose hash names + /// the keychain item (Claude Code hashes the env value as typed — `~/…` vs absolute differ). + case configDir(path: String, keychainLiteral: String) +} diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index 3f177c47d..fb27af159 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -3,9 +3,6 @@ import Foundation @MainActor final class ClaudeProvider: ProviderRuntime { - /// The default card's identity. Extra account cards inject their own `Provider` with an - /// `@`-suffixed id and an account-derived display name; everything else about the runtime is - /// identical. static func makeProvider(id: String = "claude", displayName: String = "Claude") -> Provider { Provider( id: id, @@ -19,7 +16,6 @@ final class ClaudeProvider: ProviderRuntime { } let provider: Provider - let authStore: ClaudeAuthStore let usageClient: ClaudeUsageClient let logUsageScanner: ClaudeLogUsageScanner @@ -29,8 +25,7 @@ final class ClaudeProvider: ProviderRuntime { /// Last successful live-usage result and a rate-limit cooldown, carried across refreshes (the provider /// is a long-lived singleton). `/api/oauth/usage` rate-limits aggressively, so on a 429 we serve the /// last-good bars with a staleness note instead of blanking the dashboard, and skip the live call - /// entirely until the cooldown expires so we don't keep hammering an endpoint that's already limiting - /// us. Mirrors the legacy plugin's `cachedUsageData` + `rateLimitedUntilMs`. + /// entirely until the cooldown expires so the app doesn't keep hitting an already limited endpoint. private var cachedCredentialFingerprint: Data? private var lastGoodUsage: ClaudeMappedUsage? private var rateLimitedUntil: Date? @@ -88,11 +83,18 @@ final class ClaudeProvider: ProviderRuntime { } func refresh() async -> ProviderSnapshot { - await refresh( + guard await loadOffMainActor({ [authStore] in authStore.belongsToExpectedAccount() }) else { + return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) + } + let snapshot = await refresh( credentialReloadsRemaining: 1, forceDesktopFallback: false, previousFallbackError: nil ) + guard await loadOffMainActor({ [authStore] in authStore.belongsToExpectedAccount() }) else { + return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) + } + return snapshot } /// Claude Code can replace a login while a request is in flight. Reload once when that happens so diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 43c0438b4..4c4558e13 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -14,7 +14,8 @@ enum ProviderCatalog { claudeCards: [ClaudeAccountCard] = [], defaultClaudeExtraLogRoots: [URL] = [], defaultClaudeDisplayName: String? = nil, - defaultClaudeCardID: String = "claude" + defaultClaudeCardID: String = "claude", + claudeIdentityKeys: [String: String] = [:] ) -> [ProviderRuntime] { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, // then every other provider alphabetically by display name. Account cards slot in right after @@ -28,7 +29,10 @@ enum ProviderCatalog { // Once extra Claude cards exist, an unpinned Desktop fallback could borrow a login that // belongs to one of them — fetching that account's usage onto the default card. Desktop // returns as its own properly-pinned source kind in Phase 3. - authStore: ClaudeAuthStore(allowsDesktopFallback: claudeCards.isEmpty), + authStore: ClaudeAuthStore( + allowsDesktopFallback: claudeCards.isEmpty, + expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID] + ), logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) )) for card in claudeCards { @@ -56,7 +60,8 @@ enum ProviderCatalog { ClaudeProvider( provider: ClaudeProvider.makeProvider(id: card.id, displayName: card.displayName), authStore: ClaudeAuthStore( - scope: .configDir(path: card.configDirPath, keychainLiteral: card.keychainLiteral) + scope: .configDir(path: card.configDirPath, keychainLiteral: card.keychainLiteral), + expectedIdentityKey: card.identityKey ), logUsageScanner: ClaudeLogUsageScanner( cacheIdentityOverride: "claude-account:\(card.id)", diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index ce6e6cd05..c817131ed 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -8,6 +8,7 @@ struct ClaudeAccountCard: Equatable, Sendable { /// CLI/API matching. var id: String var displayName: String + var identityKey: String /// The config dir the card's credentials and spend logs are pinned to. var configDirPath: String /// The literal string whose hash names the dir's keychain item (see `ClaudeCredentialScope`). @@ -209,6 +210,7 @@ struct ProviderAccountAssembly { displayName: ClaudeAccountCard.displayName( customLabel: record.customLabel, label: record.label, id: record.id ), + identityKey: record.identityKey, configDirPath: primary.anchorPath, keychainLiteral: primary.keychainLiteral, extraLogRoots: account.dirs.dropFirst().map { URL(fileURLWithPath: $0.anchorPath) } diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index bccd61db3..7bc8938d2 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -62,7 +62,8 @@ public struct UsageReader { claudeCards: accountAssembly.claudeCards, defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, - defaultClaudeCardID: accountAssembly.defaultClaudeCardID + defaultClaudeCardID: accountAssembly.defaultClaudeCardID, + claudeIdentityKeys: accountAssembly.identityKeysByCard ) let registry = WidgetRegistry.from(providers) let knownIDs = Set(registry.providers.map(\.id)) diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index a34b25262..d3056407d 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -79,22 +79,25 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { } } -/// The account-first registry (`openusage.providerAccounts.v1`). Reconciled at every launch from the +/// The account-first registry (`openusage.providerAccounts.v2`). Reconciled at every launch from the /// default-home identity reads; authoritative from day one — there is no parallel card model to drift /// from. With a single account per family (all Phase 1 can observe), the registry is bookkeeping the /// UI doesn't consult yet; multi-account rendering (Phase 2+) reads cards straight from these records. @MainActor final class ProviderAccountsStore { - static let storageKey = "openusage.providerAccounts.v1" + static let storageKey = "openusage.providerAccounts.v2" + static let legacyStorageKey = "openusage.providerAccounts.v1" private let defaults: UserDefaults private(set) var records: [ProviderAccountRecord] init(defaults: UserDefaults = .standard) { self.defaults = defaults - if let data = defaults.data(forKey: Self.storageKey) { + let currentData = defaults.data(forKey: Self.storageKey) + if let data = currentData ?? defaults.data(forKey: Self.legacyStorageKey) { do { self.records = try JSONDecoder().decode([ProviderAccountRecord].self, from: data) + if currentData == nil { persist() } } catch { AppLog.error(.config, "provider-account records were undecodable; starting a fresh registry: \(error.localizedDescription)") self.records = [] diff --git a/Sources/OpenUsage/Stores/TelemetryRecorder.swift b/Sources/OpenUsage/Stores/TelemetryRecorder.swift index 5a8d21ae4..79bd34fc6 100644 --- a/Sources/OpenUsage/Stores/TelemetryRecorder.swift +++ b/Sources/OpenUsage/Stores/TelemetryRecorder.swift @@ -69,6 +69,9 @@ final class TelemetryRecorder { guard store.enabled else { return } guard outcome == .refreshed || outcome == .failed else { return } + // Account-card identifiers contain a stable account-derived hash. Analytics only describe + // provider families, so normalize before either persistence or transmission. + let providerID = ProviderAccountID.family(of: providerID) let today = Self.dayString(now()) var counters = store.providerCounters() // Roll a stale prior-day counter over to its own event before accumulating today's. @@ -115,10 +118,10 @@ final class TelemetryRecorder { "install_id": store.installID, "app_version": AppInfo.version, "os_version": ProcessInfo.processInfo.operatingSystemVersionString, - "enabled_providers": config.enabledProviders, - "enabled_metric_ids": config.enabledMetricIDs, - "pinned_metric_ids": config.pinnedMetricIDs, - "expanded_metric_ids": config.expandedMetricIDs, + "enabled_providers": Self.unique(config.enabledProviders.map(ProviderAccountID.family(of:))), + "enabled_metric_ids": Self.familyMetricIDs(config.enabledMetricIDs), + "pinned_metric_ids": Self.familyMetricIDs(config.pinnedMetricIDs), + "expanded_metric_ids": Self.familyMetricIDs(config.expandedMetricIDs), "menu_bar_style": config.menuBarStyle ]) sink.flush() @@ -131,7 +134,7 @@ final class TelemetryRecorder { private static func providerRollupProperties(providerID: String, counter: ProviderDailyCounter) -> [String: Any] { var properties: [String: Any] = [ - "provider_id": providerID, + "provider_id": ProviderAccountID.family(of: providerID), "success_count": counter.success, "failure_count": counter.failure, "error_categories": counter.errors, @@ -150,6 +153,21 @@ final class TelemetryRecorder { return properties } + private static func familyMetricIDs(_ metricIDs: [String]) -> [String] { + unique(metricIDs.map { metricID in + guard let separator = metricID.firstIndex(of: ".") else { + return ProviderAccountID.family(of: metricID) + } + let providerID = String(metricID[.. [String] { + var seen: Set = [] + return values.filter { seen.insert($0).inserted } + } + /// Local-calendar `yyyy-MM-dd`. Local (not UTC) so "every day" matches the user's perception; the /// calendar is injectable for tests. static func dayString(_ date: Date, calendar: Calendar = .current) -> String { diff --git a/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift b/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift index d953c3042..8b13007fc 100644 --- a/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift +++ b/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift @@ -134,6 +134,28 @@ final class ClaudeAccountIsolationTests: XCTestCase { XCTAssertEqual(usageRequests(fixture.http).count, 2) } + func testBoundAccountRejectsAnotherLoginBeforeAndAfterAUsageRequest() async { + let identityPath = "/tmp/claude/.claude.json" + let files = FakeFiles([ + path: credentials(access: "account-a", refresh: "refresh-a", plan: "pro"), + identityPath: #"{"oauthAccount":{"accountUuid":"account-a"}}"#, + ]) + let fixture = makeFixture(files: files, expectedIdentityKey: "account-a") { request in + if request.headers["Authorization"] == "Bearer account-a" { + files.files[identityPath] = #"{"oauthAccount":{"accountUuid":"account-b"}}"# + } + return Self.usageResponse(percent: 75) + } + + let swappedDuringRefresh = await fixture.provider.refresh() + XCTAssertNil(sessionUsage(swappedDuringRefresh)) + XCTAssertNotNil(swappedDuringRefresh.errorCategory) + + let swappedBeforeRefresh = await fixture.provider.refresh() + XCTAssertNil(sessionUsage(swappedBeforeRefresh)) + XCTAssertEqual(usageRequests(fixture.http).count, 1) + } + func testHigherPriorityLoginAddedDuringUsageRequestWins() async { let accountA = credentials(access: "account-a", refresh: "refresh-a", plan: "pro") let accountB = credentials(access: "account-b", refresh: "refresh-b", plan: "max") @@ -248,6 +270,7 @@ final class ClaudeAccountIsolationTests: XCTestCase { private func makeFixture( files: FakeFiles, keychain: any KeychainAccessing = FakeKeychain(), + expectedIdentityKey: String? = nil, handler: @escaping @Sendable (HTTPRequest) async throws -> HTTPResponse ) -> Fixture { let http = RoutingHTTPClient(handler: handler) @@ -258,6 +281,7 @@ final class ClaudeAccountIsolationTests: XCTestCase { environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), files: files, keychain: keychain, + expectedIdentityKey: expectedIdentityKey, now: { now } ), usageClient: ClaudeUsageClient(httpClient: http), diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index 264c25486..74b538298 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -39,6 +39,27 @@ final class ProviderAccountsStoreTests: XCTestCase { XCTAssertTrue(records[0].sources.contains(where: \.holdsDefaultSource)) } + func testNewAccountSourcesNeverRewriteTheDowngradeCompatibleRegistry() throws { + let defaults = makeScratchDefaults() + let existing = ProviderAccountRecord( + id: "claude", family: "claude", identityKey: "original", label: "Personal", + sources: [.init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true)] + ) + let originalData = try JSONEncoder().encode([existing]) + defaults.set(originalData, forKey: ProviderAccountsStore.legacyStorageKey) + + let store = ProviderAccountsStore(defaults: defaults) + store.reconcile(with: [.init( + family: "claude", identityKey: "work", label: "Work", sources: [ + .init(kind: .configDir, anchor: "/Users/dev/.claude-work", holdsDefaultSource: false), + ] + )]) + + XCTAssertEqual(defaults.data(forKey: ProviderAccountsStore.legacyStorageKey), originalData) + XCTAssertEqual(ProviderAccountsStore(defaults: defaults).records.count, 2) + XCTAssertNotNil(defaults.data(forKey: ProviderAccountsStore.storageKey)) + } + func testSwappedDefaultMintsAHashIDAndTakesTheBadge() { let store = ProviderAccountsStore(defaults: makeScratchDefaults()) store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) diff --git a/Tests/OpenUsageTests/TelemetryRecorderTests.swift b/Tests/OpenUsageTests/TelemetryRecorderTests.swift index 2517541ef..eb721308f 100644 --- a/Tests/OpenUsageTests/TelemetryRecorderTests.swift +++ b/Tests/OpenUsageTests/TelemetryRecorderTests.swift @@ -99,6 +99,53 @@ final class TelemetryRecorderTests: XCTestCase { } } + func testDailyActiveNeverIncludesAccountDerivedIdentifiers() { + let sink = FakeSink() + let store = makeStore("account-safe-daily-active") + let accountSnapshot = TelemetryConfigSnapshot( + enabledProviders: ["claude", "claude@ab12cd34", "codex@deadbeef"], + enabledMetricIDs: ["claude.session", "claude@ab12cd34.session", "codex@deadbeef.weekly"], + pinnedMetricIDs: ["claude@ab12cd34.session"], + expandedMetricIDs: ["codex@deadbeef.weekly", "codex.weekly"], + menuBarStyle: "text" + ) + let recorder = TelemetryRecorder( + sink: sink, + store: store, + snapshot: { accountSnapshot }, + now: { self.day(25) } + ) + + recorder.tick() + + let event = try! XCTUnwrap(sink.events(named: "app_daily_active").first) + XCTAssertEqual(event["enabled_providers"] as? [String], ["claude", "codex"]) + XCTAssertEqual(event["enabled_metric_ids"] as? [String], ["claude.session", "codex.weekly"]) + XCTAssertEqual(event["pinned_metric_ids"] as? [String], ["claude.session"]) + XCTAssertEqual(event["expanded_metric_ids"] as? [String], ["codex.weekly"]) + } + + func testAccountRefreshesShareOneFamilyRollupWithoutPersistingAccountIDs() { + let sink = FakeSink() + let store = makeStore("account-safe-provider-rollup") + var clock = day(25) + let recorder = TelemetryRecorder(sink: sink, store: store, snapshot: { self.snapshot }, now: { clock }) + + recorder.record(providerID: "claude", outcome: .refreshed, category: nil, manual: false) + recorder.record(providerID: "claude@ab12cd34", outcome: .failed, category: .network, manual: true) + + XCTAssertEqual(Set(store.providerCounters().keys), ["claude"]) + + clock = day(26) + recorder.tick() + + let event = try! XCTUnwrap(sink.events(named: "provider_refresh_daily").first) + XCTAssertEqual(event["provider_id"] as? String, "claude") + XCTAssertEqual(event["success_count"] as? Int, 1) + XCTAssertEqual(event["failure_count"] as? Int, 1) + XCTAssertEqual(event["manual_refresh_count"] as? Int, 1) + } + func testTickFlushesStalePriorDayCounterEvenWithoutNewOutcomes() { let sink = FakeSink() let store = makeStore("sweep") From a3fef8d9b5a214e28caf914b9f43fadc5a45e6f3 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Sun, 19 Jul 2026 08:40:12 +0400 Subject: [PATCH 05/24] Rename cards: context menu + Customize name field, live titles from the account registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account registry (ProviderAccountsStore) is now @Observable and shared between the launch pass and the UI, so a rename (customLabel) re-titles the card everywhere without a relaunch. "Rename…" lives in the card's right-click menu (alert with a text field; empty clears back to the derived name) and as a Name field at the top of the card's Customize detail. Share cards render outside the environment, so the live name is passed in explicitly. Co-authored-by: Cursor --- Sources/OpenUsage/App/AppContainer.swift | 35 +++++++++++++- .../Services/ProviderAccountAssembly.swift | 18 ++++--- .../Stores/ProviderAccountsStore.swift | 24 ++++++---- .../OpenUsage/Support/ShareCardRenderer.swift | 8 +++- .../Views/CustomizeProviderDetailView.swift | 47 +++++++++++++++++++ Sources/OpenUsage/Views/HeaderView.swift | 6 ++- Sources/OpenUsage/Views/PopoverTopBar.swift | 9 +++- Sources/OpenUsage/Views/ProviderListRow.swift | 6 ++- .../Views/ProviderSectionHeader.swift | 7 ++- Sources/OpenUsage/Views/ShareCardView.swift | 6 ++- .../Views/WidgetGroupedListView.swift | 40 ++++++++++++++-- .../ProviderAccountsStoreTests.swift | 34 +++++++++++++- docs/dashboard.md | 4 +- docs/providers/claude.md | 16 +++++++ 14 files changed, 226 insertions(+), 34 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 2b6a2bacd..78f13645c 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -41,6 +41,9 @@ final class AppContainer { /// provider were ever removed from the registry. Injected into the view tree via /// `\.codexResetClaim`. let codexResetClaim: CodexResetClaimService? + /// The account registry the launch pass reconciled. The UI observes it live: a rename + /// (`customLabel`) re-titles the card everywhere without a relaunch. + let accounts: ProviderAccountsStore /// 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] @@ -70,7 +73,9 @@ final class AppContainer { // The launch account pass: which account is signed in at each family's default home, plus // the config-dir scan for extra Claude logins. Feeds the snapshot cache's account stamp, // reconciles the account registry, and hands the catalog its extra-card build plan. - let accountAssembly = ProviderAccountAssembly.make(waitsForLoginShell: true) + let accounts = ProviderAccountsStore() + let accountAssembly = ProviderAccountAssembly.make(accountsStore: accounts, waitsForLoginShell: true) + self.accounts = accounts let providers = ProviderCatalog.make( claudeCards: accountAssembly.claudeCards, @@ -227,6 +232,34 @@ final class AppContainer { shellEnvironmentSnapshotTask.cancel() } + /// The name a card renders under right now. Live: a rename in the account registry wins over + /// the name baked into the `Provider` at launch, so card titles update without a relaunch. + /// Non-account providers (no record) keep their static display name. + func displayName(for provider: Provider) -> String { + guard let record = accounts.records.first(where: { $0.id == provider.id }) else { + return provider.displayName + } + if let custom = record.customLabel?.nilIfEmpty { return custom } + // A rename was cleared mid-session: fall back to the derived name rather than the + // launch-baked one, which may itself carry the just-cleared rename. + return derivedDisplayName(for: record) + } + + /// The name a card carries without a rename — the Customize name field's placeholder, and the + /// fallback once a rename is cleared: the stock family name for the default card, the + /// account-label-derived name for an extra card. + func derivedDisplayName(for record: ProviderAccountRecord) -> String { + ProviderAccountID.isAccountCard(record.id) + ? ClaudeAccountCard.displayName(customLabel: nil, label: record.label, id: record.id) + : ProviderAccountID.family(of: record.id).capitalized + } + + /// Whether the card has an account record a rename can attach to (accounts-model families only, + /// and only once the account's identity has been observed at least once). + func canRename(_ providerID: String) -> Bool { + accounts.records.contains { $0.id == providerID } + } + /// Re-runs first-launch credential detection on demand — the enablement half of the Customize /// "Reset All" action (`LayoutStore.resetToDefault` handles metrics, order, pins, and expansion). /// Delegates to `FirstRunSeeder.reseed`; returns its detection task so callers can await it. diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index c817131ed..c0a5a86dc 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -53,8 +53,14 @@ struct ProviderAccountAssembly { /// `waitsForLoginShell`: true for the menu-bar app (a Finder/Dock launch inherits no shell /// exports, so the pass leans on the login-shell layers), false for the one-shot CLI (a terminal - /// launch's process environment already carries the user's exports). - static func make(defaults: UserDefaults = .standard, waitsForLoginShell: Bool) -> ProviderAccountAssembly { + /// launch's process environment already carries the user's exports). The app passes its own + /// `accountsStore` so the registry the pass reconciles is the same instance the UI observes for + /// renames; the CLI omits it and gets a throwaway. + static func make( + defaults: UserDefaults = .standard, + accountsStore: ProviderAccountsStore? = nil, + waitsForLoginShell: Bool + ) -> ProviderAccountAssembly { // The identity read needs the login shell's exports (CLAUDE_CONFIG_DIR/CODEX_HOME name the // default homes), and it reads them through the very same reader the provider auth stores // use — `ProcessEnvironmentReader`, which pins identity-relevant keys to the persisted @@ -82,7 +88,7 @@ struct ProviderAccountAssembly { } return make( observer: DefaultAccountObserver(), - accountsStore: ProviderAccountsStore(defaults: defaults), + accountsStore: accountsStore ?? ProviderAccountsStore(defaults: defaults), families: families, claudeDiscovery: ClaudeConfigDirDiscovery() ) @@ -108,7 +114,7 @@ struct ProviderAccountAssembly { claudeDiscovery: ClaudeConfigDirDiscovery? = nil ) -> ProviderAccountAssembly { var identityKeys: [String: String] = [:] - var observations: [ProviderAccountsStore.Observation] = [] + var observations: [ProviderAccountsStore.AccountObservation] = [] let outcomes: [(family: String, outcome: DefaultAccountObserver.Outcome)] = [ ("claude", { observer.observeClaude() }), @@ -120,7 +126,7 @@ struct ProviderAccountAssembly { switch outcome { case .resolved(let identityKey, let label, let anchor): identityKeys[family] = identityKey - observations.append(ProviderAccountsStore.Observation( + observations.append(ProviderAccountsStore.AccountObservation( family: family, identityKey: identityKey, label: label, @@ -178,7 +184,7 @@ struct ProviderAccountAssembly { } AppLog.info(.config, "discovery: \(findings.count) config dir(s) fold onto the default claude card (same account)") } else { - observations.append(ProviderAccountsStore.Observation( + observations.append(ProviderAccountsStore.AccountObservation( family: "claude", identityKey: identityKey, label: findings.first?.label, diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index d3056407d..6b7965b5a 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import Observation /// Card-id helpers for the account-first model. The account occupying a family's default home when /// first observed keeps the bare family id (`claude`, `codex`) as its permanent record id — that is @@ -11,9 +12,14 @@ enum ProviderAccountID { /// `claude@ab12cd34` — a stable, non-reversible id derived from the account's identity key. static func make(family: String, identityKey: String) -> String { + "\(family)@\(hash8(identityKey))" + } + + /// The 8-hex-char identity digest card ids are built from, exposed for other identity-derived + /// ids (the iCloud remote-only pseudo providers). + static func hash8(_ identityKey: String) -> String { let digest = SHA256.hash(data: Data(identityKey.lowercased().utf8)) - let hash8 = digest.prefix(4).map { String(format: "%02x", $0) }.joined() - return "\(family)@\(hash8)" + return digest.prefix(4).map { String(format: "%02x", $0) }.joined() } /// The family a card id belongs to: `claude@ab12cd34` → `claude`, bare ids map to themselves. @@ -80,10 +86,11 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { } /// The account-first registry (`openusage.providerAccounts.v2`). Reconciled at every launch from the -/// default-home identity reads; authoritative from day one — there is no parallel card model to drift -/// from. With a single account per family (all Phase 1 can observe), the registry is bookkeeping the -/// UI doesn't consult yet; multi-account rendering (Phase 2+) reads cards straight from these records. +/// default-home identity reads and the config-dir scan; authoritative from day one — there is no +/// parallel card model to drift from. Extra account cards render straight from these records, and +/// the UI observes it live for renames (`customLabel`). @MainActor +@Observable final class ProviderAccountsStore { static let storageKey = "openusage.providerAccounts.v2" static let legacyStorageKey = "openusage.providerAccounts.v1" @@ -108,7 +115,8 @@ final class ProviderAccountsStore { } /// One account observed this launch, before reconciliation assigns (or re-finds) its record id. - struct Observation { + /// (Named to avoid colliding with the `Observation` module the `@Observable` macro expands into.) + struct AccountObservation { var family: String var identityKey: String var label: String? @@ -121,7 +129,7 @@ final class ProviderAccountsStore { /// — an account that went unobserved (logged out, unreadable identity) is simply left as it was, /// except that a newly observed default-home holder takes the default badge off every sibling. @discardableResult - func reconcile(with observations: [Observation]) -> [ProviderAccountRecord] { + func reconcile(with observations: [AccountObservation]) -> [ProviderAccountRecord] { var updated = records var changed = false @@ -197,7 +205,7 @@ final class ProviderAccountsStore { /// account observed at the family's DEFAULT home may claim the bare id — that id's runtime reads /// the default home, so handing it to a custom-config-dir account would point the existing card /// at a login it can't read. - private static func availableID(for observation: Observation, in records: [ProviderAccountRecord]) -> String { + private static func availableID(for observation: AccountObservation, in records: [ProviderAccountRecord]) -> String { let observedAtDefaultHome = observation.sources.contains { $0.kind == .defaultHome } if observedAtDefaultHome, !records.contains(where: { $0.id == observation.family }) { return observation.family diff --git a/Sources/OpenUsage/Support/ShareCardRenderer.swift b/Sources/OpenUsage/Support/ShareCardRenderer.swift index 9aae39a4c..1bfad5fba 100644 --- a/Sources/OpenUsage/Support/ShareCardRenderer.swift +++ b/Sources/OpenUsage/Support/ShareCardRenderer.swift @@ -63,12 +63,15 @@ enum ShareCardRenderer { /// rows read density via `@AppStorage`, so the saved value is swapped to `.regular` for the duration /// of the render and restored on exit (synchronously), keeping the exported card consistent without /// disturbing the live popover. + /// `displayName` carries the live card title (a rename can land mid-session, after the + /// `Provider`'s own name was baked at launch); `nil` falls back to the baked name. @discardableResult static func share( group: ProviderGroup, dataStore: WidgetDataStore, layout: LayoutStore, - appearance: ColorScheme + appearance: ColorScheme, + displayName: String? = nil ) -> Bool { let isExpanded = layout.isProviderExpanded(group.provider.id) let alwaysRows = group.alwaysShownWidgets.compactMap { widget -> WidgetData? in @@ -85,7 +88,8 @@ enum ShareCardRenderer { plan: dataStore.plan(for: group.provider.id), rows: rows, appearance: appearance, - expandBoundaryIndex: isExpanded ? alwaysRows.count : nil + expandBoundaryIndex: isExpanded ? alwaysRows.count : nil, + displayNameOverride: displayName ) return renderAndCopy(view, label: group.provider.id, layout: layout) } diff --git a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift index f77854281..a8a072847 100644 --- a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift +++ b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift @@ -28,6 +28,9 @@ struct CustomizeProviderDetailView: View { var body: some View { if let group = layout.customizeDetail(for: providerID) { VStack(alignment: .leading, spacing: density.sectionSpacing) { + if container.canRename(providerID) { + CardNameSection(providerID: providerID) + } metricSections(group) .simultaneousGesture(metricDragGesture()) if let keyProvider = container.apiKeyProviders.first(where: { $0.provider.id == providerID }) { @@ -180,6 +183,50 @@ struct CustomizeProviderDetailView: View { } } +/// The card-name editor shown at the top of an account card's Customize detail (Claude/Codex cards +/// with an account record). The field holds the user's rename; the placeholder shows the derived +/// name the card falls back to, so clearing the field reads as "back to the default". Commits on +/// Return and when focus leaves the field — never per keystroke, so half-typed names don't persist. +private struct CardNameSection: View { + let providerID: String + @Environment(AppContainer.self) private var container + @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + @State private var draft = "" + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: density.headerToCardSpacing) { + Text("Name") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + TextField(placeholder, text: $draft) + .textFieldStyle(.plain) + .focused($isFocused) + .onSubmit { commit() } + .padding(.horizontal, 12) + .padding(.vertical, density.controlRowPadding) + .cardSurface() + } + .onAppear { draft = record?.customLabel ?? "" } + .onChange(of: isFocused) { _, focused in + if !focused { commit() } + } + } + + private var record: ProviderAccountRecord? { + container.accounts.records.first { $0.id == providerID } + } + + private var placeholder: String { + record.map { container.derivedDisplayName(for: $0) } ?? "" + } + + private func commit() { + container.accounts.rename(cardID: providerID, to: draft) + } +} + /// 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 diff --git a/Sources/OpenUsage/Views/HeaderView.swift b/Sources/OpenUsage/Views/HeaderView.swift index 6c5900897..5f6dedf70 100644 --- a/Sources/OpenUsage/Views/HeaderView.swift +++ b/Sources/OpenUsage/Views/HeaderView.swift @@ -23,6 +23,7 @@ import SwiftUI /// and fire while the menu is open, so the monitor and the items never double-fire. ⌘Q (Quit) is /// unowned elsewhere, so it rides its menu item directly. struct HeaderView: View { + @Environment(AppContainer.self) private var container @Environment(LayoutStore.self) private var layout @Environment(WidgetDataStore.self) private var dataStore @Environment(UpdaterController.self) private var updater @@ -132,7 +133,7 @@ struct HeaderView: View { .disabled(true) } else { ForEach(groups) { group in - Button(group.provider.displayName) { shareCard(group) } + Button(container.displayName(for: group.provider)) { shareCard(group) } } } } label: { @@ -150,7 +151,8 @@ struct HeaderView: View { group: group, dataStore: dataStore, layout: layout, - appearance: colorScheme + appearance: colorScheme, + displayName: container.displayName(for: group.provider) ) } diff --git a/Sources/OpenUsage/Views/PopoverTopBar.swift b/Sources/OpenUsage/Views/PopoverTopBar.swift index 7ce5e6aa7..066e0dfb1 100644 --- a/Sources/OpenUsage/Views/PopoverTopBar.swift +++ b/Sources/OpenUsage/Views/PopoverTopBar.swift @@ -10,6 +10,9 @@ struct PopoverTopBar: View { @Binding var isPresentingResetAllConfirm: Bool + /// Read for the live card name, so a renamed card's Customize detail title follows the rename. + @Environment(AppContainer.self) private var container + @ViewBuilder var body: some View { switch layout.screen { @@ -43,7 +46,9 @@ struct PopoverTopBar: View { } private var customizeTitle: String { - layout.customizeProviderID.flatMap { layout.provider(id: $0)?.displayName } ?? "Customize" + layout.customizeProviderID.flatMap { id in + layout.provider(id: id).map { container.displayName(for: $0) } + } ?? "Customize" } private func customizeBack() { @@ -102,7 +107,7 @@ struct PopoverTopBar: View { .glassButtonStyle() .buttonBorderShape(.circle) .controlSize(.large) - .hoverTooltip("Reset \(layout.provider(id: providerID)?.displayName ?? providerID)") + .hoverTooltip("Reset \(layout.provider(id: providerID).map { container.displayName(for: $0) } ?? providerID)") .accessibilityLabel("Reset") } diff --git a/Sources/OpenUsage/Views/ProviderListRow.swift b/Sources/OpenUsage/Views/ProviderListRow.swift index db9fccaf7..41c733b61 100644 --- a/Sources/OpenUsage/Views/ProviderListRow.swift +++ b/Sources/OpenUsage/Views/ProviderListRow.swift @@ -15,6 +15,8 @@ struct ProviderListRow: View { var onOpen: () -> Void = {} @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + /// Read for the live card name, so a rename re-titles the Customize row without a relaunch. + @Environment(AppContainer.self) private var container var body: some View { HStack(spacing: 10) { @@ -29,7 +31,7 @@ struct ProviderListRow: View { ProviderIcon(source: provider.icon) .frame(width: 18, height: 18) VStack(alignment: .leading, spacing: 0) { - Text(provider.displayName) + Text(container.displayName(for: provider)) .font(.system(size: density.headerPointSize, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) @@ -54,7 +56,7 @@ struct ProviderListRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel("Open \(provider.displayName)") + .accessibilityLabel("Open \(container.displayName(for: provider))") } .padding(.horizontal, 12) .padding(.vertical, density.controlRowPadding) diff --git a/Sources/OpenUsage/Views/ProviderSectionHeader.swift b/Sources/OpenUsage/Views/ProviderSectionHeader.swift index a079bfaf6..a16ce7cec 100644 --- a/Sources/OpenUsage/Views/ProviderSectionHeader.swift +++ b/Sources/OpenUsage/Views/ProviderSectionHeader.swift @@ -28,6 +28,9 @@ struct ProviderSectionHeader: View { /// Header type and icon track the density setting like the rows do, so Compact shrinks the /// whole section anatomy — not just the rows under it. @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + /// Read for the live card name: a rename lands in the account registry and re-titles the header + /// without a relaunch (the `Provider`'s own name is baked at launch). + @Environment(AppContainer.self) private var container /// Party easter egg: pulse the provider mark. Off by default everywhere else. @Environment(\.popoverPartyMode) private var partyMode @State private var isHovered = false @@ -61,7 +64,7 @@ struct ProviderSectionHeader: View { // Name + plan keep their width and stay on one line; under width pressure (a long plan // name like "Super Grok Heavy") the lower-priority stale tag truncates first instead of // wrapping the name to a second line. - Text(provider.displayName) + Text(container.displayName(for: provider)) .font(.system(size: density.headerPointSize, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) @@ -95,7 +98,7 @@ struct ProviderSectionHeader: View { Spacer(minLength: 8) if let onCopyScreenshot { CopyFeedbackButton( - accessibilityLabel: "Copy \(provider.displayName) Screenshot", + accessibilityLabel: "Copy \(container.displayName(for: provider)) Screenshot", isRevealed: isHovered, action: onCopyScreenshot ) diff --git a/Sources/OpenUsage/Views/ShareCardView.swift b/Sources/OpenUsage/Views/ShareCardView.swift index e1041a01f..77faad42a 100644 --- a/Sources/OpenUsage/Views/ShareCardView.swift +++ b/Sources/OpenUsage/Views/ShareCardView.swift @@ -20,6 +20,10 @@ struct ShareCardView: View { /// neighbor-aware condensing treats the expand caret as a hard boundary the way the live dashboard /// does. `nil` when the provider is collapsed (no expanded section). var expandBoundaryIndex: Int? = nil + /// The live card title when it differs from the launch-baked `provider.displayName` (a rename can + /// land mid-session). Passed explicitly — this view renders in an `ImageRenderer`, outside the + /// app's environment, so it can't read the account registry itself. + var displayNameOverride: String? = nil /// Authored card width in points. The renderer multiplies this by `ShareCardRenderer.scale` for the /// PNG's pixel width; the height is whatever the rows add up to (flexible). @@ -42,7 +46,7 @@ struct ShareCardView: View { ProviderIcon(source: provider.icon, inset: 0.04) .frame(width: 22, height: 22) HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(provider.displayName) + Text(displayNameOverride ?? provider.displayName) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) diff --git a/Sources/OpenUsage/Views/WidgetGroupedListView.swift b/Sources/OpenUsage/Views/WidgetGroupedListView.swift index 0fe233d3f..36aa50868 100644 --- a/Sources/OpenUsage/Views/WidgetGroupedListView.swift +++ b/Sources/OpenUsage/Views/WidgetGroupedListView.swift @@ -20,6 +20,9 @@ struct WidgetGroupedListView: View { @State private var frameStore = ReorderFrameStore() @State private var activeProviderID: String? @State private var activeMetricID: String? + /// The card the "Rename…" alert is currently editing; `nil` when the alert is closed. + @State private var renameCardID: String? + @State private var renameDraft = "" @AppStorage(DensitySetting.key) private var density = DensitySetting.regular var body: some View { @@ -33,6 +36,25 @@ struct WidgetGroupedListView: View { .frame(maxWidth: .infinity, alignment: .leading) .onPreferenceChange(ReorderFramePreferenceKey.self) { frameStore.frames = $0 } .animation(Motion.spring, value: layout.displayGroups.map(\.provider.id)) + .alert("Rename Card", isPresented: isRenamePresented) { + TextField("Name", text: $renameDraft) + Button("Rename") { + if let renameCardID { + // A cleared field resets the card back to its derived name. + container.accounts.rename(cardID: renameCardID, to: renameDraft) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Leave the name empty to go back to the default.") + } + } + + private var isRenamePresented: Binding { + Binding( + get: { renameCardID != nil }, + set: { if !$0 { renameCardID = nil } } + ) } private func section(_ group: ProviderGroup) -> some View { @@ -57,15 +79,24 @@ struct WidgetGroupedListView: View { .padding(.horizontal, 8) .highPriorityGesture(providerDragGesture(for: group)) .contextMenu { + let name = container.displayName(for: group.provider) // Hides the whole provider section (the Customize provider list brings it back). Mirrors // the per-metric "Hide" but one level up, so the verb order reads the same on a header as a row. - Button("Hide \(group.provider.displayName)") { + Button("Hide \(name)") { container.enablement.setEnabled(false, for: group.provider.id) } Divider() - Button("Refresh \(group.provider.displayName)") { + Button("Refresh \(name)") { Task { await dataStore.refresh(providerID: group.provider.id, force: true) } } + // Renaming needs an account record to write to, so it only shows on account-model cards + // whose identity has been observed at least once. + if container.canRename(group.provider.id) { + Button("Rename…") { + renameDraft = name + renameCardID = group.provider.id + } + } Button("Customize…") { openCustomize(for: group.provider.id) } @@ -85,7 +116,8 @@ struct WidgetGroupedListView: View { group: group, dataStore: dataStore, layout: layout, - appearance: colorScheme + appearance: colorScheme, + displayName: container.displayName(for: group.provider) ) } @@ -261,7 +293,7 @@ struct WidgetGroupedListView: View { } Divider() if let provider = layout.provider(id: providerID) { - Button("Refresh \(provider.displayName)") { + Button("Refresh \(container.displayName(for: provider))") { Task { await dataStore.refresh(providerID: providerID, force: true) } } } diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index 74b538298..6615eaa56 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -16,8 +16,8 @@ final class ProviderAccountsStoreTests: XCTestCase { identityKey: String, label: String? = nil, anchor: String = "/Users/dev/.claude" - ) -> ProviderAccountsStore.Observation { - ProviderAccountsStore.Observation( + ) -> ProviderAccountsStore.AccountObservation { + ProviderAccountsStore.AccountObservation( family: family, identityKey: identityKey, label: label, @@ -140,6 +140,36 @@ final class ProviderAccountsStoreTests: XCTestCase { XCTAssertTrue(ProviderAccountsStore(defaults: defaults).records.isEmpty) } + func testRenamePersistsAndAClearedNameFallsBackToTheDerivedOne() { + let defaults = makeScratchDefaults() + let store = ProviderAccountsStore(defaults: defaults) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a", label: "a@example.com")]) + + store.rename(cardID: "claude", to: " Work ") + XCTAssertEqual(store.records[0].customLabel, "Work", "renames are trimmed") + XCTAssertEqual(ProviderAccountsStore(defaults: defaults).records[0].customLabel, "Work", "renames persist") + + store.rename(cardID: "claude", to: " ") + XCTAssertNil(store.records[0].customLabel, "a blank rename clears back to the derived name") + XCTAssertEqual(store.records[0].displayLabel, "a@example.com") + + store.rename(cardID: "missing", to: "X") + XCTAssertEqual(store.records.count, 1, "renaming an unknown card is a no-op") + } + + func testReconcileNeverTouchesACustomLabel() { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a", label: "old")]) + store.rename(cardID: "claude", to: "Work") + + let records = store.reconcile(with: [ + defaultHomeObservation(family: "claude", identityKey: "acct-a", label: "new"), + ]) + + XCTAssertEqual(records[0].label, "new") + XCTAssertEqual(records[0].customLabel, "Work", "rescans update the label but never the rename") + } + func testFamilyHelperSplitsCardIDs() { XCTAssertEqual(ProviderAccountID.family(of: "claude"), "claude") XCTAssertEqual(ProviderAccountID.family(of: "claude@ab12cd34"), "claude") diff --git a/docs/dashboard.md b/docs/dashboard.md index 6888f41d8..24d0a8051 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -52,7 +52,7 @@ Rows with a reset date tick every 30 seconds, so countdowns and pace stay live b ## Right-click menus Every row: **Hide · Star for menu bar / Unstar · Refresh \ · Customize…** (Customize opens straight to that provider's metrics.) -Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). +Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). Claude and Codex cards also offer **Rename…** — give the card any name you like (handy with multiple accounts); leave the field empty to go back to the default name. ## Share @@ -73,7 +73,7 @@ Open Customize from the footer's **Options** menu (or press **Return**). It's a The **provider list** shows every provider with a switch to turn it on or off, a count of its metrics, and a chevron into its detail. Turn a provider off and it stays in the list, greyed — its metrics hide from the dashboard and menu bar but keep their setup for when you turn it back on. Drag enabled providers by their grip to reorder; tap a row to open its detail. On a fresh install only the providers detected on your Mac start on (see "First launch" above); this list is where you add the rest. -A provider's **detail** has a back button and provider-specific Reset control in its top bar, followed by two metric sections: **Always Visible** (shown on the dashboard card) and **On Demand** (tucked behind the card's caret). Each metric row has a drag grip, its name, an always-visible star for the menu bar, and an on/off switch. Drag a metric into the other card—or onto one of that card's rows—to move it between Always Visible and On Demand. An empty card shows a dashed **Drag metrics here** target. You can star up to two metrics per provider. OpenRouter and Z.ai also show an **API Key** section here, where you can add, replace, reveal, or clear that provider's key. +A provider's **detail** has a back button and provider-specific Reset control in its top bar. Claude and Codex cards start with a **Name** field — the same rename the card's right-click menu offers; clear it to go back to the default name. Then come two metric sections: **Always Visible** (shown on the dashboard card) and **On Demand** (tucked behind the card's caret). Each metric row has a drag grip, its name, an always-visible star for the menu bar, and an on/off switch. Drag a metric into the other card—or onto one of that card's rows—to move it between Always Visible and On Demand. An empty card shows a dashed **Drag metrics here** target. You can star up to two metrics per provider. OpenRouter and Z.ai also show an **API Key** section here, where you can add, replace, reveal, or clear that provider's key. Drag-reorder also works directly on the dashboard — drag a row within its provider, drag it across the caret boundary while the card is open, or drag a provider header to reorder sections. On a Force Touch trackpad you'll feel a light tap each time the dragged item snaps into a new slot. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 443f2c898..c6131629d 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -45,6 +45,22 @@ Today / Yesterday / Last 30 Days are computed **locally**: OpenUsage reads the C Local spend does not require a Claude OAuth login. If Claude Code uses an API-key gateway instead, the spend tiles and usage trend still load from its session logs; the Claude header shows **Not logged in** because the live Session and Weekly meters still require a Claude subscription login. +## Multiple accounts + +If you keep more than one Claude login on this Mac using custom config dirs (separate `CLAUDE_CONFIG_DIR` +homes, each with its own sign-in), OpenUsage finds them at launch and gives each **account** its own +card, with its own limits, plan, and spend tiles read from that home. A custom dir signed into the same +account as your main login doesn't become a second card — its session logs simply count into the main +card's spend tiles. + +Extra cards are named from the account ("Claude — Acme Corp"); right-click a card and choose **Rename…** +(or use the Name field in Customize) to call it whatever you like. A card only shows while its login is +still found on this Mac — log it out or delete the dir and the card disappears, keeping its +customization and history for if it returns. Turn a card off like any provider in Customize. + +In the [CLI](../cli.md) and [local API](../local-http-api.md), extra cards appear under ids like +`claude@ab12cd34`; requesting `claude` returns every Claude card. + ## Troubleshooting - **"Not logged in"** — run `claude` and sign in to enable live subscription limits, then refresh. If you use an API-key gateway, local spend still appears whenever Claude Code has written session logs. From 49e2ec667c60a1c7e28c5495a28e9b11da9cd0d4 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Sun, 19 Jul 2026 14:27:11 +0400 Subject: [PATCH 06/24] Account-first Phase 2b: one name resolver for card titles (#1031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames never bake into the launch Provider anymore — Provider.displayName only ever carries the derived default, and everything human-facing resolves the live name through the account registry at render time: Total Spend slices carry a caller-resolved title (legend + share export), menu bar VoiceOver text, notification titles, and the CLI/HTTP API resolve at their boundaries. Co-authored-by: Cursor --- Sources/OpenUsage/App/AppContainer.swift | 33 ++++++--------- .../App/StatusItemImageUpdater.swift | 3 +- Sources/OpenUsage/Models/MenuBarContent.swift | 10 ++++- .../OpenUsage/Models/ProviderSnapshot.swift | 6 ++- .../OpenUsage/Providers/ProviderCatalog.swift | 4 ++ .../OpenUsage/Services/LocalUsageAPI.swift | 16 ++++++++ .../Services/ProviderAccountAssembly.swift | 28 ++++--------- Sources/OpenUsage/Services/UsageReader.swift | 5 +++ .../Stores/ProviderAccountsStore.swift | 40 +++++++++++++++++-- .../OpenUsage/Stores/WidgetDataStore.swift | 12 +++++- .../Support/TotalSpendAggregator.swift | 22 ++++++++-- .../Views/CustomizeProviderDetailView.swift | 2 +- Sources/OpenUsage/Views/TotalSpendCard.swift | 12 ++++-- Tests/OpenUsageTests/LocalUsageAPITests.swift | 15 +++++++ .../OpenUsageTests/MenuBarContentTests.swift | 11 +++++ .../ProviderAccountAssemblyTests.swift | 13 ++++-- .../ProviderAccountsStoreTests.swift | 3 +- .../TotalSpendAggregatorTests.swift | 19 +++++++++ docs/dashboard.md | 2 +- docs/local-http-api.md | 2 + 20 files changed, 193 insertions(+), 65 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 78f13645c..06db296d3 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -98,7 +98,8 @@ final class AppContainer { isProviderEnabled: { [enablement] in enablement.isEnabled($0) }, orderedDescriptors: { [layout] in layout.visiblePlaced.compactMap { layout.descriptor(for: $0) } }, notificationSettings: { notificationSettings }, - providerIdentityKeys: accountAssembly.identityKeysByCard + providerIdentityKeys: accountAssembly.identityKeysByCard, + resolveDisplayName: { [accounts] in accounts.resolvedDisplayName(cardID: $0) } ) let iCloudSync = ICloudUsageSyncStore(dataStore: dataStore) // Re-enabling a provider should fetch it promptly, so clear any leftover failure backoff before @@ -208,7 +209,7 @@ final class AppContainer { self.telemetry = telemetry self.transparency = PopoverTransparencyStore() self.privacy = MenuBarPrivacyStore() - self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore] in + self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore, accounts] in LocalUsageAPI.State( enabledOrderedIDs: layout.orderedProviderIDs().filter { enablement.isEnabled($0) }, knownIDs: Set(registry.providers.map(\.id)), @@ -216,6 +217,9 @@ final class AppContainer { limitDescriptors: registry.limitDescriptorsByProvider, errors: dataStore.providerErrors ) + // API output is human-read too: resolve card titles at respond time so renames show, + // exactly like every UI surface. + .resolvingDisplayNames(accounts.resolvedDisplayNamesByCardID) }) self.refreshTask = Self.startPeriodicRefresh(dataStore: dataStore, telemetry: telemetry) localAPI.start() @@ -232,26 +236,13 @@ final class AppContainer { shellEnvironmentSnapshotTask.cancel() } - /// The name a card renders under right now. Live: a rename in the account registry wins over - /// the name baked into the `Provider` at launch, so card titles update without a relaunch. - /// Non-account providers (no record) keep their static display name. + /// The name a card renders under right now — the app-side face of the one resolver + /// (`ProviderAccountRecord.resolvedDisplayName`). Live: a rename in the account registry + /// re-titles the card everywhere without a relaunch. Non-account providers (no record) keep + /// their static display name; `Provider.displayName` itself only ever carries the derived + /// default, so the fallback can never be a stale rename. func displayName(for provider: Provider) -> String { - guard let record = accounts.records.first(where: { $0.id == provider.id }) else { - return provider.displayName - } - if let custom = record.customLabel?.nilIfEmpty { return custom } - // A rename was cleared mid-session: fall back to the derived name rather than the - // launch-baked one, which may itself carry the just-cleared rename. - return derivedDisplayName(for: record) - } - - /// The name a card carries without a rename — the Customize name field's placeholder, and the - /// fallback once a rename is cleared: the stock family name for the default card, the - /// account-label-derived name for an extra card. - func derivedDisplayName(for record: ProviderAccountRecord) -> String { - ProviderAccountID.isAccountCard(record.id) - ? ClaudeAccountCard.displayName(customLabel: nil, label: record.label, id: record.id) - : ProviderAccountID.family(of: record.id).capitalized + accounts.resolvedDisplayName(cardID: provider.id) ?? provider.displayName } /// Whether the card has an account record a rename can attach to (accounts-model families only, diff --git a/Sources/OpenUsage/App/StatusItemImageUpdater.swift b/Sources/OpenUsage/App/StatusItemImageUpdater.swift index b361ce0d8..dd80c0bad 100644 --- a/Sources/OpenUsage/App/StatusItemImageUpdater.swift +++ b/Sources/OpenUsage/App/StatusItemImageUpdater.swift @@ -68,7 +68,8 @@ final class StatusItemImageUpdater { } let content = MenuBarContentBuilder.build( groups: container.layout.pinnedGroups, - data: { container.dataStore.data(for: $0) } + data: { container.dataStore.data(for: $0) }, + title: { container.displayName(for: $0) } ) return MenuBarStripRenderer.image(for: content, style: container.layout.menuBarStyle) ?? MenuBarIcon.image diff --git a/Sources/OpenUsage/Models/MenuBarContent.swift b/Sources/OpenUsage/Models/MenuBarContent.swift index 39c0f373b..743d308e8 100644 --- a/Sources/OpenUsage/Models/MenuBarContent.swift +++ b/Sources/OpenUsage/Models/MenuBarContent.swift @@ -58,13 +58,19 @@ enum MenuBarContentBuilder { /// The strip is dynamic: a pinned metric without data is dropped (one of two pins renders alone at /// full size), and a provider with no data-carrying pins contributes no icon at all. Pins are /// membership; the strip shows whatever subset is real right now. - static func build(groups: [ProviderMetrics], data: (WidgetDescriptor) -> WidgetData) -> MenuBarContent { + /// `title` resolves each provider's card title (the VoiceOver summary is a human-facing name, so + /// the caller passes the account-registry resolver); defaults to the baked derived name. + static func build( + groups: [ProviderMetrics], + data: (WidgetDescriptor) -> WidgetData, + title: (Provider) -> String = { $0.displayName } + ) -> MenuBarContent { let resolvedGroups = groups.compactMap { group -> MenuBarContent.Group? in let metrics = group.metrics.map { resolve($0, data($0)) }.filter(\.hasData) guard !metrics.isEmpty else { return nil } return MenuBarContent.Group( providerID: group.provider.id, - displayName: group.provider.displayName, + displayName: title(group.provider), icon: group.provider.icon, metrics: metrics ) diff --git a/Sources/OpenUsage/Models/ProviderSnapshot.swift b/Sources/OpenUsage/Models/ProviderSnapshot.swift index aa53e62c1..47122add1 100644 --- a/Sources/OpenUsage/Models/ProviderSnapshot.swift +++ b/Sources/OpenUsage/Models/ProviderSnapshot.swift @@ -3,7 +3,11 @@ import Foundation /// Latest normalized output for one provider refresh. struct ProviderSnapshot: Hashable, Sendable, Codable { let providerID: String - let displayName: String + /// The card title at refresh time — always the baked DERIVED name (renames never reach the + /// cache or iCloud). The CLI/API boundary re-resolves it against the account registry at + /// respond time (`LocalUsageAPI.State.resolvingDisplayNames`), so human-facing output carries + /// renames without persisting them. + var displayName: String var plan: String? var lines: [MetricLine] var refreshedAt: Date diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 4c4558e13..eb96d3ff4 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -20,6 +20,10 @@ enum ProviderCatalog { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, // then every other provider alphabetically by display name. Account cards slot in right after // their family's default card. + // + // Every baked `Provider.displayName` here is the DERIVED default — renames live only in the + // account registry and are resolved at render time (`ProviderAccountRecord.resolvedDisplayName`), + // so a baked name can never be a stale copy of one. var runtimes: [ProviderRuntime] = [] runtimes.append(ClaudeProvider( provider: ClaudeProvider.makeProvider( diff --git a/Sources/OpenUsage/Services/LocalUsageAPI.swift b/Sources/OpenUsage/Services/LocalUsageAPI.swift index 401997907..d114583b5 100644 --- a/Sources/OpenUsage/Services/LocalUsageAPI.swift +++ b/Sources/OpenUsage/Services/LocalUsageAPI.swift @@ -28,6 +28,22 @@ enum LocalUsageAPI { func matchingCardIDs(for token: String) -> [String] { knownIDs.filter { $0 == token || ProviderAccountID.family(of: $0) == token }.sorted() } + + /// A copy whose snapshots carry live card titles: `titles` maps card id → resolved title + /// (from the account registry, so renames show). Applied where the state is captured — the + /// snapshots themselves always store the derived name, so a rename never persists into the + /// cache or iCloud. Cards without an entry keep their baked name. + func resolvingDisplayNames(_ titles: [String: String]) -> State { + guard !titles.isEmpty else { return self } + var state = self + state.snapshots = snapshots.mapValues { snapshot in + guard let title = titles[snapshot.providerID] else { return snapshot } + var snapshot = snapshot + snapshot.displayName = title + return snapshot + } + return state + } } struct Response: Equatable, Sendable { diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index c0a5a86dc..e56c08f20 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -7,6 +7,9 @@ struct ClaudeAccountCard: Equatable, Sendable { /// The account's stable record id (`claude@ab12cd34`) — the card id everywhere: layout, cache, /// CLI/API matching. var id: String + /// The DERIVED card name (`ProviderAccountRecord.derivedDisplayName`) baked into the launch + /// `Provider`. Never a rename: renames live only in the account registry and are resolved at + /// render time, so a baked name can never be a stale copy of one. var displayName: String var identityKey: String /// The config dir the card's credentials and spend logs are pinned to. @@ -15,20 +18,6 @@ struct ClaudeAccountCard: Equatable, Sendable { var keychainLiteral: String /// Same-account additional config dirs (rare): extra spend-log roots, never extra credentials. var extraLogRoots: [URL] = [] - - /// The card name: the user's rename, else "Claude — " from the account label, - /// else the record id itself (owner decision 2: short-hash fallback, one rename away from good). - static func displayName(customLabel: String?, label: String?, id: String) -> String { - if let customLabel = customLabel?.nilIfEmpty { return customLabel } - guard let label = label?.nilIfEmpty else { return id } - // Labels are our own "email (Org Name)" format — prefer the org for a short card title. - if label.hasSuffix(")"), let open = label.lastIndex(of: "(") { - let org = label[label.index(after: open).. String? in guard record.id != "claude" else { return nil } - return ClaudeAccountCard.displayName(customLabel: nil, label: record.label, id: record.id) + return record.derivedDisplayName } return ProviderAccountAssembly( identityKeysByCard: identityKeys, diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 7bc8938d2..9c8a550b7 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -134,6 +134,10 @@ public struct UsageReader { .compactMap { id in errors[id].map { "\(id): \($0)" } } } + // CLI output is human-read: resolve card titles against the persisted account registry so + // renames show, matching the app's UI and HTTP API. Injected-provider tests use their own + // defaults suite, so this is a no-op there. + let accountTitles = ProviderAccountsStore(defaults: defaults).resolvedDisplayNamesByCardID let state = LocalUsageAPI.State( enabledOrderedIDs: enabledOrderedIDs, knownIDs: knownIDs, @@ -141,6 +145,7 @@ public struct UsageReader { limitDescriptors: registry.limitDescriptorsByProvider, errors: errors ) + .resolvingDisplayNames(accountTitles) let path = requestedToken.map { "/v1/limits/\($0)" } ?? "/v1/limits" let response = LocalUsageAPI.respond(method: "GET", path: path, state: state) guard let data = response.body else { diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index 6b7965b5a..e0f35d8d7 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -78,10 +78,29 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { /// Set by a future "Remove Account…". A tombstoned account is never resurrected by rescans. var removedTombstone: Bool = false - /// The name the card renders under: the user's rename, else the account's own label - /// ("email (Org Name)"), else the record id itself (`claude@ab12cd34` — owner decision 2). - var displayLabel: String { - customLabel?.nilIfEmpty ?? label?.nilIfEmpty ?? id + /// The name a card carries without a rename: the stock family name for the bare card, a + /// "Claude — " derived from the account label for an extra card, or the record id + /// itself when the account has no label (owner decision 2: short-hash fallback, one rename away + /// from good). Never contains `customLabel` — this is what gets baked into the launch + /// `Provider`, and baking a rename there is how stale-name bugs are born. + var derivedDisplayName: String { + guard ProviderAccountID.isAccountCard(id) else { return family.capitalized } + guard let label = label?.nilIfEmpty else { return id } + // Labels are our own "email (Org Name)" format — prefer the org for a short card title. + if label.hasSuffix(")"), let open = label.lastIndex(of: "(") { + let org = label[label.index(after: open).. String? { + records.first { $0.id == cardID }?.resolvedDisplayName + } + + /// Card id → resolved title for every record — the map the CLI/API boundary applies to its + /// snapshots (`LocalUsageAPI.State.resolvingDisplayNames`). + var resolvedDisplayNamesByCardID: [String: String] { + Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0.resolvedDisplayName) }) + } + /// Stores a user rename for a card; `nil` or blank clears it back to the derived name. func rename(cardID: String, to name: String?) { guard let index = records.firstIndex(where: { $0.id == cardID }) else { return } diff --git a/Sources/OpenUsage/Stores/WidgetDataStore.swift b/Sources/OpenUsage/Stores/WidgetDataStore.swift index 69d335b4e..941f0d6f6 100644 --- a/Sources/OpenUsage/Stores/WidgetDataStore.swift +++ b/Sources/OpenUsage/Stores/WidgetDataStore.swift @@ -38,6 +38,10 @@ final class WidgetDataStore { /// producer, and launch loads only paint an entry whose stamp matches. A card absent here has an /// unresolved identity this launch (or isn't account-aware) — its cache behaves as it always did. private let providerIdentityKeys: [String: String] + /// The live card title for a card id, `nil` for non-account providers — the account-registry + /// name resolver, injected by `AppContainer` so notification titles carry renames. `nil` + /// (tests, the one-shot CLI) falls back to the baked derived name. + private let resolveDisplayName: (@MainActor (String) -> String?)? /// Where a fired milestone is delivered: `(idPrefix, title, subtitle, body) -> Bool`. The Bool is /// whether it was actually delivered (authorized + scheduled); on false the caller leaves the /// milestone un-marked so it retries next pass. Injected so tests can record posts without a live @@ -143,7 +147,8 @@ final class WidgetDataStore { providerRefreshTimeout: TimeInterval = WidgetDataStore.defaultProviderRefreshTimeout, notificationSettings: (@MainActor () -> NotificationSettingsStore)? = nil, postNotification: (@MainActor (String, String, String, String) async -> Bool)? = nil, - providerIdentityKeys: [String: String] = [:] + providerIdentityKeys: [String: String] = [:], + resolveDisplayName: (@MainActor (String) -> String?)? = nil ) { precondition(slowProviderRefreshThreshold >= 0) precondition(providerRefreshTimeout > 0) @@ -163,6 +168,7 @@ final class WidgetDataStore { await AppNotifications.shared.post(idPrefix: idPrefix, title: title, subtitle: subtitle, body: body) } self.providerIdentityKeys = providerIdentityKeys + self.resolveDisplayName = resolveDisplayName self.meterStyle = defaults.enumValue(forKey: Self.meterStyleKey, default: .remaining) self.resetDisplayMode = defaults.enumValue(forKey: Self.resetDisplayModeKey, default: .relative) self.alwaysShowPacing = defaults.bool(forKey: Self.alwaysShowPacingKey) @@ -256,7 +262,9 @@ final class WidgetDataStore { metrics: metrics, toggles: toggles, now: now, - providerName: { [providersByID] id in providersByID[id]?.provider.displayName ?? id }, + providerName: { [providersByID, resolveDisplayName] id in + resolveDisplayName?(id) ?? providersByID[id]?.provider.displayName ?? id + }, post: postNotification ) } diff --git a/Sources/OpenUsage/Support/TotalSpendAggregator.swift b/Sources/OpenUsage/Support/TotalSpendAggregator.swift index d8e599536..0a5324d58 100644 --- a/Sources/OpenUsage/Support/TotalSpendAggregator.swift +++ b/Sources/OpenUsage/Support/TotalSpendAggregator.swift @@ -65,6 +65,10 @@ enum TotalSpendMetric: String, CaseIterable, Identifiable, Sendable { /// plus whether the dollars are a local estimate (log-scanned providers) or measured (Cursor's CSV). struct TotalSpendSlice: Identifiable, Equatable { let provider: Provider + /// The card title, resolved by the aggregation's caller through the one name resolver — so the + /// legend, the ranking tie-break, and the share-card export (which renders outside the SwiftUI + /// environment and can't resolve for itself) all show the same live name. + let title: String let amountUSD: Double let tokenCount: Double let estimated: Bool @@ -82,6 +86,8 @@ struct TotalSpendSlice: Identifiable, Equatable { /// and ranks the legend, plus the formatted value surfaces read through `MetricFormatter`. struct TotalSpendProjectedSlice: Identifiable, Equatable { let provider: Provider + /// The already-resolved card title (see `TotalSpendSlice.title`) — what the legend renders. + let title: String let displayAmount: Double let estimated: Bool @@ -129,11 +135,16 @@ struct TotalSpend: Equatable { let ranked = included.sorted { lhs, rhs in if lhs.display != rhs.display { return lhs.display > rhs.display } - return lhs.slice.provider.displayName.localizedStandardCompare(rhs.slice.provider.displayName) == .orderedAscending + return lhs.slice.title.localizedStandardCompare(rhs.slice.title) == .orderedAscending } let projected = ranked.map { - TotalSpendProjectedSlice(provider: $0.slice.provider, displayAmount: $0.display, estimated: $0.slice.estimated) + TotalSpendProjectedSlice( + provider: $0.slice.provider, + title: $0.slice.title, + displayAmount: $0.display, + estimated: $0.slice.estimated + ) } let center: Double @@ -163,10 +174,14 @@ struct TotalSpend: Equatable { enum TotalSpendAggregator { /// The total for one period across `providers` (pass them in display order; ties keep it). /// Slices keep provider display order input only as a stable traversal; metric projection re-ranks. + /// `title` resolves each provider's card title — the live card passes the account-registry + /// resolver so slices carry renames; the default is the baked derived name for callers without + /// registry access (tests). static func total( for period: TotalSpendPeriod, providers: [Provider], - snapshots: [String: ProviderSnapshot] + snapshots: [String: ProviderSnapshot], + title: (Provider) -> String = { $0.displayName } ) -> TotalSpend { let slices = providers.compactMap { provider -> TotalSpendSlice? in guard let snapshot = snapshots[provider.id], @@ -182,6 +197,7 @@ enum TotalSpendAggregator { return TotalSpendSlice( provider: provider, + title: title(provider), amountUSD: max(amount, 0), tokenCount: max(tokens, 0), estimated: dollars.contains(where: \.estimated) diff --git a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift index a8a072847..400bea4bf 100644 --- a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift +++ b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift @@ -219,7 +219,7 @@ private struct CardNameSection: View { } private var placeholder: String { - record.map { container.derivedDisplayName(for: $0) } ?? "" + record?.derivedDisplayName ?? "" } private func commit() { diff --git a/Sources/OpenUsage/Views/TotalSpendCard.swift b/Sources/OpenUsage/Views/TotalSpendCard.swift index 54cd338b4..16b48af0a 100644 --- a/Sources/OpenUsage/Views/TotalSpendCard.swift +++ b/Sources/OpenUsage/Views/TotalSpendCard.swift @@ -11,6 +11,7 @@ import SwiftUI struct TotalSpendCard: View { @Environment(LayoutStore.self) private var layout @Environment(WidgetDataStore.self) private var dataStore + @Environment(AppContainer.self) private var container @Environment(\.colorScheme) private var colorScheme @Namespace private var pickerNamespace @@ -36,7 +37,12 @@ struct TotalSpendCard: View { } private var total: TotalSpend { - TotalSpendAggregator.total(for: period, providers: providers, snapshots: dataStore.snapshots) + TotalSpendAggregator.total( + for: period, + providers: providers, + snapshots: dataStore.snapshots, + title: { container.displayName(for: $0) } + ) } private var projection: TotalSpendProjection { @@ -104,7 +110,7 @@ struct TotalSpendCard: View { /// hardcoded list, so disabling a provider (or a new spend provider shipping) can't make the /// tooltip lie about what the total reflects. private var infoTooltip: String { - let names = providers.map(\.displayName) + let names = providers.map { container.displayName(for: $0) } return "Only includes \(names.formatted(.list(type: .and)))." } @@ -332,7 +338,7 @@ struct TotalSpendRingContent: View { Circle() .fill(TotalSpendPalette.color(for: slice.provider.id)) .frame(width: 8, height: 8) - Text(slice.provider.displayName) + Text(slice.title) .font(.system(size: density.supportingPointSize)) .foregroundStyle(.primary) .lineLimit(1) diff --git a/Tests/OpenUsageTests/LocalUsageAPITests.swift b/Tests/OpenUsageTests/LocalUsageAPITests.swift index 74b86f436..86fad798e 100644 --- a/Tests/OpenUsageTests/LocalUsageAPITests.swift +++ b/Tests/OpenUsageTests/LocalUsageAPITests.swift @@ -129,6 +129,21 @@ final class LocalUsageAPITests: XCTestCase { XCTAssertEqual(Set(providers.keys), ["claude", "claude@ab12cd34"]) } + func testResolvedTitlesOverrideSnapshotDisplayNamesAtTheBoundary() throws { + // Snapshots always store the derived name; the boundary re-resolves against the account + // registry so API/CLI output carries renames without ever persisting them. + let state = makeState().resolvingDisplayNames(["claude": "Claude Team"]) + + let response = LocalUsageAPI.respond(method: "GET", path: "/v1/usage", state: state) + let array = try XCTUnwrap(try json(response.body) as? [[String: Any]]) + XCTAssertEqual(array.first { $0["providerId"] as? String == "claude" }?["displayName"] as? String, "Claude Team") + XCTAssertEqual( + array.first { $0["providerId"] as? String == "cursor" }?["displayName"] as? String, + "Cursor", + "cards without a record keep their baked name" + ) + } + func testMethodAndRouteErrors() throws { let state = makeState() diff --git a/Tests/OpenUsageTests/MenuBarContentTests.swift b/Tests/OpenUsageTests/MenuBarContentTests.swift index 80f78df9d..1a15eccc0 100644 --- a/Tests/OpenUsageTests/MenuBarContentTests.swift +++ b/Tests/OpenUsageTests/MenuBarContentTests.swift @@ -87,6 +87,17 @@ final class MenuBarContentTests: XCTestCase { XCTAssertEqual(content.accessibilityText, "A Session 41%, Weekly 12%") } + func testAccessibilityTextUsesTheResolvedTitle() { + // The VoiceOver summary is a human-facing name, so it goes through the caller's resolver + // (the account registry) instead of the baked provider name. + let content = MenuBarContentBuilder.build( + groups: [group("a", percent("a.m1", "Session", 41))], + data: { $0.sample }, + title: { _ in "Claude Team" } + ) + XCTAssertEqual(content.accessibilityText, "Claude Team Session 41%") + } + func testTrayLabelsShortenLongTimeWindows() { let content = MenuBarContentBuilder.build( groups: [group("a", percent("a.today", "Today", 5), percent("a.month", "Last 30 Days", 80))], diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 3c35fa1d2..d68c39346 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -205,7 +205,7 @@ final class ProviderAccountAssemblyTests: XCTestCase { ) } - func testARenamedRecordDrivesTheCardDisplayName() throws { + func testARenameNeverBakesIntoTheCardOnlyTheResolverCarriesIt() throws { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) let observer = DefaultAccountObserver( @@ -230,12 +230,16 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(first.claudeCards.first?.displayName, cardID, "no label → the short-hash id fallback") store.rename(cardID: cardID, to: "Work Max") + let reloadedStore = ProviderAccountsStore(defaults: defaults) let second = ProviderAccountAssembly.make( observer: observer, - accountsStore: ProviderAccountsStore(defaults: defaults), + accountsStore: reloadedStore, claudeDiscovery: discovery ) - XCTAssertEqual(second.claudeCards.first?.displayName, "Work Max") + // The baked card name stays the DERIVED default — a rename lives only in the registry and + // is resolved at render time, so a baked name can never be a stale copy of it. + XCTAssertEqual(second.claudeCards.first?.displayName, cardID) + XCTAssertEqual(reloadedStore.resolvedDisplayName(cardID: cardID), "Work Max") } func testAccountSwapKeepsTheOriginalCardBoundToItsOwnConfigDirectory() throws { @@ -272,7 +276,8 @@ final class ProviderAccountAssemblyTests: XCTestCase { let originalCard = try XCTUnwrap(assembly.claudeCards.first) XCTAssertEqual(originalCard.id, "claude") XCTAssertEqual(originalCard.configDirPath, path) - XCTAssertEqual(originalCard.displayName, "Personal") + XCTAssertEqual(originalCard.displayName, "Claude") + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "Personal") XCTAssertNotEqual(assembly.defaultClaudeCardID, "claude") XCTAssertEqual(assembly.identityKeysByCard["claude"], "account-a") XCTAssertEqual(assembly.identityKeysByCard[assembly.defaultClaudeCardID], "account-b") diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index 6615eaa56..b7e71b7f0 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -147,11 +147,12 @@ final class ProviderAccountsStoreTests: XCTestCase { store.rename(cardID: "claude", to: " Work ") XCTAssertEqual(store.records[0].customLabel, "Work", "renames are trimmed") + XCTAssertEqual(store.records[0].resolvedDisplayName, "Work", "the resolver surfaces the rename") XCTAssertEqual(ProviderAccountsStore(defaults: defaults).records[0].customLabel, "Work", "renames persist") store.rename(cardID: "claude", to: " ") XCTAssertNil(store.records[0].customLabel, "a blank rename clears back to the derived name") - XCTAssertEqual(store.records[0].displayLabel, "a@example.com") + XCTAssertEqual(store.records[0].resolvedDisplayName, "Claude", "the bare card derives the stock family name") store.rename(cardID: "missing", to: "X") XCTAssertEqual(store.records.count, 1, "renaming an unknown card is a no-op") diff --git a/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift b/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift index c4a4ae92c..4b408419b 100644 --- a/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift +++ b/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift @@ -50,6 +50,25 @@ final class TotalSpendAggregatorTests: XCTestCase { XCTAssertEqual(spend.centerValue, 9.75, accuracy: 0.0001) } + func testSlicesCarryTheCallerResolvedTitleThroughProjection() { + // The caller (the live card, with registry access) resolves each slice's title once; the + // legend and the share export both read that resolved string, so a mid-session rename can + // never show on one and not the other. + let snapshots = [ + "claude": snapshot(claude, lines: [spendLine("Today", dollars: 2.50)]), + "cursor": snapshot(cursor, lines: [spendLine("Today", dollars: 7.25)]) + ] + + let total = TotalSpendAggregator.total( + for: .today, + providers: [claude, cursor], + snapshots: snapshots, + title: { $0.id == "claude" ? "Claude Team" : $0.displayName } + ) + + XCTAssertEqual(total.projection(for: .cost).slices.map(\.title), ["Cursor", "Claude Team"]) + } + func testProviderWithoutPeriodLineIsExcludedNotZero() { let snapshots = [ "claude": snapshot(claude, lines: [spendLine("Today", dollars: 1.00)]), diff --git a/docs/dashboard.md b/docs/dashboard.md index 24d0a8051..b4b6a4c58 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -52,7 +52,7 @@ Rows with a reset date tick every 30 seconds, so countdowns and pace stay live b ## Right-click menus Every row: **Hide · Star for menu bar / Unstar · Refresh \ · Customize…** (Customize opens straight to that provider's metrics.) -Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). Claude and Codex cards also offer **Rename…** — give the card any name you like (handy with multiple accounts); leave the field empty to go back to the default name. +Provider headers: **Hide \ · Refresh \ · Customize…** (Hide turns the whole provider off; turn it back on in Customize. Customize opens straight to that provider's metrics.) plus **Share Screenshot** (see below). Claude and Codex cards also offer **Rename…** — give the card any name you like (handy with multiple accounts); leave the field empty to go back to the default name. The name follows the card everywhere it's shown: the dashboard, the Total Spend legend, share screenshots, notifications, and the CLI/API output. ## Share diff --git a/docs/local-http-api.md b/docs/local-http-api.md index 9055dcbf0..d6098dae1 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -170,6 +170,8 @@ Line types are `progress`, `text`, `badge`, and `barChart`. A `barChart` line ca The in-app model breakdown shown when hovering spend rows is not included in this API yet. Spend rows continue to serialize as the same `text` lines so existing local integrations keep their current shape. +In both response shapes, `displayName` is the card's current name — if you renamed a card in the app, the rename shows here too. Match on `providerId` (or the envelope key), never on the name. + ## Errors ```json From 220d4f9c86e0bbe913f2f911a626c188c36d5b70 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 15:46:42 +0400 Subject: [PATCH 07/24] feat(ui): give account cards stable provider-colored chart slices --- .../OpenUsage/Support/TotalSpendPalette.swift | 113 ++++++++++++++++++ Sources/OpenUsage/Views/TotalSpendCard.swift | 58 --------- .../TotalSpendAggregatorTests.swift | 27 +++++ 3 files changed, 140 insertions(+), 58 deletions(-) create mode 100644 Sources/OpenUsage/Support/TotalSpendPalette.swift diff --git a/Sources/OpenUsage/Support/TotalSpendPalette.swift b/Sources/OpenUsage/Support/TotalSpendPalette.swift new file mode 100644 index 000000000..e5c2fb772 --- /dev/null +++ b/Sources/OpenUsage/Support/TotalSpendPalette.swift @@ -0,0 +1,113 @@ +import AppKit +import SwiftUI + +/// Stable provider and account colors shared by the Total Spend chart, legend, and export. +enum TotalSpendPalette { + private static let accountBrandHex: [String: UInt32] = [ + "claude": 0xDE7356, + "codex": 0x10A37F, + ] + + private static let byProviderID: [String: Color] = [ + "claude": hex(0xDE7356), + "codex": hex(0x10A37F), + "cursor": dynamic(light: 0x13120A, dark: 0xF5F5F7), + "grok": dynamic(light: 0x8E8E93, dark: 0x98989D), + "opencode": dynamic(light: 0x6E6E73, dark: 0xAEAEB2), + "openrouter": hex(0x6467F2), + "antigravity": hex(0x4285F4), + "copilot": hex(0xA855F7), + "amp": hex(0xF34E3F), + "factory": dynamic(light: 0x48484A, dark: 0xC7C7CC), + "kimi": hex(0x0A66FF), + "minimax": hex(0xF5433C), + "zai": dynamic(light: 0x2D2D2D, dark: 0xD1D1D6), + ] + + private static let fallback = [hex(0x34C759), hex(0x5856D6), hex(0xFF2D55), hex(0xA2845E)] + + static func color(for providerID: String) -> Color { + if let brand = byProviderID[providerID] { return brand } + if let components = accountComponents(for: providerID) { + return Color(hue: components.hue, saturation: components.saturation, brightness: components.brightness) + } + let hash = providerID.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0xFFFF } + return fallback[hash % fallback.count] + } + + struct AccountColorComponents: Equatable { + let hue: Double + let saturation: Double + let brightness: Double + } + + /// Remote-only aliases normalize to their eventual local account ID, keeping their color stable. + static func accountComponents(for providerID: String) -> AccountColorComponents? { + guard let separator = providerID.firstIndex(of: "@"), + let brandHex = accountBrandHex[String(providerID[..> 1) & 0x7FFF) / Double(0x7FFF) * 0.055) + let hue = (brand.hue + hueOffset + 1).truncatingRemainder(dividingBy: 1) + let saturationSpread = Double((hash >> 16) & 0xFFFF) / Double(0xFFFF) + let brightnessSpread = Double((hash >> 32) & 0xFFFF) / Double(0xFFFF) + return AccountColorComponents( + hue: hue, + saturation: min(0.92, max(0.50, brand.saturation * (0.82 + saturationSpread * 0.25))), + brightness: min(0.94, max(0.62, brand.brightness * (0.78 + brightnessSpread * 0.32))) + ) + } + + private static func hueSaturationBrightness(for value: UInt32) -> AccountColorComponents { + let red = Double((value >> 16) & 0xFF) / 255 + let green = Double((value >> 8) & 0xFF) / 255 + let blue = Double(value & 0xFF) / 255 + let brightness = max(red, max(green, blue)) + let delta = brightness - min(red, min(green, blue)) + guard delta > 0 else { + return AccountColorComponents(hue: 0, saturation: 0, brightness: brightness) + } + + let rawHue: Double + if brightness == red { + rawHue = (green - blue) / delta + } else if brightness == green { + rawHue = 2 + (blue - red) / delta + } else { + rawHue = 4 + (red - green) / delta + } + return AccountColorComponents( + hue: (rawHue / 6 + 1).truncatingRemainder(dividingBy: 1), + saturation: delta / brightness, + brightness: brightness + ) + } + + private static func hex(_ value: UInt32) -> Color { + Color(red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255) + } + + private static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let value = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light + return NSColor(red: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1) + }) + } +} diff --git a/Sources/OpenUsage/Views/TotalSpendCard.swift b/Sources/OpenUsage/Views/TotalSpendCard.swift index 16b48af0a..6ccf405f2 100644 --- a/Sources/OpenUsage/Views/TotalSpendCard.swift +++ b/Sources/OpenUsage/Views/TotalSpendCard.swift @@ -373,61 +373,3 @@ struct TotalSpendRingContent: View { } } } - -/// Stable per-provider brand tints for the Total Spend ring and legend — the one place the app maps -/// a provider to a color, so the chart, legend, and share card always agree. Colors are keyed by -/// provider ID only (never by rank or position), so a provider keeps its color across period -/// switches, re-sorts, and launches. Hexes come from the legacy edition's per-plugin `brandColor` -/// values; brands whose color is plain black (Cursor, Grok) get adaptive near-black/near-white -/// dynamic colors so they read on both appearances without both landing on the same gray. -enum TotalSpendPalette { - private static let byProviderID: [String: Color] = [ - "claude": hex(0xDE7356), // Claude terracotta - "codex": hex(0x10A37F), // OpenAI green (#10A37F) - "cursor": dynamic(light: 0x13120A, dark: 0xF5F5F7), // brand black (#13120A), flipped near-white in dark mode - "grok": dynamic(light: 0x8E8E93, dark: 0x98989D), // brand black, offset to gray next to Cursor - "opencode": dynamic(light: 0x6E6E73, dark: 0xAEAEB2), // OpenCode — grayscale brand, medium gray - "openrouter": hex(0x6467F2), // OpenRouter indigo - "antigravity": hex(0x4285F4), // Google blue - "copilot": hex(0xA855F7), // Copilot purple - "amp": hex(0xF34E3F), - "factory": dynamic(light: 0x48484A, dark: 0xC7C7CC), - "kimi": hex(0x0A66FF), - "minimax": hex(0xF5433C), - "zai": dynamic(light: 0x2D2D2D, dark: 0xD1D1D6) - ] - - /// Deterministic backstop hues for a provider that ships without a palette entry — keyed off the - /// provider ID (not rank), so the color holds steady across periods and launches. - private static let fallback: [Color] = [ - hex(0x34C759), hex(0x5856D6), hex(0xFF2D55), hex(0xA2845E) - ] - - static func color(for providerID: String) -> Color { - if let brand = byProviderID[providerID] { return brand } - let stableHash = providerID.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0xFFFF } - return fallback[stableHash % fallback.count] - } - - private static func hex(_ value: UInt32) -> Color { - Color( - red: Double((value >> 16) & 0xFF) / 255, - green: Double((value >> 8) & 0xFF) / 255, - blue: Double(value & 0xFF) / 255 - ) - } - - /// A light/dark-adaptive color, for brands whose mark is pure black — invisible on a dark card - /// unless flipped. - private static func dynamic(light: UInt32, dark: UInt32) -> Color { - Color(nsColor: NSColor(name: nil) { appearance in - let value = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light - return NSColor( - red: CGFloat((value >> 16) & 0xFF) / 255, - green: CGFloat((value >> 8) & 0xFF) / 255, - blue: CGFloat(value & 0xFF) / 255, - alpha: 1 - ) - }) - } -} diff --git a/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift b/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift index 4b408419b..5b79df2d2 100644 --- a/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift +++ b/Tests/OpenUsageTests/TotalSpendAggregatorTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import XCTest @testable import OpenUsage @@ -177,3 +178,29 @@ final class TotalSpendAggregatorTests: XCTestCase { XCTAssertTrue(total.projection(for: .costPerMtok).isEmpty) } } + +final class TotalSpendPaletteTests: XCTestCase { + func testExistingBrandColorsRemainStable() { + XCTAssertEqual(TotalSpendPalette.color(for: "claude"), + Color(red: 222.0 / 255, green: 115.0 / 255, blue: 86.0 / 255)) + XCTAssertEqual(TotalSpendPalette.color(for: "codex"), + Color(red: 16.0 / 255, green: 163.0 / 255, blue: 127.0 / 255)) + XCTAssertNil(TotalSpendPalette.accountComponents(for: "claude")) + } + + func testAccountAndRemoteAliasShareOneDistinctBrandShade() throws { + let first = try XCTUnwrap(TotalSpendPalette.accountComponents(for: "claude@11111111")) + let second = try XCTUnwrap(TotalSpendPalette.accountComponents(for: "claude@22222222")) + XCTAssertNotEqual(first, second) + XCTAssertEqual(first, TotalSpendPalette.accountComponents(for: "claude@peer-11111111")) + XCTAssertEqual(first, TotalSpendPalette.accountComponents(for: "claude@11111111")) + XCTAssertTrue(first.hue <= 0.15 || first.hue >= 0.90) + XCTAssertTrue((0.62...0.94).contains(first.brightness)) + } + + func testUnknownProvidersKeepTheirFallbackColor() { + XCTAssertEqual(TotalSpendPalette.color(for: "mystery-provider"), + Color(red: 162.0 / 255, green: 132.0 / 255, blue: 94.0 / 255)) + XCTAssertNil(TotalSpendPalette.accountComponents(for: "cursor@ab12cd34")) + } +} From 321b6fc077fdc652f380d359fd2ad0bdd288c120 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 16:34:38 +0400 Subject: [PATCH 08/24] Keep derived account names live when rename is unchanged --- Sources/OpenUsage/Views/WidgetGroupedListView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OpenUsage/Views/WidgetGroupedListView.swift b/Sources/OpenUsage/Views/WidgetGroupedListView.swift index 36aa50868..20955b916 100644 --- a/Sources/OpenUsage/Views/WidgetGroupedListView.swift +++ b/Sources/OpenUsage/Views/WidgetGroupedListView.swift @@ -93,7 +93,8 @@ struct WidgetGroupedListView: View { // whose identity has been observed at least once. if container.canRename(group.provider.id) { Button("Rename…") { - renameDraft = name + renameDraft = container.accounts.records + .first { $0.id == group.provider.id }?.customLabel ?? "" renameCardID = group.provider.id } } From 0202c36489b193ccefcef3690c3d741ffa0df79d Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 17:09:05 +0400 Subject: [PATCH 09/24] fix: keep account card names bound to their verified owners --- Sources/OpenUsage/App/AppContainer.swift | 2 +- .../Stores/ProviderAccountsStore.swift | 59 ++++++++++++++++--- .../OpenUsage/Support/TotalSpendPalette.swift | 4 +- .../Views/CustomizeProviderDetailView.swift | 4 +- .../Views/WidgetGroupedListView.swift | 8 ++- .../ProviderAccountsStoreTests.swift | 58 ++++++++++++++++++ .../TotalSpendAggregatorTests.swift | 3 +- 7 files changed, 118 insertions(+), 20 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 06db296d3..dc30e024d 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -248,7 +248,7 @@ final class AppContainer { /// Whether the card has an account record a rename can attach to (accounts-model families only, /// and only once the account's identity has been observed at least once). func canRename(_ providerID: String) -> Bool { - accounts.records.contains { $0.id == providerID } + accounts.runtimeRecord(for: providerID) != nil } /// Re-runs first-launch credential detection on demand — the enablement half of the Customize diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index e0f35d8d7..3e50a5177 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -84,13 +84,29 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { /// from good). Never contains `customLabel` — this is what gets baked into the launch /// `Provider`, and baking a rename there is how stale-name bugs are born. var derivedDisplayName: String { - guard ProviderAccountID.isAccountCard(id) else { return family.capitalized } - guard let label = label?.nilIfEmpty else { return id } + derivedDisplayName(identifyingBareAccount: false) + } + + func derivedDisplayName(identifyingBareAccount: Bool) -> String { + guard ProviderAccountID.isAccountCard(id) || identifyingBareAccount else { + return family.capitalized + } + guard let label = label?.nilIfEmpty else { + return ProviderAccountID.isAccountCard(id) + ? id : "\(family.capitalized) — \(ProviderAccountID.hash8(identityKey).prefix(4))" + } // Labels are our own "email (Org Name)" format — prefer the org for a short card title. if label.hasSuffix(")"), let open = label.lastIndex(of: "(") { let org = label[label.index(after: open).. ProviderAccountRecord? { + records.first { $0.id == cardID } + } + + func runtimeRecord(for cardID: String) -> ProviderAccountRecord? { + if cardID == "codex" { return defaultBadgeHolder(family: "codex") } + return record(for: cardID) + } + + func derivedDisplayName(cardID: String) -> String? { + guard let record = runtimeRecord(for: cardID) else { return nil } + let siblings = records.filter { $0.family == record.family && !$0.removedTombstone } + let identifyBareAccount = siblings.count > 1 + let proposed = record.derivedDisplayName(identifyingBareAccount: identifyBareAccount) + let collides = siblings.contains { sibling in + guard sibling.id != record.id else { return false } + let siblingName = sibling.customLabel?.nilIfEmpty + ?? sibling.derivedDisplayName(identifyingBareAccount: identifyBareAccount) + return siblingName == proposed + } + return collides ? "\(proposed) · \(ProviderAccountID.hash8(record.identityKey).prefix(4))" : proposed + } + func resolvedDisplayName(cardID: String) -> String? { - records.first { $0.id == cardID }?.resolvedDisplayName + guard let record = runtimeRecord(for: cardID) else { return nil } + return record.customLabel?.nilIfEmpty ?? derivedDisplayName(cardID: cardID) } /// Card id → resolved title for every record — the map the CLI/API boundary applies to its /// snapshots (`LocalUsageAPI.State.resolvingDisplayNames`). var resolvedDisplayNamesByCardID: [String: String] { - Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0.resolvedDisplayName) }) + Dictionary(uniqueKeysWithValues: records.compactMap { record in + resolvedDisplayName(cardID: record.id).map { (record.id, $0) } + }) } /// Stores a user rename for a card; `nil` or blank clears it back to the derived name. func rename(cardID: String, to name: String?) { - guard let index = records.firstIndex(where: { $0.id == cardID }) else { return } + guard let record = runtimeRecord(for: cardID), + let index = records.firstIndex(where: { $0.id == record.id }) + else { return } let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty guard records[index].customLabel != trimmed else { return } records[index].customLabel = trimmed diff --git a/Sources/OpenUsage/Support/TotalSpendPalette.swift b/Sources/OpenUsage/Support/TotalSpendPalette.swift index e5c2fb772..91140e88f 100644 --- a/Sources/OpenUsage/Support/TotalSpendPalette.swift +++ b/Sources/OpenUsage/Support/TotalSpendPalette.swift @@ -41,14 +41,12 @@ enum TotalSpendPalette { let brightness: Double } - /// Remote-only aliases normalize to their eventual local account ID, keeping their color stable. static func accountComponents(for providerID: String) -> AccountColorComponents? { guard let separator = providerID.firstIndex(of: "@"), let brandHex = accountBrandHex[String(providerID[..= 0.90) XCTAssertTrue((0.62...0.94).contains(first.brightness)) From 5de0d622e90ed5e6c959daaf5841221aa3a3afec Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 17:39:01 +0400 Subject: [PATCH 10/24] fix: preserve verified credential homes and reject incomplete discovery --- Sources/OpenUsage/App/AppContainer.swift | 3 +- .../Providers/Claude/ClaudeAuthStore.swift | 11 ++-- .../Claude/ClaudeConfigDirDiscovery.swift | 20 +++++-- .../Providers/DefaultAccountObserver.swift | 23 +++++--- .../OpenUsage/Providers/ProviderCatalog.swift | 5 +- .../Services/ProviderAccountAssembly.swift | 20 ++++++- Sources/OpenUsage/Services/UsageReader.swift | 3 +- .../ClaudeConfigDirDiscoveryTests.swift | 14 +++++ .../DefaultAccountObserverTests.swift | 19 +++++++ .../ProviderAccountAssemblyTests.swift | 55 +++++++++++++++++++ 10 files changed, 149 insertions(+), 24 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index dc30e024d..ab6f0d86a 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -82,7 +82,8 @@ final class AppContainer { defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, defaultClaudeCardID: accountAssembly.defaultClaudeCardID, - claudeIdentityKeys: accountAssembly.identityKeysByCard + claudeIdentityKeys: accountAssembly.identityKeysByCard, + isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete ) let registry = WidgetRegistry.from(providers) let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index 8838238f7..f0d62c41a 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -14,6 +14,7 @@ struct ClaudeAuthStore: Sendable { var files: TextFileAccessing var keychain: KeychainAccessing var desktop: ClaudeDesktopAuthStore + var homeDirectory: @Sendable () -> URL var now: @Sendable () -> Date let scope: ClaudeCredentialScope let expectedIdentityKey: String? @@ -31,12 +32,14 @@ struct ClaudeAuthStore: Sendable { scope: ClaudeCredentialScope = .standard, allowsDesktopFallback: Bool = true, expectedIdentityKey: String? = nil, + homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, now: @escaping @Sendable () -> Date = Date.init ) { self.environment = environment self.files = files self.keychain = keychain self.desktop = desktop ?? ClaudeDesktopAuthStore(files: files, now: now) + self.homeDirectory = homeDirectory self.scope = scope self.allowsDesktopFallback = allowsDesktopFallback self.expectedIdentityKey = expectedIdentityKey @@ -113,11 +116,9 @@ struct ClaudeAuthStore: Sendable { case .standard: let home = claudeHomeOverride() ?? Self.defaultClaudeHome guard !home.contains(",") else { return false } - let expandedHome = (home as NSString).expandingTildeInPath - let expandedDefault = (Self.defaultClaudeHome as NSString).expandingTildeInPath - identityPath = URL(fileURLWithPath: expandedHome).standardizedFileURL.path - == URL(fileURLWithPath: expandedDefault).standardizedFileURL.path - ? "\(home).json" : "\(home)/.claude.json" + identityPath = DefaultAccountObserver.claudeLocation( + configDir: home, homeDirectory: homeDirectory() + ).identityPath } guard let text = try? files.readTextIfPresent(identityPath), let state = try? JSONDecoder().decode( diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift index 384db769b..9826c2010 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -29,6 +29,7 @@ struct ClaudeConfigDirDiscovery { /// the log so a "my account didn't show up" report is diagnosable from a default log. /// Token-free and email-free by construction — identity hashes, kinds, and paths only. var notes: [String] = [] + var truncated = false } var environment: EnvironmentReading @@ -46,7 +47,7 @@ struct ClaudeConfigDirDiscovery { keychain: KeychainAccessing = SecurityKeychainAccessor(), homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, listSubdirectories: @escaping @Sendable (URL) -> [URL] = Self.filesystemSubdirectories, - timeBudget: TimeInterval = 0.4, + timeBudget: TimeInterval = 3, now: @escaping @Sendable () -> Date = Date.init ) { self.environment = environment @@ -58,14 +59,23 @@ struct ClaudeConfigDirDiscovery { self.now = now } - func run() -> Result { + func run(prioritizing preferredAnchors: Set = []) -> Result { let started = now() var result = Result() let excluded = Set(defaultClaudeConfigDirs().map(canonical)) + let preferred = Set(preferredAnchors.map(canonical)) + let candidates = candidateDirectories().sorted { first, second in + let firstIsPreferred = preferred.contains(canonical(first.path)) + let secondIsPreferred = preferred.contains(canonical(second.path)) + return firstIsPreferred == secondIsPreferred + ? first.path < second.path + : firstIsPreferred + } - for candidate in candidateDirectories() { - if now().timeIntervalSince(started) > timeBudget { - result.notes.append("claude config-dir scan hit its \(Int(timeBudget * 1000))ms budget; finishing with partial results") + for candidate in candidates { + if Task.isCancelled || now().timeIntervalSince(started) > timeBudget { + result.notes.append("claude config-dir scan ended early; account ownership remains incomplete") + result.truncated = true break } guard !excluded.contains(canonical(candidate.path)) else { continue } diff --git a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift index c49af7f38..6b80a2079 100644 --- a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift +++ b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift @@ -69,6 +69,19 @@ struct DefaultAccountObserver: Sendable { return email.map { "\($0) (\(org))" } ?? org } + /// Both launch discovery and bound refreshes must resolve the exact same state-file path. + static func claudeLocation(configDir: String, homeDirectory: URL) -> (anchor: String, identityPath: String) { + func expand(_ value: String) -> String { + guard value == "~" || value.hasPrefix("~/") else { return value } + return homeDirectory.path + String(value.dropFirst()) + } + let anchor = expand(configDir) + let identityPath = anchor == expand("~/.claude") + ? expand("~/.claude.json") + : anchor + "/.claude.json" + return (anchor, identityPath) + } + /// The default Claude home, mirroring `ClaudeAuthStore`'s resolution exactly (the observer must /// name the account whose credentials the provider actually refreshes with): `CLAUDE_CONFIG_DIR` /// when exported, else `~/.claude`. A comma-separated list can't be assigned one identity. @@ -81,15 +94,11 @@ struct DefaultAccountObserver: Sendable { } configDir = raw } - let anchor = expandTilde(configDir) - // The identity file sits inside a custom config dir, but next to (not inside) the default - // `~/.claude` — Claude Code keeps the default's state at `~/.claude.json`. - let identityPath = anchor == expandTilde("~/.claude") - ? expandTilde("~/.claude.json") - : anchor + "/.claude.json" + let location = Self.claudeLocation(configDir: configDir, homeDirectory: homeDirectory()) + let anchor = location.anchor let text: String? do { - text = try files.readTextIfPresent(identityPath) + text = try files.readTextIfPresent(location.identityPath) } catch { return .unresolved(reason: "identity file unreadable: \(error.localizedDescription)") } diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index eb96d3ff4..457b929ba 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -15,7 +15,8 @@ enum ProviderCatalog { defaultClaudeExtraLogRoots: [URL] = [], defaultClaudeDisplayName: String? = nil, defaultClaudeCardID: String = "claude", - claudeIdentityKeys: [String: String] = [:] + claudeIdentityKeys: [String: String] = [:], + isClaudeDiscoveryComplete: Bool = true ) -> [ProviderRuntime] { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, // then every other provider alphabetically by display name. Account cards slot in right after @@ -34,7 +35,7 @@ enum ProviderCatalog { // belongs to one of them — fetching that account's usage onto the default card. Desktop // returns as its own properly-pinned source kind in Phase 3. authStore: ClaudeAuthStore( - allowsDesktopFallback: claudeCards.isEmpty, + allowsDesktopFallback: claudeCards.isEmpty && isClaudeDiscoveryComplete, expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID] ), logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index e56c08f20..92736f00e 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -39,6 +39,8 @@ struct ProviderAccountAssembly { var defaultClaudeDisplayName: String? /// The default runtime follows its account record, even when another account keeps `claude`. var defaultClaudeCardID = "claude" + /// An incomplete scan cannot prove that an unpinned Desktop login belongs to the default card. + var isClaudeDiscoveryComplete = true /// `waitsForLoginShell`: true for the menu-bar app (a Finder/Dock launch inherits no shell /// exports, so the pass leans on the login-shell layers), false for the one-shot CLI (a terminal @@ -137,13 +139,18 @@ struct ProviderAccountAssembly { // and a custom-dir-only login should still get its card. var foundClaudeAccounts: [(identityKey: String, label: String?, dirs: [ClaudeConfigDirDiscovery.Finding])] = [] var defaultClaudeExtraLogRoots: [URL] = [] + var isClaudeDiscoveryComplete = true + let preferredConfigAnchors = Dictionary(uniqueKeysWithValues: accountsStore.records.compactMap { record in + record.sources.first { $0.kind == .configDir }?.anchor.map { (record.identityKey, $0) } + }) let claudeOutcome = outcomes.first { $0.family == "claude" }?.outcome if let claudeDiscovery, let claudeOutcome { if case .unresolved = claudeOutcome { AppLog.info(.config, "discovery: claude default login present but its identity is unreadable → skipping extra-account candidates this launch") } else { let defaultKey = identityKeys["claude"] - let scan = claudeDiscovery.run() + let scan = claudeDiscovery.run(prioritizing: Set(preferredConfigAnchors.values)) + isClaudeDiscoveryComplete = !scan.truncated for note in scan.notes { AppLog.info(.config, "discovery: \(note)") } @@ -154,7 +161,13 @@ struct ProviderAccountAssembly { grouped[finding.identityKey, default: []].append(finding) } for identityKey in order { - let findings = grouped[identityKey] ?? [] + var findings = grouped[identityKey] ?? [] + if let preferred = preferredConfigAnchors[identityKey], + let index = findings.firstIndex(where: { $0.anchorPath == preferred }), + index != 0 + { + findings.insert(findings.remove(at: index), at: 0) + } let sources = findings.map { ProviderAccountSource( kind: .configDir, @@ -222,7 +235,8 @@ struct ProviderAccountAssembly { claudeCards: claudeCards, defaultClaudeExtraLogRoots: defaultClaudeExtraLogRoots, defaultClaudeDisplayName: defaultClaudeName, - defaultClaudeCardID: defaultClaudeRecord?.id ?? "claude" + defaultClaudeCardID: defaultClaudeRecord?.id ?? "claude", + isClaudeDiscoveryComplete: isClaudeDiscoveryComplete ) } } diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 9c8a550b7..cf8819dae 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -63,7 +63,8 @@ public struct UsageReader { defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, defaultClaudeCardID: accountAssembly.defaultClaudeCardID, - claudeIdentityKeys: accountAssembly.identityKeysByCard + claudeIdentityKeys: accountAssembly.identityKeysByCard, + isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete ) let registry = WidgetRegistry.from(providers) let knownIDs = Set(registry.providers.map(\.id)) diff --git a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift index c867ea32a..56b0bec98 100644 --- a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift +++ b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift @@ -107,4 +107,18 @@ final class ClaudeConfigDirDiscoveryTests: XCTestCase { ) XCTAssertEqual(withoutOverride.run().findings.map(\.identityKey), ["acct-main"]) } + + func testExhaustedBudgetExplicitlyMarksDiscoveryAsIncomplete() { + let discovery = ClaudeConfigDirDiscovery( + environment: FakeEnvironment(), files: FakeFiles(), keychain: ServiceKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { _ in [URL(fileURLWithPath: "/Users/dev/.claude-work")] }, + timeBudget: -1 + ) + + let result = discovery.run() + + XCTAssertTrue(result.truncated) + XCTAssertTrue(result.findings.isEmpty) + } } diff --git a/Tests/OpenUsageTests/DefaultAccountObserverTests.swift b/Tests/OpenUsageTests/DefaultAccountObserverTests.swift index 38ec8f031..32ea14022 100644 --- a/Tests/OpenUsageTests/DefaultAccountObserverTests.swift +++ b/Tests/OpenUsageTests/DefaultAccountObserverTests.swift @@ -71,6 +71,25 @@ final class DefaultAccountObserverTests: XCTestCase { ) } + func testTrailingSlashOverrideUsesTheSameIdentityPathDuringObservationAndRefresh() { + let environment = FakeEnvironment(["CLAUDE_CONFIG_DIR": "~/.claude/"]) + let files = FakeFiles(["/Users/dev/.claude//.claude.json": claudeStateJSON()]) + let observer = DefaultAccountObserver( + environment: environment, files: files, keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let auth = ClaudeAuthStore( + environment: environment, files: files, keychain: FakeKeychain(), + expectedIdentityKey: "acct-uuid-1", homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + + XCTAssertEqual( + observer.observeClaude(), + .resolved(identityKey: "acct-uuid-1", label: "dev@example.com", anchor: "/Users/dev/.claude/") + ) + XCTAssertTrue(auth.belongsToExpectedAccount()) + } + func testClaudeCommaListConfigDirIsUnresolved() { // `ClaudeAuthStore` treats the env value as ONE credential path; a scanner-style comma list // cannot be assigned a single account identity. diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index d68c39346..6514fe4bd 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -242,6 +242,61 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(reloadedStore.resolvedDisplayName(cardID: cardID), "Work Max") } + func testPreviouslyVerifiedConfigDirectoryRemainsTheCredentialPrimary() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let observer = DefaultAccountObserver( + environment: FakeEnvironment(), files: FakeFiles(), keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let previous = "/Users/dev/.claude-z-work" + let earlier = "/Users/dev/.claude-a-work" + let files = [ + previous + "/.claude.json": #"{"oauthAccount":{"accountUuid":"work"}}"#, + previous + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"current"}}"#, + earlier + "/.claude.json": #"{"oauthAccount":{"accountUuid":"work"}}"#, + earlier + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"older"}}"#, + ] + _ = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, + claudeDiscovery: makeDiscovery(files: files, subdirectories: [previous]) + ) + + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, + claudeDiscovery: makeDiscovery(files: files, subdirectories: [earlier, previous]) + ) + + let card = try XCTUnwrap(assembly.claudeCards.first) + XCTAssertEqual(card.configDirPath, previous) + XCTAssertEqual(card.extraLogRoots.map(\.path), [earlier]) + XCTAssertEqual(store.record(for: card.id)?.sources.first?.anchor, previous) + } + + func testIncompleteDiscoveryDisablesUnverifiedDesktopFallback() throws { + let store = ProviderAccountsStore(defaults: makeScratchDefaults()) + let observer = DefaultAccountObserver( + environment: FakeEnvironment(), + files: FakeFiles(["/Users/dev/.claude.json": #"{"oauthAccount":{"accountUuid":"main"}}"#]), + keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let discovery = ClaudeConfigDirDiscovery( + environment: FakeEnvironment(), files: FakeFiles(), keychain: ServiceKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { _ in [URL(fileURLWithPath: "/Users/dev/.claude-work")] }, + timeBudget: -1 + ) + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + let provider = try XCTUnwrap(ProviderCatalog.make( + claudeCards: assembly.claudeCards, claudeIdentityKeys: assembly.identityKeysByCard, + isClaudeDiscoveryComplete: assembly.isClaudeDiscoveryComplete + ).first as? ClaudeProvider) + + XCTAssertFalse(assembly.isClaudeDiscoveryComplete) + XCTAssertFalse(provider.authStore.allowsDesktopFallback) + } + func testAccountSwapKeepsTheOriginalCardBoundToItsOwnConfigDirectory() throws { let store = ProviderAccountsStore(defaults: makeScratchDefaults()) let original = DefaultAccountObserver( From 45032f63f8f2a3f0fa103d00e149d8e0bd501f37 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 17:40:17 +0400 Subject: [PATCH 11/24] fix: canonicalize Claude home paths without increasing launch budget --- .../Providers/Claude/ClaudeConfigDirDiscovery.swift | 2 +- Sources/OpenUsage/Providers/DefaultAccountObserver.swift | 7 ++++--- Tests/OpenUsageTests/DefaultAccountObserverTests.swift | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift index 9826c2010..e38b77a45 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -47,7 +47,7 @@ struct ClaudeConfigDirDiscovery { keychain: KeychainAccessing = SecurityKeychainAccessor(), homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, listSubdirectories: @escaping @Sendable (URL) -> [URL] = Self.filesystemSubdirectories, - timeBudget: TimeInterval = 3, + timeBudget: TimeInterval = 0.4, now: @escaping @Sendable () -> Date = Date.init ) { self.environment = environment diff --git a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift index 6b80a2079..e9fceb5f1 100644 --- a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift +++ b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift @@ -75,10 +75,11 @@ struct DefaultAccountObserver: Sendable { guard value == "~" || value.hasPrefix("~/") else { return value } return homeDirectory.path + String(value.dropFirst()) } - let anchor = expand(configDir) - let identityPath = anchor == expand("~/.claude") + let anchor = URL(fileURLWithPath: expand(configDir)).standardizedFileURL.path + let defaultAnchor = URL(fileURLWithPath: expand("~/.claude")).standardizedFileURL.path + let identityPath = anchor == defaultAnchor ? expand("~/.claude.json") - : anchor + "/.claude.json" + : URL(fileURLWithPath: anchor).appendingPathComponent(".claude.json").path return (anchor, identityPath) } diff --git a/Tests/OpenUsageTests/DefaultAccountObserverTests.swift b/Tests/OpenUsageTests/DefaultAccountObserverTests.swift index 32ea14022..762eb2595 100644 --- a/Tests/OpenUsageTests/DefaultAccountObserverTests.swift +++ b/Tests/OpenUsageTests/DefaultAccountObserverTests.swift @@ -73,7 +73,7 @@ final class DefaultAccountObserverTests: XCTestCase { func testTrailingSlashOverrideUsesTheSameIdentityPathDuringObservationAndRefresh() { let environment = FakeEnvironment(["CLAUDE_CONFIG_DIR": "~/.claude/"]) - let files = FakeFiles(["/Users/dev/.claude//.claude.json": claudeStateJSON()]) + let files = FakeFiles(["/Users/dev/.claude.json": claudeStateJSON()]) let observer = DefaultAccountObserver( environment: environment, files: files, keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } @@ -85,7 +85,7 @@ final class DefaultAccountObserverTests: XCTestCase { XCTAssertEqual( observer.observeClaude(), - .resolved(identityKey: "acct-uuid-1", label: "dev@example.com", anchor: "/Users/dev/.claude/") + .resolved(identityKey: "acct-uuid-1", label: "dev@example.com", anchor: "/Users/dev/.claude") ) XCTAssertTrue(auth.belongsToExpectedAccount()) } From bd55890b28903d5242146e359dd3e1840bc830f0 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 18:14:43 +0400 Subject: [PATCH 12/24] fix: reject stale account cards and discard unverified cached usage --- .../Providers/Claude/ClaudeProvider.swift | 6 ++-- .../OpenUsage/Providers/ProviderCatalog.swift | 30 ++++++++++--------- .../Services/ProviderAccountAssembly.swift | 5 +++- .../ClaudeAccountIsolationTests.swift | 12 +++++++- .../ProviderAccountAssemblyTests.swift | 17 +++++++++++ 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index fb27af159..d8f6632ac 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -84,6 +84,7 @@ final class ClaudeProvider: ProviderRuntime { func refresh() async -> ProviderSnapshot { guard await loadOffMainActor({ [authStore] in authStore.belongsToExpectedAccount() }) else { + activateLiveUsageCache(for: nil) return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) } let snapshot = await refresh( @@ -92,6 +93,7 @@ final class ClaudeProvider: ProviderRuntime { previousFallbackError: nil ) guard await loadOffMainActor({ [authStore] in authStore.belongsToExpectedAccount() }) else { + activateLiveUsageCache(for: nil) return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.credentialsChanged) } return snapshot @@ -418,8 +420,8 @@ final class ClaudeProvider: ProviderRuntime { /// Cache state belongs to the complete access + refresh credential pair. A login change therefore /// clears both last-good usage and cooldown, even when the two accounts share an access token. - private func activateLiveUsageCache(for credentials: ClaudeOAuth) { - let fingerprint = Self.credentialFingerprint(credentials) + private func activateLiveUsageCache(for credentials: ClaudeOAuth?) { + let fingerprint = credentials.map(Self.credentialFingerprint) guard cachedCredentialFingerprint != fingerprint else { return } cachedCredentialFingerprint = fingerprint lastGoodUsage = nil diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 457b929ba..0b1b912ee 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -26,20 +26,22 @@ enum ProviderCatalog { // account registry and are resolved at render time (`ProviderAccountRecord.resolvedDisplayName`), // so a baked name can never be a stale copy of one. var runtimes: [ProviderRuntime] = [] - runtimes.append(ClaudeProvider( - provider: ClaudeProvider.makeProvider( - id: defaultClaudeCardID, - displayName: defaultClaudeDisplayName ?? "Claude" - ), - // Once extra Claude cards exist, an unpinned Desktop fallback could borrow a login that - // belongs to one of them — fetching that account's usage onto the default card. Desktop - // returns as its own properly-pinned source kind in Phase 3. - authStore: ClaudeAuthStore( - allowsDesktopFallback: claudeCards.isEmpty && isClaudeDiscoveryComplete, - expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID] - ), - logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) - )) + if !claudeCards.contains(where: { $0.id == defaultClaudeCardID }) { + runtimes.append(ClaudeProvider( + provider: ClaudeProvider.makeProvider( + id: defaultClaudeCardID, + displayName: defaultClaudeDisplayName ?? "Claude" + ), + // Once extra Claude cards exist, an unpinned Desktop fallback could borrow a login that + // belongs to one of them — fetching that account's usage onto the default card. Desktop + // returns as its own properly-pinned source kind in Phase 3. + authStore: ClaudeAuthStore( + allowsDesktopFallback: claudeCards.isEmpty && isClaudeDiscoveryComplete, + expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID] + ), + logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) + )) + } for card in claudeCards { runtimes.append(claudeAccountRuntime(card: card)) } diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 92736f00e..44d34fa71 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -199,7 +199,10 @@ struct ProviderAccountAssembly { } let records = accountsStore.reconcile(with: observations) - let defaultClaudeRecord = accountsStore.defaultBadgeHolder(family: "claude") + let observedDefaultIdentity = identityKeys["claude"] + let defaultClaudeRecord = accountsStore.defaultBadgeHolder(family: "claude").flatMap { record in + record.identityKey == observedDefaultIdentity ? record : nil + } if let defaultClaudeRecord, defaultClaudeRecord.id != "claude" { identityKeys.removeValue(forKey: "claude") identityKeys[defaultClaudeRecord.id] = defaultClaudeRecord.identityKey diff --git a/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift b/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift index 8b13007fc..3046b397c 100644 --- a/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift +++ b/Tests/OpenUsageTests/ClaudeAccountIsolationTests.swift @@ -140,9 +140,14 @@ final class ClaudeAccountIsolationTests: XCTestCase { path: credentials(access: "account-a", refresh: "refresh-a", plan: "pro"), identityPath: #"{"oauthAccount":{"accountUuid":"account-a"}}"#, ]) + let calls = IsolationCallCounter() let fixture = makeFixture(files: files, expectedIdentityKey: "account-a") { request in if request.headers["Authorization"] == "Bearer account-a" { - files.files[identityPath] = #"{"oauthAccount":{"accountUuid":"account-b"}}"# + if calls.next() == 1 { + files.files[identityPath] = #"{"oauthAccount":{"accountUuid":"account-b"}}"# + return Self.usageResponse(percent: 75) + } + return HTTPResponse(statusCode: 429, headers: ["retry-after": "600"], body: Data()) } return Self.usageResponse(percent: 75) } @@ -154,6 +159,11 @@ final class ClaudeAccountIsolationTests: XCTestCase { let swappedBeforeRefresh = await fixture.provider.refresh() XCTAssertNil(sessionUsage(swappedBeforeRefresh)) XCTAssertEqual(usageRequests(fixture.http).count, 1) + + files.files[identityPath] = #"{"oauthAccount":{"accountUuid":"account-a"}}"# + let restoredDuringRateLimit = await fixture.provider.refresh() + XCTAssertNil(sessionUsage(restoredDuringRateLimit), "rejected account usage must never survive in the rate-limit cache") + XCTAssertEqual(status(restoredDuringRateLimit)?.hasPrefix("Rate limited"), true) } func testHigherPriorityLoginAddedDuringUsageRequestWins() async { diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 6514fe4bd..f4771187f 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -348,6 +348,23 @@ final class ProviderAccountAssemblyTests: XCTestCase { .configDir(path: path, keychainLiteral: path)) XCTAssertEqual(runtimes.last?.provider.id, assembly.defaultClaudeCardID) XCTAssertEqual(runtimes.last?.authStore.scope, .standard) + + let signedOut = DefaultAccountObserver( + environment: FakeEnvironment(), files: FakeFiles(), keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let afterLogout = ProviderAccountAssembly.make( + observer: signedOut, accountsStore: store, claudeDiscovery: discovery + ) + let remaining = ProviderCatalog.make( + claudeCards: afterLogout.claudeCards, + defaultClaudeCardID: afterLogout.defaultClaudeCardID, + claudeIdentityKeys: afterLogout.identityKeysByCard + ).compactMap { $0 as? ClaudeProvider } + + XCTAssertEqual(remaining.map { $0.provider.id }, ["claude"]) + XCTAssertEqual(remaining.first?.authStore.scope, .configDir(path: path, keychainLiteral: path)) + XCTAssertNil(afterLogout.identityKeysByCard[assembly.defaultClaudeCardID]) } func testNothingObservedLeavesRegistryAndKeysEmpty() { From 90f63f815a21f13821cf5bbb4ec4c832c491ad26 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 18:37:19 +0400 Subject: [PATCH 13/24] fix: retain verified Claude ownership while identity is unreadable --- .../Services/ProviderAccountAssembly.swift | 18 ++++++--- .../ProviderAccountAssemblyTests.swift | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 44d34fa71..a7df12e1c 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -199,12 +199,20 @@ struct ProviderAccountAssembly { } let records = accountsStore.reconcile(with: observations) - let observedDefaultIdentity = identityKeys["claude"] - let defaultClaudeRecord = accountsStore.defaultBadgeHolder(family: "claude").flatMap { record in - record.identityKey == observedDefaultIdentity ? record : nil + let badgeHolder = accountsStore.defaultBadgeHolder(family: "claude") + let defaultClaudeRecord: ProviderAccountRecord? + switch claudeOutcome { + case .resolved: + defaultClaudeRecord = badgeHolder?.identityKey == identityKeys["claude"] ? badgeHolder : nil + case .unresolved: + defaultClaudeRecord = badgeHolder + case .absent, .none: + defaultClaudeRecord = nil } - if let defaultClaudeRecord, defaultClaudeRecord.id != "claude" { - identityKeys.removeValue(forKey: "claude") + if let defaultClaudeRecord { + if defaultClaudeRecord.id != "claude" { + identityKeys.removeValue(forKey: "claude") + } identityKeys[defaultClaudeRecord.id] = defaultClaudeRecord.identityKey } diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index f4771187f..ca3bd57d4 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -37,6 +37,24 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(record.sources.map(\.kind), [.defaultHome]) // An unresolved family claims no account: no record, no identity key. XCTAssertNil(store.defaultBadgeHolder(family: "codex")) + + let unreadable = DefaultAccountObserver( + environment: FakeEnvironment(), + files: FakeFiles([ + "/Users/dev/.claude/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"still-signed-in"}}"#, + ]), + keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let retained = ProviderAccountAssembly.make(observer: unreadable, accountsStore: store) + let runtime = try XCTUnwrap(ProviderCatalog.make( + defaultClaudeCardID: retained.defaultClaudeCardID, + claudeIdentityKeys: retained.identityKeysByCard + ).first as? ClaudeProvider) + + XCTAssertEqual(retained.identityKeysByCard["claude"], "acct-1") + XCTAssertEqual(runtime.authStore.expectedIdentityKey, "acct-1") } /// A family whose home facts aren't readable this launch (first Finder/Dock launch racing a @@ -349,6 +367,27 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(runtimes.last?.provider.id, assembly.defaultClaudeCardID) XCTAssertEqual(runtimes.last?.authStore.scope, .standard) + let temporarilyUnreadable = DefaultAccountObserver( + environment: FakeEnvironment(), + files: FakeFiles([ + "/Users/dev/.claude/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"account-b"}}"#, + ]), + keychain: FakeKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let unresolved = ProviderAccountAssembly.make( + observer: temporarilyUnreadable, accountsStore: store + ) + let retained = ProviderCatalog.make( + defaultClaudeCardID: unresolved.defaultClaudeCardID, + claudeIdentityKeys: unresolved.identityKeysByCard + ).compactMap { $0 as? ClaudeProvider } + + XCTAssertEqual(unresolved.defaultClaudeCardID, assembly.defaultClaudeCardID) + XCTAssertEqual(retained.map { $0.provider.id }, [assembly.defaultClaudeCardID]) + XCTAssertEqual(retained.first?.authStore.expectedIdentityKey, "account-b") + let signedOut = DefaultAccountObserver( environment: FakeEnvironment(), files: FakeFiles(), keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } From 89794817532270dbc93ca4a26333aa0c8ed219d1 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:19:36 +0400 Subject: [PATCH 14/24] fix: protect account discovery and persisted identity boundaries --- .github/workflows/ci.yml | 2 +- .../Providers/Claude/ClaudeAuthStore.swift | 2 +- .../Claude/ClaudeConfigDirDiscovery.swift | 92 +++++++++++++------ .../Services/ProviderAccountAssembly.swift | 2 +- .../Stores/ProviderAccountsStore.swift | 27 ++++-- .../ClaudeConfigDirDiscoveryTests.swift | 29 ++++++ .../ClaudeScopedAuthStoreTests.swift | 19 ++++ .../ProviderAccountsStoreTests.swift | 20 ++++ 8 files changed, 156 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a6d5a01f..0095a2158 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, 'codex/account-*'] permissions: contents: read diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index f0d62c41a..d1b0d9b9a 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -320,7 +320,7 @@ struct ClaudeAuthStore: Sendable { return ["\(base)-\(hashSuffix(keychainLiteral))"] case .standard: if let configDir = claudeHomeOverride() { - return ["\(base)-\(hashSuffix(configDir))", base] + return ["\(base)-\(hashSuffix(configDir))"] } return [base] } diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift index e38b77a45..edea75f22 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -36,7 +36,7 @@ struct ClaudeConfigDirDiscovery { var files: TextFileAccessing var keychain: KeychainAccessing var homeDirectory: @Sendable () -> URL - var listSubdirectories: @Sendable (URL) -> [URL] + var listSubdirectories: @Sendable (URL) throws -> [URL] /// Wall-clock budget; on overrun the scan returns what it has (and the next launch resumes). var timeBudget: TimeInterval var now: @Sendable () -> Date @@ -46,7 +46,7 @@ struct ClaudeConfigDirDiscovery { files: TextFileAccessing = LocalTextFileAccessor(), keychain: KeychainAccessing = SecurityKeychainAccessor(), homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, - listSubdirectories: @escaping @Sendable (URL) -> [URL] = Self.filesystemSubdirectories, + listSubdirectories: @escaping @Sendable (URL) throws -> [URL] = Self.filesystemSubdirectories, timeBudget: TimeInterval = 0.4, now: @escaping @Sendable () -> Date = Date.init ) { @@ -64,12 +64,26 @@ struct ClaudeConfigDirDiscovery { var result = Result() let excluded = Set(defaultClaudeConfigDirs().map(canonical)) let preferred = Set(preferredAnchors.map(canonical)) - let candidates = candidateDirectories().sorted { first, second in - let firstIsPreferred = preferred.contains(canonical(first.path)) - let secondIsPreferred = preferred.contains(canonical(second.path)) - return firstIsPreferred == secondIsPreferred - ? first.path < second.path - : firstIsPreferred + guard !Task.isCancelled, now().timeIntervalSince(started) <= timeBudget else { + result.notes.append("claude config-dir scan ended before enumeration; account ownership remains incomplete") + result.truncated = true + return result + } + let candidates: [URL] + do { + candidates = try candidateDirectories().sorted { first, second in + let firstIsPreferred = preferred.contains(canonical(first.path)) + let secondIsPreferred = preferred.contains(canonical(second.path)) + return firstIsPreferred == secondIsPreferred + ? first.path < second.path + : firstIsPreferred + } + } catch { + let message = "claude config-dir enumeration failed; account ownership remains incomplete: \(error.localizedDescription)" + AppLog.error(.config, message) + result.notes.append(message) + result.truncated = true + return result } for candidate in candidates { @@ -79,9 +93,10 @@ struct ClaudeConfigDirDiscovery { break } guard !excluded.contains(canonical(candidate.path)) else { continue } - if let finding = claudeCandidate(at: candidate, notes: &result.notes) { + if let finding = claudeCandidate(at: candidate, result: &result) { result.findings.append(finding) } + if result.truncated { break } } return result } @@ -89,30 +104,38 @@ struct ClaudeConfigDirDiscovery { // MARK: - Candidates /// Dot-dirs at `~` plus dirs under `~/.config`, in stable path order. - private func candidateDirectories() -> [URL] { + private func candidateDirectories() throws -> [URL] { let home = homeDirectory() - var candidates = listSubdirectories(home).filter { $0.lastPathComponent.hasPrefix(".") } - candidates += listSubdirectories(home.appendingPathComponent(".config")) - return candidates.sorted { $0.path < $1.path } + var candidates = try listSubdirectories(home).filter { $0.lastPathComponent.hasPrefix(".") } + candidates += try listSubdirectories(home.appendingPathComponent(".config")) + return candidates } - private static func filesystemSubdirectories(of url: URL) -> [URL] { - let contents = (try? FileManager.default.contentsOfDirectory( - at: url, - includingPropertiesForKeys: [.isDirectoryKey], - options: [] - )) ?? [] - return contents.filter { - (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + private static func filesystemSubdirectories(of url: URL) throws -> [URL] { + let contents: [URL] + do { + contents = try FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [] + ) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + return [] + } + return try contents.filter { + try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true } } - private func claudeCandidate(at url: URL, notes: inout [String]) -> Finding? { + private func claudeCandidate(at url: URL, result: inout Result) -> Finding? { // Pre-gate: only dirs that carry an identity file at all enter the trail — everything else // is a random dot-dir and stays out of the log. (A custom config dir keeps its state INSIDE // the dir; only the default `~/.claude` keeps it next door at `~/.claude.json`, and the // default homes are excluded before this runs.) - guard let identityText = try? files.readTextIfPresent(url.path + "/.claude.json") else { + let identityText: String + do { + guard let text = try files.readTextIfPresent(url.path + "/.claude.json") else { return nil } + identityText = text + } catch { + recordReadFailure(error, candidate: url, result: &result) return nil } guard let parsed = try? JSONDecoder().decode( @@ -121,15 +144,21 @@ struct ClaudeConfigDirDiscovery { let account = parsed.oauthAccount, let key = DefaultAccountObserver.claudeIdentityKey(account) else { - notes.append("claude candidate \(logPath(url.path)): identity file present but names no account → skipped") + result.notes.append("claude candidate \(logPath(url.path)): identity file present but names no account → skipped") return nil } // Credential shape: the dir's own `.credentials.json`, or its *computed* keychain item. // Claude Code hashes the literal CLAUDE_CONFIG_DIR string, so every plausible spelling of // this path is probed (attributes only — no secret, no prompt). - let fileBacked = (try? files.readTextIfPresent(url.path + "/.credentials.json")) - .flatMap { $0 } + let credentialsText: String? + do { + credentialsText = try files.readTextIfPresent(url.path + "/.credentials.json") + } catch { + recordReadFailure(error, candidate: url, result: &result) + return nil + } + let fileBacked = credentialsText .flatMap { ClaudeAuthStore.parseCredentials($0) }? .claudeAiOauth?.accessToken?.nilIfEmpty != nil @@ -146,11 +175,11 @@ struct ClaudeConfigDirDiscovery { } } guard fileBacked || matchedLiteral != nil else { - notes.append("claude candidate \(logPath(url.path)): identity \(hash8(key)) but no credential (no .credentials.json, no keychain item for \(literals.count) path spellings) → skipped") + result.notes.append("claude candidate \(logPath(url.path)): identity \(hash8(key)) but no credential (no .credentials.json, no keychain item for \(literals.count) path spellings) → skipped") return nil } - notes.append("claude candidate \(logPath(url.path)): accepted (\(hash8(key)), \(fileBacked ? "file" : "keychain") credential)") + result.notes.append("claude candidate \(logPath(url.path)): accepted (\(hash8(key)), \(fileBacked ? "file" : "keychain") credential)") return Finding( identityKey: key, label: DefaultAccountObserver.claudeIdentityLabel(account), @@ -159,6 +188,13 @@ struct ClaudeConfigDirDiscovery { ) } + private func recordReadFailure(_ error: Error, candidate: URL, result: inout Result) { + let message = "claude candidate \(logPath(candidate.path)): account files unreadable; ownership remains incomplete: \(error.localizedDescription)" + AppLog.error(.config, message) + result.notes.append(message) + result.truncated = true + } + /// Every plausible spelling Claude Code might have hashed for this dir's keychain item: the path /// as listed, symlink-resolved, and each with the home prefix swapped for `~` (users export /// `CLAUDE_CONFIG_DIR=~/x` and `=/Users/me/x` interchangeably). diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index a7df12e1c..accd64102 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -156,7 +156,7 @@ struct ProviderAccountAssembly { } var order: [String] = [] var grouped: [String: [ClaudeConfigDirDiscovery.Finding]] = [:] - for finding in scan.findings { + for finding in scan.findings where isClaudeDiscoveryComplete { if grouped[finding.identityKey] == nil { order.append(finding.identityKey) } grouped[finding.identityKey, default: []].append(finding) } diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index 3e50a5177..e7e258675 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -39,11 +39,26 @@ enum ProviderAccountID { /// a swap re-points source edges, cards don't move. Phase 1 only observes the default home; later /// phases add config dirs, cswap vault slots, Codex homes, and Desktop logins as more kinds. struct ProviderAccountSource: Codable, Equatable, Sendable { - enum Kind: String, Codable, Sendable { - /// The provider's standard home for this machine (`~/.claude`, `~/.codex`, env override). - case defaultHome - /// A custom Claude config dir (a `CLAUDE_CONFIG_DIR` home kept besides the default). - case configDir + struct Kind: RawRepresentable, Codable, Equatable, Hashable, Sendable { + static let defaultHome = Self(rawValue: "defaultHome") + static let configDir = Self(rawValue: "configDir") + + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + init(from decoder: Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + var isKnown: Bool { self == .defaultHome || self == .configDir } } var kind: Kind @@ -176,7 +191,7 @@ final class ProviderAccountsStore { guard !updated[index].removedTombstone else { continue } var record = updated[index] record.label = observation.label ?? record.label - record.sources = observation.sources + record.sources = observation.sources + record.sources.filter { !$0.kind.isKnown } if record != updated[index] { updated[index] = record changed = true diff --git a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift index 56b0bec98..da81953ba 100644 --- a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift +++ b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift @@ -121,4 +121,33 @@ final class ClaudeConfigDirDiscoveryTests: XCTestCase { XCTAssertTrue(result.truncated) XCTAssertTrue(result.findings.isEmpty) } + + func testEnumerationFailureExplicitlyMarksOwnershipAsIncomplete() { + let discovery = ClaudeConfigDirDiscovery( + environment: FakeEnvironment(), files: FakeFiles(), keychain: ServiceKeychain(), + homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { _ in throw CocoaError(.fileReadNoPermission) } + ) + + let result = discovery.run() + + XCTAssertTrue(result.truncated) + XCTAssertTrue(result.findings.isEmpty) + XCTAssertEqual(result.notes.count, 1) + } + + func testUnreadableAccountFileExplicitlyMarksOwnershipAsIncomplete() { + let path = "/Users/dev/.claude-work" + let discovery = ClaudeConfigDirDiscovery( + environment: FakeEnvironment(), + files: UnreadableFiles(present: [path + "/.claude.json"]), + keychain: ServiceKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { _ in [URL(fileURLWithPath: path)] } + ) + + let result = discovery.run() + + XCTAssertTrue(result.truncated) + XCTAssertTrue(result.findings.isEmpty) + } } diff --git a/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift b/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift index fa5a73c0e..7060ab55a 100644 --- a/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift +++ b/Tests/OpenUsageTests/ClaudeScopedAuthStoreTests.swift @@ -46,6 +46,25 @@ final class ClaudeScopedAuthStoreTests: XCTestCase { XCTAssertFalse(candidates.contains { $0.source == .environment }) } + func testStandardCustomHomeNeverBorrowsAnotherAccountsBareKeychain() { + let literal = "~/.claude-work" + let environment = FakeEnvironment(["CLAUDE_CONFIG_DIR": literal]) + let scoped = ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: literal, environment: environment + ) + let bare = ClaudeAuthStore.baseKeychainServiceName(environment: environment) + let store = ClaudeAuthStore( + environment: environment, + files: FakeFiles([literal + "/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"own-account"}}"#]), + keychain: ServiceKeychain(currentUserValues: [bare: + #"{"claudeAiOauth":{"accessToken":"another-account"}}"#]) + ) + + XCTAssertEqual(store.keychainServiceCandidates(), [scoped]) + XCTAssertEqual(store.loadCredentialCandidates().map(\.oauth.accessToken), ["own-account"]) + } + func testFootprintProbeSeesFileAndKeychainShapesWithoutReadingSecrets() { let scopedService = ClaudeAuthStore.scopedKeychainServiceName( forConfigDirLiteral: "~/.claude-work", environment: FakeEnvironment([:]) diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index f9cd99a8c..45c3328d0 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -60,6 +60,26 @@ final class ProviderAccountsStoreTests: XCTestCase { XCTAssertNotNil(defaults.data(forKey: ProviderAccountsStore.storageKey)) } + func testUnknownFutureSourceSurvivesDecodingReconciliationAndPersistence() throws { + let defaults = makeScratchDefaults() + let future = ProviderAccountSource.Kind(rawValue: "desktop") + let existing = ProviderAccountRecord( + id: "claude", family: "claude", identityKey: "account-a", label: "Personal", + sources: [ + .init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true), + .init(kind: future, anchor: nil, holdsDefaultSource: false), + ] + ) + defaults.set(try JSONEncoder().encode([existing]), forKey: ProviderAccountsStore.storageKey) + + let store = ProviderAccountsStore(defaults: defaults) + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "account-a")]) + + let restored = try XCTUnwrap(ProviderAccountsStore(defaults: defaults).records.first) + XCTAssertEqual(restored.identityKey, "account-a") + XCTAssertEqual(restored.sources.map(\.kind.rawValue), ["defaultHome", "desktop"]) + } + func testSwappedDefaultMintsAHashIDAndTakesTheBadge() { let store = ProviderAccountsStore(defaults: makeScratchDefaults()) store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) From ac770079c0d8eb710abee4a180ab17e5f59e3189 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:21:02 +0400 Subject: [PATCH 15/24] fix: retain account identity aliases across downgrades --- Sources/OpenUsage/Stores/ProviderAccountsStore.swift | 1 + Tests/OpenUsageTests/ProviderAccountsStoreTests.swift | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index e7e258675..b43ad3ca6 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -85,6 +85,7 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { var id: String var family: String var identityKey: String + var identityAliases: [String]? = nil var label: String? /// A user-chosen card name (Rename in the card's context menu / Customize). Wins over `label` /// and the id-derived fallback; never touched by reconciliation. diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index 45c3328d0..04f0e8a60 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -64,7 +64,8 @@ final class ProviderAccountsStoreTests: XCTestCase { let defaults = makeScratchDefaults() let future = ProviderAccountSource.Kind(rawValue: "desktop") let existing = ProviderAccountRecord( - id: "claude", family: "claude", identityKey: "account-a", label: "Personal", + id: "claude", family: "claude", identityKey: "account-a", + identityAliases: ["previous-account-a"], label: "Personal", sources: [ .init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true), .init(kind: future, anchor: nil, holdsDefaultSource: false), @@ -77,6 +78,7 @@ final class ProviderAccountsStoreTests: XCTestCase { let restored = try XCTUnwrap(ProviderAccountsStore(defaults: defaults).records.first) XCTAssertEqual(restored.identityKey, "account-a") + XCTAssertEqual(restored.identityAliases, ["previous-account-a"]) XCTAssertEqual(restored.sources.map(\.kind.rawValue), ["defaultHome", "desktop"]) } From 59ac61993808edb3f2227d39350fc5a4a71fb926 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:23:16 +0400 Subject: [PATCH 16/24] fix: fail closed on corrupt Claude account files --- .../Claude/ClaudeConfigDirDiscovery.swift | 20 +++++++++++++------ .../ClaudeConfigDirDiscoveryTests.swift | 10 ++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift index edea75f22..fc63724f2 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -138,10 +138,14 @@ struct ClaudeConfigDirDiscovery { recordReadFailure(error, candidate: url, result: &result) return nil } - guard let parsed = try? JSONDecoder().decode( - DefaultAccountObserver.ClaudeStateFile.self, from: Data(identityText.utf8) - ), - let account = parsed.oauthAccount, + let parsed: DefaultAccountObserver.ClaudeStateFile + do { + parsed = try JSONDecoder().decode(DefaultAccountObserver.ClaudeStateFile.self, from: Data(identityText.utf8)) + } catch { + recordReadFailure(error, candidate: url, result: &result) + return nil + } + guard let account = parsed.oauthAccount, let key = DefaultAccountObserver.claudeIdentityKey(account) else { result.notes.append("claude candidate \(logPath(url.path)): identity file present but names no account → skipped") @@ -158,8 +162,12 @@ struct ClaudeConfigDirDiscovery { recordReadFailure(error, candidate: url, result: &result) return nil } - let fileBacked = credentialsText - .flatMap { ClaudeAuthStore.parseCredentials($0) }? + let parsedCredentials = credentialsText.flatMap(ClaudeAuthStore.parseCredentials) + if let credentialsText, !credentialsText.isEmpty, parsedCredentials == nil { + recordReadFailure(CocoaError(.fileReadCorruptFile), candidate: url, result: &result) + return nil + } + let fileBacked = parsedCredentials? .claudeAiOauth?.accessToken?.nilIfEmpty != nil var matchedLiteral: String? diff --git a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift index da81953ba..6a78e5f11 100644 --- a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift +++ b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift @@ -149,5 +149,15 @@ final class ClaudeConfigDirDiscoveryTests: XCTestCase { XCTAssertTrue(result.truncated) XCTAssertTrue(result.findings.isEmpty) + + for files in [ + [path + "/.claude.json": "{"], + [path + "/.claude.json": #"{"oauthAccount":{"accountUuid":"work"}}"#, + path + "/.credentials.json": "{"], + ] { + var malformed = discovery + malformed.files = FakeFiles(files) + XCTAssertTrue(malformed.run().truncated) + } } } From 795d4813440a213dc81cf74e929c98b8f902a93b Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:42:31 +0400 Subject: [PATCH 17/24] fix: harden account discovery and downgrade identity ownership --- .../Claude/ClaudeConfigDirDiscovery.swift | 13 ++++++- .../Providers/DefaultAccountObserver.swift | 17 ++++++--- .../Services/ProviderAccountAssembly.swift | 16 ++++++--- .../OpenUsage/Services/SystemClients.swift | 1 + .../Stores/ProviderAccountsStore.swift | 18 +++++++--- .../ClaudeConfigDirDiscoveryTests.swift | 35 +++++++++++++++++++ .../DefaultAccountObserverTests.swift | 7 ++++ .../ProviderAccountAssemblyTests.swift | 26 ++++++++++++++ .../ProviderAccountsStoreTests.swift | 17 +++++++++ 9 files changed, 137 insertions(+), 13 deletions(-) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift index fc63724f2..06bad9fa3 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -171,16 +171,27 @@ struct ClaudeConfigDirDiscovery { .claudeAiOauth?.accessToken?.nilIfEmpty != nil var matchedLiteral: String? + var keychainOwnershipUncertain = false let literals = keychainLiterals(for: url) for literal in literals { let service = ClaudeAuthStore.scopedKeychainServiceName( forConfigDirLiteral: literal, environment: environment ) - if keychain.genericPasswordExists(service: service) == true { + switch keychain.genericPasswordExists(service: service) { + case true: matchedLiteral = literal break + case nil: + keychainOwnershipUncertain = true + case false: + break } + if matchedLiteral != nil { break } + } + if !fileBacked, matchedLiteral == nil, keychainOwnershipUncertain { + recordReadFailure(CocoaError(.fileReadNoPermission), candidate: url, result: &result) + return nil } guard fileBacked || matchedLiteral != nil else { result.notes.append("claude candidate \(logPath(url.path)): identity \(hash8(key)) but no credential (no .credentials.json, no keychain item for \(literals.count) path spellings) → skipped") diff --git a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift index e9fceb5f1..d02375326 100644 --- a/Sources/OpenUsage/Providers/DefaultAccountObserver.swift +++ b/Sources/OpenUsage/Providers/DefaultAccountObserver.swift @@ -88,12 +88,14 @@ struct DefaultAccountObserver: Sendable { /// when exported, else `~/.claude`. A comma-separated list can't be assigned one identity. func observeClaude() -> Outcome { var configDir = "~/.claude" + var usesScopedKeychain = false if let raw = environment.value(for: "CLAUDE_CONFIG_DIR")? .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { guard !raw.contains(",") else { return .unresolved(reason: "CLAUDE_CONFIG_DIR is a comma-separated list") } configDir = raw + usesScopedKeychain = true } let location = Self.claudeLocation(configDir: configDir, homeDirectory: homeDirectory()) let anchor = location.anchor @@ -104,10 +106,17 @@ struct DefaultAccountObserver: Sendable { return .unresolved(reason: "identity file unreadable: \(error.localizedDescription)") } guard let text else { - // No state file. A credential file without it can't be attributed; no footprint = absent. - return files.exists(anchor + "/.credentials.json") - ? .unresolved(reason: "credentials present but no identity file") - : .absent + if files.exists(anchor + "/.credentials.json") { + return .unresolved(reason: "credentials present but no identity file") + } + let service = usesScopedKeychain + ? ClaudeAuthStore.scopedKeychainServiceName( + forConfigDirLiteral: configDir, environment: environment + ) + : ClaudeAuthStore.baseKeychainServiceName(environment: environment) + return keychain.genericPasswordExists(service: service) == false + ? .absent + : .unresolved(reason: "keychain credentials present or unverifiable without an identity file") } guard let parsed = try? JSONDecoder().decode(ClaudeStateFile.self, from: Data(text.utf8)), let account = parsed.oauthAccount, diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index accd64102..1178b595e 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -144,6 +144,9 @@ struct ProviderAccountAssembly { record.sources.first { $0.kind == .configDir }?.anchor.map { (record.identityKey, $0) } }) let claudeOutcome = outcomes.first { $0.family == "claude" }?.outcome + if case .unresolved = claudeOutcome { + isClaudeDiscoveryComplete = false + } if let claudeDiscovery, let claudeOutcome { if case .unresolved = claudeOutcome { AppLog.info(.config, "discovery: claude default login present but its identity is unreadable → skipping extra-account candidates this launch") @@ -203,31 +206,36 @@ struct ProviderAccountAssembly { let defaultClaudeRecord: ProviderAccountRecord? switch claudeOutcome { case .resolved: - defaultClaudeRecord = badgeHolder?.identityKey == identityKeys["claude"] ? badgeHolder : nil + defaultClaudeRecord = identityKeys["claude"].flatMap { observed in + badgeHolder?.matches(identityKey: observed) == true ? badgeHolder : nil + } case .unresolved: defaultClaudeRecord = badgeHolder case .absent, .none: defaultClaudeRecord = nil } if let defaultClaudeRecord { + let observedDefaultIdentity = identityKeys["claude"] if defaultClaudeRecord.id != "claude" { identityKeys.removeValue(forKey: "claude") } - identityKeys[defaultClaudeRecord.id] = defaultClaudeRecord.identityKey + identityKeys[defaultClaudeRecord.id] = observedDefaultIdentity ?? defaultClaudeRecord.identityKey } // The extra-card build plan: one card per distinct account found this launch, under its // reconciled record id. var claudeCards: [ClaudeAccountCard] = [] for account in foundClaudeAccounts { - guard let record = records.first(where: { $0.family == "claude" && $0.identityKey == account.identityKey }) else { + guard let record = records.first(where: { + $0.family == "claude" && $0.matches(identityKey: account.identityKey) + }) else { continue } guard let primary = account.dirs.first else { continue } claudeCards.append(ClaudeAccountCard( id: record.id, displayName: record.derivedDisplayName, - identityKey: record.identityKey, + identityKey: account.identityKey, configDirPath: primary.anchorPath, keychainLiteral: primary.keychainLiteral, extraLogRoots: account.dirs.dropFirst().map { URL(fileURLWithPath: $0.anchorPath) } diff --git a/Sources/OpenUsage/Services/SystemClients.swift b/Sources/OpenUsage/Services/SystemClients.swift index e6113d301..d07ec5266 100644 --- a/Sources/OpenUsage/Services/SystemClients.swift +++ b/Sources/OpenUsage/Services/SystemClients.swift @@ -222,6 +222,7 @@ enum SQLiteError: Error, LocalizedError, Equatable { } protocol KeychainAccessing: Sendable { + func genericPasswordExists(service: String) -> Bool? func readGenericPassword(service: String) throws -> String? func writeGenericPassword(service: String, value: String) throws func readGenericPasswordForCurrentUser(service: String) throws -> String? diff --git a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift index b43ad3ca6..495e19786 100644 --- a/Sources/OpenUsage/Stores/ProviderAccountsStore.swift +++ b/Sources/OpenUsage/Stores/ProviderAccountsStore.swift @@ -94,6 +94,12 @@ struct ProviderAccountRecord: Codable, Equatable, Sendable { /// Set by a future "Remove Account…". A tombstoned account is never resurrected by rescans. var removedTombstone: Bool = false + func matches(identityKey observed: String) -> Bool { + ([identityKey] + (identityAliases ?? [])).contains { + $0.caseInsensitiveCompare(observed) == .orderedSame + } + } + /// The name a card carries without a rename: the stock family name for the bare card, a /// "Claude — " derived from the account label for an extra card, or the record id /// itself when the account has no label (owner decision 2: short-hash fallback, one rename away @@ -186,8 +192,9 @@ final class ProviderAccountsStore { for observation in observations { let index = updated.firstIndex { - $0.family == observation.family && $0.identityKey == observation.identityKey + $0.family == observation.family && $0.matches(identityKey: observation.identityKey) } + let observedRecordID: String if let index { guard !updated[index].removedTombstone else { continue } var record = updated[index] @@ -197,14 +204,17 @@ final class ProviderAccountsStore { updated[index] = record changed = true } + observedRecordID = record.id } else { - updated.append(ProviderAccountRecord( + let record = ProviderAccountRecord( id: Self.availableID(for: observation, in: updated), family: observation.family, identityKey: observation.identityKey, label: observation.label, sources: observation.sources - )) + ) + updated.append(record) + observedRecordID = record.id changed = true } @@ -213,7 +223,7 @@ final class ProviderAccountsStore { if observation.sources.contains(where: \.holdsDefaultSource) { for index in updated.indices where updated[index].family == observation.family - && updated[index].identityKey != observation.identityKey + && updated[index].id != observedRecordID && updated[index].sources.contains(where: \.holdsDefaultSource) { updated[index].sources = updated[index].sources.map { source in diff --git a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift index 6a78e5f11..25c25ec45 100644 --- a/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift +++ b/Tests/OpenUsageTests/ClaudeConfigDirDiscoveryTests.swift @@ -160,4 +160,39 @@ final class ClaudeConfigDirDiscoveryTests: XCTestCase { XCTAssertTrue(malformed.run().truncated) } } + + func testKeychainMetadataProbeNeverReadsSecretsAndQuarantinesUnknownOwnership() { + let path = "/Users/dev/.claude-work" + for existence in [Optional(true), nil] { + let keychain = MetadataProbeKeychain(existence: existence) + let discovery = ClaudeConfigDirDiscovery( + environment: FakeEnvironment(), + files: FakeFiles([path + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"work"}}"#]), + keychain: keychain, homeDirectory: { URL(fileURLWithPath: "/Users/dev") }, + listSubdirectories: { directory in + directory.path == "/Users/dev" ? [URL(fileURLWithPath: path)] : [] + } + ) + + let result = discovery.run() + + XCTAssertEqual(result.truncated, existence == nil) + XCTAssertEqual(result.findings.count, existence == true ? 1 : 0) + XCTAssertEqual(keychain.secretReads, 0) + } + } +} + +private final class MetadataProbeKeychain: KeychainAccessing, @unchecked Sendable { + let existence: Bool? + private(set) var secretReads = 0 + + init(existence: Bool?) { self.existence = existence } + func genericPasswordExists(service: String) -> Bool? { existence } + func readGenericPassword(service: String) throws -> String? { + secretReads += 1 + return "this secret must never be read" + } + func writeGenericPassword(service: String, value: String) throws {} } diff --git a/Tests/OpenUsageTests/DefaultAccountObserverTests.swift b/Tests/OpenUsageTests/DefaultAccountObserverTests.swift index 762eb2595..41e41c525 100644 --- a/Tests/OpenUsageTests/DefaultAccountObserverTests.swift +++ b/Tests/OpenUsageTests/DefaultAccountObserverTests.swift @@ -109,6 +109,13 @@ final class DefaultAccountObserverTests: XCTestCase { XCTAssertEqual(observer.observeClaude(), .unresolved(reason: "credentials present but no identity file")) } + func testClaudeKeychainCredentialsWithoutStateFileAreUnresolvedNotAbsent() { + let observer = makeObserver(keychainValue: "keychain-only-account") + guard case .unresolved = observer.observeClaude() else { + return XCTFail("an unidentifiable Keychain login must never be considered logged out") + } + } + func testClaudeNoFootprintIsAbsent() { XCTAssertEqual(makeObserver().observeClaude(), .absent) } diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index ca3bd57d4..3cd78778b 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -192,9 +192,35 @@ final class ProviderAccountAssemblyTests: XCTestCase { assembly.claudeCards.isEmpty, "with a nameless default login, an accepted candidate could be that very account — skip" ) + XCTAssertFalse(assembly.isClaudeDiscoveryComplete) + let runtime = ProviderCatalog.make( + isClaudeDiscoveryComplete: assembly.isClaudeDiscoveryComplete + ).first as? ClaudeProvider + XCTAssertEqual(runtime?.authStore.allowsDesktopFallback, false) XCTAssertTrue(store.records.isEmpty) } + func testCanonicalAccountAliasRetainsTheSameCardWithObservedCredentialOwnership() throws { + let defaults = makeScratchDefaults() + let existing = ProviderAccountRecord( + id: "claude", family: "claude", identityKey: "acct-1|org-1", + identityAliases: ["acct-1"], label: "Work", + sources: [.init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true)] + ) + defaults.set(try JSONEncoder().encode([existing]), forKey: ProviderAccountsStore.storageKey) + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + files: FakeFiles(["/Users/dev/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCT-1"}}"#]), + keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + + let assembly = ProviderAccountAssembly.make(observer: observer, accountsStore: store) + + XCTAssertEqual(store.records.map(\.id), ["claude"]) + XCTAssertEqual(assembly.identityKeysByCard["claude"], "acct-1") + } + func testNoDefaultLoginStillAcceptsAConfigDirOnlyAccount() throws { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) diff --git a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift index 04f0e8a60..7e0f7f9a7 100644 --- a/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountsStoreTests.swift @@ -82,6 +82,23 @@ final class ProviderAccountsStoreTests: XCTestCase { XCTAssertEqual(restored.sources.map(\.kind.rawValue), ["defaultHome", "desktop"]) } + func testObservedIdentityAliasPreservesExistingAccountNameAndDefaultBadge() throws { + let defaults = makeScratchDefaults() + let existing = ProviderAccountRecord( + id: "claude", family: "claude", identityKey: "account-a|org-a", + identityAliases: ["ACCOUNT-A"], label: "Personal", customLabel: "My Account", + sources: [.init(kind: .defaultHome, anchor: "/old", holdsDefaultSource: true)] + ) + defaults.set(try JSONEncoder().encode([existing]), forKey: ProviderAccountsStore.storageKey) + let store = ProviderAccountsStore(defaults: defaults) + + store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "account-a")]) + + XCTAssertEqual(store.records.count, 1) + XCTAssertEqual(store.defaultBadgeHolder(family: "claude")?.identityKey, "account-a|org-a") + XCTAssertEqual(store.record(for: "claude")?.customLabel, "My Account") + } + func testSwappedDefaultMintsAHashIDAndTakesTheBadge() { let store = ProviderAccountsStore(defaults: makeScratchDefaults()) store.reconcile(with: [defaultHomeObservation(family: "claude", identityKey: "acct-a")]) From d0ebed24ec1359362a4518cde6c15e243a5d7343 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:49:59 +0400 Subject: [PATCH 18/24] fix: quarantine skipped Claude account discovery --- Sources/OpenUsage/Services/ProviderAccountAssembly.swift | 4 ++-- Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 1178b595e..19fdbc257 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -75,7 +75,7 @@ struct ProviderAccountAssembly { AppLog.info(.config, "account identity read skipped for \(ProviderAccountID.families.subtracting(families).sorted().joined(separator: ", ")): login shell cold and no shell-environment snapshot exists yet") } guard !families.isEmpty else { - return ProviderAccountAssembly(identityKeysByCard: [:]) + return ProviderAccountAssembly(identityKeysByCard: [:], isClaudeDiscoveryComplete: false) } return make( observer: DefaultAccountObserver(), @@ -139,7 +139,7 @@ struct ProviderAccountAssembly { // and a custom-dir-only login should still get its card. var foundClaudeAccounts: [(identityKey: String, label: String?, dirs: [ClaudeConfigDirDiscovery.Finding])] = [] var defaultClaudeExtraLogRoots: [URL] = [] - var isClaudeDiscoveryComplete = true + var isClaudeDiscoveryComplete = families.contains("claude") let preferredConfigAnchors = Dictionary(uniqueKeysWithValues: accountsStore.records.compactMap { record in record.sources.first { $0.kind == .configDir }?.anchor.map { (record.identityKey, $0) } }) diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 3cd78778b..a2be24633 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -77,6 +77,13 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(assembly.identityKeysByCard, ["codex": "codex-1"]) XCTAssertNil(store.defaultBadgeHolder(family: "claude"), "an out-of-pass family must not be reconciled") + XCTAssertFalse(assembly.isClaudeDiscoveryComplete) + let unavailable = ProviderAccountAssembly.make(observer: observer, accountsStore: store, families: []) + XCTAssertFalse(unavailable.isClaudeDiscoveryComplete) + let runtime = ProviderCatalog.make( + isClaudeDiscoveryComplete: unavailable.isClaudeDiscoveryComplete + ).first as? ClaudeProvider + XCTAssertEqual(runtime?.authStore.allowsDesktopFallback, false) } private func makeDiscovery( From f12747a88a2de8835acae5001f06a8fd70ea4292 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:51:55 +0400 Subject: [PATCH 19/24] fix: fold verified account aliases into their existing card --- .../Services/ProviderAccountAssembly.swift | 14 ++++++++++++-- .../ProviderAccountAssemblyTests.swift | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 19fdbc257..4a7024f8d 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -179,12 +179,22 @@ struct ProviderAccountAssembly { keychainLiteral: $0.keychainLiteral ) } - if identityKey == defaultKey { + let matchesDefault = defaultKey.map { observedDefault in + identityKey.caseInsensitiveCompare(observedDefault) == .orderedSame + || accountsStore.records.contains { + $0.family == "claude" + && $0.matches(identityKey: observedDefault) + && $0.matches(identityKey: identityKey) + } + } ?? false + if matchesDefault { // Same account as the default card: its dirs are extra spend-log roots on // that card, never a second card — duplicate cards are structurally // impossible because identity routes the source to the existing record. defaultClaudeExtraLogRoots += findings.map { URL(fileURLWithPath: $0.anchorPath) } - if let index = observations.firstIndex(where: { $0.family == "claude" && $0.identityKey == identityKey }) { + if let index = observations.firstIndex(where: { + $0.family == "claude" && $0.identityKey == defaultKey + }) { observations[index].sources += sources } AppLog.info(.config, "discovery: \(findings.count) config dir(s) fold onto the default claude card (same account)") diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index a2be24633..1c1370e1e 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -222,10 +222,23 @@ final class ProviderAccountAssemblyTests: XCTestCase { keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } ) - let assembly = ProviderAccountAssembly.make(observer: observer, accountsStore: store) + let discovery = makeDiscovery( + files: [ + "/Users/dev/.claude-work/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCT-1","organizationUuid":"ORG-1"}}"#, + "/Users/dev/.claude-work/.credentials.json": + #"{"claudeAiOauth":{"accessToken":"same-account"}}"#, + ], + subdirectories: ["/Users/dev/.claude-work"] + ) + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) XCTAssertEqual(store.records.map(\.id), ["claude"]) XCTAssertEqual(assembly.identityKeysByCard["claude"], "acct-1") + XCTAssertTrue(assembly.claudeCards.isEmpty) + XCTAssertEqual(assembly.defaultClaudeExtraLogRoots.map(\.path), ["/Users/dev/.claude-work"]) } func testNoDefaultLoginStillAcceptsAConfigDirOnlyAccount() throws { From bc6a3b8cbb84f7363d63854969d8b662b6f1af7f Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 21:54:49 +0400 Subject: [PATCH 20/24] fix: deduplicate config homes belonging to one account --- .../Services/ProviderAccountAssembly.swift | 16 +++++++-- .../ProviderAccountAssemblyTests.swift | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 4a7024f8d..131388a13 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -160,8 +160,18 @@ struct ProviderAccountAssembly { var order: [String] = [] var grouped: [String: [ClaudeConfigDirDiscovery.Finding]] = [:] for finding in scan.findings where isClaudeDiscoveryComplete { - if grouped[finding.identityKey] == nil { order.append(finding.identityKey) } - grouped[finding.identityKey, default: []].append(finding) + let records = accountsStore.records.filter { + $0.family == "claude" && $0.matches(identityKey: finding.identityKey) + } + guard records.count <= 1 else { + isClaudeDiscoveryComplete = false + order.removeAll() + grouped.removeAll() + break + } + let identityKey = records.first?.identityKey ?? finding.identityKey + if grouped[identityKey] == nil { order.append(identityKey) } + grouped[identityKey, default: []].append(finding) } for identityKey in order { var findings = grouped[identityKey] ?? [] @@ -205,7 +215,7 @@ struct ProviderAccountAssembly { label: findings.first?.label, sources: sources )) - foundClaudeAccounts.append((identityKey, findings.first?.label, findings)) + foundClaudeAccounts.append((findings[0].identityKey, findings[0].label, findings)) } } } diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 1c1370e1e..e048d81d6 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -241,6 +241,41 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertEqual(assembly.defaultClaudeExtraLogRoots.map(\.path), ["/Users/dev/.claude-work"]) } + func testAliasedConfigDirectoriesProduceOneStableNonDefaultAccountCard() throws { + let defaults = makeScratchDefaults() + let records = [ + ProviderAccountRecord( + id: "claude", family: "claude", identityKey: "default", label: nil, + sources: [.init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true)] + ), + ProviderAccountRecord( + id: "claude@work", family: "claude", identityKey: "work|org", + identityAliases: ["work"], label: "Work", sources: [] + ), + ] + defaults.set(try JSONEncoder().encode(records), forKey: ProviderAccountsStore.storageKey) + let store = ProviderAccountsStore(defaults: defaults) + let observer = DefaultAccountObserver( + files: FakeFiles(["/Users/dev/.claude.json": + #"{"oauthAccount":{"accountUuid":"default"}}"#]), + keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } + ) + let paths = ["/Users/dev/.claude-work-a", "/Users/dev/.claude-work-b"] + let discovery = makeDiscovery(files: [ + paths[0] + "/.claude.json": #"{"oauthAccount":{"accountUuid":"work"}}"#, + paths[1] + "/.claude.json": #"{"oauthAccount":{"accountUuid":"work","organizationUuid":"org"}}"#, + paths[0] + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"a"}}"#, + paths[1] + "/.credentials.json": #"{"claudeAiOauth":{"accessToken":"b"}}"#, + ], subdirectories: paths) + + let assembly = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, claudeDiscovery: discovery + ) + + XCTAssertEqual(assembly.claudeCards.map(\.id), ["claude@work"]) + XCTAssertEqual(assembly.claudeCards.first?.identityKey, "work") + } + func testNoDefaultLoginStillAcceptsAConfigDirOnlyAccount() throws { let defaults = makeScratchDefaults() let store = ProviderAccountsStore(defaults: defaults) From 04a0de70da6f41c0cd01cffdf501ef72dec05c11 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 22:04:25 +0400 Subject: [PATCH 21/24] fix: retain bound Claude accounts when discovery is skipped --- .../Services/ProviderAccountAssembly.swift | 11 ++++++---- .../ProviderAccountAssemblyTests.swift | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 131388a13..97b46f0f9 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -74,12 +74,15 @@ struct ProviderAccountAssembly { if families.count < ProviderAccountID.families.count { AppLog.info(.config, "account identity read skipped for \(ProviderAccountID.families.subtracting(families).sorted().joined(separator: ", ")): login shell cold and no shell-environment snapshot exists yet") } + let resolvedAccountsStore = accountsStore ?? ProviderAccountsStore(defaults: defaults) guard !families.isEmpty else { - return ProviderAccountAssembly(identityKeysByCard: [:], isClaudeDiscoveryComplete: false) + return make( + observer: DefaultAccountObserver(), accountsStore: resolvedAccountsStore, families: [] + ) } return make( observer: DefaultAccountObserver(), - accountsStore: accountsStore ?? ProviderAccountsStore(defaults: defaults), + accountsStore: resolvedAccountsStore, families: families, claudeDiscovery: ClaudeConfigDirDiscovery() ) @@ -229,9 +232,9 @@ struct ProviderAccountAssembly { defaultClaudeRecord = identityKeys["claude"].flatMap { observed in badgeHolder?.matches(identityKey: observed) == true ? badgeHolder : nil } - case .unresolved: + case .unresolved, .none: defaultClaudeRecord = badgeHolder - case .absent, .none: + case .absent: defaultClaudeRecord = nil } if let defaultClaudeRecord { diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index e048d81d6..c4978d3fd 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -84,6 +84,26 @@ final class ProviderAccountAssemblyTests: XCTestCase { isClaudeDiscoveryComplete: unavailable.isClaudeDiscoveryComplete ).first as? ClaudeProvider XCTAssertEqual(runtime?.authStore.allowsDesktopFallback, false) + + for account in ["original-account", "swapped-account"] { + store.reconcile(with: [.init( + family: "claude", identityKey: account, label: nil, + sources: [.init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true)] + )]) + } + for families: Set in [["codex"], []] { + let skipped = ProviderAccountAssembly.make( + observer: observer, accountsStore: store, families: families + ) + XCTAssertNotEqual(skipped.defaultClaudeCardID, "claude") + XCTAssertEqual(skipped.identityKeysByCard[skipped.defaultClaudeCardID], "swapped-account") + let bound = ProviderCatalog.make( + defaultClaudeCardID: skipped.defaultClaudeCardID, + claudeIdentityKeys: skipped.identityKeysByCard, + isClaudeDiscoveryComplete: skipped.isClaudeDiscoveryComplete + ).first as? ClaudeProvider + XCTAssertEqual(bound?.authStore.expectedIdentityKey, "swapped-account") + } } private func makeDiscovery( From 2941f5267f7953f54722e78a5d995416e3be9c0b Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 22:46:11 +0400 Subject: [PATCH 22/24] fix: canonicalize account aliases and skip irrelevant discovery entries --- Sources/OpenUsage/App/AppContainer.swift | 1 + .../Providers/Claude/ClaudeAuthStore.swift | 9 +++- .../Claude/ClaudeConfigDirDiscovery.swift | 5 +- .../OpenUsage/Providers/ProviderCatalog.swift | 7 ++- .../Services/ProviderAccountAssembly.swift | 23 +++++++-- Sources/OpenUsage/Services/UsageReader.swift | 1 + .../ProviderAccountAssemblyTests.swift | 47 +++++++++++++++++-- 7 files changed, 79 insertions(+), 14 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index ab6f0d86a..3d6a5efca 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -81,6 +81,7 @@ final class AppContainer { claudeCards: accountAssembly.claudeCards, defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, + defaultClaudeVerifiedIdentityAliases: accountAssembly.defaultClaudeVerifiedIdentityAliases, defaultClaudeCardID: accountAssembly.defaultClaudeCardID, claudeIdentityKeys: accountAssembly.identityKeysByCard, isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift index d1b0d9b9a..960dd07f0 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeAuthStore.swift @@ -18,6 +18,8 @@ struct ClaudeAuthStore: Sendable { var now: @Sendable () -> Date let scope: ClaudeCredentialScope let expectedIdentityKey: String? + /// Aliases explicitly verified against this card's selected credential source at launch. + let verifiedIdentityAliases: Set /// Whether the `.standard` store may fall back to Claude Desktop's credentials. On by default /// (the historical behavior); the catalog turns it OFF once extra Claude account cards exist, /// because the Desktop login could belong to any of them — borrowing it unpinned could fetch one @@ -32,6 +34,7 @@ struct ClaudeAuthStore: Sendable { scope: ClaudeCredentialScope = .standard, allowsDesktopFallback: Bool = true, expectedIdentityKey: String? = nil, + verifiedIdentityAliases: Set = [], homeDirectory: @escaping @Sendable () -> URL = { FileManager.default.homeDirectoryForCurrentUser }, now: @escaping @Sendable () -> Date = Date.init ) { @@ -43,6 +46,7 @@ struct ClaudeAuthStore: Sendable { self.scope = scope self.allowsDesktopFallback = allowsDesktopFallback self.expectedIdentityKey = expectedIdentityKey + self.verifiedIdentityAliases = verifiedIdentityAliases self.now = now } @@ -127,7 +131,10 @@ struct ClaudeAuthStore: Sendable { let account = state.oauthAccount, let observed = DefaultAccountObserver.claudeIdentityKey(account) else { return false } - return observed.lowercased() == expectedIdentityKey.lowercased() + return observed.caseInsensitiveCompare(expectedIdentityKey) == .orderedSame + || verifiedIdentityAliases.contains { + $0.caseInsensitiveCompare(observed) == .orderedSame + } } private func applyingEnvironmentToken(to stored: [ClaudeCredentialState]) -> [ClaudeCredentialState] { diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift index 06bad9fa3..356b6f82a 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeConfigDirDiscovery.swift @@ -121,7 +121,10 @@ struct ClaudeConfigDirDiscovery { return [] } return try contents.filter { - try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true + guard url.lastPathComponent == ".config" || $0.lastPathComponent.hasPrefix(".") else { + return false + } + return try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true } } diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 0b1b912ee..ac68bc393 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -14,6 +14,7 @@ enum ProviderCatalog { claudeCards: [ClaudeAccountCard] = [], defaultClaudeExtraLogRoots: [URL] = [], defaultClaudeDisplayName: String? = nil, + defaultClaudeVerifiedIdentityAliases: Set = [], defaultClaudeCardID: String = "claude", claudeIdentityKeys: [String: String] = [:], isClaudeDiscoveryComplete: Bool = true @@ -37,7 +38,8 @@ enum ProviderCatalog { // returns as its own properly-pinned source kind in Phase 3. authStore: ClaudeAuthStore( allowsDesktopFallback: claudeCards.isEmpty && isClaudeDiscoveryComplete, - expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID] + expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID], + verifiedIdentityAliases: defaultClaudeVerifiedIdentityAliases ), logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) )) @@ -68,7 +70,8 @@ enum ProviderCatalog { provider: ClaudeProvider.makeProvider(id: card.id, displayName: card.displayName), authStore: ClaudeAuthStore( scope: .configDir(path: card.configDirPath, keychainLiteral: card.keychainLiteral), - expectedIdentityKey: card.identityKey + expectedIdentityKey: card.identityKey, + verifiedIdentityAliases: card.verifiedIdentityAliases ), logUsageScanner: ClaudeLogUsageScanner( cacheIdentityOverride: "claude-account:\(card.id)", diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 97b46f0f9..42408aea2 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -18,6 +18,8 @@ struct ClaudeAccountCard: Equatable, Sendable { var keychainLiteral: String /// Same-account additional config dirs (rare): extra spend-log roots, never extra credentials. var extraLogRoots: [URL] = [] + /// Only identities observed at this card's verified credential source may authorize aliases. + var verifiedIdentityAliases: Set = [] } /// The launch-time account pass: read which account is signed in at each family's default home, @@ -37,6 +39,8 @@ struct ProviderAccountAssembly { var defaultClaudeExtraLogRoots: [URL] = [] /// The default account's derived title; custom names are resolved at presentation boundaries. var defaultClaudeDisplayName: String? + /// Identity observed at the verified default source, even when its record uses another alias. + var defaultClaudeVerifiedIdentityAliases: Set = [] /// The default runtime follows its account record, even when another account keeps `claude`. var defaultClaudeCardID = "claude" /// An incomplete scan cannot prove that an unpinned Desktop login belongs to the default card. @@ -224,7 +228,15 @@ struct ProviderAccountAssembly { } } + let observedDefaultIdentity = identityKeys["claude"] let records = accountsStore.reconcile(with: observations) + for (family, observedIdentity) in Array(identityKeys) { + if let record = records.first(where: { + $0.family == family && $0.matches(identityKey: observedIdentity) + }) { + identityKeys[family] = record.identityKey + } + } let badgeHolder = accountsStore.defaultBadgeHolder(family: "claude") let defaultClaudeRecord: ProviderAccountRecord? switch claudeOutcome { @@ -238,11 +250,10 @@ struct ProviderAccountAssembly { defaultClaudeRecord = nil } if let defaultClaudeRecord { - let observedDefaultIdentity = identityKeys["claude"] if defaultClaudeRecord.id != "claude" { identityKeys.removeValue(forKey: "claude") } - identityKeys[defaultClaudeRecord.id] = observedDefaultIdentity ?? defaultClaudeRecord.identityKey + identityKeys[defaultClaudeRecord.id] = defaultClaudeRecord.identityKey } // The extra-card build plan: one card per distinct account found this launch, under its @@ -258,12 +269,13 @@ struct ProviderAccountAssembly { claudeCards.append(ClaudeAccountCard( id: record.id, displayName: record.derivedDisplayName, - identityKey: account.identityKey, + identityKey: record.identityKey, configDirPath: primary.anchorPath, keychainLiteral: primary.keychainLiteral, - extraLogRoots: account.dirs.dropFirst().map { URL(fileURLWithPath: $0.anchorPath) } + extraLogRoots: account.dirs.dropFirst().map { URL(fileURLWithPath: $0.anchorPath) }, + verifiedIdentityAliases: [primary.identityKey] )) - identityKeys[record.id] = account.identityKey + identityKeys[record.id] = record.identityKey AppLog.info(.config, "accounts: extra claude card \(record.id) from \(account.dirs.count) config dir(s)") } claudeCards.sort { $0.id < $1.id } @@ -277,6 +289,7 @@ struct ProviderAccountAssembly { claudeCards: claudeCards, defaultClaudeExtraLogRoots: defaultClaudeExtraLogRoots, defaultClaudeDisplayName: defaultClaudeName, + defaultClaudeVerifiedIdentityAliases: Set(observedDefaultIdentity.map { [$0] } ?? []), defaultClaudeCardID: defaultClaudeRecord?.id ?? "claude", isClaudeDiscoveryComplete: isClaudeDiscoveryComplete ) diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index cf8819dae..815a689c1 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -62,6 +62,7 @@ public struct UsageReader { claudeCards: accountAssembly.claudeCards, defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots, defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName, + defaultClaudeVerifiedIdentityAliases: accountAssembly.defaultClaudeVerifiedIdentityAliases, defaultClaudeCardID: accountAssembly.defaultClaudeCardID, claudeIdentityKeys: accountAssembly.identityKeysByCard, isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index c4978d3fd..bd8ea2953 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -231,14 +231,15 @@ final class ProviderAccountAssemblyTests: XCTestCase { let defaults = makeScratchDefaults() let existing = ProviderAccountRecord( id: "claude", family: "claude", identityKey: "acct-1|org-1", - identityAliases: ["acct-1"], label: "Work", + identityAliases: ["acct-1"], label: "Work", customLabel: "My Work Account", sources: [.init(kind: .defaultHome, anchor: "/Users/dev/.claude", holdsDefaultSource: true)] ) defaults.set(try JSONEncoder().encode([existing]), forKey: ProviderAccountsStore.storageKey) let store = ProviderAccountsStore(defaults: defaults) + let defaultFiles = FakeFiles(["/Users/dev/.claude.json": + #"{"oauthAccount":{"accountUuid":"ACCT-1"}}"#]) let observer = DefaultAccountObserver( - files: FakeFiles(["/Users/dev/.claude.json": - #"{"oauthAccount":{"accountUuid":"ACCT-1"}}"#]), + files: defaultFiles, keychain: FakeKeychain(), homeDirectory: { URL(fileURLWithPath: "/Users/dev") } ) @@ -256,9 +257,30 @@ final class ProviderAccountAssemblyTests: XCTestCase { ) XCTAssertEqual(store.records.map(\.id), ["claude"]) - XCTAssertEqual(assembly.identityKeysByCard["claude"], "acct-1") + XCTAssertEqual(assembly.identityKeysByCard["claude"], "acct-1|org-1") + XCTAssertEqual(assembly.defaultClaudeVerifiedIdentityAliases, ["acct-1"]) XCTAssertTrue(assembly.claudeCards.isEmpty) XCTAssertEqual(assembly.defaultClaudeExtraLogRoots.map(\.path), ["/Users/dev/.claude-work"]) + XCTAssertEqual(store.resolvedDisplayName(cardID: "claude"), "My Work Account") + + let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "alias-cache") + cache.store(ProviderSnapshot(providerID: "claude", displayName: "Claude", lines: []), + producedByIdentityKey: existing.identityKey) + XCTAssertFalse(cache.hasStaleAccountStamp( + providerID: "claude", currentIdentityKey: assembly.identityKeysByCard["claude"] + )) + + let runtime = try XCTUnwrap(ProviderCatalog.make( + defaultClaudeVerifiedIdentityAliases: assembly.defaultClaudeVerifiedIdentityAliases, + claudeIdentityKeys: assembly.identityKeysByCard + ).first as? ClaudeProvider) + var auth = runtime.authStore + auth.files = defaultFiles + auth.homeDirectory = { URL(fileURLWithPath: "/Users/dev") } + XCTAssertTrue(auth.belongsToExpectedAccount()) + defaultFiles.files["/Users/dev/.claude.json"] = + #"{"oauthAccount":{"accountUuid":"ACCT-1","organizationUuid":"OTHER"}}"# + XCTAssertFalse(auth.belongsToExpectedAccount(), "an unverified organization must stay isolated") } func testAliasedConfigDirectoriesProduceOneStableNonDefaultAccountCard() throws { @@ -292,8 +314,23 @@ final class ProviderAccountAssemblyTests: XCTestCase { observer: observer, accountsStore: store, claudeDiscovery: discovery ) + let card = try XCTUnwrap(assembly.claudeCards.first) XCTAssertEqual(assembly.claudeCards.map(\.id), ["claude@work"]) - XCTAssertEqual(assembly.claudeCards.first?.identityKey, "work") + XCTAssertEqual(card.identityKey, "work|org") + XCTAssertEqual(assembly.identityKeysByCard[card.id], "work|org") + XCTAssertEqual(card.verifiedIdentityAliases, ["work"]) + + let runtime = try XCTUnwrap(ProviderCatalog.make( + claudeCards: assembly.claudeCards, + claudeIdentityKeys: assembly.identityKeysByCard + ).first { $0.provider.id == card.id } as? ClaudeProvider) + var auth = runtime.authStore + auth.files = FakeFiles([paths[0] + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"work"}}"#]) + XCTAssertTrue(auth.belongsToExpectedAccount()) + auth.files = FakeFiles([paths[0] + "/.claude.json": + #"{"oauthAccount":{"accountUuid":"work","organizationUuid":"other"}}"#]) + XCTAssertFalse(auth.belongsToExpectedAccount(), "aliases from other organizations are never authorized") } func testNoDefaultLoginStillAcceptsAConfigDirOnlyAccount() throws { From bcbd0c9343c46303d7b747c80fbcca31726ba8c8 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 22:55:45 +0400 Subject: [PATCH 23/24] fix: preserve first-launch Desktop login and isolate Pi spend --- Sources/OpenUsage/App/AppContainer.swift | 3 ++- .../OpenUsage/Providers/Claude/ClaudeProvider.swift | 6 +++++- Sources/OpenUsage/Providers/ProviderCatalog.swift | 9 ++++++--- .../Services/ProviderAccountAssembly.swift | 6 +++++- Sources/OpenUsage/Services/UsageReader.swift | 3 ++- .../ProviderAccountAssemblyTests.swift | 13 ++++++++++--- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/OpenUsage/App/AppContainer.swift b/Sources/OpenUsage/App/AppContainer.swift index 3d6a5efca..244128c3b 100644 --- a/Sources/OpenUsage/App/AppContainer.swift +++ b/Sources/OpenUsage/App/AppContainer.swift @@ -84,7 +84,8 @@ final class AppContainer { defaultClaudeVerifiedIdentityAliases: accountAssembly.defaultClaudeVerifiedIdentityAliases, defaultClaudeCardID: accountAssembly.defaultClaudeCardID, claudeIdentityKeys: accountAssembly.identityKeysByCard, - isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete + isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete, + allowsUnownedClaudeDesktopFallback: accountAssembly.allowsUnownedClaudeDesktopFallback ) let registry = WidgetRegistry.from(providers) let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging } diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift index d8f6632ac..8b723ffe0 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeProvider.swift @@ -19,6 +19,8 @@ final class ClaudeProvider: ProviderRuntime { let authStore: ClaudeAuthStore let usageClient: ClaudeUsageClient let logUsageScanner: ClaudeLogUsageScanner + /// Pi logs name Claude but not an account, so multiple accounts make them unsafe to attribute. + let allowsUnattributedPiUsage: Bool let now: @Sendable () -> Date let pricing: @Sendable () async -> ModelPricing @@ -36,6 +38,7 @@ final class ClaudeProvider: ProviderRuntime { authStore: ClaudeAuthStore = ClaudeAuthStore(), usageClient: ClaudeUsageClient = ClaudeUsageClient(), logUsageScanner: ClaudeLogUsageScanner = ClaudeLogUsageScanner(), + allowsUnattributedPiUsage: Bool = true, now: @escaping @Sendable () -> Date = Date.init, pricing: @escaping @Sendable () async -> ModelPricing = { await ModelPricingStore.shared.current() } ) { @@ -43,6 +46,7 @@ final class ClaudeProvider: ProviderRuntime { self.authStore = authStore self.usageClient = usageClient self.logUsageScanner = logUsageScanner + self.allowsUnattributedPiUsage = allowsUnattributedPiUsage self.now = now self.pricing = pricing } @@ -294,7 +298,7 @@ final class ClaudeProvider: ProviderRuntime { let pricing = await pricing() let nativeScan = await logUsageScanner.scan(now: now(), pricing: pricing) let piScan: LogUsageScan? - if authStore.scope == .standard { + if authStore.scope == .standard && allowsUnattributedPiUsage { piScan = await PiUsageScanner.shared.scan(cardID: "claude", now: now(), pricing: pricing) } else { piScan = nil diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index ac68bc393..2f801975a 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -17,7 +17,8 @@ enum ProviderCatalog { defaultClaudeVerifiedIdentityAliases: Set = [], defaultClaudeCardID: String = "claude", claudeIdentityKeys: [String: String] = [:], - isClaudeDiscoveryComplete: Bool = true + isClaudeDiscoveryComplete: Bool = true, + allowsUnownedClaudeDesktopFallback: Bool = false ) -> [ProviderRuntime] { // Default provider order (see AGENTS.md "## Providers"): the three established providers first, // then every other provider alphabetically by display name. Account cards slot in right after @@ -37,11 +38,13 @@ enum ProviderCatalog { // belongs to one of them — fetching that account's usage onto the default card. Desktop // returns as its own properly-pinned source kind in Phase 3. authStore: ClaudeAuthStore( - allowsDesktopFallback: claudeCards.isEmpty && isClaudeDiscoveryComplete, + allowsDesktopFallback: claudeCards.isEmpty + && (isClaudeDiscoveryComplete || allowsUnownedClaudeDesktopFallback), expectedIdentityKey: claudeIdentityKeys[defaultClaudeCardID], verifiedIdentityAliases: defaultClaudeVerifiedIdentityAliases ), - logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots) + logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots), + allowsUnattributedPiUsage: claudeCards.isEmpty )) } for card in claudeCards { diff --git a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift index 42408aea2..c54eb49a4 100644 --- a/Sources/OpenUsage/Services/ProviderAccountAssembly.swift +++ b/Sources/OpenUsage/Services/ProviderAccountAssembly.swift @@ -45,6 +45,8 @@ struct ProviderAccountAssembly { var defaultClaudeCardID = "claude" /// An incomplete scan cannot prove that an unpinned Desktop login belongs to the default card. var isClaudeDiscoveryComplete = true + /// A first-launch shell delay cannot hide another account when no Claude record exists yet. + var allowsUnownedClaudeDesktopFallback = false /// `waitsForLoginShell`: true for the menu-bar app (a Finder/Dock launch inherits no shell /// exports, so the pass leans on the login-shell layers), false for the one-shot CLI (a terminal @@ -291,7 +293,9 @@ struct ProviderAccountAssembly { defaultClaudeDisplayName: defaultClaudeName, defaultClaudeVerifiedIdentityAliases: Set(observedDefaultIdentity.map { [$0] } ?? []), defaultClaudeCardID: defaultClaudeRecord?.id ?? "claude", - isClaudeDiscoveryComplete: isClaudeDiscoveryComplete + isClaudeDiscoveryComplete: isClaudeDiscoveryComplete, + allowsUnownedClaudeDesktopFallback: + !families.contains("claude") && !records.contains { $0.family == "claude" } ) } } diff --git a/Sources/OpenUsage/Services/UsageReader.swift b/Sources/OpenUsage/Services/UsageReader.swift index 815a689c1..449a689a7 100644 --- a/Sources/OpenUsage/Services/UsageReader.swift +++ b/Sources/OpenUsage/Services/UsageReader.swift @@ -65,7 +65,8 @@ public struct UsageReader { defaultClaudeVerifiedIdentityAliases: accountAssembly.defaultClaudeVerifiedIdentityAliases, defaultClaudeCardID: accountAssembly.defaultClaudeCardID, claudeIdentityKeys: accountAssembly.identityKeysByCard, - isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete + isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete, + allowsUnownedClaudeDesktopFallback: accountAssembly.allowsUnownedClaudeDesktopFallback ) let registry = WidgetRegistry.from(providers) let knownIDs = Set(registry.providers.map(\.id)) diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index bd8ea2953..05fa948c5 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -80,10 +80,12 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertFalse(assembly.isClaudeDiscoveryComplete) let unavailable = ProviderAccountAssembly.make(observer: observer, accountsStore: store, families: []) XCTAssertFalse(unavailable.isClaudeDiscoveryComplete) + XCTAssertTrue(unavailable.allowsUnownedClaudeDesktopFallback) let runtime = ProviderCatalog.make( - isClaudeDiscoveryComplete: unavailable.isClaudeDiscoveryComplete + isClaudeDiscoveryComplete: unavailable.isClaudeDiscoveryComplete, + allowsUnownedClaudeDesktopFallback: unavailable.allowsUnownedClaudeDesktopFallback ).first as? ClaudeProvider - XCTAssertEqual(runtime?.authStore.allowsDesktopFallback, false) + XCTAssertEqual(runtime?.authStore.allowsDesktopFallback, true) for account in ["original-account", "swapped-account"] { store.reconcile(with: [.init( @@ -97,12 +99,15 @@ final class ProviderAccountAssemblyTests: XCTestCase { ) XCTAssertNotEqual(skipped.defaultClaudeCardID, "claude") XCTAssertEqual(skipped.identityKeysByCard[skipped.defaultClaudeCardID], "swapped-account") + XCTAssertFalse(skipped.allowsUnownedClaudeDesktopFallback) let bound = ProviderCatalog.make( defaultClaudeCardID: skipped.defaultClaudeCardID, claudeIdentityKeys: skipped.identityKeysByCard, - isClaudeDiscoveryComplete: skipped.isClaudeDiscoveryComplete + isClaudeDiscoveryComplete: skipped.isClaudeDiscoveryComplete, + allowsUnownedClaudeDesktopFallback: skipped.allowsUnownedClaudeDesktopFallback ).first as? ClaudeProvider XCTAssertEqual(bound?.authStore.expectedIdentityKey, "swapped-account") + XCTAssertEqual(bound?.authStore.allowsDesktopFallback, false) } } @@ -504,6 +509,8 @@ final class ProviderAccountAssemblyTests: XCTestCase { .configDir(path: path, keychainLiteral: path)) XCTAssertEqual(runtimes.last?.provider.id, assembly.defaultClaudeCardID) XCTAssertEqual(runtimes.last?.authStore.scope, .standard) + XCTAssertEqual(runtimes.last?.allowsUnattributedPiUsage, false) + XCTAssertEqual((ProviderCatalog.make().first as? ClaudeProvider)?.allowsUnattributedPiUsage, true) let temporarilyUnreadable = DefaultAccountObserver( environment: FakeEnvironment(), From 3932758bc05baa74fd4cc184cc55f416ecc5dc45 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Mon, 24 Aug 2026 23:38:57 +0400 Subject: [PATCH 24/24] fix: quarantine Pi spending when Claude discovery is incomplete --- Sources/OpenUsage/Providers/ProviderCatalog.swift | 2 +- Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OpenUsage/Providers/ProviderCatalog.swift b/Sources/OpenUsage/Providers/ProviderCatalog.swift index 2f801975a..3539f65d6 100644 --- a/Sources/OpenUsage/Providers/ProviderCatalog.swift +++ b/Sources/OpenUsage/Providers/ProviderCatalog.swift @@ -44,7 +44,7 @@ enum ProviderCatalog { verifiedIdentityAliases: defaultClaudeVerifiedIdentityAliases ), logUsageScanner: ClaudeLogUsageScanner(additionalRoots: defaultClaudeExtraLogRoots), - allowsUnattributedPiUsage: claudeCards.isEmpty + allowsUnattributedPiUsage: claudeCards.isEmpty && isClaudeDiscoveryComplete )) } for card in claudeCards { diff --git a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift index 05fa948c5..a8ae13f4e 100644 --- a/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift +++ b/Tests/OpenUsageTests/ProviderAccountAssemblyTests.swift @@ -456,6 +456,7 @@ final class ProviderAccountAssemblyTests: XCTestCase { XCTAssertFalse(assembly.isClaudeDiscoveryComplete) XCTAssertFalse(provider.authStore.allowsDesktopFallback) + XCTAssertFalse(provider.allowsUnattributedPiUsage) } func testAccountSwapKeepsTheOriginalCardBoundToItsOwnConfigDirectory() throws {