diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index 9c93b521d6..53042c695b 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -19,6 +19,11 @@ struct DashboardOptions: CommanderParsable { name: .long("timeout"), help: "Overall fetch timeout in seconds, 0...86400 (default 30; 0 disables)") var timeout: Double? + + @Option( + name: .long("identity"), + help: "Account identity detail: redacted (default) or full. The HTTP serve transport is always redacted; full is for one-shot snapshots consumed on trusted, private surfaces.") + var identity: String? } struct DashboardSnapshotResult { @@ -44,7 +49,8 @@ struct DashboardSnapshotProducer: Sendable { func collect( config: CodexBarConfig, refreshInterval: TimeInterval, - codexBarVersion: String?) async throws -> DashboardSnapshotResult + codexBarVersion: String?, + identityMode: DashboardIdentityMode = .redacted) async throws -> DashboardSnapshotResult { let selection = CodexBarCLI.providerSelection( rawOverride: nil, @@ -60,7 +66,7 @@ struct DashboardSnapshotProducer: Sendable { usagePayloads: usageOutput.payload, costPayloads: costPayloads, config: config, - identityMode: .redacted, + identityMode: identityMode, generatedAt: generatedAt, refreshInterval: refreshInterval, codexBarVersion: codexBarVersion, @@ -145,6 +151,12 @@ extension CodexBarCLI { message: "--timeout must be a finite number of seconds from 0 through 86400.", kind: .args) } + guard let identityMode = decodeDashboardIdentityMode(from: values) else { + exit( + code: .failure, + message: "--identity must be redacted or full.", + kind: .args) + } let configSnapshot: CLIServeConfigSnapshot do { @@ -191,7 +203,8 @@ extension CodexBarCLI { result = try await DashboardSnapshotProducer.live(context: context).collect( config: context.config, refreshInterval: context.usage.refreshInterval, - codexBarVersion: context.codexBarVersion) + codexBarVersion: context.codexBarVersion, + identityMode: identityMode) } catch { await Self.shutdownDashboardRuntime( providerOperations: providerOperations, @@ -209,6 +222,21 @@ extension CodexBarCLI { print(json) } + /// `.none` is deliberately not accepted: the flag chooses between the safe + /// default and full identity for trusted local consumers; suppressing + /// identity entirely is not a supported dashboard shape. + static func decodeDashboardIdentityMode(from values: ParsedValues) -> DashboardIdentityMode? { + guard let raw = values.options["identity"]?.last else { return .redacted } + switch raw.lowercased() { + case DashboardIdentityMode.redacted.rawValue: + return .redacted + case DashboardIdentityMode.full.rawValue: + return .full + default: + return nil + } + } + static func decodeDashboardTimeout(from values: ParsedValues) -> TimeInterval? { let raw = values.options["timeout"]?.last ?? String(Int(Self.defaultServeRequestTimeout)) guard let timeout = TimeInterval(raw), diff --git a/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift b/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift index 181c6f07d2..077ae98961 100644 --- a/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift +++ b/Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Commander import Foundation import Testing @testable import CodexBarCLI @@ -38,6 +39,37 @@ struct DashboardClaudeSwapSnapshotTests { #expect(active["updatedAt"] as? String == "2027-01-15T08:00:00Z") } + @Test + func `full identity mode keeps real account emails`() throws { + let accounts = ClaudeSwapAccountProjection.accountSnapshots( + from: self.threeAccountList(), + now: self.generatedAt) + let providers = try self.providers( + identityMode: .full, + claudeSwap: DashboardClaudeSwapInput(accounts: accounts, adapterError: nil, weeklyWorkDays: nil)) + let claude = try #require(providers.first { $0["id"] as? String == "claude" }) + let rows = try #require(claude["accounts"] as? [[String: Any]]) + let emails = rows.compactMap { ($0["identity"] as? [String: Any])?["accountEmail"] as? String } + #expect(emails == ["personal@personal.example", "work@example.com", "third@example.net"]) + } + + @Test + func `dashboard identity flag decodes redacted full and rejects others`() { + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: nil)) == .redacted) + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: "redacted")) == .redacted) + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: "full")) == .full) + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: "FULL")) == .full) + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: "none")) == nil) + #expect(CodexBarCLI.decodeDashboardIdentityMode(from: self.parsedValues(identity: "bogus")) == nil) + } + + private func parsedValues(identity: String?) -> ParsedValues { + ParsedValues( + positional: [], + options: identity.map { ["identity": [$0]] } ?? [:], + flags: []) + } + @Test func `per account failure keeps healthy siblings and ambient row intact`() throws { let list = ClaudeSwapAccountList(