diff --git a/Sources/CodexBarCLI/CLICardsCommand.swift b/Sources/CodexBarCLI/CLICardsCommand.swift index 4754048c1f..d1da98f3b4 100644 --- a/Sources/CodexBarCLI/CLICardsCommand.swift +++ b/Sources/CodexBarCLI/CLICardsCommand.swift @@ -167,7 +167,7 @@ extension CodexBarCLI { resetStyle: resetStyle, weeklyWorkDays: weeklyWorkDays, jsonOnly: output.jsonOnly, - includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex], + includeAllAccounts: tokenSelection.allAccounts, fetcher: fetcher, claudeFetcher: claudeFetcher, browserDetection: browserDetection, diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index ff165c6daa..36c267e04e 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -211,7 +211,9 @@ extension CodexBarCLI { startedAt: startedAt, requestTimeout: timeout), providerOperations: providerOperations, - includeAllCodexAccounts: false, + selectedAccountOnlyProviders: Self.dashboardClaudeSwapIsEligible(config: configSnapshot.config) + ? [.claude] + : [], persistCLISessions: false), costCollection: ServeCostCollectionContext( configFingerprint: configSnapshot.cacheToken, diff --git a/Sources/CodexBarCLI/CLIErrorReporting.swift b/Sources/CodexBarCLI/CLIErrorReporting.swift index b52027c420..8d8358acb9 100644 --- a/Sources/CodexBarCLI/CLIErrorReporting.swift +++ b/Sources/CodexBarCLI/CLIErrorReporting.swift @@ -50,6 +50,8 @@ extension CodexBarCLI { provider: UsageProvider, account: String?, cacheAccountKey: String? = nil, + accountIsActive: Bool? = nil, + accountCollectionError: String? = nil, source: String, status: ProviderStatusPayload?, error: Error, @@ -59,6 +61,8 @@ extension CodexBarCLI { provider: provider, account: account, cacheAccountKey: cacheAccountKey, + accountIsActive: accountIsActive, + accountCollectionError: accountCollectionError, version: nil, source: source, status: status, diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 3399ea8374..59b299c024 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -217,6 +217,8 @@ extension CodexBarCLI { beyond a trusted network segment. Snapshot identity defaults to full account emails. --identity redacted hides email local parts and is recommended whenever responses cross a network. + /usage fetches every configured account; dashboard snapshots nest providers + with multiple accounts under providers[].accounts. Endpoints: GET / Built-in web dashboard diff --git a/Sources/CodexBarCLI/CLIPayloads.swift b/Sources/CodexBarCLI/CLIPayloads.swift index 2d39d5451c..330d67bf20 100644 --- a/Sources/CodexBarCLI/CLIPayloads.swift +++ b/Sources/CodexBarCLI/CLIPayloads.swift @@ -8,6 +8,10 @@ struct ProviderPayload: Encodable { let provider: String let account: String? let cacheAccountKey: String? + /// Internal account-selection metadata for dashboard projection. This is deliberately + /// excluded from the public `/usage` payload, whose schema remains unchanged. + let accountIsActive: Bool? + let accountCollectionError: String? let version: String? let source: String let status: ProviderStatusPayload? @@ -38,6 +42,8 @@ struct ProviderPayload: Encodable { provider: UsageProvider, account: String?, cacheAccountKey: String? = nil, + accountIsActive: Bool? = nil, + accountCollectionError: String? = nil, version: String?, source: String, status: ProviderStatusPayload?, @@ -52,6 +58,8 @@ struct ProviderPayload: Encodable { self.provider = provider.rawValue self.account = account self.cacheAccountKey = cacheAccountKey + self.accountIsActive = accountIsActive + self.accountCollectionError = accountCollectionError self.version = version self.source = source self.status = status @@ -68,6 +76,8 @@ struct ProviderPayload: Encodable { providerID: String, account: String?, cacheAccountKey: String? = nil, + accountIsActive: Bool? = nil, + accountCollectionError: String? = nil, version: String?, source: String, status: ProviderStatusPayload?, @@ -82,6 +92,8 @@ struct ProviderPayload: Encodable { self.provider = providerID self.account = account self.cacheAccountKey = cacheAccountKey + self.accountIsActive = accountIsActive + self.accountCollectionError = accountCollectionError self.version = version self.source = source self.status = status diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 60bf2c4827..ce567f0420 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -179,7 +179,8 @@ struct ServeUsageContext: Sendable { let providerTimeout: TimeInterval? let providerDeadline: ContinuousClock.Instant? let providerOperations: CLIServeOperationCoordinator - let includeAllCodexAccounts: Bool + let includeAllAccounts: Bool + let selectedAccountOnlyProviders: Set let persistCLISessions: Bool init( @@ -189,7 +190,8 @@ struct ServeUsageContext: Sendable { providerTimeout: TimeInterval?, providerDeadline: ContinuousClock.Instant?, providerOperations: CLIServeOperationCoordinator, - includeAllCodexAccounts: Bool = true, + includeAllAccounts: Bool = true, + selectedAccountOnlyProviders: Set = [], persistCLISessions: Bool = true) { self.config = config @@ -198,7 +200,8 @@ struct ServeUsageContext: Sendable { self.providerTimeout = providerTimeout self.providerDeadline = providerDeadline self.providerOperations = providerOperations - self.includeAllCodexAccounts = includeAllCodexAccounts + self.includeAllAccounts = includeAllAccounts + self.selectedAccountOnlyProviders = selectedAccountOnlyProviders self.persistCLISessions = persistCLISessions } } @@ -997,7 +1000,10 @@ extension CodexBarCLI { providerTimeout: providerTimeout, providerDeadline: providerDeadline, providerOperations: runtime.providerOperations, - includeAllCodexAccounts: false), + selectedAccountOnlyProviders: Self.dashboardClaudeSwapIsEligible( + config: snapshot.config) + ? [.claude] + : []), costCollection: ServeCostCollectionContext( configFingerprint: snapshot.cacheToken, providerTimeout: providerTimeout, @@ -1267,7 +1273,7 @@ extension CodexBarCLI { resetStyle: Self.resetTimeDisplayStyleFromDefaults(), weeklyWorkDays: Self.weeklyProgressWorkDaysFromDefaults(), jsonOnly: true, - includeAllCodexAccounts: context.includeAllCodexAccounts, + includeAllAccounts: context.includeAllAccounts, fetcher: UsageFetcher(), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection, @@ -1279,25 +1285,32 @@ extension CodexBarCLI { providers: selection.asList, configFingerprint: Self.serveUsageOperationFingerprint( configFingerprint: context.configFingerprint, - includeAllCodexAccounts: context.includeAllCodexAccounts), + includeAllAccounts: context.includeAllAccounts, + selectedAccountOnlyProviders: context.selectedAccountOnlyProviders), deadline: context.providerDeadline, operations: context.providerOperations) { provider in - await ProviderInteractionContext.$current.withValue(.background) { + var scopedCommand = command + scopedCommand.includeAllAccounts = context.includeAllAccounts + && !context.selectedAccountOnlyProviders.contains(provider) + let providerCommand = scopedCommand + return await ProviderInteractionContext.$current.withValue(.background) { await Self.fetchUsageOutputs( provider: provider, status: nil, tokenContext: tokenContext, - command: command) + command: providerCommand) } } } static func serveUsageOperationFingerprint( configFingerprint: String, - includeAllCodexAccounts: Bool) -> String + includeAllAccounts: Bool, + selectedAccountOnlyProviders: Set = []) -> String { - "\(configFingerprint):codex-accounts=\(includeAllCodexAccounts ? "all" : "selected")" + let selectedOnly = selectedAccountOnlyProviders.map(\.rawValue).sorted().joined(separator: ",") + return "\(configFingerprint):accounts=\(includeAllAccounts ? "all" : "selected"):selected-only=\(selectedOnly)" } /// Adapts the shared dashboard snapshot producer to the authenticated HTTP diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index b7ea849f32..693aa7ba7a 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -15,7 +15,7 @@ struct UsageCommandContext { let resetStyle: ResetTimeDisplayStyle let weeklyWorkDays: Int? let jsonOnly: Bool - let includeAllCodexAccounts: Bool + var includeAllAccounts: Bool let fetcher: UsageFetcher let claudeFetcher: ClaudeUsageFetcher let browserDetection: BrowserDetection @@ -41,6 +41,8 @@ private struct UsageSuccessRenderInput { let provider: UsageProvider let accountLabel: String? let cacheAccountKey: String? + let accountIsActive: Bool? + let accountCollectionError: String? let version: String? let source: String let status: ProviderStatusPayload? @@ -179,7 +181,7 @@ extension CodexBarCLI { resetStyle: resetStyle, weeklyWorkDays: weeklyWorkDays, jsonOnly: output.jsonOnly, - includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex], + includeAllAccounts: tokenSelection.allAccounts, fetcher: fetcher, claudeFetcher: claudeFetcher, browserDetection: browserDetection, @@ -257,16 +259,22 @@ extension CodexBarCLI { tokenContext: TokenAccountCLIContext, command: UsageCommandContext) async -> UsageCommandOutput { - // Provider-specific by design: Codex can enumerate reconciled live, managed, and profile-home accounts. - if provider == .codex, command.includeAllCodexAccounts { + // Provider-specific by design: Codex reconciles accounts beyond token-account config. + if provider == .codex, command.includeAllAccounts { var output = UsageCommandOutput() - let accounts = tokenContext.visibleCodexAccounts().visibleAccounts + let projection = tokenContext.visibleCodexAccounts() + let accounts = projection.visibleAccounts + let accountCollectionError = projection.hasUnreadableAddedAccountStore + ? "Managed Codex account storage is unreadable." + : nil let selections: [CodexVisibleAccount?] = accounts.isEmpty ? [nil] : accounts.map { Optional($0) } for visibleAccount in selections { let result = await Self.fetchUsageOutput( provider: provider, account: nil, codexVisibleAccount: visibleAccount, + accountIsActive: visibleAccount.map { projection.activeVisibleAccountID == $0.id }, + accountCollectionError: accountCollectionError, status: status, tokenContext: tokenContext, command: command) @@ -277,7 +285,9 @@ extension CodexBarCLI { let accounts: [ProviderTokenAccount] do { - accounts = try tokenContext.resolvedAccounts(for: provider) + accounts = try tokenContext.resolvedAccounts( + for: provider, + includeAllAccounts: command.includeAllAccounts) } catch { return Self.usageOutputForAccountResolutionError( provider: provider, @@ -288,6 +298,7 @@ extension CodexBarCLI { let selections = Self.accountSelections(from: accounts) var output = UsageCommandOutput() + let activeAccountID = tokenContext.activeConfiguredAccountID(for: provider) let accountRefreshDelay = TokenAccountSupportCatalog .support(for: provider)?.minimumDelayBetweenAccountRefreshes for (index, account) in selections.enumerated() { @@ -301,6 +312,7 @@ extension CodexBarCLI { let result = await Self.fetchUsageOutput( provider: provider, account: account, + accountIsActive: account.map { activeAccountID == $0.id }, status: status, tokenContext: tokenContext, command: command) @@ -348,6 +360,8 @@ extension CodexBarCLI { provider: UsageProvider, accountLabel: String?, cacheAccountKey: String?, + accountIsActive: Bool?, + accountCollectionError: String?, version: String?, source: String, status: ProviderStatusPayload?, @@ -362,6 +376,8 @@ extension CodexBarCLI { provider: provider, account: accountLabel, cacheAccountKey: cacheAccountKey, + accountIsActive: accountIsActive, + accountCollectionError: accountCollectionError, version: version, source: source, status: status, @@ -418,6 +434,8 @@ extension CodexBarCLI { provider: input.provider, accountLabel: input.accountLabel, cacheAccountKey: input.cacheAccountKey, + accountIsActive: input.accountIsActive, + accountCollectionError: input.accountCollectionError, version: input.version, source: input.source, status: input.status, @@ -434,6 +452,8 @@ extension CodexBarCLI { provider: UsageProvider, account: ProviderTokenAccount?, codexVisibleAccount: CodexVisibleAccount? = nil, + accountIsActive: Bool? = nil, + accountCollectionError: String? = nil, status: ProviderStatusPayload?, tokenContext: TokenAccountCLIContext, command: UsageCommandContext) async -> UsageCommandOutput @@ -470,7 +490,9 @@ extension CodexBarCLI { provider: provider, account: ( label: account?.label ?? codexVisibleAccount?.menuDisplayName, - cacheKey: cacheAccountKey), + cacheKey: cacheAccountKey, + isActive: accountIsActive, + collectionError: accountCollectionError), source: effectiveSourceMode.rawValue, status: status, command: command) @@ -539,6 +561,8 @@ extension CodexBarCLI { provider: provider, accountLabel: account?.label ?? codexVisibleAccount?.menuDisplayName, cacheAccountKey: cacheAccountKey, + accountIsActive: accountIsActive, + accountCollectionError: accountCollectionError, version: version, source: source, status: status, @@ -558,6 +582,8 @@ extension CodexBarCLI { provider: provider, account: account?.label ?? codexVisibleAccount?.menuDisplayName, cacheAccountKey: cacheAccountKey, + accountIsActive: accountIsActive, + accountCollectionError: accountCollectionError, source: effectiveSourceMode.rawValue, status: status, error: error, @@ -671,7 +697,7 @@ extension CodexBarCLI { private static func webSourceUnsupportedOutput( provider: UsageProvider, - account: (label: String?, cacheKey: String?), + account: (label: String?, cacheKey: String?, isActive: Bool?, collectionError: String?), source: String, status: ProviderStatusPayload?, command: UsageCommandContext) -> UsageCommandOutput @@ -688,6 +714,8 @@ extension CodexBarCLI { provider: provider, account: account.label, cacheAccountKey: account.cacheKey, + accountIsActive: account.isActive, + accountCollectionError: account.collectionError, source: source, status: status, error: error, diff --git a/Sources/CodexBarCLI/DashboardPayloads.swift b/Sources/CodexBarCLI/DashboardPayloads.swift index 627058497b..4adc2836ea 100644 --- a/Sources/CodexBarCLI/DashboardPayloads.swift +++ b/Sources/CodexBarCLI/DashboardPayloads.swift @@ -51,8 +51,8 @@ struct DashboardProviderPayload: Encodable { let display: DashboardDisplayPayload let error: ProviderErrorPayload? let updatedAt: Date? - /// Per-account entries from a local multi-account source (today: claude-swap). - /// Additive schema-v1 data; absent for providers without such a source. + /// Per-account entries from the provider's reconciled multi-account source. + /// Additive schema-v1 data; absent for single-account providers. let accounts: [DashboardAccountPayload]? /// Row-local failure of the multi-account source; the ambient provider row stays intact. let accountsError: String? diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index bc168c8ce6..94b9011e46 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Crypto import Foundation struct DashboardClaudeSwapInput { @@ -17,6 +18,11 @@ enum DashboardSnapshotBuilder { let display: DashboardDisplayPayload } + private struct ProviderUsageGroup { + let id: String + var payloads: [ProviderPayload] + } + // swiftlint:disable:next function_parameter_count static func makeSnapshot( usagePayloads: [ProviderPayload], @@ -32,21 +38,20 @@ enum DashboardSnapshotBuilder { for cost in costPayloads { costByProvider[cost.provider] = cost } - var attachedClaudeSwap = false - let providers = usagePayloads.enumerated().map { index, payload in - var rowClaudeSwap: DashboardClaudeSwapInput? - // Provider-specific by design: claude-swap account data belongs only on the first Claude row. - if !attachedClaudeSwap, UsageProvider(rawValue: payload.provider) == .claude { - rowClaudeSwap = claudeSwap - attachedClaudeSwap = true - } + let usageGroups = self.groupUsagePayloads(usagePayloads) + let providers = usageGroups.enumerated().map { index, group in + let payload = group.payloads.first { $0.accountIsActive == true } ?? group.payloads[0] + // Provider-specific by design: claude-swap remains Claude's preferred account source when enabled. + let rowClaudeSwap = UsageProvider(rawValue: group.id) == .claude ? claudeSwap : nil let presentation = self.providerPresentation( - id: payload.provider, + id: group.id, config: config, fallbackSortKey: 10000 + index) return self.makeProvider( payload: payload, - cost: costByProvider[payload.provider], + accountPayloads: group.payloads, + cost: costByProvider[group.id], + config: config, presentation: presentation, identityMode: identityMode, generatedAt: generatedAt, @@ -64,6 +69,20 @@ enum DashboardSnapshotBuilder { providers: providers) } + private static func groupUsagePayloads(_ payloads: [ProviderPayload]) -> [ProviderUsageGroup] { + var groups: [ProviderUsageGroup] = [] + var indexByProvider: [String: Int] = [:] + for payload in payloads { + if let index = indexByProvider[payload.provider] { + groups[index].payloads.append(payload) + } else { + indexByProvider[payload.provider] = groups.count + groups.append(ProviderUsageGroup(id: payload.provider, payloads: [payload])) + } + } + return groups + } + static func makeShellSnapshot( config: CodexBarConfig, providers requestedProviders: [UsageProvider]? = nil, @@ -109,7 +128,9 @@ enum DashboardSnapshotBuilder { // swiftlint:disable:next function_parameter_count private static func makeProvider( payload: ProviderPayload, + accountPayloads: [ProviderPayload], cost: CostPayload?, + config: CodexBarConfig, presentation: ProviderPresentation, identityMode: DashboardIdentityMode, generatedAt: Date, @@ -120,15 +141,37 @@ enum DashboardSnapshotBuilder { let metadata = descriptor?.metadata let error = payload.error ?? cost?.error - let accounts = claudeSwap?.adapterError == nil - ? claudeSwap?.accounts?.map { account in - self.makeClaudeSwapAccount( - account, - identityMode: identityMode, - weeklyWorkDays: claudeSwap?.weeklyWorkDays, - generatedAt: generatedAt) - } - : nil + let accounts: [DashboardAccountPayload]? + let accountsError: String? + if let claudeSwap { + accounts = claudeSwap.adapterError == nil + ? claudeSwap.accounts?.map { account in + self.makeClaudeSwapAccount( + account, + identityMode: identityMode, + weeklyWorkDays: claudeSwap.weeklyWorkDays, + generatedAt: generatedAt) + } + : nil + accountsError = claudeSwap.adapterError + } else { + let expectedAccountCount = provider.flatMap { + config.providerConfig(for: $0.instanceID)?.tokenAccounts?.accounts.count + } ?? 0 + let shouldProjectAccounts = accountPayloads.count > 1 || expectedAccountCount > 1 + accounts = shouldProjectAccounts + ? accountPayloads.enumerated().map { index, accountPayload in + self.makeAccount( + accountPayload, + index: index, + identityMode: identityMode) + } + : nil + accountsError = accountPayloads.compactMap(\.accountCollectionError).first + ?? (expectedAccountCount > accountPayloads.count + ? "Failed to collect usage for every configured account." + : nil) + } return DashboardProviderPayload( id: presentation.id, name: presentation.name, @@ -147,7 +190,44 @@ enum DashboardSnapshotBuilder { error: error, generatedAt: generatedAt), accounts: accounts, - accountsError: claudeSwap?.adapterError) + accountsError: accountsError) + } + + private static func makeAccount( + _ payload: ProviderPayload, + index: Int, + identityMode: DashboardIdentityMode) -> DashboardAccountPayload + { + let provider = UsageProvider(rawValue: payload.provider) + let metadata = provider.map { ProviderDescriptorRegistry.descriptor(for: $0).metadata } + let rawIdentifier = payload.cacheAccountKey + ?? "\(payload.provider):\(payload.account ?? "account-\(index + 1)")" + let identifier = SHA256.hash(data: Data(rawIdentifier.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return DashboardAccountPayload( + id: "account:\(identifier)", + label: self.dashboardAccountLabel(payload.account, index: index, mode: identityMode), + active: payload.accountIsActive == true, + identity: self.makeIdentity(provider: provider, usage: payload.usage, mode: identityMode), + windows: self.makeWindows(provider: provider, metadata: metadata, usage: payload.usage), + pace: payload.pace, + error: payload.error?.message, + updatedAt: payload.usage?.updatedAt) + } + + private static func dashboardAccountLabel( + _ rawLabel: String?, + index: Int, + mode: DashboardIdentityMode) -> String + { + let fallback = "Account \(index + 1)" + guard mode != .none, + let label = rawLabel?.trimmingCharacters(in: .whitespacesAndNewlines), + !label.isEmpty + else { return fallback } + guard mode == .redacted, label.contains("@") else { return label } + return self.dashboardEmail(label, mode: mode) ?? fallback } private static func providerPresentation( diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index d6dec7c704..254aef695a 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -66,7 +66,10 @@ struct TokenAccountCLIContext { } } - func resolvedAccounts(for provider: UsageProvider) throws -> [ProviderTokenAccount] { + func resolvedAccounts( + for provider: UsageProvider, + includeAllAccounts: Bool = false) throws -> [ProviderTokenAccount] + { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } guard let data = self.accountsByProvider[provider], !data.accounts.isEmpty else { if self.selection.usesOverride { @@ -75,7 +78,7 @@ struct TokenAccountCLIContext { return [] } - if self.selection.allAccounts { + if includeAllAccounts || self.selection.allAccounts { return data.accounts } @@ -98,6 +101,11 @@ struct TokenAccountCLIContext { return [data.accounts[clamped]] } + func activeConfiguredAccountID(for provider: UsageProvider) -> UUID? { + guard let data = self.accountsByProvider[provider], !data.accounts.isEmpty else { return nil } + return data.accounts[data.clampedActiveIndex()].id + } + func settingsSnapshot( for provider: UsageProvider, account: ProviderTokenAccount?, diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index c2a3b211ae..9f830f1176 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -29,18 +29,22 @@ struct CLIServeRouterTests { } @Test - func `usage operation fingerprint separates dashboard account mode`() { + func `usage operation fingerprint separates account collection mode`() { let allAccounts = CodexBarCLI.serveUsageOperationFingerprint( configFingerprint: "config", - includeAllCodexAccounts: true) + includeAllAccounts: true) let selectedAccount = CodexBarCLI.serveUsageOperationFingerprint( configFingerprint: "config", - includeAllCodexAccounts: false) + includeAllAccounts: false) #expect(allAccounts != selectedAccount) #expect(allAccounts == CodexBarCLI.serveUsageOperationFingerprint( configFingerprint: "config", - includeAllCodexAccounts: true)) + includeAllAccounts: true)) + #expect(allAccounts != CodexBarCLI.serveUsageOperationFingerprint( + configFingerprint: "config", + includeAllAccounts: true, + selectedAccountOnlyProviders: [.claude])) } @Test diff --git a/Tests/CodexBarTests/DashboardMultiAccountSnapshotTests.swift b/Tests/CodexBarTests/DashboardMultiAccountSnapshotTests.swift new file mode 100644 index 0000000000..c128701123 --- /dev/null +++ b/Tests/CodexBarTests/DashboardMultiAccountSnapshotTests.swift @@ -0,0 +1,226 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct DashboardMultiAccountSnapshotTests { + private let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) + private let primaryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private let secondaryID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + + @Test + func `projects generic multi account usage into one provider row`() throws { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [ + self.payload( + label: "alice@corp.example", + id: self.primaryID, + active: false, + usedPercent: 20, + source: "api-primary"), + self.payload( + label: "IBM Bob Team", + id: self.secondaryID, + active: true, + usedPercent: 65, + source: "api-secondary"), + ], + costPayloads: [], + config: self.config(accountCount: 2, activeIndex: 1), + identityMode: .redacted, + generatedAt: self.generatedAt, + refreshInterval: 60, + codexBarVersion: nil) + let provider = try self.provider(snapshot) + let accounts = try #require(provider["accounts"] as? [[String: Any]]) + + #expect(provider["id"] as? String == "ibmbob") + #expect(provider["source"] as? String == "api-secondary") + #expect((provider["windows"] as? [[String: Any]])?.first?["usedPercent"] as? Double == 65) + #expect(accounts.count == 2) + #expect(accounts.compactMap { $0["label"] as? String } == ["redacted@corp.example", "IBM Bob Team"]) + #expect(accounts.compactMap { $0["active"] as? Bool } == [false, true]) + #expect(Set(accounts.compactMap { $0["id"] as? String }).count == 2) + #expect(accounts.allSatisfy { row in + guard let id = row["id"] as? String else { return false } + return id.hasPrefix("account:") && !id.contains("alice") && !id.contains("corp.example") + }) + #expect(provider["accountsError"] == nil) + } + + @Test + func `keeps account fetch failure row local`() throws { + let failed = ProviderPayload( + provider: .ibmbob, + account: "Secondary", + cacheAccountKey: "token:\(self.secondaryID.uuidString.lowercased())", + accountIsActive: true, + version: nil, + source: "api", + status: nil, + usage: nil, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: ProviderErrorPayload(code: 1, message: "account unavailable", kind: .provider)) + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [ + self.payload(label: "Primary", id: self.primaryID, active: false, usedPercent: 20), + failed, + ], + costPayloads: [], + config: self.config(accountCount: 2, activeIndex: 1), + identityMode: .full, + generatedAt: self.generatedAt, + refreshInterval: 60, + codexBarVersion: nil) + let provider = try self.provider(snapshot) + let accounts = try #require(provider["accounts"] as? [[String: Any]]) + + #expect(accounts.count == 2) + #expect(accounts[1]["error"] as? String == "account unavailable") + #expect((accounts[1]["windows"] as? [Any])?.isEmpty == true) + #expect(provider["accountsError"] == nil) + } + + @Test + func `reports incomplete configured account collection`() throws { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [self.payload( + label: "Primary", + id: self.primaryID, + active: true, + usedPercent: 20)], + costPayloads: [], + config: self.config(accountCount: 2, activeIndex: 0), + identityMode: .full, + generatedAt: self.generatedAt, + refreshInterval: 60, + codexBarVersion: nil) + let provider = try self.provider(snapshot) + + #expect((provider["accounts"] as? [Any])?.count == 1) + #expect(provider["accountsError"] as? String == "Failed to collect usage for every configured account.") + } + + @Test + func `reports reconciler account collection failure`() throws { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [ + self.payload( + label: "Primary", + id: self.primaryID, + active: true, + usedPercent: 20, + accountCollectionError: "Managed account storage is unreadable."), + self.payload(label: "Secondary", id: self.secondaryID, active: false, usedPercent: 40), + ], + costPayloads: [], + config: self.config(accountCount: 2, activeIndex: 0), + identityMode: .full, + generatedAt: self.generatedAt, + refreshInterval: 60, + codexBarVersion: nil) + let provider = try self.provider(snapshot) + + #expect((provider["accounts"] as? [Any])?.count == 2) + #expect(provider["accountsError"] as? String == "Managed account storage is unreadable.") + } + + @Test + func `single account provider keeps additive account keys absent`() throws { + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [self.payload( + label: "Only", + id: self.primaryID, + active: true, + usedPercent: 20)], + costPayloads: [], + config: self.config(accountCount: 1, activeIndex: 0), + identityMode: .full, + generatedAt: self.generatedAt, + refreshInterval: 60, + codexBarVersion: nil) + let provider = try self.provider(snapshot) + + #expect(provider["accounts"] == nil) + #expect(provider["accountsError"] == nil) + } + + @Test + func `headless resolver can enumerate every configured generic account`() throws { + let config = self.config(accountCount: 2, activeIndex: 1) + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + baseEnvironment: [:]) + + #expect(try context.resolvedAccounts(for: .ibmbob).map(\.id) == [self.secondaryID]) + #expect(try context.resolvedAccounts(for: .ibmbob, includeAllAccounts: true).map(\.id) == [ + self.primaryID, + self.secondaryID, + ]) + } + + private func payload( + label: String, + id: UUID, + active: Bool, + usedPercent: Double, + source: String = "api", + accountCollectionError: String? = nil) -> ProviderPayload + { + ProviderPayload( + provider: .ibmbob, + account: label, + cacheAccountKey: "token:\(id.uuidString.lowercased())", + accountIsActive: active, + accountCollectionError: accountCollectionError, + version: nil, + source: source, + status: nil, + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.generatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: self.generatedAt, + identity: ProviderIdentitySnapshot( + providerID: .ibmbob, + accountEmail: label.contains("@") ? label : nil, + accountOrganization: nil, + loginMethod: nil)), + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + } + + private func config(accountCount: Int, activeIndex: Int) -> CodexBarConfig { + let ids = [self.primaryID, self.secondaryID] + var provider = ProviderConfig(id: .ibmbob, enabled: true) + provider.tokenAccounts = ProviderTokenAccountData( + version: 1, + accounts: (0.. [String: Any] { + let json = try #require(CodexBarCLI.encodeJSON(payload, pretty: false)) + let data = try #require(json.data(using: .utf8)) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try #require((object["providers"] as? [[String: Any]])?.first) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index a00abeba1c..aa7da2a4f4 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -3264,14 +3264,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["claude@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), - AllowedProviderConstruct( - path: "Sources/CodexBarCLI/CLICardsCommand.swift", - line: 170, - anchor: "includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex],", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", line: 208, @@ -3304,14 +3296,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["cursor@0"], reason: "This exact CLI construct preserves the provider-specific command and output contract."), - AllowedProviderConstruct( - path: "Sources/CodexBarCLI/CLIUsageCommand.swift", - line: 182, - anchor: "includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex],", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", line: 198, diff --git a/docs/cli.md b/docs/cli.md index c910a4ae49..21b1b8328a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -181,11 +181,13 @@ The CLI reads multi-account tokens from the same resolved config file as the app - Select by index (1-based): `--account-index `. - Fetch all accounts for the provider: `--all-accounts`. Account selection flags require a single provider (`--provider claude`, etc.). +`codexbar serve` fetches every configured token account for `/usage` and projects providers with multiple accounts into +`providers[].accounts` in `/dashboard/v1/snapshot`. For Claude, token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth usage requires the `user:profile` scope; inference-only tokens will return an error. ### Codex accounts -For Codex, `--all-accounts` and `codexbar serve` enumerate the same visible accounts as the app switcher: +For Codex, `--all-accounts` and `codexbar serve` enumerate the same reconciled visible accounts as the app switcher: managed Codex accounts from `managed-codex-accounts.json` plus the live system account when present. Each fetch is scoped to that account's Codex home before the normal Codex web/OAuth/CLI strategy runs, and JSON payloads include the visible account label in `account`. diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index bdc7283f76..c8b13e2a16 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -192,15 +192,19 @@ The snapshot is a stable display contract, not a raw dump of provider internals. } ``` -### Multi-account providers (claude-swap) +### Multi-account providers -When the claude-swap integration is enabled, the Claude provider row additionally includes an `accounts` array. This -is an additive schema-v1 extension: other provider rows and Claude rows without the integration keep their existing -shape. An account's `label` is its email when known and otherwise falls back to its slot label; `identity` is present -whenever claude-swap reports an email, independently of whether that account's usage fetch succeeds. Both fields follow -the dashboard identity mode: full by default, or redacted with `--identity redacted`. -A failure limited to one account stays in that account's `error`; a failure of the whole adapter sets `accountsError` -while leaving the ambient Claude row intact. +When a provider has multiple configured token accounts, its provider row additionally includes an `accounts` array +using the same account order and active selection as the app. Codex uses its reconciled visible accounts, including +managed and profile-home accounts. A single-account provider keeps the previous shape without `accounts`. + +The opt-in claude-swap integration remains Claude's preferred account source when enabled. Its account `label` is the +email when known and otherwise falls back to its slot label; `identity` is present whenever claude-swap reports an +email, independently of whether that account's usage fetch succeeds. Account labels and identity follow the dashboard +identity mode: full by default, or redacted with `--identity redacted`. + +A failure limited to one account stays in that account's `error`. A failure of the whole account collection sets +`accountsError` while preserving the provider row and any account results already collected. ```json { @@ -258,18 +262,18 @@ while leaving the ambient Claude row intact. - `providers[].display`: UI hints for ordering and coloring. - `providers[].error`: Provider error payload when the latest fetch failed. - `providers[].updatedAt`: Best-known update timestamp for the provider row. -- `providers[].accounts`: Ordered local multi-account entries when an integration supplies them; an enabled source - with no accounts emits `[]`. - - `id`: Stable source and slot identifier, such as `claude-swap:2`. +- `providers[].accounts`: Ordered local multi-account entries. The field is absent for single-account providers; an + enabled claude-swap source with no accounts emits `[]`. + - `id`: Stable opaque account identifier. Claude-swap retains its source and slot form, such as `claude-swap:2`. - `label`: Account email when known, otherwise a slot label such as `Account 2`; email labels follow the dashboard identity mode. - `active`: Whether this is the source's active account. - - `identity`: Account email with a `null` plan whenever claude-swap reports one, even if usage fetching fails; - otherwise `null`. The email local part is hidden only in redacted mode. + - `identity`: Account email and plan when available, or `null`. Claude-swap reports a `null` plan. The email local + part is hidden only in redacted mode. - `windows`: Account-local session, weekly, and scoped windows in the same shape as `providers[].windows`. - `pace`: Account-local primary, secondary, and tertiary pace values when computable. Each pace value contains `stage`, `deltaPercent`, `expectedUsedPercent`, `willLastToReset`, `etaSeconds`, `runOutProbability`, and `summary`. - `error`: Account-local diagnostic, or `null`. - `updatedAt`: Account snapshot update timestamp, or `null`. -- `providers[].accountsError`: Whole-adapter diagnostic when account collection fails; `accounts` is then absent and - the ambient provider data remains available. +- `providers[].accountsError`: Account-collection diagnostic when the full configured account set cannot be projected; + provider data and any collected account rows remain available.