diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index f6b7aa061..4692ab925 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -88,7 +88,7 @@ enum CodexBarCLI { signature: costSignature), CommandDescriptor( name: "serve", - abstract: "Serve usage and cost JSON over localhost HTTP", + abstract: "Serve usage, cost, and dashboard JSON over HTTP", discussion: nil, signature: serveSignature), CommandDescriptor( diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 083259487..c3f87cd3d 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -77,13 +77,17 @@ extension CodexBarCLI { CodexBar \(version) Usage: - codexbar serve [--port ] [--refresh-interval ] + codexbar serve [--host ] [--port ] [--refresh-interval ] + [--dashboard-token ] [--dashboard-identity none|redacted|full] [--json-output] [--log-level ] [-v|--verbose] Description: - Start a foreground localhost-only HTTP server that exposes existing CLI JSON payloads. - The server binds to 127.0.0.1 only in this initial version. + Start a foreground HTTP server that exposes CLI JSON payloads and a stable dashboard snapshot. + By default the server binds to 127.0.0.1 only. Non-loopback --host values require + --dashboard-token, and data routes require Authorization: Bearer YOUR_TOKEN when configured. + Dashboard identity exposure defaults to redacted. + Redacted identity keeps plan labels and email domains, but hides email local parts. Endpoints: GET /health @@ -92,11 +96,15 @@ extension CodexBarCLI { GET /usage?provider=all GET /cost GET /cost?provider=codex + GET /dashboard/v1/snapshot Examples: codexbar serve codexbar serve --port 8080 --refresh-interval 60 + codexbar serve --host 0.0.0.0 --port 8080 --dashboard-token "$CODEXBAR_DASHBOARD_TOKEN" curl http://127.0.0.1:8080/usage?provider=all + curl -H "Authorization: Bearer $CODEXBAR_DASHBOARD_TOKEN" \\ + http://127.0.0.1:8080/dashboard/v1/snapshot """ } @@ -186,7 +194,8 @@ extension CodexBarCLI { [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] - codexbar serve [--port ] [--refresh-interval ] + codexbar serve [--host ] [--port ] [--refresh-interval ] + [--dashboard-token ] [--dashboard-identity none|redacted|full] [--json-output] [--log-level ] [-v|--verbose] codexbar config [--format text|json] [--json] diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift index 65a486db4..82b154b09 100644 --- a/Sources/CodexBarCLI/CLILocalHTTPServer.swift +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -7,14 +7,18 @@ import Glibc private let requestReadTimeoutMilliseconds: Int32 = 5000 -struct CLILocalHTTPRequest { +struct CLILocalHTTPRequest: Sendable { let method: String let target: String let host: String let path: String let queryItems: [String: String] + let headers: [String: String] - static func parse(_ data: Data) -> Result { + static func parse( + _ data: Data, + allowNonLoopbackHost: Bool = false) -> Result + { guard let raw = String(data: data, encoding: .utf8), let firstLine = raw.components(separatedBy: "\r\n").first else { @@ -29,15 +33,19 @@ struct CLILocalHTTPRequest { guard target.hasPrefix("/") else { return .failure(.invalidRequest) } let headerResult = Self.parseHeaders(raw) + let headerPairs: [(String, String)] let host: String switch headerResult { - case let .success(headers): - let hosts = headers.compactMap { name, value in + case let .success(parsedHeaders): + headerPairs = parsedHeaders + let hosts = parsedHeaders.compactMap { name, value in name.lowercased() == "host" ? value : nil } guard let candidate = hosts.first else { return .failure(.missingHost) } guard hosts.count == 1 else { return .failure(.duplicateHost) } - guard Self.isAllowedLoopbackHost(candidate) else { return .failure(.disallowedHost) } + guard Self.isAllowedHost(candidate, allowNonLoopbackHost: allowNonLoopbackHost) else { + return .failure(.disallowedHost) + } host = candidate case let .failure(error): return .failure(error) @@ -52,12 +60,18 @@ struct CLILocalHTTPRequest { } } + var headers: [String: String] = [:] + for (name, value) in headerPairs { + headers[name.lowercased()] = value + } + return .success(CLILocalHTTPRequest( method: method, target: target, host: host, path: path, - queryItems: queryItems)) + queryItems: queryItems, + headers: headers)) } private static func parseHeaders(_ raw: String) -> Result<[(String, String)], CLILocalHTTPRequestParseError> { @@ -78,7 +92,7 @@ struct CLILocalHTTPRequest { return .success(headers) } - private static func isAllowedLoopbackHost(_ host: String) -> Bool { + private static func isAllowedHost(_ host: String, allowNonLoopbackHost: Bool) -> Bool { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !trimmed.contains(",") else { return false } @@ -105,7 +119,7 @@ struct CLILocalHTTPRequest { case "127.0.0.1", "localhost", "localhost.", "[::1]": return true default: - return false + return allowNonLoopbackHost } } @@ -127,10 +141,11 @@ enum CLILocalHTTPRequestParseError: Error, Equatable { case disallowedHost } -enum CLIHTTPStatus { +enum CLIHTTPStatus: Sendable { case ok case badRequest case forbidden + case unauthorized case notFound case methodNotAllowed case internalServerError @@ -140,6 +155,7 @@ enum CLIHTTPStatus { case .ok: 200 case .badRequest: 400 case .forbidden: 403 + case .unauthorized: 401 case .notFound: 404 case .methodNotAllowed: 405 case .internalServerError: 500 @@ -151,6 +167,7 @@ enum CLIHTTPStatus { case .ok: "OK" case .badRequest: "Bad Request" case .forbidden: "Forbidden" + case .unauthorized: "Unauthorized" case .notFound: "Not Found" case .methodNotAllowed: "Method Not Allowed" case .internalServerError: "Internal Server Error" @@ -158,7 +175,7 @@ enum CLIHTTPStatus { } } -struct CLILocalHTTPResponse { +struct CLILocalHTTPResponse: Sendable { let status: CLIHTTPStatus let body: Data let contentType: String @@ -187,11 +204,18 @@ final class CLILocalHTTPServer { private let host: String private let port: UInt16 + private let allowNonLoopbackHostHeaders: Bool private let handler: Handler - init(host: String, port: UInt16, handler: @escaping Handler) { + init( + host: String, + port: UInt16, + allowNonLoopbackHostHeaders: Bool = false, + handler: @escaping Handler) + { self.host = host self.port = port + self.allowNonLoopbackHostHeaders = allowNonLoopbackHostHeaders self.handler = handler } @@ -248,9 +272,13 @@ final class CLILocalHTTPServer { let clientFD = accept(serverFD, &clientAddress, &clientLength) guard clientFD >= 0 else { continue } let handler = self.handler + let allowNonLoopbackHostHeaders = self.allowNonLoopbackHostHeaders Task { defer { closeSocket(clientFD) } - await handleClient(clientFD, handler: handler) + await handleClient( + clientFD, + allowNonLoopbackHostHeaders: allowNonLoopbackHostHeaders, + handler: handler) } } } @@ -258,10 +286,11 @@ final class CLILocalHTTPServer { private func handleClient( _ clientFD: Int32, + allowNonLoopbackHostHeaders: Bool, handler: @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse) async { let request: CLILocalHTTPRequest - switch readRequest(clientFD) { + switch readRequest(clientFD, allowNonLoopbackHostHeaders: allowNonLoopbackHostHeaders) { case let .success(parsedRequest): request = parsedRequest case .failure(.disallowedHost): @@ -284,7 +313,10 @@ private func handleClient( sendResponse(response, to: clientFD) } -private func readRequest(_ fd: Int32) -> Result { +private func readRequest( + _ fd: Int32, + allowNonLoopbackHostHeaders: Bool) -> Result +{ var data = Data() var buffer = [UInt8](repeating: 0, count: 4096) let bufferSize = buffer.count @@ -306,7 +338,7 @@ private func readRequest(_ fd: Int32) -> Result Bool { + guard let token else { return true } + guard let authorization = request.headers["authorization"] else { return false } + return authorization.trimmingCharacters(in: .whitespacesAndNewlines) == "Bearer \(token)" + } + + func authorizeDashboardRequest(_ request: CLILocalHTTPRequest) -> Bool { + self.authorizeDataRequest(request) + } +} + +enum CLIServeSecurity { + static func bindHost(_ host: String) -> String { + host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "localhost" ? "127.0.0.1" : host + } + + static func isLoopbackHost(_ host: String) -> Bool { + let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "localhost" || normalized == "::1" { return true } + if normalized.hasPrefix("127.") { return true } + return normalized == "0:0:0:0:0:0:0:1" + } + + static func requiresDashboardToken(host: String) -> Bool { + !self.isLoopbackHost(host) + } +} diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index f5eb9fc86..15e61b74c 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -15,14 +15,24 @@ struct ServeOptions: CommanderParsable { @Option(name: .long("port"), help: "Local HTTP port (default: 8080)") var port: Int? + @Option(name: .long("host"), help: "HTTP bind host (default: 127.0.0.1)") + var host: String? + @Option(name: .long("refresh-interval"), help: "Response cache TTL in seconds (default: 60)") var refreshInterval: Double? + + @Option(name: .long("dashboard-token"), help: "Bearer token for serve data routes") + var dashboardToken: String? + + @Option(name: .long("dashboard-identity"), help: "Dashboard identity exposure: none | redacted | full") + var dashboardIdentity: String? } enum CLIServeRoute: Equatable { case health case usage(provider: String?) case cost(provider: String?) + case dashboardSnapshot } enum CLIServeRouteError: Error, Equatable { @@ -46,6 +56,8 @@ enum CLIServeRouter { return .usage(provider: normalizedProvider) case "/cost": return .cost(provider: normalizedProvider) + case "/dashboard/v1/snapshot": + return .dashboardSnapshot default: throw CLIServeRouteError.notFound } @@ -83,17 +95,59 @@ private actor CLIServeResponseCache { } } +actor CLIServeDashboardSnapshotCache { + private struct Entry { + let cacheKey: String + let expiresAt: Date + let response: CLILocalHTTPResponse + } + + private var entry: Entry? + private var refreshingKeys: Set = [] + + func response(for cacheKey: String, now: Date) -> CLILocalHTTPResponse? { + guard let entry, entry.cacheKey == cacheKey, entry.expiresAt > now else { return nil } + return entry.response + } + + func staleResponse(for cacheKey: String) -> CLILocalHTTPResponse? { + guard let entry, entry.cacheKey == cacheKey else { return nil } + return entry.response + } + + func beginRefresh(for cacheKey: String) -> Bool { + guard !self.refreshingKeys.contains(cacheKey) else { return false } + self.refreshingKeys.insert(cacheKey) + return true + } + + func finishRefresh(response: CLILocalHTTPResponse, for cacheKey: String, ttl: TimeInterval, now: Date) { + self.refreshingKeys.remove(cacheKey) + guard ttl > 0, response.status == .ok else { return } + self.entry = Entry(cacheKey: cacheKey, expiresAt: now.addingTimeInterval(ttl), response: response) + } +} + private enum CLIServeArgumentError: LocalizedError { + case invalidHost case invalidPort case invalidRefreshInterval + case invalidDashboardIdentity + case missingDashboardToken(String) case invalidProvider(String) var errorDescription: String? { switch self { + case .invalidHost: + "--host must not be empty." case .invalidPort: "--port must be between 1 and 65535." case .invalidRefreshInterval: "--refresh-interval must be zero or greater." + case .invalidDashboardIdentity: + "--dashboard-identity must be none, redacted, or full." + case let .missingDashboardToken(host): + "--dashboard-token is required when --host is non-loopback ('\(host)')." case let .invalidProvider(provider): "Unknown provider '\(provider)'." } @@ -104,7 +158,10 @@ extension CodexBarCLI { static func runServe(_ values: ParsedValues) async { let output = CLIOutputPreferences(format: .json, jsonOnly: true, pretty: false) let port = Self.decodeServePort(from: values) + let host = Self.decodeServeHost(from: values) let refreshInterval = Self.decodeServeRefreshInterval(from: values) + let dashboardToken = Self.decodeDashboardToken(from: values) + let dashboardIdentity = Self.decodeDashboardIdentity(from: values) guard let port else { Self.exit( @@ -114,6 +171,14 @@ extension CodexBarCLI { kind: .args) } + guard let host else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidHost.localizedDescription, + output: output, + kind: .args) + } + guard let refreshInterval else { Self.exit( code: .failure, @@ -122,25 +187,57 @@ extension CodexBarCLI { kind: .args) } - let config = Self.loadConfig(output: output) + guard let dashboardIdentity else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidDashboardIdentity.localizedDescription, + output: output, + kind: .args) + } + + if CLIServeSecurity.requiresDashboardToken(host: host), dashboardToken == nil { + Self.exit( + code: .failure, + message: CLIServeArgumentError.missingDashboardToken(host).localizedDescription, + output: output, + kind: .args) + } + let cache = CLIServeResponseCache() - let server = CLILocalHTTPServer(host: "127.0.0.1", port: port) { request in + let dashboardCache = CLIServeDashboardSnapshotCache() + let auth = CLIServeAuth(dashboardToken: dashboardToken) + let bindHost = CLIServeSecurity.bindHost(host) + let allowNonLoopbackHostHeaders = !CLIServeSecurity.isLoopbackHost(host) + let server = CLILocalHTTPServer( + host: bindHost, + port: port, + allowNonLoopbackHostHeaders: allowNonLoopbackHostHeaders) + { request in await Self.handleServeRequest( request, - config: config, + output: output, cache: cache, - refreshInterval: refreshInterval) + dashboardCache: dashboardCache, + refreshInterval: refreshInterval, + auth: auth, + dashboardIdentity: dashboardIdentity) } do { try await server.run { - Self.writeStderr("CodexBar server listening on http://127.0.0.1:\(port)\n") + Self.writeStderr("CodexBar server listening on http://\(bindHost):\(port)\n") } } catch { Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .runtime) } } + static func decodeServeHost(from values: ParsedValues) -> String? { + let raw = values.options["host"]?.last ?? "127.0.0.1" + let host = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return host.isEmpty ? nil : host + } + static func decodeServePort(from values: ParsedValues) -> UInt16? { let raw = values.options["port"]?.last let parsed: Int @@ -163,15 +260,34 @@ extension CodexBarCLI { } else { parsed = 60 } - guard parsed >= 0 else { return nil } + guard parsed.isFinite, parsed >= 0 else { return nil } + + let staleBase = parsed.rounded(.up) + let refreshSeconds = parsed.rounded() + guard staleBase <= Double(Int.max / 3), refreshSeconds <= Double(Int.max) else { return nil } return parsed } + static func decodeDashboardToken(from values: ParsedValues) -> String? { + guard let raw = values.options["dashboardToken"]?.last else { return nil } + let token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } + + static func decodeDashboardIdentity(from values: ParsedValues) -> DashboardIdentityMode? { + let raw = values.options["dashboardIdentity"]?.last ?? DashboardIdentityMode.redacted.rawValue + return DashboardIdentityMode(rawValue: raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + // swiftlint:disable:next function_parameter_count private static func handleServeRequest( _ request: CLILocalHTTPRequest, - config: CodexBarConfig, + output: CLIOutputPreferences, cache: CLIServeResponseCache, - refreshInterval: TimeInterval) async -> CLILocalHTTPResponse + dashboardCache: CLIServeDashboardSnapshotCache, + refreshInterval: TimeInterval, + auth: CLIServeAuth, + dashboardIdentity: DashboardIdentityMode) async -> CLILocalHTTPResponse { let route: CLIServeRoute do { @@ -189,21 +305,41 @@ extension CodexBarCLI { case .health: return Self.serveJSON(ServeHealthPayload(status: "ok")) case let .usage(provider): + guard auth.authorizeDataRequest(request) else { + return Self.serveError(status: .unauthorized, message: "unauthorized") + } + let config = Self.loadConfig(output: output) + let configKey = Self.serveConfigCacheKey(config) return await Self.cachedServeResponse( - key: "usage:\(provider ?? "")", + key: "usage:\(configKey):\(provider ?? "")", cache: cache, refreshInterval: refreshInterval) { await Self.serveUsage(provider: provider, config: config) } case let .cost(provider): + guard auth.authorizeDataRequest(request) else { + return Self.serveError(status: .unauthorized, message: "unauthorized") + } + let config = Self.loadConfig(output: output) + let configKey = Self.serveConfigCacheKey(config) return await Self.cachedServeResponse( - key: "cost:\(provider ?? "")", + key: "cost:\(configKey):\(provider ?? "")", cache: cache, refreshInterval: refreshInterval) { await Self.serveCost(provider: provider, config: config) } + case .dashboardSnapshot: + guard auth.authorizeDataRequest(request) else { + return Self.serveError(status: .unauthorized, message: "unauthorized") + } + let config = Self.loadConfig(output: output) + return await Self.serveCachedDashboardSnapshot( + config: config, + cache: dashboardCache, + refreshInterval: refreshInterval, + identityMode: dashboardIdentity) } } @@ -227,13 +363,94 @@ extension CodexBarCLI { static func shouldCacheServeResponse(_ response: CLILocalHTTPResponse) -> Bool { guard response.status == .ok else { return false } - guard let payload = try? JSONSerialization.jsonObject(with: response.body) as? [[String: Any]] else { + guard let payload = try? JSONSerialization.jsonObject(with: response.body) else { return true } - return !payload.contains { item in - guard let error = item["error"] else { return false } - return !(error is NSNull) + if let providers = payload as? [[String: Any]] { + return !providers.contains { item in + guard let error = item["error"] else { return false } + return !(error is NSNull) + } } + return true + } + + static func serveConfigCacheKey(_ config: CodexBarConfig) -> String { + config.enabledProviders().map(\.rawValue).joined(separator: ",") + } + + static func serveCachedDashboardSnapshot( + config: CodexBarConfig, + cache: CLIServeDashboardSnapshotCache, + refreshInterval: TimeInterval, + identityMode: DashboardIdentityMode) async -> CLILocalHTTPResponse + { + let cacheKey = "\(identityMode.rawValue):\(Self.serveConfigCacheKey(config))" + if let response = await cache.response(for: cacheKey, now: Date()) { + return response + } + + await Self.startDashboardSnapshotRefresh( + config: config, + cache: cache, + cacheKey: cacheKey, + refreshInterval: refreshInterval, + identityMode: identityMode) + + if let response = await cache.staleResponse(for: cacheKey) { + return response + } + + return Self.serveJSON(Self.makeDashboardRefreshingSnapshot( + config: config, + refreshInterval: refreshInterval, + identityMode: identityMode)) + } + + private static func startDashboardSnapshotRefresh( + config: CodexBarConfig, + cache: CLIServeDashboardSnapshotCache, + cacheKey: String, + refreshInterval: TimeInterval, + identityMode: DashboardIdentityMode) async + { + guard await cache.beginRefresh(for: cacheKey) else { return } + Task { + let response = await Self.serveDashboardSnapshot( + config: config, + refreshInterval: refreshInterval, + identityMode: identityMode) + await cache.finishRefresh(response: response, for: cacheKey, ttl: refreshInterval, now: Date()) + } + } + + static func makeDashboardRefreshingSnapshot( + config: CodexBarConfig, + refreshInterval: TimeInterval, + identityMode: DashboardIdentityMode) -> DashboardSnapshotPayload + { + let generatedAt = Date() + let providers = config.enabledProviders().map { provider in + ProviderPayload( + provider: provider, + account: nil, + version: nil, + source: "refreshing", + status: nil, + usage: nil, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: ProviderErrorPayload(code: 0, message: "refreshing", kind: .provider)) + } + return DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: providers, + costPayloads: [], + config: config, + identityMode: identityMode, + generatedAt: generatedAt, + refreshInterval: refreshInterval, + codexBarVersion: Self.currentVersion()) } private static func serveUsage( @@ -317,6 +534,100 @@ extension CodexBarCLI { return Self.serveJSON(payload) } + private static func serveDashboardSnapshot( + config: CodexBarConfig, + refreshInterval: TimeInterval, + identityMode: DashboardIdentityMode) async -> CLILocalHTTPResponse + { + let selection = Self.providerSelection(rawOverride: nil, enabled: config.enabledProviders()) + let usagePayloads = await Self.dashboardUsagePayloads(selection: selection, config: config) + let costPayloads = await Self.dashboardCostPayloads(selection: selection) + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: usagePayloads, + costPayloads: costPayloads, + config: config, + identityMode: identityMode, + generatedAt: Date(), + refreshInterval: refreshInterval, + codexBarVersion: Self.currentVersion()) + + return Self.serveJSON(snapshot) + } + + private static func dashboardUsagePayloads( + selection: ProviderSelection, + config: CodexBarConfig) async -> [ProviderPayload] + { + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + } catch { + return [ProviderPayload( + providerID: "cli", + account: nil, + version: nil, + source: "cli", + status: nil, + usage: nil, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: Self.makeErrorPayload(error, kind: .config))] + } + + let browserDetection = BrowserDetection() + let command = UsageCommandContext( + format: .json, + includeCredits: true, + sourceModeOverride: nil, + antigravityPlanDebug: false, + augmentDebug: false, + webDebugDumpHTML: false, + webTimeout: 60, + verbose: false, + useColor: false, + resetStyle: Self.resetTimeDisplayStyleFromDefaults(), + jsonOnly: true, + includeAllCodexAccounts: false, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + var output = UsageCommandOutput() + for provider in selection.asList { + let status = await Self.fetchStatus(for: provider) + let providerOutput = await ProviderInteractionContext.$current.withValue(.background) { + await Self.fetchUsageOutputs( + provider: provider, + status: status, + tokenContext: tokenContext, + command: command) + } + output.merge(providerOutput) + } + return output.payload + } + + private static func dashboardCostPayloads(selection: ProviderSelection) async -> [CostPayload] { + let providers = Self.costProviders(from: selection) + guard !providers.isEmpty else { return [] } + + let fetcher = CostUsageFetcher() + var payload: [CostPayload] = [] + for provider in providers { + do { + let snapshot = try await fetcher.loadTokenSnapshot(provider: provider, forceRefresh: false) + payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil)) + } catch { + payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) + } + } + return payload + } + private static func serveProviderSelection( rawProvider: String?, config: CodexBarConfig) throws -> ProviderSelection diff --git a/Sources/CodexBarCLI/DashboardPayloads.swift b/Sources/CodexBarCLI/DashboardPayloads.swift new file mode 100644 index 000000000..e7a664558 --- /dev/null +++ b/Sources/CodexBarCLI/DashboardPayloads.swift @@ -0,0 +1,165 @@ +import CodexBarCore +import Foundation + +enum DashboardIdentityMode: String, Equatable, Sendable { + case none + case redacted + case full +} + +struct DashboardSnapshotPayload: Encodable { + let schemaVersion: Int + let generatedAt: Date + let staleAfterSeconds: Int + let host: DashboardHostPayload + let providers: [DashboardProviderPayload] +} + +struct DashboardHostPayload: Encodable { + let codexBarVersion: String? + let refreshIntervalSeconds: Int + + private enum CodingKeys: String, CodingKey { + case codexBarVersion + case refreshIntervalSeconds + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.codexBarVersion, forKey: .codexBarVersion) + try container.encode(self.refreshIntervalSeconds, forKey: .refreshIntervalSeconds) + } +} + +struct DashboardProviderPayload: Encodable { + let id: String + let name: String + let enabled: Bool + let source: String + let status: DashboardStatusPayload? + let identity: DashboardIdentityPayload? + let windows: [DashboardWindowPayload] + let credits: DashboardCreditsPayload? + let cost: DashboardCostPayload? + let display: DashboardDisplayPayload + let error: ProviderErrorPayload? + let updatedAt: Date? + + private enum CodingKeys: String, CodingKey { + case id + case name + case enabled + case source + case status + case identity + case windows + case credits + case cost + case display + case error + case updatedAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.name, forKey: .name) + try container.encode(self.enabled, forKey: .enabled) + try container.encode(self.source, forKey: .source) + try container.encode(self.status, forKey: .status) + try container.encode(self.identity, forKey: .identity) + try container.encode(self.windows, forKey: .windows) + try container.encode(self.credits, forKey: .credits) + try container.encode(self.cost, forKey: .cost) + try container.encode(self.display, forKey: .display) + try container.encode(self.error, forKey: .error) + try container.encode(self.updatedAt, forKey: .updatedAt) + } +} + +struct DashboardStatusPayload: Encodable { + let level: String + let label: String + let updatedAt: Date? + + private enum CodingKeys: String, CodingKey { + case level + case label + case updatedAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.level, forKey: .level) + try container.encode(self.label, forKey: .label) + try container.encode(self.updatedAt, forKey: .updatedAt) + } +} + +struct DashboardIdentityPayload: Encodable { + let accountEmail: String? + let plan: String? + + private enum CodingKeys: String, CodingKey { + case accountEmail + case plan + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.accountEmail, forKey: .accountEmail) + try container.encode(self.plan, forKey: .plan) + } +} + +struct DashboardWindowPayload: Encodable { + let kind: String + let label: String + let usedPercent: Double + let remainingPercent: Double + let resetAt: Date? + + private enum CodingKeys: String, CodingKey { + case kind + case label + case usedPercent + case remainingPercent + case resetAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.kind, forKey: .kind) + try container.encode(self.label, forKey: .label) + try container.encode(self.usedPercent, forKey: .usedPercent) + try container.encode(self.remainingPercent, forKey: .remainingPercent) + try container.encode(self.resetAt, forKey: .resetAt) + } +} + +struct DashboardCreditsPayload: Encodable { + let remaining: Double + let unit: String +} + +struct DashboardCostPayload: Encodable { + let todayUSD: Double? + let last30DaysUSD: Double? + + private enum CodingKeys: String, CodingKey { + case todayUSD + case last30DaysUSD + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.todayUSD, forKey: .todayUSD) + try container.encode(self.last30DaysUSD, forKey: .last30DaysUSD) + } +} + +struct DashboardDisplayPayload: Encodable { + let accentColor: String + let sortKey: Int + let priority: String +} diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift new file mode 100644 index 000000000..85da7a5fe --- /dev/null +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -0,0 +1,254 @@ +import CodexBarCore +import Foundation + +enum DashboardSnapshotBuilder { + // swiftlint:disable:next function_parameter_count + static func makeSnapshot( + usagePayloads: [ProviderPayload], + costPayloads: [CostPayload], + config: CodexBarConfig, + identityMode: DashboardIdentityMode, + generatedAt: Date, + refreshInterval: TimeInterval, + codexBarVersion: String?) -> DashboardSnapshotPayload + { + var costByProvider: [String: CostPayload] = [:] + for cost in costPayloads { + costByProvider[cost.provider] = cost + } + let enabledProviders = Set(config.enabledProviders()) + var sortKeys: [String: Int] = [:] + for (index, provider) in config.orderedProviders().enumerated() where sortKeys[provider.rawValue] == nil { + sortKeys[provider.rawValue] = index * 10 + } + + let providers = usagePayloads.enumerated().map { index, payload in + self.makeProvider( + payload: payload, + cost: costByProvider[payload.provider], + enabledProviders: enabledProviders, + sortKey: sortKeys[payload.provider] ?? (10000 + index), + identityMode: identityMode, + generatedAt: generatedAt) + } + + return DashboardSnapshotPayload( + schemaVersion: 1, + generatedAt: generatedAt, + staleAfterSeconds: max(180, Int(refreshInterval.rounded(.up)) * 3), + host: DashboardHostPayload( + codexBarVersion: codexBarVersion, + refreshIntervalSeconds: max(0, Int(refreshInterval.rounded()))), + providers: providers) + } + + // swiftlint:disable:next function_parameter_count + private static func makeProvider( + payload: ProviderPayload, + cost: CostPayload?, + enabledProviders: Set, + sortKey: Int, + identityMode: DashboardIdentityMode, + generatedAt: Date) -> DashboardProviderPayload + { + let provider = UsageProvider(rawValue: payload.provider) + let descriptor = provider.map { ProviderDescriptorRegistry.descriptor(for: $0) } + let metadata = descriptor?.metadata + + return DashboardProviderPayload( + id: payload.provider, + name: metadata?.displayName ?? payload.provider, + enabled: provider.map { enabledProviders.contains($0) } ?? true, + source: self.dashboardSource(from: payload.source), + status: self.makeStatus(payload.status), + identity: self.makeIdentity(provider: provider, usage: payload.usage, mode: identityMode), + windows: self.makeWindows(provider: provider, metadata: metadata, usage: payload.usage), + credits: self.makeCredits(payload.credits), + cost: self.makeCost(cost), + display: DashboardDisplayPayload( + accentColor: self.hexColor(descriptor?.branding.color), + sortKey: sortKey, + priority: "normal"), + error: payload.error, + updatedAt: self.updatedAt( + usage: payload.usage, + credits: payload.credits, + cost: cost, + error: payload.error, + generatedAt: generatedAt)) + } + + private static func dashboardSource(from source: String) -> String { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "unknown" : trimmed + } + + private static func makeStatus(_ status: ProviderStatusPayload?) -> DashboardStatusPayload? { + guard let status else { return nil } + return DashboardStatusPayload( + level: self.dashboardStatusLevel(status.indicator), + label: status.indicator.label, + updatedAt: status.updatedAt) + } + + private static func dashboardStatusLevel(_ indicator: ProviderStatusPayload.ProviderStatusIndicator) -> String { + switch indicator { + case .none: + "ok" + case .minor, .maintenance: + "warning" + case .major, .critical: + "critical" + case .unknown: + "unknown" + } + } + + private static func makeIdentity( + provider: UsageProvider?, + usage: UsageSnapshot?, + mode: DashboardIdentityMode) -> DashboardIdentityPayload? + { + guard mode != .none, + let provider, + let identity = usage?.identity(for: provider) + else { + return nil + } + + let email = self.dashboardEmail(identity.accountEmail, mode: mode) + let plan = self.dashboardPlan(identity.loginMethod, provider: provider) + guard email != nil || plan != nil else { return nil } + return DashboardIdentityPayload(accountEmail: email, plan: plan) + } + + private static func dashboardEmail(_ email: String?, mode: DashboardIdentityMode) -> String? { + guard let email = email?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty + else { + return nil + } + guard mode == .redacted else { return email } + guard let at = email.firstIndex(of: "@") else { return "redacted" } + return "redacted\(email[at...])" + } + + private static func dashboardPlan(_ raw: String?, provider: UsageProvider) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return nil + } + + if provider == .codex { + return CodexPlanFormatting.displayName(raw) ?? UsageFormatter.cleanPlanName(raw) + } + if provider == .kilo { + let firstPlanSegment = raw + .components(separatedBy: "ยท") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty && !$0.lowercased().hasPrefix("auto top-up:") } + return firstPlanSegment.map(UsageFormatter.cleanPlanName) + } + return UsageFormatter.cleanPlanName(raw) + } + + private static func makeWindows( + provider: UsageProvider?, + metadata: ProviderMetadata?, + usage: UsageSnapshot?) -> [DashboardWindowPayload] + { + guard let usage else { return [] } + let labels = self.rateWindowLabels(provider: provider, metadata: metadata, usage: usage) + var windows: [DashboardWindowPayload] = [] + + if let primary = usage.primary { + windows.append(self.makeWindow(kind: "session", label: labels.primary, window: primary)) + } + if let secondary = usage.secondary { + windows.append(self.makeWindow(kind: "weekly", label: labels.secondary, window: secondary)) + } + if let tertiary = usage.tertiary { + windows.append(self.makeWindow(kind: "tertiary", label: labels.tertiary, window: tertiary)) + } + for extra in usage.extraRateWindows ?? [] { + windows.append(self.makeWindow(kind: extra.id, label: extra.title, window: extra.window)) + } + + return windows + } + + private struct RateWindowLabels { + let primary: String + let secondary: String + let tertiary: String + } + + private static func rateWindowLabels( + provider: UsageProvider?, + metadata: ProviderMetadata?, + usage: UsageSnapshot) -> RateWindowLabels + { + if provider == .factory, usage.tertiary != nil { + return RateWindowLabels(primary: "5-hour", secondary: "Weekly", tertiary: "Monthly") + } + + return RateWindowLabels( + primary: metadata?.sessionLabel ?? "Session", + secondary: metadata?.weeklyLabel ?? "Weekly", + tertiary: metadata?.opusLabel ?? "Tertiary") + } + + private static func makeWindow(kind: String, label: String, window: RateWindow) -> DashboardWindowPayload { + let used = self.clampedPercent(window.usedPercent) + let remaining = self.clampedPercent(100 - used) + return DashboardWindowPayload( + kind: kind, + label: label, + usedPercent: used, + remainingPercent: remaining, + resetAt: window.resetsAt) + } + + private static func clampedPercent(_ value: Double) -> Double { + min(100, max(0, value)) + } + + private static func makeCredits(_ credits: CreditsSnapshot?) -> DashboardCreditsPayload? { + guard let credits else { return nil } + return DashboardCreditsPayload(remaining: credits.remaining, unit: "credits") + } + + private static func makeCost(_ cost: CostPayload?) -> DashboardCostPayload? { + guard let cost else { return nil } + guard cost.sessionCostUSD != nil || cost.last30DaysCostUSD != nil else { return nil } + return DashboardCostPayload( + todayUSD: cost.sessionCostUSD, + last30DaysUSD: cost.last30DaysCostUSD) + } + + private static func updatedAt( + usage: UsageSnapshot?, + credits: CreditsSnapshot?, + cost: CostPayload?, + error: ProviderErrorPayload?, + generatedAt: Date) -> Date? + { + if let usage { return usage.updatedAt } + if let credits { return credits.updatedAt } + if let cost { return cost.updatedAt } + return error == nil ? nil : generatedAt + } + + private static func hexColor(_ color: ProviderColor?) -> String { + guard let color else { return "#6E6E6E" } + let red = Int((self.clampedColor(color.red) * 255).rounded()) + let green = Int((self.clampedColor(color.green) * 255).rounded()) + let blue = Int((self.clampedColor(color.blue) * 255).rounded()) + return String(format: "#%02X%02X%02X", red, green, blue) + } + + private static func clampedColor(_ value: Double) -> Double { + min(1, max(0, value)) + } +} diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 84149ed5e..b4110763b 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -1,3 +1,4 @@ +import CodexBarCore import Commander import Foundation import Testing @@ -34,6 +35,24 @@ struct CLIServeRouterTests { .duplicateHost) } + @Test + func `local http parser allows non loopback host only when explicitly enabled`() throws { + let raw = "GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 192.168.1.10:8080\r\n\r\n" + + Self.expectParseFailure(raw: raw, .disallowedHost) + + let request = try CLILocalHTTPRequest.parse( + Data(raw.utf8), + allowNonLoopbackHost: true).get() + #expect(request.host == "192.168.1.10:8080") + #expect(request.path == "/dashboard/v1/snapshot") + + Self.expectParseFailure( + raw: "GET /usage HTTP/1.1\r\nHost: 192.168.1.10, evil.test\r\n\r\n", + .disallowedHost, + allowNonLoopbackHost: true) + } + @Test func `routes health usage and cost endpoints`() throws { #expect(try CLIServeRouter.route(method: "GET", path: "/health", queryItems: [:]) == .health) @@ -48,6 +67,11 @@ struct CLIServeRouterTests { method: "GET", path: "/cost", queryItems: ["provider": "codex"]) == .cost(provider: "codex")) + #expect( + try CLIServeRouter.route( + method: "GET", + path: "/dashboard/v1/snapshot", + queryItems: [:]) == .dashboardSnapshot) } @Test @@ -101,12 +125,100 @@ struct CLIServeRouterTests { positional: [], options: ["refreshInterval": ["-1"]], flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: ["refreshInterval": ["1e300"]], + flags: [])) == nil) #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( positional: [], options: [:], flags: [])) == 60) } + @Test + func `serve host and dashboard options parse and validate defaults`() { + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == "127.0.0.1") + #expect(CodexBarCLI.decodeServeHost(from: ParsedValues( + positional: [], + options: ["host": [" "]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeDashboardToken(from: ParsedValues( + positional: [], + options: ["dashboardToken": [" secret "]], + flags: [])) == "secret") + #expect(CodexBarCLI.decodeDashboardToken(from: ParsedValues( + positional: [], + options: ["dashboardToken": [" "]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeDashboardIdentity(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == .redacted) + #expect(CodexBarCLI.decodeDashboardIdentity(from: ParsedValues( + positional: [], + options: ["dashboardIdentity": ["FULL"]], + flags: [])) == .full) + #expect(CodexBarCLI.decodeDashboardIdentity(from: ParsedValues( + positional: [], + options: ["dashboardIdentity": ["invalid"]], + flags: [])) == nil) + + #expect(!CLIServeSecurity.requiresDashboardToken(host: "127.0.0.1")) + #expect(!CLIServeSecurity.requiresDashboardToken(host: "localhost")) + #expect(CLIServeSecurity.requiresDashboardToken(host: "0.0.0.0")) + #expect(CLIServeSecurity.requiresDashboardToken(host: "192.168.1.10")) + } + + @Test + func `request parser captures headers case insensitively`() throws { + let raw = [ + "GET /dashboard/v1/snapshot HTTP/1.1", + "Host: localhost", + "Authorization: Bearer token", + "X-Test: value", + "", + "", + ].joined(separator: "\r\n") + let request = try CLILocalHTTPRequest.parse(Data(raw.utf8)).get() + + #expect(request.method == "GET") + #expect(request.path == "/dashboard/v1/snapshot") + #expect(request.headers["authorization"] == "Bearer token") + #expect(request.headers["x-test"] == "value") + } + + @Test + func `serve auth allows no token and requires matching bearer token when configured`() { + let request = CLILocalHTTPRequest( + method: "GET", + target: "/dashboard/v1/snapshot", + host: "localhost", + path: "/dashboard/v1/snapshot", + queryItems: [:], + headers: ["authorization": "Bearer secret"]) + let missingHeader = CLILocalHTTPRequest( + method: "GET", + target: "/dashboard/v1/snapshot", + host: "localhost", + path: "/dashboard/v1/snapshot", + queryItems: [:], + headers: [:]) + + #expect(CLIServeAuth(dashboardToken: nil).authorizeDataRequest(missingHeader)) + #expect(CLIServeAuth(dashboardToken: "secret").authorizeDataRequest(request)) + #expect(!CLIServeAuth(dashboardToken: "secret").authorizeDataRequest(missingHeader)) + #expect(!CLIServeAuth(dashboardToken: "secret").authorizeDataRequest(CLILocalHTTPRequest( + method: "GET", + target: "/dashboard/v1/snapshot", + host: "localhost", + path: "/dashboard/v1/snapshot", + queryItems: [:], + headers: ["authorization": "Bearer wrong"]))) + } + @Test func `serve cache skips provider error payloads`() { let success = CLILocalHTTPResponse( @@ -122,6 +234,69 @@ struct CLIServeRouterTests { #expect(CodexBarCLI.shouldCacheServeResponse(success)) #expect(!CodexBarCLI.shouldCacheServeResponse(providerError)) #expect(!CodexBarCLI.shouldCacheServeResponse(routeError)) + + let dashboardSuccess = CLILocalHTTPResponse( + status: .ok, + body: Data(#"{"providers":[{"id":"codex","error":null}]}"#.utf8)) + let dashboardProviderError = CLILocalHTTPResponse( + status: .ok, + body: Data(#"{"providers":[{"id":"codex","error":{"message":"temporary"}}]}"#.utf8)) + + #expect(CodexBarCLI.shouldCacheServeResponse(dashboardSuccess)) + #expect(CodexBarCLI.shouldCacheServeResponse(dashboardProviderError)) + } + + @Test + func `dashboard snapshot cache keeps stale response while expired`() async { + let cache = CLIServeDashboardSnapshotCache() + let response = CLILocalHTTPResponse(status: .ok, body: Data(#"{"schemaVersion":1}"#.utf8)) + let now = Date(timeIntervalSince1970: 100) + + await cache.finishRefresh(response: response, for: "codex", ttl: 10, now: now) + + #expect(await cache.response(for: "codex", now: Date(timeIntervalSince1970: 105))?.body == response.body) + #expect(await cache.response(for: "codex", now: Date(timeIntervalSince1970: 111)) == nil) + #expect(await cache.staleResponse(for: "codex")?.body == response.body) + #expect(await cache.staleResponse(for: "claude") == nil) + } + + @Test + func `dashboard refreshing snapshot describes enabled providers without fetching`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .codex, enabled: true), + ProviderConfig(id: .claude, enabled: false), + ]) + + let snapshot = CodexBarCLI.makeDashboardRefreshingSnapshot( + config: config, + refreshInterval: 60, + identityMode: .redacted) + let json = try #require(CodexBarCLI.encodeJSON(snapshot, pretty: false)) + let data = try #require(json.data(using: .utf8)) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let providers = try #require(object["providers"] as? [[String: Any]]) + let provider = try #require(providers.first) + let error = try #require(provider["error"] as? [String: Any]) + + #expect(providers.count == 1) + #expect(provider["id"] as? String == "codex") + #expect(provider["source"] as? String == "refreshing") + #expect(error["message"] as? String == "refreshing") + } + + @Test + func `serve config cache key follows enabled provider set`() { + let initial = CodexBarConfig(providers: [ + ProviderConfig(id: .codex, enabled: true), + ProviderConfig(id: .claude, enabled: false), + ]) + let changed = CodexBarConfig(providers: [ + ProviderConfig(id: .codex, enabled: true), + ProviderConfig(id: .claude, enabled: true), + ]) + + #expect(CodexBarCLI.serveConfigCacheKey(initial) == "codex") + #expect(CodexBarCLI.serveConfigCacheKey(changed) == "codex,claude") } private static func parsedRequest(host: String) throws -> CLILocalHTTPRequest { @@ -129,8 +304,12 @@ struct CLIServeRouterTests { return try CLILocalHTTPRequest.parse(Data(raw.utf8)).get() } - private static func expectParseFailure(raw: String, _ expected: CLILocalHTTPRequestParseError) { - switch CLILocalHTTPRequest.parse(Data(raw.utf8)) { + private static func expectParseFailure( + raw: String, + _ expected: CLILocalHTTPRequestParseError, + allowNonLoopbackHost: Bool = false) + { + switch CLILocalHTTPRequest.parse(Data(raw.utf8), allowNonLoopbackHost: allowNonLoopbackHost) { case .success: Issue.record("Expected \(expected)") case let .failure(error): diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift new file mode 100644 index 000000000..fef302b87 --- /dev/null +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -0,0 +1,194 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +struct DashboardSnapshotBuilderTests { + @Test + func `builds stable display-oriented dashboard snapshot`() throws { + let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_010) + let resetAt = Date(timeIntervalSince1970: 1_800_003_600) + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 28, + windowMinutes: 300, + resetsAt: resetAt, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 59, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro")) + + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "oauth", + status: ProviderStatusPayload( + indicator: .none, + description: "Operational", + updatedAt: updatedAt, + url: "https://status.example.com"), + usage: usage, + credits: CreditsSnapshot(remaining: 112.4, events: [], updatedAt: updatedAt), + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + let cost = CostPayload( + provider: "codex", + source: "local", + updatedAt: updatedAt, + sessionTokens: 1000, + sessionCostUSD: 1.04, + historyDays: 30, + last30DaysTokens: 30000, + last30DaysCostUSD: 18.22, + daily: [], + totals: nil, + error: nil) + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .codex, enabled: true), + ProviderConfig(id: .claude, enabled: false), + ]) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [cost], + config: config, + identityMode: .redacted, + generatedAt: generatedAt, + refreshInterval: 60, + codexBarVersion: "9.8.7") + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let host = try #require(object["host"] as? [String: Any]) + let identity = try #require(provider["identity"] as? [String: Any]) + let status = try #require(provider["status"] as? [String: Any]) + let windows = try #require(provider["windows"] as? [[String: Any]]) + let credits = try #require(provider["credits"] as? [String: Any]) + let costObject = try #require(provider["cost"] as? [String: Any]) + let display = try #require(provider["display"] as? [String: Any]) + + #expect(object["schemaVersion"] as? Int == 1) + #expect(object["staleAfterSeconds"] as? Int == 180) + #expect(host["codexBarVersion"] as? String == "9.8.7") + #expect(host["refreshIntervalSeconds"] as? Int == 60) + + #expect(provider["id"] as? String == "codex") + #expect(provider["name"] as? String == "Codex") + #expect(provider["enabled"] as? Bool == true) + #expect(provider["source"] as? String == "oauth") + #expect(provider["error"] is NSNull) + #expect(provider["updatedAt"] as? String == "2027-01-15T08:00:10Z") + + #expect(status["level"] as? String == "ok") + #expect(status["label"] as? String == "Operational") + #expect(identity["accountEmail"] as? String == "redacted@example.com") + #expect(identity["plan"] as? String == "Pro 20x") + + #expect(windows.count == 2) + #expect(windows[0]["kind"] as? String == "session") + #expect(windows[0]["label"] as? String == "Session") + #expect(windows[0]["usedPercent"] as? Double == 28) + #expect(windows[0]["remainingPercent"] as? Double == 72) + #expect(windows[0]["resetAt"] as? String == "2027-01-15T09:00:00Z") + #expect(windows[1]["kind"] as? String == "weekly") + #expect(windows[1]["label"] as? String == "Weekly") + + #expect(credits["remaining"] as? Double == 112.4) + #expect(credits["unit"] as? String == "credits") + #expect(costObject["todayUSD"] as? Double == 1.04) + #expect(costObject["last30DaysUSD"] as? Double == 18.22) + #expect(display["accentColor"] as? String == "#49A3B0") + #expect(display["sortKey"] as? Int == 0) + #expect(display["priority"] as? String == "normal") + } + + @Test + func `dashboard identity mode none emits null identity`() throws { + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro")) + let payload = ProviderPayload( + provider: .claude, + account: nil, + version: nil, + source: "web", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .claude, enabled: true)]), + identityMode: .none, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + + #expect(provider["identity"] is NSNull) + #expect(provider["status"] is NSNull) + #expect(provider["credits"] is NSNull) + #expect(provider["cost"] is NSNull) + } + + @Test + func `dashboard provider errors are projected without raw usage internals`() throws { + let payload = ProviderPayload( + provider: .codex, + account: nil, + version: nil, + source: "auto", + status: nil, + usage: nil, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: ProviderErrorPayload(code: 1, message: "temporary failure", kind: .provider)) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .codex, enabled: true)]), + identityMode: .redacted, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let error = try #require(provider["error"] as? [String: Any]) + + #expect((provider["windows"] as? [Any])?.isEmpty == true) + #expect(error["message"] as? String == "temporary failure") + #expect(provider["usage"] == nil) + #expect(provider["openaiDashboard"] == nil) + } + + private func jsonObject(_ payload: some Encodable) throws -> [String: Any] { + let json = try #require(CodexBarCLI.encodeJSON(payload, pretty: false)) + let data = try #require(json.data(using: .utf8)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} diff --git a/docs/cli.md b/docs/cli.md index 9e42531c6..5816565e4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,11 +44,21 @@ See `docs/configuration.md` for the schema. - `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. - `--format text|json` (default: text). - `--refresh` ignores cached scans. -- `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. +- `codexbar serve` starts a foreground HTTP server for usage, cost, and dashboard JSON. + - `--host ` defaults to `127.0.0.1`. The default remains localhost-only. - `--port ` defaults to `8080`. - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. - - v1 binds to `127.0.0.1` only and rejects non-loopback `Host` headers. It does not expose remote bind, auth, CORS, TLS, or daemon mode. - - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`. + - `--dashboard-token ` enables bearer auth for serve data routes. Non-loopback hosts, such as + `0.0.0.0` or a LAN address, require this token. + - `--dashboard-identity none|redacted|full` controls identity fields in dashboard payloads. The default is + `redacted`, which redacts account email local parts while still showing plan labels. + `none` omits identity, `redacted` preserves email domains only, and `full` includes full account emails. + - Auth uses `Authorization: Bearer YOUR_TOKEN` for data routes (`/usage`, `/cost`, and `/dashboard/v1/snapshot`) + when a token is configured. `/health` remains unauthenticated. + - The default localhost mode rejects non-loopback `Host` headers. Non-loopback bind hosts opt into LAN `Host` + headers only when `--dashboard-token` is configured. + - No CORS, TLS, or daemon mode is provided. + - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`, `GET /dashboard/v1/snapshot`. - Codex usage responses include every visible Codex account, matching the menu bar switcher. - `codexbar cache clear` clears local CodexBar caches. - `--cookies` removes cached browser-cookie headers from the CodexBar Keychain cache. @@ -109,6 +119,22 @@ payloads include the visible account label in `account`. - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) - `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost` +### Dashboard snapshot payload +`GET /dashboard/v1/snapshot` emits a stable display-oriented object instead of raw CLI provider models. +- Top level: `schemaVersion`, `generatedAt`, `staleAfterSeconds`, `host`, `providers`. +- `host`: `codexBarVersion`, `refreshIntervalSeconds`. +- Each provider: `id`, `name`, `enabled`, `source`, `status`, `identity`, `windows`, `credits`, `cost`, `display`, `error`, `updatedAt`. +- `windows[]`: `kind`, `label`, `usedPercent`, `remainingPercent`, `resetAt`. +- `display`: `accentColor`, `sortKey`, `priority`. + +LAN serving requires an explicit host and dashboard token: + +``` +codexbar serve --host 0.0.0.0 --port 8080 --dashboard-token "$CODEXBAR_DASHBOARD_TOKEN" +curl -H "Authorization: Bearer $CODEXBAR_DASHBOARD_TOKEN" \ + http://127.0.0.1:8080/dashboard/v1/snapshot +``` + ## Example usage ``` codexbar # text, respects app toggles @@ -120,6 +146,7 @@ codexbar cost # local cost usage (default 30-day window + to codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider claude --format json --pretty codexbar serve --port 8080 # localhost HTTP JSON server +codexbar serve --host 0.0.0.0 --dashboard-token "$CODEXBAR_DASHBOARD_TOKEN" COPILOT_API_TOKEN=... codexbar --provider copilot --format json --pretty codexbar --status # include status page indicator/description codexbar --provider codex --source oauth --format json --pretty diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md new file mode 100644 index 000000000..96b61820e --- /dev/null +++ b/docs/dashboard-api.md @@ -0,0 +1,100 @@ +# Dashboard Snapshot API + +`codexbar serve` exposes a versioned dashboard snapshot for clients that need a compact, display-oriented view of CodexBar usage data. + +```text +GET /dashboard/v1/snapshot +``` + +The endpoint is available from the foreground HTTP server started by `codexbar serve`. The default bind host remains `127.0.0.1`. Serving on a non-loopback host requires `--dashboard-token`, and data routes require `Authorization: Bearer YOUR_TOKEN` when a token is configured. `/health` remains unauthenticated. + +Identity exposure is controlled with: + +```text +--dashboard-identity none|redacted|full +``` + +The default is `redacted`. + +Identity modes: + +- `none`: omit the `identity` object entirely. +- `redacted`: include non-secret plan labels and redact account email local parts while preserving domains, for example `redacted@example.com`. Addresses without a domain become `redacted`. +- `full`: include the full account email and plan label. + +## Payload + +The snapshot is a stable display contract, not a raw dump of provider internals. + +```json +{ + "schemaVersion": 1, + "generatedAt": "2026-05-16T12:00:00Z", + "staleAfterSeconds": 180, + "host": { + "codexBarVersion": "0.17.0", + "refreshIntervalSeconds": 60 + }, + "providers": [ + { + "id": "codex", + "name": "Codex", + "enabled": true, + "source": "oauth", + "status": { + "level": "ok", + "label": "Operational", + "updatedAt": "2026-05-16T11:59:00Z" + }, + "identity": { + "accountEmail": "redacted@example.com", + "plan": "Plus" + }, + "windows": [ + { + "kind": "session", + "label": "Session", + "usedPercent": 28, + "remainingPercent": 72, + "resetAt": "2026-05-16T17:15:00Z" + } + ], + "credits": { + "remaining": 112.4, + "unit": "credits" + }, + "cost": { + "todayUSD": 1.04, + "last30DaysUSD": 18.22 + }, + "display": { + "accentColor": "#6E5AFF", + "sortKey": 10, + "priority": "normal" + }, + "error": null, + "updatedAt": "2026-05-16T11:59:45Z" + } + ] +} +``` + +## Fields + +- `schemaVersion`: Dashboard API schema version. +- `generatedAt`: Snapshot generation timestamp. +- `staleAfterSeconds`: Client-side staleness hint. +- `host.codexBarVersion`: CodexBar version when available. +- `host.refreshIntervalSeconds`: Server response cache interval. +- `providers[].id`: Provider identifier. +- `providers[].name`: Provider display name. +- `providers[].enabled`: Whether the provider is enabled in CodexBar config. +- `providers[].source`: Source used for the provider data. +- `providers[].status`: Provider service status when available. +- `providers[].identity`: Account and plan identity according to the configured identity mode. +- `providers[].windows`: Session, weekly, tertiary, or provider-specific rate windows. +- `providers[].credits`: Remaining credits or balance when available. +- `providers[].cost`: Local cost data when available. +- `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.