Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions Sources/CodexBarCLI/CLIDashboardCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -60,7 +66,7 @@ struct DashboardSnapshotProducer: Sendable {
usagePayloads: usageOutput.payload,
costPayloads: costPayloads,
config: config,
identityMode: .redacted,
identityMode: identityMode,
generatedAt: generatedAt,
refreshInterval: refreshInterval,
codexBarVersion: codexBarVersion,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
32 changes: 32 additions & 0 deletions Tests/CodexBarTests/DashboardClaudeSwapSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CodexBarCore
import Commander
import Foundation
import Testing
@testable import CodexBarCLI
Expand Down Expand Up @@ -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(
Expand Down