diff --git a/CHANGELOG.md b/CHANGELOG.md index 003371d..d4a6d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ as you cut a tag. ## [Unreleased] +## [0.3.0] - 2026-07-23 + +### Changed +- **Claude accounts are now just what you sign into — no more path scanning.** The app no longer + auto-discovers Claude "profiles" by reading `CLAUDE_CONFIG_DIR` from your shell config. Each account + is one you add via **Add account** (browser sign-in), and you can give it your own **name** in + Settings → Claude. + +### Added +- **Per-account cost, opt-in.** Type a logs-folder path (e.g. `~/.claude`) into an account's **Cost + logs** field to show its $ equivalent-cost, token totals, and plan; leave it blank for live limits + only. There is no cost API — cost is derived from local logs, so each account points at its own + folder (which keeps cost attributed to the right account). + +### Fixed +- Renaming an account or changing its cost folder no longer risks **rate-limiting** the usage endpoint: + a per-account usage cache means those changes re-render without re-calling the endpoint, and the name + / path fields commit on blur, not on every keystroke. +- Removed a stale "Copy sign-in command" hint left over from the pre-OAuth design. + ## [0.2.0] - 2026-07-16 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index bcd8fcf..04ca32b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,8 +50,12 @@ Scripts/install-hooks.sh # activate the pre-commit personal-data guard serializes refreshes); `invalid_grant` on refresh is terminal → re-auth. Keychain gotcha: `kSecReturnData` + `kSecMatchLimitAll` in one query is `errSecParam` (-50) — list attributes, then read each item. Headers otherwise: `Authorization: Bearer`, `anthropic-beta: oauth-2025-04-20`. - Cache ≥ 180s. Config-dir profiles are still auto-discovered; live tokens are matched to them by - account email/UUID, and a signed-in account with no local profile gets its own live-only card. + Cache ≥ 180s (per-account usage cache in `ClaudeTokenProvider.cachedUsage`, so re-rendering after a + config change doesn't re-hit the endpoint). **No path auto-discovery**: accounts are exactly the + OAuth sign-ins, each with a user config (`ClaudeAccountConfig`: name + optional logs dir). Cost + + plan are opt-in per account — shown only when its `logsDir` points at a config dir (`ClaudeCostReader + .summary(configDir:)`; `.claude.json` is at `/.claude.json` except the default `~/.claude` → + `~/.claude.json`). `ClaudeProfileDiscovery` survives for the `usageprobe` CLI only. - **Cost** — subscriptions have no per-token bill, so cost is an **equivalent API cost**: local JSONL token counts × per-model pricing (`Pricing.swift`). - **Gemini** — detection only; no local live quota exists. diff --git a/README.md b/README.md index fbf142e..589a45d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Everything is local — no proxy, no telemetry, no account of ours. | Provider | Source | Fidelity | |---|---|---| | **Codex** | Newest `token_count` event under the configured Codex root (defaults to `$CODEX_HOME`, then `~/.codex`) — real 5h/weekly %, resets, plan, credits, tokens. Zero-auth. | ✅ Full | -| **Claude** | `GET api.anthropic.com/api/oauth/usage` per account, using a token the app mints via its **own** OAuth (**Add account**) and keeps in its **own** Keychain item — no prompt, refreshed in place. Before signing in (or if the endpoint is unavailable) shows **local 5H / 7D** token activity + equivalent cost from `~/.claude` logs. | ✅ Full % | +| **Claude** | `GET api.anthropic.com/api/oauth/usage` per account, using a token the app mints via its **own** OAuth (**Add account**) and keeps in its **own** Keychain item — no prompt, refreshed in place. Each account is user-named; point it at a logs folder to also show **$ cost** + tokens + plan (from local `~/.claude` logs — there's no cost API). | ✅ Full % | | **Gemini** | Detects `gemini-cli` and reads the selected configuration root (defaults to `~/.gemini`); shows the plan cap or "not detected". gemini-cli persists no live quota. | ⚠️ Best-effort | | **Custom** | Any folder of `.jsonl` logs + the dot-paths you configure (e.g. `rate_limit.used_percent`, `rate_limit.resets_at`). Newest file, last matching line. | ✅ Whatever the tool writes | @@ -119,8 +119,10 @@ Nothing prompts for credentials at launch. To see **live** Claude limits, open t click **Add account** (or Settings → Claude) — this signs in with Claude in your browser and mints the app's **own** token, stored in its **own** Keychain item. Because the app created that item, it reads it back with **no macOS prompt**, and refreshes it in place, so you sign in once and it sticks -across restarts. Add more than one account (Personal + Work) to track each separately. Your account, -cost, and **last 5H / 7D local usage** (from your `~/.claude` logs) show without signing in. +across restarts. Add more than one account (Personal + Work) and **name** each in Settings → Claude. +To also see **$ cost** for an account, type its logs-folder path (e.g. `~/.claude`) into that account's +**Cost logs** field — cost is derived from local logs (there's no cost API), so each account points at +its own folder; leave it blank for live limits only. (Building locally auto-signs with an installed Developer ID for a stable signature; released builds are notarized, so the Keychain item stays readable across launches.) diff --git a/Sources/AIUsageBarCore/Claude/ClaudeAccountConfig.swift b/Sources/AIUsageBarCore/Claude/ClaudeAccountConfig.swift new file mode 100644 index 0000000..5a46641 --- /dev/null +++ b/Sources/AIUsageBarCore/Claude/ClaudeAccountConfig.swift @@ -0,0 +1,21 @@ +import Foundation + +/// User-controlled settings for one signed-in Claude account, keyed by the account's +/// Keychain key (`ClaudeTokenStore.accountKey`). Persisted outside the Keychain (it +/// holds no secrets). `logsDir` is opt-in: when set, the reader computes the $ / +/// token breakdown from that config dir's logs; when nil, the account shows live +/// limits only (there is no cost API — it can only come from local logs). +public struct ClaudeAccountConfig: Codable, Sendable, Hashable { + /// User-assigned display name; falls back to the account email in the UI. + public var name: String? + /// Absolute path to the Claude config dir (containing `projects/`) whose logs + /// this account's cost is read from. Nil = cost off. + public var logsDir: String? + + public init(name: String? = nil, logsDir: String? = nil) { + self.name = name + self.logsDir = logsDir + } + + public var logsURL: URL? { logsDir.map { URL(fileURLWithPath: $0) } } +} diff --git a/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift b/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift index 1a487b8..c6a0820 100644 --- a/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift +++ b/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift @@ -4,6 +4,18 @@ import Foundation /// logs: totals for today / last 30 days, split by model and by repo, plus /// cache-efficiency. Pure and Sendable so it can run off the main actor. public enum ClaudeCostReader { + /// Cost from a Claude **config dir** (the folder containing `projects/`), for the + /// account-driven model where the user points an account at its logs. Only + /// `projects/` is read; the dir's identity/`.claude.json` is not used. + public static func summary(configDir: URL, + now: Date = Date(), + maxFiles: Int = 400, + maxFileBytes: Int = 40_000_000, + cacheDirectory: URL? = nil) -> CostSummary? { + summary(for: ClaudeProfile(name: configDir.lastPathComponent, configDir: configDir, isDefault: false), + now: now, maxFiles: maxFiles, maxFileBytes: maxFileBytes, cacheDirectory: cacheDirectory) + } + public static func summary(for profile: ClaudeProfile, now: Date = Date(), maxFiles: Int = 400, diff --git a/Sources/AIUsageBarCore/Claude/ClaudeJSONLReader.swift b/Sources/AIUsageBarCore/Claude/ClaudeJSONLReader.swift index a5d105a..8b1a945 100644 --- a/Sources/AIUsageBarCore/Claude/ClaudeJSONLReader.swift +++ b/Sources/AIUsageBarCore/Claude/ClaudeJSONLReader.swift @@ -12,6 +12,13 @@ public struct ClaudeTokenActivity: Sendable, Hashable { /// Sums `message.usage` tokens over trailing 5h / 7d wall-clock windows from /// `/projects/**/*.jsonl`, deduped by message id + request id. public enum ClaudeJSONLReader { + /// Local activity from a Claude **config dir** (reads its `projects/`). + public static func activity(configDir: URL, now: Date = Date(), + maxFiles: Int = 40) -> ClaudeTokenActivity? { + activity(for: ClaudeProfile(name: configDir.lastPathComponent, configDir: configDir, isDefault: false), + now: now, maxFiles: maxFiles) + } + public static func activity(for profile: ClaudeProfile, now: Date = Date(), maxFiles: Int = 40) -> ClaudeTokenActivity? { diff --git a/Sources/AIUsageBarCore/Claude/ClaudeProfile.swift b/Sources/AIUsageBarCore/Claude/ClaudeProfile.swift index 2755962..3a289c5 100644 --- a/Sources/AIUsageBarCore/Claude/ClaudeProfile.swift +++ b/Sources/AIUsageBarCore/Claude/ClaudeProfile.swift @@ -83,7 +83,26 @@ public struct ClaudeAccount: Codable, Sendable, Hashable { public enum ClaudeAccountLoader { /// Reads the `oauthAccount` block from a profile's `.claude.json`. public static func load(_ profile: ClaudeProfile) -> ClaudeAccount? { - guard let data = try? Data(contentsOf: profile.dotClaudeJSON), + load(dotClaudeJSON: profile.dotClaudeJSON) + } + + /// Reads identity/plan from a Claude **config dir** — used by the account-driven + /// model when the user points an account at its logs. `.claude.json` lives inside + /// a custom config dir but at `~/.claude.json` for the default `~/.claude`, so try + /// both. + public static func load(configDir: URL) -> ClaudeAccount? { + if let a = load(dotClaudeJSON: configDir.appendingPathComponent(".claude.json")) { return a } + // Only the default ~/.claude keeps its .claude.json at the home root — don't + // apply that fallback to other dirs, or an account whose logs dir lacks a + // .claude.json would inherit the default account's plan (a cross-account mislabel). + let home = FileManager.default.homeDirectoryForCurrentUser + guard configDir.standardizedFileURL == home.appendingPathComponent(".claude").standardizedFileURL + else { return nil } + return load(dotClaudeJSON: home.appendingPathComponent(".claude.json")) + } + + public static func load(dotClaudeJSON url: URL) -> ClaudeAccount? { + guard let data = try? Data(contentsOf: url), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let oa = root["oauthAccount"] as? [String: Any] else { return nil } diff --git a/Sources/AIUsageBarCore/Claude/ClaudeReader.swift b/Sources/AIUsageBarCore/Claude/ClaudeReader.swift index bed9a3b..6f0d3ed 100644 --- a/Sources/AIUsageBarCore/Claude/ClaudeReader.swift +++ b/Sources/AIUsageBarCore/Claude/ClaudeReader.swift @@ -1,138 +1,90 @@ import Foundation -/// Produces one `ProviderUsage` per Claude account/profile. +/// Produces one `ProviderUsage` per **signed-in Claude account** (from our own OAuth +/// store — never Claude Code's Keychain, so nothing prompts). There is no path +/// auto-discovery: accounts come from `Add account`, and each carries a user config +/// (`ClaudeAccountConfig`: name + optional logs dir). /// -/// Token source is our **own** OAuth store (`ClaudeTokenProvider`) — never Claude -/// Code's Keychain item — so nothing prompts for Keychain access. Strategy: -/// 1. Identity/cost/local-activity from local files — always available offline. -/// 2. Live windows from `GET /api/oauth/usage` using a token we minted, when the -/// account has been added (Settings → Claude → Add account). -/// 3. Tokens carry their own account identity, so they map to a configured -/// profile locally (no profile round-trip). A signed-in account with no local -/// profile still gets a live-only card. +/// Per account: +/// - Identity (email) from the OAuth token; a friendly name from the config. +/// - Live 5H/7D windows from `GET /api/oauth/usage`. +/// - Cost + plan **only when** the account is pointed at a logs folder (there is no +/// cost API — it comes from local `~/.claude` JSONL). Otherwise: live limits only. public struct ClaudeReader: Sendable { - public var profiles: [ClaudeProfile] + /// Per-account user config, keyed by `ClaudeTokenStore.accountKey`. + public var accountConfigs: [String: ClaudeAccountConfig] public var api: ClaudeUsageAPI /// Gates live/token access. Off for the CLI probe and unit tests. public var allowKeychain: Bool /// Injected tokens for tests (used when `allowKeychain` is false). public var tokens: [ClaudeOAuthToken] - public init(profiles: [ClaudeProfile], api: ClaudeUsageAPI = ClaudeUsageAPI(), + public init(accountConfigs: [String: ClaudeAccountConfig] = [:], api: ClaudeUsageAPI = ClaudeUsageAPI(), allowKeychain: Bool = true, tokens: [ClaudeOAuthToken] = []) { - self.profiles = profiles + self.accountConfigs = accountConfigs self.api = api self.allowKeychain = allowKeychain self.tokens = tokens } - /// An identity-only card (no windows) to show instantly while live data loads. - public static func placeholder(for profile: ClaudeProfile) -> ProviderUsage { - let account = ClaudeAccountLoader.load(profile) - return ProviderUsage( - id: "claude:\(profile.name.lowercased())", - kind: .claude, - displayName: "Claude — \(profile.name)", - accountLabel: account?.emailAddress ?? account?.displayName, - planType: account?.planLabel, - status: .noData, - detail: "Loading…", - sourcePath: profile.configDir.path - ) + /// Identity-only cards from the stored accounts, shown instantly while live loads. + public static func placeholders(configs: [String: ClaudeAccountConfig]) -> [ProviderUsage] { + ClaudeTokenProvider.shared.accounts().map { token in + let key = ClaudeTokenStore.accountKey(for: token) + return ProviderUsage( + id: "claude:\(key)", kind: .claude, + displayName: "Claude — \(configs[key]?.name ?? token.accountEmail ?? "Account")", + accountLabel: token.accountEmail, + status: .noData, detail: "Loading…", sourcePath: configs[key]?.logsDir) + } } public func read() async -> [ProviderUsage] { let live = allowKeychain ? await ClaudeTokenProvider.shared.validTokens() : tokens - - // Bind each token to the best-matching profile by the identity it carries. - var tokenForProfile: [String: ClaudeOAuthToken] = [:] - var usedTokens = Set() - for profile in profiles { - let account = ClaudeAccountLoader.load(profile) - if let hit = live.enumerated().first(where: { entry in - !usedTokens.contains(entry.offset) && Self.identityMatches(entry.element, account) - }) { - tokenForProfile[profile.id] = hit.element - usedTokens.insert(hit.offset) - } - } - // Exactly one profile and one token, unmatched: no ambiguity, pair them. - // (Requires live.count == 1 — with 2+ tokens we'd bind an arbitrary one and - // drop the others' cards.) - if profiles.count == 1, live.count == 1, tokenForProfile.isEmpty, let only = live.first { - tokenForProfile[profiles[0].id] = only - usedTokens.insert(0) - } - var results: [ProviderUsage] = [] - for profile in profiles { - results.append(await readProfile(profile, token: tokenForProfile[profile.id])) - } - // Signed-in accounts with no local profile → live-only cards. - for entry in live.enumerated() where !usedTokens.contains(entry.offset) { - results.append(await readAccountOnly(entry.element)) + for token in live { + results.append(await readAccount(token)) } return results } - static func identityMatches(_ token: ClaudeOAuthToken, _ account: ClaudeAccount?) -> Bool { - guard let account else { return false } - // Precise per-account identifiers are definitive: when both sides carry one, - // it settles the match (== true / != false). A shared organization UUID is - // only a last resort — two accounts in one org share it, so matching on org - // alone would bind a token to the wrong same-org profile. - if let a = token.accountUUID, let b = account.accountUuid { return a == b } - if let a = token.accountEmail?.lowercased(), let b = account.emailAddress?.lowercased() { return a == b } - if let a = token.orgUUID, let b = account.organizationUuid { return a == b } - return false - } - - private func readProfile(_ profile: ClaudeProfile, token: ClaudeOAuthToken?) async -> ProviderUsage { - let account = ClaudeAccountLoader.load(profile) - let label = account?.emailAddress ?? account?.displayName - let plan = account?.planLabel - let id = "claude:\(profile.name.lowercased())" - - let cost = await Task.detached(priority: .utility) { - ClaudeCostReader.summary(for: profile) - }.value + private func readAccount(_ token: ClaudeOAuthToken) async -> ProviderUsage { + let key = ClaudeTokenStore.accountKey(for: token) + let config = accountConfigs[key] + let logsURL = config?.logsURL + let name = config?.name ?? token.accountEmail ?? "Account" + let id = "claude:\(key)" + + // Cost + plan come only from a configured logs folder (no cost API exists). + let cost: CostSummary? = await { + guard let logsURL else { return nil } + return await Task.detached(priority: .utility) { + ClaudeCostReader.summary(configDir: logsURL) + }.value + }() + let plan = logsURL.flatMap { ClaudeAccountLoader.load(configDir: $0)?.planLabel } func base(status: UsageStatus, windows: [UsageWindow] = [], tokens: TokenStats? = nil, detail: String? = nil, updated: Date? = nil) -> ProviderUsage { - ProviderUsage(id: id, kind: .claude, displayName: "Claude — \(profile.name)", - accountLabel: label, planType: plan, windows: windows, tokens: tokens, - cost: cost, status: status, detail: detail, lastUpdated: updated, - sourcePath: profile.configDir.path) + ProviderUsage(id: id, kind: .claude, displayName: "Claude — \(name)", + accountLabel: token.accountEmail, planType: plan, windows: windows, + tokens: tokens, cost: cost, status: status, detail: detail, + lastUpdated: updated, sourcePath: config?.logsDir) } - if let token { - return await live(token: token, base: base) { - self.activityOrError(profile, base: base, note: $0) - } - } - return activityOrError(profile, base: base, note: "Add a Claude account to see live limits") - } - - /// A live-only card for a signed-in account that has no local profile/logs. - private func readAccountOnly(_ token: ClaudeOAuthToken) async -> ProviderUsage { - let name = token.accountEmail ?? "Account" - let id = "claude:oauth:\(ClaudeTokenStore.accountKey(for: token))" - func base(status: UsageStatus, windows: [UsageWindow] = [], tokens: TokenStats? = nil, - detail: String? = nil, updated: Date? = nil) -> ProviderUsage { - ProviderUsage(id: id, kind: .claude, displayName: "Claude — \(name)", - accountLabel: token.accountEmail, planType: nil, windows: windows, - tokens: tokens, cost: nil, status: status, detail: detail, - lastUpdated: updated, sourcePath: nil) + return await live(token: token, key: key, base: base) { note in + self.activityFallback(logsURL: logsURL, base: base, note: note) } - return await live(token: token, base: base) { base(status: .notConfigured, detail: $0) } } - /// Shared live-fetch: hits the usage endpoint and maps errors to a fallback. - private func live(token: ClaudeOAuthToken, + /// Shared live-fetch: usage endpoint (cached per account when live), errors → fallback. + private func live(token: ClaudeOAuthToken, key: String, base: (UsageStatus, [UsageWindow], TokenStats?, String?, Date?) -> ProviderUsage, fallback: (String) -> ProviderUsage) async -> ProviderUsage { do { - let usage = try await api.fetch(accessToken: token.accessToken) + let usage = allowKeychain + ? try await ClaudeTokenProvider.shared.cachedUsage(key: key, accessToken: token.accessToken, api: api) + : try await api.fetch(accessToken: token.accessToken) let windows = usage.windows.sorted { ($0.windowMinutes ?? .max) < ($1.windowMinutes ?? .max) } var u = base(windows.isEmpty ? .noData : .ok, windows, nil, windows.isEmpty ? "No active usage windows" : nil, Date()) @@ -142,19 +94,20 @@ public struct ClaudeReader: Sendable { return u } catch let ClaudeAPIError.rateLimited(retry) { let hint = retry.map { " (retry \(Int($0))s)" } ?? "" - return fallback("Rate limited\(hint) — using local activity") + return fallback("Rate limited\(hint)") } catch ClaudeAPIError.unauthorized { return fallback("Session expired — reconnect this account") } catch { - return fallback("Endpoint unavailable — using local activity") + return fallback("Endpoint unavailable") } } - /// Fall back to local token activity; if there's none, report the note. - private func activityOrError(_ profile: ClaudeProfile, - base: (UsageStatus, [UsageWindow], TokenStats?, String?, Date?) -> ProviderUsage, - note: String) -> ProviderUsage { - if let act = ClaudeJSONLReader.activity(for: profile) { + /// On a live failure, fall back to local activity from the configured logs (if + /// any); otherwise just surface the note. + private func activityFallback(logsURL: URL?, + base: (UsageStatus, [UsageWindow], TokenStats?, String?, Date?) -> ProviderUsage, + note: String) -> ProviderUsage { + if let logsURL, let act = ClaudeJSONLReader.activity(configDir: logsURL) { let tokens = TokenStats(totalTokens: act.sevenDayTokens) return base(.ok, [], tokens, note + " · " + Self.tokenNote(act), act.lastActivity) } diff --git a/Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift b/Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift index b3841df..bb5c062 100644 --- a/Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift +++ b/Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift @@ -12,6 +12,29 @@ public actor ClaudeTokenProvider { /// One in-flight refresh Task per account key, so concurrent `validTokens()` /// calls coalesce onto a single network refresh instead of racing the rotation. private var refreshing: [String: Task] = [:] + /// Per-account usage response cache (TTL-bounded) so re-rendering after a config + /// change (rename, cost folder) doesn't re-hit the rate-limited usage endpoint. + private var usageCache: [String: (usage: ClaudeUsage, at: Date)] = [:] + + /// Usage for an account, served from cache within `ttl` seconds. On a **transient** + /// error (rate-limit / network blip) the last good usage is served up to `staleTTL` + /// so the % bars don't vanish; `unauthorized` is terminal and always propagates so + /// the card can prompt to reconnect. + public func cachedUsage(key: String, accessToken: String, api: ClaudeUsageAPI, + ttl: TimeInterval = 180, staleTTL: TimeInterval = 1800, + now: Date = Date()) async throws -> ClaudeUsage { + if let hit = usageCache[key], now.timeIntervalSince(hit.at) < ttl { return hit.usage } + do { + let usage = try await api.fetch(accessToken: accessToken) + usageCache[key] = (usage, now) + return usage + } catch ClaudeAPIError.unauthorized { + throw ClaudeAPIError.unauthorized // terminal — never mask + } catch { + if let hit = usageCache[key], now.timeIntervalSince(hit.at) < staleTTL { return hit.usage } + throw error + } + } // MARK: Interactive sign-in (not actor-isolated, so the up-to-5-min browser // wait never blocks a usage refresh). @@ -33,13 +56,14 @@ public actor ClaudeTokenProvider { var token = try await ClaudeOAuth.exchange(code: cb.code, state: cb.state, redirectURI: redirect, pkce: pkce) - // The token response usually carries identity; if not, ask the profile - // endpoint so we can key/label the account. - if token.accountUUID == nil, token.accountEmail == nil, + // The token response usually carries identity; if the UUID is missing, ask the + // profile endpoint — the account key is the UUID, so resolving it up front keeps + // the key (and the config keyed by it) stable across future refreshes. + if token.accountUUID == nil, let id = try? await ClaudeUsageAPI().fetchProfile(accessToken: token.accessToken) { - token.accountEmail = id.email + token.accountEmail = token.accountEmail ?? id.email token.accountUUID = id.accountUuid - token.orgUUID = id.orgUuid + token.orgUUID = token.orgUUID ?? id.orgUuid } await shared.store(token) return token @@ -108,5 +132,6 @@ public actor ClaudeTokenProvider { public func signOut(account: String) { ClaudeTokenStore.delete(account: account) cache[account] = nil + usageCache[account] = nil } } diff --git a/Sources/AIUsageBarCore/UsageService.swift b/Sources/AIUsageBarCore/UsageService.swift index d936248..f2b6187 100644 --- a/Sources/AIUsageBarCore/UsageService.swift +++ b/Sources/AIUsageBarCore/UsageService.swift @@ -1,13 +1,15 @@ import Foundation -/// Which providers to read and how the Claude profiles are configured. +/// Which providers to read and how Claude accounts are configured. public struct UsageConfig: Sendable { public var codexEnabled: Bool public var claudeEnabled: Bool public var geminiEnabled: Bool public var codexHome: URL? public var geminiHome: URL? - public var claudeProfiles: [ClaudeProfile] + /// Per-account user config (name + optional logs dir), keyed by account key. + /// Accounts themselves come from the OAuth token store, not path discovery. + public var claudeAccountConfigs: [String: ClaudeAccountConfig] public var customProviders: [CustomProviderConfig] /// Minimum seconds between live Claude endpoint calls (avoids 429s). public var claudeMinInterval: TimeInterval @@ -15,24 +17,19 @@ public struct UsageConfig: Sendable { public init(codexEnabled: Bool = true, claudeEnabled: Bool = true, geminiEnabled: Bool = true, codexHome: URL? = nil, geminiHome: URL? = nil, - claudeProfiles: [ClaudeProfile] = [], customProviders: [CustomProviderConfig] = [], + claudeAccountConfigs: [String: ClaudeAccountConfig] = [:], + customProviders: [CustomProviderConfig] = [], claudeMinInterval: TimeInterval = 180, allowKeychain: Bool = true) { self.codexEnabled = codexEnabled self.claudeEnabled = claudeEnabled self.geminiEnabled = geminiEnabled self.codexHome = codexHome self.geminiHome = geminiHome - self.claudeProfiles = claudeProfiles + self.claudeAccountConfigs = claudeAccountConfigs self.customProviders = customProviders self.claudeMinInterval = claudeMinInterval self.allowKeychain = allowKeychain } - - /// Detects the default `~/.claude` profile plus any `CLAUDE_CONFIG_DIR` - /// profiles defined by shell aliases/exports (e.g. a `claude-work` alias). - public static func autoDetect() -> UsageConfig { - UsageConfig(claudeProfiles: ClaudeProfileDiscovery.discover()) - } } /// Aggregates all providers into a single snapshot. Codex and Gemini are read @@ -47,10 +44,10 @@ public actor UsageService { } public func update(config: UsageConfig) { - let profilesChanged = self.config.claudeProfiles != config.claudeProfiles + let accountsChanged = self.config.claudeAccountConfigs != config.claudeAccountConfigs self.config = config - if profilesChanged { claudeCache = [] } - // Force a Claude refresh on next call if the profile set changed. + if accountsChanged { claudeCache = [] } + // Force a Claude refresh on next call if the account config changed. lastClaudeFetch = nil } @@ -81,8 +78,8 @@ public actor UsageService { /// Offline identity-only placeholder cards, shown instantly while the live /// Claude data is still loading. public func claudePlaceholders() -> [ProviderUsage] { - guard config.claudeEnabled else { return [] } - return config.claudeProfiles.map { ClaudeReader.placeholder(for: $0) } + guard config.claudeEnabled, config.allowKeychain else { return [] } + return ClaudeReader.placeholders(configs: config.claudeAccountConfigs) } private func claude(now: Date) async -> [ProviderUsage] { @@ -91,7 +88,7 @@ public actor UsageService { !claudeCache.isEmpty { return claudeCache } - let reader = ClaudeReader(profiles: config.claudeProfiles, allowKeychain: config.allowKeychain) + let reader = ClaudeReader(accountConfigs: config.claudeAccountConfigs, allowKeychain: config.allowKeychain) let result = await reader.read() // Keep any previous window data if a refresh degraded to no-data (endpoint blip). claudeCache = result diff --git a/Sources/AIUsageBarUI/AppModel.swift b/Sources/AIUsageBarUI/AppModel.swift index f0ab3fa..1551012 100644 --- a/Sources/AIUsageBarUI/AppModel.swift +++ b/Sources/AIUsageBarUI/AppModel.swift @@ -22,8 +22,11 @@ public struct LabelChip: Identifiable, Sendable { public struct ClaudeAccountSummary: Identifiable, Sendable, Hashable { public let key: String // Keychain account key (UUID/email) public let email: String? + public let name: String? // user-assigned display name + public let logsDir: String? // configured cost-logs folder (nil = cost off) public var id: String { key } - public var label: String { email ?? key } + public var label: String { name ?? email ?? key } + public var hasCost: Bool { logsDir != nil } } /// How the menu-bar title is drawn. @@ -71,7 +74,7 @@ public final class AppModel { private var lastClaude: [ProviderUsage] = [] /// The resolved Claude profiles represented by the currently applied settings. /// Disabled Claude is intentionally represented by no effective profiles. - private var appliedClaudeProfiles: [ClaudeProfile] + private var appliedClaudeConfigs: [String: ClaudeAccountConfig] private let debugEnabled = ProcessInfo.processInfo.environment["AIUSAGEBAR_DEBUG"] != nil // Settings (persisted). The menu's live controls mutate these directly; the @@ -105,13 +108,8 @@ public final class AppModel { private var service: UsageService private var pollTask: Task? private let defaults: UserDefaults - private let discoverClaudeProfiles: () -> [ClaudeProfile] - public convenience init(defaults: UserDefaults = .standard) { - self.init(defaults: defaults, discoverClaudeProfiles: { ClaudeProfileDiscovery.discover() }) - } - - init(defaults: UserDefaults, discoverClaudeProfiles: @escaping () -> [ClaudeProfile]) { + public init(defaults: UserDefaults = .standard) { let cadenceSeconds = defaults.object(forKey: "cadenceSeconds") as? Double ?? 45 let codexEnabled = defaults.object(forKey: "codexEnabled") as? Bool ?? true let claudeEnabled = defaults.object(forKey: "claudeEnabled") as? Bool ?? true @@ -125,12 +123,10 @@ public final class AppModel { providerSettings: providerSettings, codexEnabled: codexEnabled, claudeEnabled: claudeEnabled, - geminiEnabled: geminiEnabled, - discovered: discoverClaudeProfiles() + geminiEnabled: geminiEnabled ) self.defaults = defaults - self.discoverClaudeProfiles = discoverClaudeProfiles self.cadenceSeconds = cadenceSeconds self.codexEnabled = codexEnabled self.claudeEnabled = claudeEnabled @@ -139,9 +135,9 @@ public final class AppModel { self.menuBarStyle = menuBarStyle self.monthlyBudgetUSD = monthlyBudgetUSD self.maskAccounts = maskAccounts - self.claudeAccounts = Self.loadClaudeAccounts() + self.claudeAccounts = Self.loadClaudeAccounts(configs: providerSettings.claudeAccountConfigs) self.providerSettings = providerSettings - self.appliedClaudeProfiles = Self.effectiveClaudeProfiles(for: initialConfig) + self.appliedClaudeConfigs = Self.effectiveClaudeConfigs(for: initialConfig) self.service = UsageService(config: initialConfig) notifier.enabled = notificationsEnabled } @@ -349,28 +345,21 @@ public final class AppModel { ) } - public func automaticClaudeProfiles() -> [ClaudeProfile] { - discoverClaudeProfiles() - } - /// Validates and applies every setting as a single refresh operation. /// Returns a validation message without changing live state when invalid. public func apply(_ draft: SettingsDraft) async -> String? { - let discovered = discoverClaudeProfiles() - var config: UsageConfig - do { - config = try draft.providerSettings.usageConfig( - codexEnabled: draft.codexEnabled, - claudeEnabled: draft.claudeEnabled, - geminiEnabled: draft.geminiEnabled, - discoveredProfiles: discovered - ) - } catch { - return error.localizedDescription - } + // Claude account configs (name/logs) are edited live on the model, not via the + // settings draft — so an Apply of *other* settings must not revert them. + var settings = draft.providerSettings + settings.claudeAccountConfigs = providerSettings.claudeAccountConfigs + var config = settings.usageConfig( + codexEnabled: draft.codexEnabled, + claudeEnabled: draft.claudeEnabled, + geminiEnabled: draft.geminiEnabled + ) Self.applyRuntimeOverrides(to: &config) - let effectiveClaudeProfiles = Self.effectiveClaudeProfiles(for: config) - let shouldClearClaudeCards = appliedClaudeProfiles != effectiveClaudeProfiles + let effectiveClaudeConfigs = Self.effectiveClaudeConfigs(for: config) + let shouldClearClaudeCards = appliedClaudeConfigs != effectiveClaudeConfigs isApplying = true cadenceSeconds = draft.cadenceSeconds @@ -381,14 +370,14 @@ public final class AppModel { menuBarStyle = draft.menuBarStyle monthlyBudgetUSD = draft.monthlyBudgetUSD maskAccounts = draft.maskAccounts - providerSettings = draft.providerSettings + providerSettings = settings // draft settings + preserved account configs isApplying = false notifier.enabled = notificationsEnabled launchAtLogin = draft.launchAtLogin persist() if shouldClearClaudeCards || !draft.claudeEnabled { lastClaude = [] } - appliedClaudeProfiles = effectiveClaudeProfiles + appliedClaudeConfigs = effectiveClaudeConfigs await service.update(config: config) await refresh() return nil @@ -419,7 +408,9 @@ public final class AppModel { Task { @MainActor in NSWorkspace.shared.open(url) } }) signingInClaude = false + lastClaude = [] reloadClaudeAccounts() + reconfigure() await refresh() } catch ClaudeOAuthError.cancelled { signingInClaude = false // user closed the browser — silent @@ -430,21 +421,58 @@ public final class AppModel { } } - /// Remove a signed-in account (deletes its stored token) and drop its live card. + /// Remove a signed-in account (deletes its stored token + config) and its card. public func removeClaudeAccount(_ key: String) { Task { await ClaudeTokenProvider.shared.signOut(account: key) + providerSettings.claudeAccountConfigs[key] = nil + lastClaude = [] + persist() reloadClaudeAccounts() + reconfigure() await refresh() } } - /// Refresh the account list shown in Settings from the token store. - public func reloadClaudeAccounts() { claudeAccounts = Self.loadClaudeAccounts() } + /// Set a friendly display name for an account (empty clears back to the email). + public func renameClaudeAccount(_ key: String, to name: String) { + var cfg = providerSettings.claudeAccountConfigs[key] ?? ClaudeAccountConfig() + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + cfg.name = trimmed.isEmpty ? nil : trimmed + updateClaudeConfig(key, cfg) + } + + /// Point an account at a logs folder to show $ cost (nil = cost off). + public func setClaudeAccountLogs(_ key: String, dir: String?) { + var cfg = providerSettings.claudeAccountConfigs[key] ?? ClaudeAccountConfig() + cfg.logsDir = dir + updateClaudeConfig(key, cfg) + } - private static func loadClaudeAccounts() -> [ClaudeAccountSummary] { - ClaudeTokenProvider.shared.accounts().map { - ClaudeAccountSummary(key: ClaudeTokenStore.accountKey(for: $0), email: $0.accountEmail) + private func updateClaudeConfig(_ key: String, _ cfg: ClaudeAccountConfig) { + // Drop the entry entirely when it carries nothing (keeps the map sparse). + providerSettings.claudeAccountConfigs[key] = (cfg.name == nil && cfg.logsDir == nil) ? nil : cfg + lastClaude = [] + persist() + reloadClaudeAccounts() + reconfigure() + Task { await refresh() } + } + + /// Refresh the account list shown in Settings from the token store + configs. + public func reloadClaudeAccounts() { + claudeAccounts = Self.loadClaudeAccounts(configs: providerSettings.claudeAccountConfigs) + } + + private static func loadClaudeAccounts(configs: [String: ClaudeAccountConfig]) -> [ClaudeAccountSummary] { + ClaudeTokenProvider.shared.accounts().map { token in + let key = ClaudeTokenStore.accountKey(for: token) + // A stored name equal to the email is not a real custom name (older builds + // could persist that) — treat it as none so the email shows as default. + var name = configs[key]?.name + if let n = name, n.caseInsensitiveCompare(token.accountEmail ?? "\u{0}") == .orderedSame { name = nil } + return ClaudeAccountSummary(key: key, email: token.accountEmail, + name: name, logsDir: configs[key]?.logsDir) } } @@ -470,9 +498,8 @@ public final class AppModel { let config = Self.usageConfig(providerSettings: providerSettings, codexEnabled: codexEnabled, claudeEnabled: claudeEnabled, - geminiEnabled: geminiEnabled, - discovered: discoverClaudeProfiles()) - appliedClaudeProfiles = Self.effectiveClaudeProfiles(for: config) + geminiEnabled: geminiEnabled) + appliedClaudeConfigs = Self.effectiveClaudeConfigs(for: config) if !claudeEnabled { lastClaude = [] } Task { await service.update(config: config) } } @@ -480,25 +507,16 @@ public final class AppModel { private static func usageConfig(providerSettings: ProviderSettings, codexEnabled: Bool, claudeEnabled: Bool, - geminiEnabled: Bool, - discovered: [ClaudeProfile] = ClaudeProfileDiscovery.discover()) -> UsageConfig { - var config = (try? providerSettings.usageConfig( - codexEnabled: codexEnabled, - claudeEnabled: claudeEnabled, - geminiEnabled: geminiEnabled, - discoveredProfiles: discovered - )) ?? UsageConfig( - codexEnabled: codexEnabled, - claudeEnabled: claudeEnabled, - geminiEnabled: geminiEnabled, - claudeProfiles: discovered - ) + geminiEnabled: Bool) -> UsageConfig { + var config = providerSettings.usageConfig(codexEnabled: codexEnabled, + claudeEnabled: claudeEnabled, + geminiEnabled: geminiEnabled) applyRuntimeOverrides(to: &config) return config } - private static func effectiveClaudeProfiles(for config: UsageConfig) -> [ClaudeProfile] { - config.claudeEnabled ? config.claudeProfiles : [] + private static func effectiveClaudeConfigs(for config: UsageConfig) -> [String: ClaudeAccountConfig] { + config.claudeEnabled ? config.claudeAccountConfigs : [:] } private static func applyRuntimeOverrides(to config: inout UsageConfig) { diff --git a/Sources/AIUsageBarUI/ProviderDetailView.swift b/Sources/AIUsageBarUI/ProviderDetailView.swift index dba626a..01df57b 100644 --- a/Sources/AIUsageBarUI/ProviderDetailView.swift +++ b/Sources/AIUsageBarUI/ProviderDetailView.swift @@ -99,11 +99,6 @@ struct AccountBlock: View { return false } - private var needsSignIn: Bool { - usage.kind == .claude && usage.windows.isEmpty && - (usage.status == .notConfigured || (usage.detail?.contains("Not signed in") ?? false)) - } - var body: some View { VStack(alignment: .leading, spacing: 11) { header @@ -214,7 +209,6 @@ struct AccountBlock: View { .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - if needsSignIn { SignInHint(configDir: usage.sourcePath) } } } @@ -299,34 +293,3 @@ struct WindowRow: View { } } -/// Inline helper for a Claude profile that isn't signed into the Keychain. -struct SignInHint: View { - let configDir: String? - @State private var copied = false - - private var command: String { - guard let dir = configDir else { return "claude" } - return dir.hasSuffix("/.claude") ? "claude" : "CLAUDE_CONFIG_DIR=\(abbreviate(dir)) claude" - } - - var body: some View { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(command + " # then run /login", forType: .string) - copied = true - } label: { - HStack(spacing: 4) { - Image(systemName: copied ? "checkmark" : "doc.on.doc") - Text(copied ? "Copied — run it, then /login" : "Copy sign-in command") - } - .font(.caption2) - } - .buttonStyle(.plain) - .foregroundStyle(.blue) - } - - private func abbreviate(_ path: String) -> String { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path - } -} diff --git a/Sources/AIUsageBarUI/ProviderSettings.swift b/Sources/AIUsageBarUI/ProviderSettings.swift index 3b9775d..035b16f 100644 --- a/Sources/AIUsageBarUI/ProviderSettings.swift +++ b/Sources/AIUsageBarUI/ProviderSettings.swift @@ -1,111 +1,43 @@ import Foundation import AIUsageBarCore -public struct ManualClaudeProfile: Codable, Sendable, Hashable, Identifiable { - public var id: UUID - public var name: String - public var configDir: URL - - public init(id: UUID = UUID(), name: String, configDir: URL) { - self.id = id - self.name = name - self.configDir = configDir.standardizedFileURL - } - - func asClaudeProfile(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> ClaudeProfile { - let root = configDir.standardizedFileURL - let defaultRoot = home.appendingPathComponent(".claude").standardizedFileURL - return ClaudeProfile(name: name.trimmingCharacters(in: .whitespacesAndNewlines), - configDir: root, isDefault: root.path == defaultRoot.path) - } -} - -public enum ProviderSettingsError: LocalizedError, Equatable { - case emptyProfileName - case duplicateProfilePath(String) - case duplicateProfileName(String) - - public var errorDescription: String? { - switch self { - case .emptyProfileName: - return "Each Claude profile needs a name." - case .duplicateProfilePath: - return "Each manual Claude profile must use a different folder." - case .duplicateProfileName: - return "Claude profile names must be unique." - } - } -} - +/// Persisted provider settings. Claude accounts come from OAuth sign-in (the token +/// store); this holds the user's per-account config (name + optional logs dir), keyed +/// by `ClaudeTokenStore.accountKey`. public struct ProviderSettings: Codable, Sendable, Hashable { public static let defaultsKey = "providerSettings" public var codexHome: URL? public var geminiHome: URL? - public var manualClaudeProfiles: [ManualClaudeProfile] + public var claudeAccountConfigs: [String: ClaudeAccountConfig] public var customProviders: [CustomProviderConfig] public init(codexHome: URL? = nil, geminiHome: URL? = nil, - manualClaudeProfiles: [ManualClaudeProfile] = [], + claudeAccountConfigs: [String: ClaudeAccountConfig] = [:], customProviders: [CustomProviderConfig] = []) { self.codexHome = codexHome?.standardizedFileURL self.geminiHome = geminiHome?.standardizedFileURL - self.manualClaudeProfiles = manualClaudeProfiles + self.claudeAccountConfigs = claudeAccountConfigs self.customProviders = customProviders } - // Tolerant decoder so older saved settings (without newer fields) still load. + // Tolerant decoder so older saved settings (without newer fields, or with the + // retired `manualClaudeProfiles` key) still load. private enum CodingKeys: String, CodingKey { - case codexHome, geminiHome, manualClaudeProfiles, customProviders + case codexHome, geminiHome, claudeAccountConfigs, customProviders } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) codexHome = try c.decodeIfPresent(URL.self, forKey: .codexHome) geminiHome = try c.decodeIfPresent(URL.self, forKey: .geminiHome) - manualClaudeProfiles = try c.decodeIfPresent([ManualClaudeProfile].self, forKey: .manualClaudeProfiles) ?? [] + claudeAccountConfigs = try c.decodeIfPresent([String: ClaudeAccountConfig].self, + forKey: .claudeAccountConfigs) ?? [:] customProviders = try c.decodeIfPresent([CustomProviderConfig].self, forKey: .customProviders) ?? [] } - public func resolvedClaudeProfiles(discoveredProfiles: [ClaudeProfile]) throws -> [ClaudeProfile] { - var manualByPath: [String: ClaudeProfile] = [:] - for entry in manualClaudeProfiles { - let profile = entry.asClaudeProfile() - let path = profile.configDir.standardizedFileURL.path - guard manualByPath[path] == nil else { throw ProviderSettingsError.duplicateProfilePath(path) } - manualByPath[path] = profile - } - - var result: [ClaudeProfile] = [] - var emittedPaths = Set() - for automatic in discoveredProfiles { - let path = automatic.configDir.standardizedFileURL.path - guard emittedPaths.insert(path).inserted else { continue } - result.append(manualByPath.removeValue(forKey: path) ?? automatic) - } - for entry in manualClaudeProfiles { - let profile = entry.asClaudeProfile() - let path = profile.configDir.standardizedFileURL.path - guard emittedPaths.insert(path).inserted else { continue } - result.append(profile) - } - - var names = Set() - for profile in result { - let name = profile.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw ProviderSettingsError.emptyProfileName } - guard names.insert(name.lowercased()).inserted else { - throw ProviderSettingsError.duplicateProfileName(name) - } - } - return result - } - - public func usageConfig(codexEnabled: Bool, claudeEnabled: Bool, geminiEnabled: Bool, - discoveredProfiles: [ClaudeProfile]) throws -> UsageConfig { + public func usageConfig(codexEnabled: Bool, claudeEnabled: Bool, geminiEnabled: Bool) -> UsageConfig { UsageConfig(codexEnabled: codexEnabled, claudeEnabled: claudeEnabled, - geminiEnabled: geminiEnabled, codexHome: codexHome, - geminiHome: geminiHome, - claudeProfiles: try resolvedClaudeProfiles(discoveredProfiles: discoveredProfiles), - customProviders: customProviders) + geminiEnabled: geminiEnabled, codexHome: codexHome, geminiHome: geminiHome, + claudeAccountConfigs: claudeAccountConfigs, customProviders: customProviders) } public static func load(from defaults: UserDefaults) -> ProviderSettings { diff --git a/Sources/AIUsageBarUI/SettingsView.swift b/Sources/AIUsageBarUI/SettingsView.swift index 3fa799f..0f73a6f 100644 --- a/Sources/AIUsageBarUI/SettingsView.swift +++ b/Sources/AIUsageBarUI/SettingsView.swift @@ -10,14 +10,12 @@ import AIUsageBarCore public struct SettingsView: View { @Bindable private var model: AppModel @State private var draft: SettingsDraft - @State private var detectedProfiles: [ClaudeProfile] @State private var errorMessage: String? @MainActor public init(model: AppModel) { self.model = model _draft = State(initialValue: model.settingsDraft()) - _detectedProfiles = State(initialValue: model.automaticClaudeProfiles()) } public var body: some View { @@ -41,14 +39,7 @@ public struct SettingsView: View { private var claudeSection: some View { Section("Claude") { ForEach(model.claudeAccounts) { account in - LabeledContent { - Button("Remove") { model.removeClaudeAccount(account.key) } - .foregroundStyle(.red) - } label: { - Label(account.label, systemImage: "checkmark.circle.fill") - .foregroundStyle(.primary) - .labelStyle(.titleAndIcon) - } + ClaudeAccountRow(account: account, model: model) } LabeledContent("Account") { if model.signingInClaude { @@ -65,11 +56,12 @@ public struct SettingsView: View { if let err = model.claudeSignInError { Text(err).font(.caption).foregroundStyle(.red) } - Text("Sign in with Claude to show live 5-hour and weekly limits. The app mints its own token and stores it in its own Keychain item, so macOS never prompts for access to Claude Code's credentials. Your account and cost show without signing in.") + Text("Sign in with Claude to show live 5-hour and weekly limits — the app mints its own token, so macOS never prompts. Point an account at its logs folder to also show $ cost (there's no cost API; it's read from local logs).") .font(.caption).foregroundStyle(.secondary) } } + private var generalSection: some View { Section("General") { Picker("Refresh cadence", selection: $draft.cadenceSeconds) { @@ -124,38 +116,6 @@ public struct SettingsView: View { choose: chooseGeminiFolder, reset: { draft.providerSettings.geminiHome = nil } ) - - Divider() - - Text("Automatic Claude profiles") - .font(.subheadline.weight(.semibold)) - - if detectedProfiles.isEmpty { - Text("No Claude profiles detected.") - .foregroundStyle(.secondary) - } else { - ForEach(detectedProfiles) { profile in - detectedProfileRow(profile) - } - } - - Button("Rescan", action: rescanProfiles) - - Divider() - - Text("Manual Claude profiles") - .font(.subheadline.weight(.semibold)) - - if draft.providerSettings.manualClaudeProfiles.isEmpty { - Text("Add a named profile to use another Claude configuration folder.") - .foregroundStyle(.secondary) - } - - ForEach($draft.providerSettings.manualClaudeProfiles) { profile in - manualProfileRow(profile) - } - - Button("Add profile", action: addProfile) } } @@ -244,42 +204,6 @@ public struct SettingsView: View { } } - private func detectedProfileRow(_ profile: ClaudeProfile) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(profile.name) - if profile.isDefault { - Text("Default") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Text(profile.configDir.path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - - private func manualProfileRow(_ profile: Binding) -> some View { - let id = profile.wrappedValue.id - return VStack(alignment: .leading, spacing: 6) { - TextField("Name", text: profile.name) - Text(profile.wrappedValue.configDir.path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - HStack { - Button("Choose folder") { chooseClaudeFolder(id: id) } - Button("Remove", role: .destructive) { - draft.providerSettings.manualClaudeProfiles.removeAll { $0.id == id } - } - } - } - } - private func chooseCodexFolder() { chooseFolder(title: "Choose Codex data folder") { url in draft.providerSettings.codexHome = url @@ -292,14 +216,6 @@ public struct SettingsView: View { } } - private func chooseClaudeFolder(id: UUID) { - chooseFolder(title: "Choose Claude profile folder") { url in - guard let index = draft.providerSettings.manualClaudeProfiles.firstIndex(where: { $0.id == id }) - else { return } - draft.providerSettings.manualClaudeProfiles[index].configDir = url - } - } - private func chooseFolder(title: String, onChoose: (URL) -> Void) { let panel = NSOpenPanel() panel.title = title @@ -313,19 +229,6 @@ public struct SettingsView: View { onChoose(url.standardizedFileURL) } - private func addProfile() { - draft.providerSettings.manualClaudeProfiles.append( - ManualClaudeProfile( - name: "New profile", - configDir: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude") - ) - ) - } - - private func rescanProfiles() { - detectedProfiles = model.automaticClaudeProfiles() - } - private func applyDraft() { let draftToApply = draft Task { @MainActor in @@ -338,7 +241,78 @@ public struct SettingsView: View { private func resetFromModel() { draft = model.settingsDraft() - detectedProfiles = model.automaticClaudeProfiles() errorMessage = nil } } + +/// One signed-in Claude account: an editable name and a typed cost-logs path (both +/// committed on blur/Enter, never per-keystroke — per-keystroke would re-hit the +/// usage endpoint on every character). +struct ClaudeAccountRow: View { + let account: ClaudeAccountSummary + let model: AppModel + @State private var draftName: String = "" + @State private var draftLogs: String = "" + @FocusState private var nameFocused: Bool + @FocusState private var logsFocused: Bool + + /// The custom name as edited ("" = no custom name → falls back to the email). + private var currentName: String { account.name ?? "" } + /// The logs path as typed (tilde-abbreviated), "" = cost off. + private var currentLogs: String { + account.logsDir.map { ($0 as NSString).abbreviatingWithTildeInPath } ?? "" + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + // Email is the placeholder — an empty field means "use the email". + TextField(account.email ?? "Name", text: $draftName) + .textFieldStyle(.roundedBorder).frame(maxWidth: 160) + .focused($nameFocused) + .onSubmit(commitName) + .onChange(of: nameFocused) { _, focused in if !focused { commitName() } } + if let email = account.email, account.name != nil { + Text(email).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button("Remove", role: .destructive) { model.removeClaudeAccount(account.key) } + } + HStack(spacing: 6) { + Text("Cost logs").font(.caption).foregroundStyle(.secondary) + TextField("~/.claude (blank = cost off)", text: $draftLogs) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .focused($logsFocused) + .onSubmit(commitLogs) + .onChange(of: logsFocused) { _, focused in if !focused { commitLogs() } } + Button("~/.claude") { draftLogs = "~/.claude"; commitLogs() } + .controlSize(.small) + } + } + .padding(.vertical, 2) + .onAppear { draftName = currentName; draftLogs = currentLogs } + .onChange(of: account) { _, _ in + if !nameFocused { draftName = currentName } + if !logsFocused { draftLogs = currentLogs } + } + } + + private func commitName() { + let trimmed = draftName.trimmingCharacters(in: .whitespacesAndNewlines) + // Empty, or literally the email, means "no custom name" → store nothing. + let newName = (trimmed.isEmpty || + trimmed.caseInsensitiveCompare(account.email ?? "\u{0}") == .orderedSame) ? "" : trimmed + guard newName != currentName else { return } // only when actually changed + draftName = newName // reflect the normalization + model.renameClaudeAccount(account.key, to: newName) + } + + private func commitLogs() { + let trimmed = draftLogs.trimmingCharacters(in: .whitespacesAndNewlines) + let expanded = trimmed.isEmpty ? nil : (trimmed as NSString).expandingTildeInPath + guard expanded != account.logsDir else { return } + model.setClaudeAccountLogs(account.key, dir: expanded) + } +} diff --git a/Sources/usageprobe/main.swift b/Sources/usageprobe/main.swift index 5bef42f..8e47b81 100644 --- a/Sources/usageprobe/main.swift +++ b/Sources/usageprobe/main.swift @@ -40,21 +40,27 @@ case "codex": case "gemini": print(fmt(GeminiReader().read())) case "claude": - let profiles = UsageConfig.autoDetect().claudeProfiles - let results = await ClaudeReader(profiles: profiles).read() - print(results.map(fmt).joined(separator: "\n\n")) + // The CLI can't do OAuth (its code signature differs from the app's, so it can't + // read the app's Keychain token), so it shows local cost per discovered config + // dir rather than live limits. Live 5H/7D % needs the app's Add-account sign-in. + for p in ClaudeProfileDiscovery.discover() { + let cost = ClaudeCostReader.summary(configDir: p.configDir) + let usd = cost.map { "$" + String(format: "%.2f", $0.monthUSD) } ?? "-" + print("• \(p.name) dir=\(p.configDir.path) 30d=\(usd) tokens=\(cost?.totalTokens ?? 0)") + } + print("(live 5H/7D % requires the app's OAuth sign-in)") case "profiles": for p in ClaudeProfileDiscovery.discover() { let a = ClaudeAccountLoader.load(p) print("• \(p.name.padding(toLength: 10, withPad: " ", startingAt: 0)) dir=\(p.configDir.path) default=\(p.isDefault) email=\(a?.emailAddress ?? "-") plan=\(a?.planLabel ?? "-")") } case "all": - let service = UsageService(config: .autoDetect()) + let service = UsageService(config: UsageConfig()) let results = await service.refresh() print(results.map(fmt).joined(separator: "\n\n")) case "statusline": // One compact line for a shell prompt / Claude Code statusLine. - let results = await UsageService(config: .autoDetect()).refresh() + let results = await UsageService(config: UsageConfig()).refresh() var parts: [String] = [] for kind in [ProviderKind.codex, .claude, .gemini] { let pct = results.filter { $0.kind == kind }.compactMap { $0.maxUsedPercent }.max() diff --git a/Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift b/Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift index 9c8e5de..dcd6a11 100644 --- a/Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift +++ b/Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift @@ -105,34 +105,11 @@ final class ClaudeOAuthTests: XCTestCase { XCTAssertEqual(ClaudeOAuthLoopback.parse(noCode), .ignore) } - // MARK: Identity mapping - - func testIdentityMatchesByEmailOrgOrAccount() { - let acct = ClaudeAccount(emailAddress: "You@Example.com", accountUuid: "acc-9", organizationUuid: "org-9") - XCTAssertTrue(ClaudeReader.identityMatches( - ClaudeOAuthToken(accessToken: "t", accountEmail: "you@example.com"), acct)) // case-insensitive - XCTAssertTrue(ClaudeReader.identityMatches( - ClaudeOAuthToken(accessToken: "t", orgUUID: "org-9"), acct)) - XCTAssertTrue(ClaudeReader.identityMatches( - ClaudeOAuthToken(accessToken: "t", accountUUID: "acc-9"), acct)) - XCTAssertFalse(ClaudeReader.identityMatches( - ClaudeOAuthToken(accessToken: "t", accountEmail: "other@example.com"), acct)) - XCTAssertFalse(ClaudeReader.identityMatches(ClaudeOAuthToken(accessToken: "t"), nil)) - } + // MARK: Account config - func testIdentitySharedOrgDoesNotMisbind() { - // Two accounts in the SAME org must not match on org UUID when their precise - // per-account identifiers differ (else a token binds to the wrong profile). - let acctA = ClaudeAccount(accountUuid: "acc-A", organizationUuid: "org-shared") - let tokenB = ClaudeOAuthToken(accessToken: "t", accountUUID: "acc-B", orgUUID: "org-shared") - XCTAssertFalse(ClaudeReader.identityMatches(tokenB, acctA)) - // Same-email in the shared org still matches. - let acctEmail = ClaudeAccount(emailAddress: "a@example.com", organizationUuid: "org-shared") - let tokenEmail = ClaudeOAuthToken(accessToken: "t", accountEmail: "a@example.com", orgUUID: "org-shared") - XCTAssertTrue(ClaudeReader.identityMatches(tokenEmail, acctEmail)) - // Org-only match survives when no precise field is comparable on both sides. - let acctOrgOnly = ClaudeAccount(organizationUuid: "org-shared") - let tokenOrgOnly = ClaudeOAuthToken(accessToken: "t", orgUUID: "org-shared") - XCTAssertTrue(ClaudeReader.identityMatches(tokenOrgOnly, acctOrgOnly)) + func testAccountConfigLogsURL() { + XCTAssertNil(ClaudeAccountConfig().logsURL) + XCTAssertEqual(ClaudeAccountConfig(logsDir: "/tmp/.claude").logsURL, + URL(fileURLWithPath: "/tmp/.claude")) } } diff --git a/Tests/AIUsageBarUITests/ProviderSettingsTests.swift b/Tests/AIUsageBarUITests/ProviderSettingsTests.swift index cc022fa..738a4be 100644 --- a/Tests/AIUsageBarUITests/ProviderSettingsTests.swift +++ b/Tests/AIUsageBarUITests/ProviderSettingsTests.swift @@ -46,66 +46,33 @@ final class ProviderSettingsTests: XCTestCase { } } - func testProviderSettingsMergesManualProfilesAndUsesExplicitRoots() throws { - let discovered = [ClaudeProfile(name: "Personal", configDir: URL(fileURLWithPath: "/tmp/personal"), isDefault: false)] - let manual = ManualClaudeProfile(name: "Work", configDir: URL(fileURLWithPath: "/tmp/work")) - let settings = ProviderSettings(codexHome: URL(fileURLWithPath: "/tmp/codex"), - geminiHome: URL(fileURLWithPath: "/tmp/gemini"), - manualClaudeProfiles: [manual]) + func testUsageConfigCarriesAccountConfigsAndRoots() { + let settings = ProviderSettings( + codexHome: URL(fileURLWithPath: "/tmp/codex"), + geminiHome: URL(fileURLWithPath: "/tmp/gemini"), + claudeAccountConfigs: ["acc-1": ClaudeAccountConfig(name: "Work", logsDir: "/tmp/.claude")]) - let config = try settings.usageConfig(codexEnabled: true, claudeEnabled: true, - geminiEnabled: true, discoveredProfiles: discovered) + let config = settings.usageConfig(codexEnabled: true, claudeEnabled: true, geminiEnabled: true) XCTAssertEqual(config.codexHome?.path, "/tmp/codex") XCTAssertEqual(config.geminiHome?.path, "/tmp/gemini") - XCTAssertEqual(config.claudeProfiles.map(\.name), ["Personal", "Work"]) + XCTAssertEqual(config.claudeAccountConfigs["acc-1"]?.name, "Work") + XCTAssertEqual(config.claudeAccountConfigs["acc-1"]?.logsDir, "/tmp/.claude") } - func testManualProfileReplacesDiscoveredProfileAtSamePath() throws { - let path = URL(fileURLWithPath: "/tmp/claude-work") - let discovered = [ClaudeProfile(name: "Detected", configDir: path, isDefault: false)] - let settings = ProviderSettings(manualClaudeProfiles: [ManualClaudeProfile(name: "Client", configDir: path)]) - XCTAssertEqual(try settings.resolvedClaudeProfiles(discoveredProfiles: discovered).map(\.name), ["Client"]) - } - - func testProviderSettingsRejectsBlankAndDuplicateManualProfileNames() { - XCTAssertThrowsError(try ProviderSettings(manualClaudeProfiles: [ - ManualClaudeProfile(name: " ", configDir: URL(fileURLWithPath: "/tmp/a")) - ]).resolvedClaudeProfiles(discoveredProfiles: [])) - XCTAssertThrowsError(try ProviderSettings(manualClaudeProfiles: [ - ManualClaudeProfile(name: "Work", configDir: URL(fileURLWithPath: "/tmp/a")), - ManualClaudeProfile(name: "work", configDir: URL(fileURLWithPath: "/tmp/b")) - ]).resolvedClaudeProfiles(discoveredProfiles: [])) - } - - func testManualDefaultClaudeDirectoryKeepsDefaultIdentitySemantics() throws { - let home = FileManager.default.homeDirectoryForCurrentUser - let profiles = try ProviderSettings(manualClaudeProfiles: [ - ManualClaudeProfile(name: "Personal", configDir: home.appendingPathComponent(".claude")) - ]).resolvedClaudeProfiles(discoveredProfiles: []) - XCTAssertEqual(profiles.count, 1) - XCTAssertTrue(profiles[0].isDefault) - XCTAssertEqual(profiles[0].dotClaudeJSON.path, home.appendingPathComponent(".claude.json").path) - } - - func testProviderSettingsRoundTripsAndBuildsSameConfiguration() throws { + func testProviderSettingsRoundTripsAccountConfigs() throws { let suite = "ProviderSettingsTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } - let original = ProviderSettings(codexHome: URL(fileURLWithPath: "/tmp/codex"), - geminiHome: URL(fileURLWithPath: "/tmp/gemini"), - manualClaudeProfiles: [ManualClaudeProfile(name: "Work", configDir: URL(fileURLWithPath: "/tmp/work"))]) + let original = ProviderSettings( + codexHome: URL(fileURLWithPath: "/tmp/codex"), + claudeAccountConfigs: ["acc-1": ClaudeAccountConfig(name: "Work", logsDir: "/tmp/w/.claude")]) original.save(to: defaults) let restored = ProviderSettings.load(from: defaults) - let config = try restored.usageConfig(codexEnabled: false, claudeEnabled: true, - geminiEnabled: false, discoveredProfiles: []) XCTAssertEqual(restored, original) - XCTAssertFalse(config.codexEnabled) - XCTAssertTrue(config.claudeEnabled) - XCTAssertFalse(config.geminiEnabled) - XCTAssertEqual(config.claudeProfiles.map(\.name), ["Work"]) + XCTAssertEqual(restored.claudeAccountConfigs["acc-1"], ClaudeAccountConfig(name: "Work", logsDir: "/tmp/w/.claude")) } @@ -137,116 +104,4 @@ final class ProviderSettingsTests: XCTestCase { XCTAssertTrue(defaults.bool(forKey: "maskAccounts")) } - @MainActor - func testAppModelApplyLeavesLiveSettingsUntouchedWhenValidationFails() async { - let suite = "AppModelSettingsTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - - defaults.set(30.0, forKey: "cadenceSeconds") - defaults.set(true, forKey: "codexEnabled") - defaults.set(true, forKey: "claudeEnabled") - defaults.set(true, forKey: "geminiEnabled") - defaults.set(false, forKey: "notificationsEnabled") - defaults.set(MenuBarStyle.meters.rawValue, forKey: "menuBarStyle") - defaults.set("must-survive-invalid-apply", forKey: "unrelatedSetting") - ProviderSettings(codexHome: URL(fileURLWithPath: "/tmp/original-codex")).save(to: defaults) - let originalDomain = defaults.persistentDomain(forName: suite)! as NSDictionary - - let model = AppModel(defaults: defaults) - let original = model.settingsDraft() - var invalid = original - invalid.cadenceSeconds = 90 - invalid.codexEnabled = false - invalid.claudeEnabled = false - invalid.geminiEnabled = false - invalid.notificationsEnabled = true - invalid.menuBarStyle = .dot - invalid.providerSettings = ProviderSettings(manualClaudeProfiles: [ - ManualClaudeProfile(name: " ", configDir: URL(fileURLWithPath: "/tmp/claude")) - ]) - - let error = await model.apply(invalid) - - XCTAssertEqual(error, "Each Claude profile needs a name.") - XCTAssertEqual(model.settingsDraft(), original) - let resultingDomain = defaults.persistentDomain(forName: suite)! as NSDictionary - XCTAssertTrue(originalDomain.isEqual(resultingDomain)) - } - - @MainActor - func testAppModelApplyClearsClaudeCardsWhenClaudeIsDisabled() async { - let suite = "AppModelSettingsTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - - let originalNoKeychain = ProcessInfo.processInfo.environment["AIUSAGEBAR_NO_KEYCHAIN"] - setenv("AIUSAGEBAR_NO_KEYCHAIN", "1", 1) - defer { - if let originalNoKeychain { - setenv("AIUSAGEBAR_NO_KEYCHAIN", originalNoKeychain, 1) - } else { - unsetenv("AIUSAGEBAR_NO_KEYCHAIN") - } - } - - let model = AppModel(defaults: defaults) - var enabled = model.settingsDraft() - enabled.codexEnabled = false - enabled.claudeEnabled = true - enabled.geminiEnabled = false - - let enableError = await model.apply(enabled) - - XCTAssertNil(enableError) - XCTAssertFalse(model.cards(for: .claude).isEmpty) - - var disabled = enabled - disabled.claudeEnabled = false - let disableError = await model.apply(disabled) - - XCTAssertNil(disableError) - XCTAssertTrue(model.cards(for: .claude).isEmpty) - XCTAssertFalse(model.providers.contains { $0.kind == .claude }) - } - - @MainActor - func testAppModelApplyClearsPreviouslyAppliedClaudeCardsAfterAutomaticProfileRescan() async { - let suite = "AppModelSettingsTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defer { defaults.removePersistentDomain(forName: suite) } - - let originalNoKeychain = ProcessInfo.processInfo.environment["AIUSAGEBAR_NO_KEYCHAIN"] - setenv("AIUSAGEBAR_NO_KEYCHAIN", "1", 1) - defer { - if let originalNoKeychain { - setenv("AIUSAGEBAR_NO_KEYCHAIN", originalNoKeychain, 1) - } else { - unsetenv("AIUSAGEBAR_NO_KEYCHAIN") - } - } - - let initialProfile = ClaudeProfile( - name: "Before Rescan", - configDir: URL(fileURLWithPath: "/tmp/ai-usage-bar-before-rescan"), - isDefault: false - ) - var discoveredProfiles = [initialProfile] - let model = AppModel(defaults: defaults, discoverClaudeProfiles: { discoveredProfiles }) - var draft = model.settingsDraft() - draft.codexEnabled = false - draft.claudeEnabled = true - draft.geminiEnabled = false - - let initialError = await model.apply(draft) - - XCTAssertNil(initialError) - XCTAssertEqual(model.cards(for: .claude).map { $0.sourcePath }, [initialProfile.configDir.path]) - - discoveredProfiles = [] - let rescanError = await model.apply(draft) - - XCTAssertNil(rescanError) - XCTAssertTrue(model.cards(for: .claude).isEmpty) - } }