From 55eb99c0e0735cc90a8b6f0a165306b8e93827ff Mon Sep 17 00:00:00 2001 From: Umut Keltek Date: Wed, 29 Jul 2026 17:18:00 +0300 Subject: [PATCH 1/4] Add one-shot dashboard snapshot command --- Sources/CodexBarCLI/CLIDashboardCommand.swift | 181 ++++++++++++++++++ Sources/CodexBarCLI/CLIEntry.swift | 22 ++- Sources/CodexBarCLI/CLIHelp.swift | 32 ++++ Sources/CodexBarCLI/CLIIO.swift | 2 + Sources/CodexBarCLI/CLIServeCommand.swift | 66 ++----- Tests/CodexBarTests/CLIEntryTests.swift | 101 ++++++++++ .../DashboardSnapshotBuilderTests.swift | 49 +++++ docs/cli.md | 10 + docs/dashboard-api.md | 34 +++- 9 files changed, 442 insertions(+), 55 deletions(-) create mode 100644 Sources/CodexBarCLI/CLIDashboardCommand.swift diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift new file mode 100644 index 0000000000..53dfa9e7a1 --- /dev/null +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -0,0 +1,181 @@ +import CodexBarCore +import Commander +import Foundation + +struct DashboardOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Option( + name: .long("timeout"), + help: "Overall fetch timeout in seconds, 0...86400 (default 30; 0 disables)") + var timeout: Double? +} + +struct DashboardSnapshotResult { + let payload: DashboardSnapshotPayload + let usageCacheKeys: [String?] +} + +/// Collects the stable dashboard-v1 payload independently of its transport. +/// The CLI command encodes it directly while `codexbar serve` wraps it in the +/// existing authenticated HTTP cache. +struct DashboardSnapshotProducer: Sendable { + let collectUsage: @Sendable ([UsageProvider]) async throws -> UsageCommandOutput + let collectCost: @Sendable ([UsageProvider]) async -> [CostPayload] + let now: @Sendable () -> Date + + func collect( + config: CodexBarConfig, + refreshInterval: TimeInterval, + codexBarVersion: String?) async throws -> DashboardSnapshotResult + { + let selection = CodexBarCLI.providerSelection( + rawOverride: nil, + enabled: config.enabledProviders()) + let usageOutput = try await self.collectUsage(selection.asList) + let costPayloads = await self.collectCost(CodexBarCLI.costProviders(from: selection)) + + let payload = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: usageOutput.payload, + costPayloads: costPayloads, + config: config, + identityMode: .redacted, + generatedAt: self.now(), + refreshInterval: refreshInterval, + codexBarVersion: codexBarVersion) + return DashboardSnapshotResult( + payload: payload, + usageCacheKeys: usageOutput.payload.map(\.cacheAccountKey)) + } + + static func live(context: DashboardSnapshotContext) -> Self { + Self( + collectUsage: { providers in + try await CodexBarCLI.serveUsageOutput( + selection: .custom(providers), + context: context.usage) + }, + collectCost: { providers in + let costFetcher = CostUsageFetcher() + return await CodexBarCLI.serveCollectCostPayloads( + providers: providers, + context: context.costCollection) + { provider in + do { + let snapshot = try await costFetcher.loadTokenSnapshot( + provider: provider, + forceRefresh: false, + refreshPricingInBackground: CodexBarCLI.serveCostRefreshesPricingInBackground) + return CodexBarCLI.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) + } catch { + return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: error) + } + } + }, + now: { Date() }) + } +} + +extension CodexBarCLI { + static func runDashboard(_ values: ParsedValues) async { + guard let timeout = decodeDashboardTimeout(from: values) else { + exit( + code: .failure, + message: "--timeout must be a finite number of seconds from 0 through 86400.", + kind: .args) + } + + let configSnapshot: CLIServeConfigSnapshot + do { + configSnapshot = try Self.loadServeConfigSnapshot() + } catch { + Self.exit(code: .failure, message: error.localizedDescription, kind: .config) + } + + let providerOperations = CLIServeOperationCoordinator() + let costOperations = CLIServeOperationCoordinator() + let signalMonitor = CLITerminationSignalMonitor { signalNumber in + CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber) + } + defer { signalMonitor.cancel() } + + let startedAt = ContinuousClock().now + let providerTimeout = Self.serveProviderTimeout(requestTimeout: timeout) + let context = DashboardSnapshotContext( + config: configSnapshot.config, + usage: ServeUsageContext( + config: configSnapshot.config, + configFingerprint: configSnapshot.cacheToken, + refreshInterval: 0, + providerTimeout: providerTimeout, + providerDeadline: Self.serveProviderDeadline( + startedAt: startedAt, + requestTimeout: timeout), + providerOperations: providerOperations, + includeAllCodexAccounts: false, + persistCLISessions: false), + costCollection: ServeCostCollectionContext( + configFingerprint: configSnapshot.cacheToken, + providerTimeout: providerTimeout, + requestDeadline: Self.serveRequestDeadline( + startedAt: startedAt, + requestTimeout: timeout), + now: { ContinuousClock().now }, + providerOperations: costOperations), + codexBarVersion: Self.currentVersion()) + + let result: DashboardSnapshotResult + do { + result = try await DashboardSnapshotProducer.live(context: context).collect( + config: context.config, + refreshInterval: context.usage.refreshInterval, + codexBarVersion: context.codexBarVersion) + } catch { + await Self.shutdownDashboardRuntime( + providerOperations: providerOperations, + costOperations: costOperations) + Self.exit(code: .failure, message: error.localizedDescription) + } + + await Self.shutdownDashboardRuntime( + providerOperations: providerOperations, + costOperations: costOperations) + + guard let json = Self.encodeJSON(result.payload, pretty: values.flags.contains("pretty")) else { + Self.exit(code: .failure, message: "Could not encode dashboard snapshot.") + } + print(json) + } + + static func decodeDashboardTimeout(from values: ParsedValues) -> TimeInterval? { + let raw = values.options["timeout"]?.last ?? String(Int(Self.defaultServeRequestTimeout)) + guard let timeout = TimeInterval(raw), + timeout.isFinite, + timeout >= 0, + timeout <= 86400 + else { + return nil + } + return timeout + } + + private static func shutdownDashboardRuntime( + providerOperations: CLIServeOperationCoordinator, + costOperations: CLIServeOperationCoordinator) async + { + await providerOperations.shutdown() + await costOperations.shutdown() + await ProviderCLISessionLifecycle.shutdownPersistentSessions() + TTYCommandRunner.terminateActiveProcessesForAppShutdown() + } +} diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index a24e4e6e46..9842105367 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -23,6 +23,7 @@ enum CodexBarCLI { let rawArgv = Array(CommandLine.arguments.dropFirst()) let argv = Self.effectiveArgv(rawArgv) let outputPreferences = CLIOutputPreferences.from(argv: argv) + let errorOutputPreferences: CLIOutputPreferences? = argv.first == "dashboard" ? nil : outputPreferences // Fast path: global help/version before building descriptors. if let helpIndex = argv.firstIndex(where: { $0 == "-h" || $0 == "--help" }) { @@ -47,6 +48,8 @@ enum CodexBarCLI { await self.runSessions(invocation.parsedValues) case ["sessions", "focus"]: await self.runSessionsFocus(invocation.parsedValues) + case ["dashboard"]: + await self.runDashboard(invocation.parsedValues) case ["serve"]: await self.runServe(invocation.parsedValues) case let path where path.first == "config": @@ -74,9 +77,13 @@ enum CodexBarCLI { } } catch let error as CommanderProgramError { let exitCode: ExitCode = argv.first == "guard" ? .usage : .failure - Self.exit(code: exitCode, message: error.description, output: outputPreferences, kind: .args) + Self.exit(code: exitCode, message: error.description, output: errorOutputPreferences, kind: .args) } catch { - Self.exit(code: .failure, message: error.localizedDescription, output: outputPreferences, kind: .runtime) + Self.exit( + code: .failure, + message: error.localizedDescription, + output: errorOutputPreferences, + kind: .runtime) } } @@ -101,7 +108,7 @@ enum CodexBarCLI { await self.runCookieRefresh(values) } - private static func commandDescriptors() -> [CommandDescriptor] { + static func commandDescriptors() -> [CommandDescriptor] { let cardsSignature = CommandSignature.describe(CardsOptions()) let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) @@ -162,6 +169,7 @@ enum CodexBarCLI { abstract: "Serve usage, cost, and dashboard JSON over HTTP", discussion: nil, signature: serveSignature), + Self.dashboardCommandDescriptor(), CommandDescriptor( name: "config", abstract: "Config utilities", @@ -250,6 +258,14 @@ enum CodexBarCLI { ] } + private static func dashboardCommandDescriptor() -> CommandDescriptor { + CommandDescriptor( + name: "dashboard", + abstract: "Print a dashboard-v1 snapshot as JSON", + discussion: nil, + signature: CommandSignature.describe(DashboardOptions())) + } + private static func cookieCommandDescriptor() -> CommandDescriptor { CommandDescriptor( name: "cookie", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 3eb27e8bca..beb8420ba1 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -132,6 +132,36 @@ extension CodexBarCLI { """ } + static func dashboardHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar dashboard [--pretty] [--timeout ] + [--json-output] [--log-level ] + [-v|--verbose] + + Description: + Print one dashboard-v1 snapshot as JSON, then exit. Honors enabled providers + in stable order, always redacts account identity, and keeps provider + failures as row-level errors without dropping healthy rows. + Stdout contains only the JSON document; diagnostics are written to stderr. + --timeout accepts 0...86400 seconds and defaults to 30; 0 disables the deadline. + + Global flags: + -h, --help Show help + -V, --version Show version + -v, --verbose Enable verbose logging + --log-level + --json-output Emit machine-readable logs (JSONL) to stderr + + Examples: + codexbar dashboard + codexbar dashboard --pretty + codexbar dashboard --timeout 60 + """ + } + static func serveHelp(version: String) -> String { """ CodexBar \(version) @@ -389,6 +419,7 @@ extension CodexBarCLI { [--days ] [--group-by project] codexbar sessions [--json] [--pretty] codexbar sessions focus + codexbar dashboard [--pretty] [--timeout ] codexbar serve [--host ] [--port ] [--refresh-interval ] [--request-timeout ] [--dashboard-token ] [--allow-plain-http] @@ -428,6 +459,7 @@ extension CodexBarCLI { codexbar cards --brief codexbar cost --provider claude --format json --pretty codexbar sessions --json + codexbar dashboard --pretty codexbar serve --port 8080 codexbar config validate --format json --pretty codexbar config enable --provider grok diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index f9244f1337..3fecf21175 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -33,6 +33,8 @@ extension CodexBarCLI { print(Self.costHelp(version: version)) case "sessions", "focus": print(Self.sessionsHelp(version: version)) + case "dashboard": + print(Self.dashboardHelp(version: version)) case "serve": print(Self.serveHelp(version: version)) case "config", "validate", "dump": diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 5588e4601f..7dcaab7b46 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -148,7 +148,7 @@ struct CLIServeCoordinatedResponse: Sendable { let isCommitted: Bool } -private struct ServeUsageContext: Sendable { +struct ServeUsageContext: Sendable { let config: CodexBarConfig let configFingerprint: String let refreshInterval: TimeInterval @@ -156,6 +156,7 @@ private struct ServeUsageContext: Sendable { let providerDeadline: ContinuousClock.Instant? let providerOperations: CLIServeOperationCoordinator let includeAllCodexAccounts: Bool + let persistCLISessions: Bool init( config: CodexBarConfig, @@ -164,7 +165,8 @@ private struct ServeUsageContext: Sendable { providerTimeout: TimeInterval?, providerDeadline: ContinuousClock.Instant?, providerOperations: CLIServeOperationCoordinator, - includeAllCodexAccounts: Bool = true) + includeAllCodexAccounts: Bool = true, + persistCLISessions: Bool = true) { self.config = config self.configFingerprint = configFingerprint @@ -173,10 +175,11 @@ private struct ServeUsageContext: Sendable { self.providerDeadline = providerDeadline self.providerOperations = providerOperations self.includeAllCodexAccounts = includeAllCodexAccounts + self.persistCLISessions = persistCLISessions } } -private struct ServeDashboardContext: Sendable { +struct DashboardSnapshotContext: Sendable { let config: CodexBarConfig let usage: ServeUsageContext let costCollection: ServeCostCollectionContext @@ -911,7 +914,7 @@ extension CodexBarCLI { cache: runtime.cache, makeResponse: { await Self.serveDashboardSnapshot( - context: ServeDashboardContext( + context: DashboardSnapshotContext( config: snapshot.config, usage: ServeUsageContext( config: snapshot.config, @@ -1100,7 +1103,7 @@ extension CodexBarCLI { usageCacheKeys: output.payload.map(\.cacheAccountKey)) } - private static func serveUsageOutput( + static func serveUsageOutput( selection: ProviderSelection, context: ServeUsageContext) async throws -> UsageCommandOutput { @@ -1127,7 +1130,7 @@ extension CodexBarCLI { fetcher: UsageFetcher(), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection, - persistCLISessions: true, + persistCLISessions: context.persistCLISessions, persistentCLISessionIdleWindow: Self.serveCLISessionIdleWindow( refreshInterval: context.refreshInterval)) @@ -1156,52 +1159,23 @@ extension CodexBarCLI { "\(configFingerprint):codex-accounts=\(includeAllCodexAccounts ? "all" : "selected")" } - /// Builds the token-gated dashboard snapshot. Reuses the same coordinated - /// usage/cost collection as `/usage` and `/cost` — per-provider budgets, - /// in-flight dedup, and config fingerprints all apply unchanged — then - /// projects the results through `DashboardSnapshotBuilder`. - private static func serveDashboardSnapshot(context: ServeDashboardContext) async -> CLILocalHTTPResponse { - let selection = Self.providerSelection( - rawOverride: nil, - enabled: context.config.enabledProviders()) - - let usageOutput: UsageCommandOutput + /// Adapts the shared dashboard snapshot producer to the authenticated HTTP + /// route. Auth, response caching, and `Cache-Control: no-store` remain owned + /// by the surrounding serve request path. + private static func serveDashboardSnapshot(context: DashboardSnapshotContext) async -> CLILocalHTTPResponse { + let result: DashboardSnapshotResult do { - usageOutput = try await Self.serveUsageOutput(selection: selection, context: context.usage) + result = try await DashboardSnapshotProducer.live(context: context).collect( + config: context.config, + refreshInterval: context.usage.refreshInterval, + codexBarVersion: context.codexBarVersion) } catch { return Self.serveError(status: .internalServerError, message: error.localizedDescription) } - let costProviders = Self.costProviders(from: selection) - let fetcher = CostUsageFetcher() - let costPayloads = await Self.serveCollectCostPayloads( - providers: costProviders, - context: context.costCollection) - { provider in - do { - let snapshot = try await fetcher.loadTokenSnapshot( - provider: provider, - forceRefresh: false, - refreshPricingInBackground: Self.serveCostRefreshesPricingInBackground) - return Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) - } catch { - return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) - } - } - - let snapshot = DashboardSnapshotBuilder.makeSnapshot( - usagePayloads: usageOutput.payload, - costPayloads: costPayloads, - config: context.config, - identityMode: .redacted, - generatedAt: Date(), - refreshInterval: context.usage.refreshInterval, - codexBarVersion: context.codexBarVersion) - - // Cache-Control: no-store is applied uniformly at the route level. return Self.serveJSON( - snapshot, - usageCacheKeys: usageOutput.payload.map(\.cacheAccountKey)) + result.payload, + usageCacheKeys: result.usageCacheKeys) } /// Per-provider fetch budget for `/usage` and `/cost`. Finite provider work diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index 3e22667461..6e4d59496e 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -11,6 +11,77 @@ final class CLIEntryTests: XCTestCase { XCTAssertEqual(CodexBarCLI.effectiveArgv(["usage", "--json"]), ["usage", "--json"]) } + func test_rootHelpAdvertisesDashboardSnapshotCommand() { + let help = CodexBarCLI.rootHelp(version: "0.0.0") + + XCTAssertTrue(help.contains("codexbar dashboard [--pretty] [--timeout ]")) + } + + func test_dashboardCommandIsRegisteredAndParsesOptions() throws { + let program = Program(descriptors: CodexBarCLI.commandDescriptors()) + let invocation = try program.resolve(argv: ["dashboard", "--pretty", "--timeout", "45"]) + + XCTAssertEqual(invocation.path, ["dashboard"]) + XCTAssertTrue(invocation.parsedValues.flags.contains("pretty")) + XCTAssertEqual(invocation.parsedValues.options["timeout"], ["45"]) + } + + func test_dashboardTimeoutIsBoundedAndCanBeDisabled() { + XCTAssertEqual( + CodexBarCLI.decodeDashboardTimeout(from: ParsedValues(positional: [], options: [:], flags: [])), + 30) + XCTAssertEqual( + CodexBarCLI.decodeDashboardTimeout( + from: ParsedValues(positional: [], options: ["timeout": ["0"]], flags: [])), + 0) + XCTAssertEqual( + CodexBarCLI.decodeDashboardTimeout( + from: ParsedValues(positional: [], options: ["timeout": ["86400"]], flags: [])), + 86400) + + for value in ["-1", "nan", "inf", "86401"] { + XCTAssertNil(CodexBarCLI.decodeDashboardTimeout( + from: ParsedValues(positional: [], options: ["timeout": [value]], flags: []))) + } + } + + func test_dashboardCommanderErrorsStayOffStdout() throws { + let result = try Self.runCLI(arguments: ["dashboard", "--json"]) + + XCTAssertNotEqual(result.status, 0) + XCTAssertTrue(result.stdout.isEmpty) + XCTAssertFalse(result.stderr.isEmpty) + } + + func test_dashboardCommandPrintsOneSnapshotAndExits() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-dashboard-command-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + var config = CodexBarConfig.makeDefault() + config.providers = config.providers.map { provider in + var disabled = provider + disabled.enabled = false + return disabled + } + let configURL = root.appendingPathComponent("config.json") + try CodexBarConfigStore(fileURL: configURL).save(config) + + let result = try Self.runCLI( + arguments: ["dashboard"], + environment: [CodexBarConfigStore.pathEnvironmentKey: configURL.path]) + XCTAssertEqual(result.status, 0) + XCTAssertEqual(result.stdout.last, 0x0A) + + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: result.stdout) as? [String: Any]) + XCTAssertEqual(object["schemaVersion"] as? Int, 1) + let providers = try XCTUnwrap(object["providers"] as? [[String: Any]]) + XCTAssertTrue(providers.isEmpty) + let host = try XCTUnwrap(object["host"] as? [String: Any]) + XCTAssertEqual(host["refreshIntervalSeconds"] as? Int, 0) + } + func test_decodesFormatFromOptionsAndFlags() { let jsonOption = ParsedValues(positional: [], options: ["format": ["json"]], flags: []) XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: jsonOption), .json) @@ -521,4 +592,34 @@ final class CLIEntryTests: XCTestCase { provider: .factory, environment: [:])) } + + private static var cliExecutableURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(".build/debug/CodexBarCLI") + } + + private static func runCLI( + arguments: [String], + environment: [String: String] = [:]) throws -> (status: Int32, stdout: Data, stderr: Data) + + { + let process = Process() + process.executableURL = Self.cliExecutableURL + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, override in override } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + return ( + process.terminationStatus, + stdout.fileHandleForReading.readDataToEndOfFile(), + stderr.fileHandleForReading.readDataToEndOfFile()) + } } diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift index 60751dbbce..511de4fc41 100644 --- a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -4,6 +4,55 @@ import Testing @testable import CodexBarCLI struct DashboardSnapshotBuilderTests { + @Test + func `producer keeps stable order redaction and partial errors`() async throws { + let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) + let healthy = self.identityPayload(email: "user@example.com") + let failed = 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 rows: [UsageProvider: ProviderPayload] = [.claude: healthy, .codex: failed] + let producer = DashboardSnapshotProducer( + collectUsage: { providers in + var output = UsageCommandOutput() + output.payload = providers.compactMap { rows[$0] } + output.exitCode = .failure + return output + }, + collectCost: { _ in [] }, + now: { generatedAt }) + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .claude, enabled: true), + ProviderConfig(id: .codex, enabled: true), + ]) + + let result = try await producer.collect( + config: config, + refreshInterval: 0, + codexBarVersion: "9.8.7") + let object = try self.jsonObject(result.payload) + let providers = try #require(object["providers"] as? [[String: Any]]) + let error = try #require(providers[0]["error"] as? [String: Any]) + let identity = try #require(providers[1]["identity"] as? [String: Any]) + let codexDisplay = try #require(providers[0]["display"] as? [String: Any]) + let claudeDisplay = try #require(providers[1]["display"] as? [String: Any]) + + #expect(providers.compactMap { $0["id"] as? String } == ["codex", "claude"]) + #expect(identity["accountEmail"] as? String == "redacted@example.com") + #expect(error["message"] as? String == "temporary failure") + #expect(codexDisplay["sortKey"] as? Int == 10) + #expect(claudeDisplay["sortKey"] as? Int == 0) + #expect(object["generatedAt"] as? String == "2027-01-15T08:00:00Z") + } + @Test func `builds stable display-oriented dashboard snapshot`() throws { let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) diff --git a/docs/cli.md b/docs/cli.md index ad834cb7b2..a16003a96b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -70,6 +70,12 @@ See `docs/configuration.md` for the schema. - Kitty, Ghostty, WezTerm, and other truecolor terminals auto-enable enhanced gradients/outlines. - Force enhanced mode elsewhere with `CODEXBAR_CARDS_ENHANCED=1`. - Exit code is non-zero when any provider fetch fails. +- `codexbar dashboard` prints one dashboard-v1 JSON snapshot and exits. + - Honors enabled providers in stable order, carries configured display sort keys, and always redacts account identity. + - Provider failures remain row-level errors alongside healthy rows; a valid partial snapshot exits `0`. + - Stdout contains only the snapshot document. Diagnostics and optional `--json-output` logs go to stderr. + - `--pretty` formats the document. `--timeout ` accepts `0...86400`, defaults to `30`, and uses `0` to disable the command deadline. + - Starts no HTTP server and requires no dashboard bearer token. See `docs/dashboard-api.md` for the shared payload contract. - `codexbar serve` starts a foreground HTTP server for usage and cost JSON plus a token-gated dashboard snapshot. - `--host ` accepts `localhost` or an IPv4 address and defaults to `127.0.0.1`; `localhost` is normalized to `127.0.0.1`. Binding a non-loopback host requires a dashboard token **and** `--allow-plain-http` (see `docs/dashboard-api.md` for the threat model). - `--port ` defaults to `8080`. @@ -180,6 +186,7 @@ codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty codexbar guard --provider codex --min-remaining 20 --window weekly --json codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered) +codexbar dashboard | jq '.providers[] | {id, windows, error}' codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines CODEXBAR_DASHBOARD_TOKEN=YOUR_TOKEN codexbar serve # token-gated dashboard snapshot @@ -288,6 +295,9 @@ Note: Using CLI fallback - 4: CLI timeout - 1: unexpected failure +For `codexbar dashboard`, `0` includes a valid partial snapshot whose provider rows contain errors. The command exits +non-zero only when it cannot produce a valid snapshot document. + ## Notes - CLI uses the config file for enabled providers, ordering, and secrets. - CLI binary discovery checks explicit overrides, captured login PATH, inherited PATH, and known install paths before falling back to an interactive shell probe. diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index 2338be0f7a..9e856d3d84 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -1,24 +1,46 @@ --- -summary: "Dashboard snapshot API for codexbar serve: bearer-token auth, plain-HTTP threat model, and the display-oriented payload contract." +summary: "Dashboard-v1 snapshot contract for one-shot CLI and HTTP clients, including serve auth and transport." read_when: - - "Building a dashboard client against codexbar serve" + - "Building a dashboard or adapter against CodexBar" + - "Using codexbar dashboard from scripts" - "Configuring --dashboard-token, --host, or --allow-plain-http" - "Reviewing the serve auth or transport security model" --- -# Dashboard Snapshot API +# Dashboard v1 Snapshot -`codexbar serve` exposes a versioned, display-oriented snapshot of CodexBar usage data for dashboard clients: +CodexBar exposes one versioned, display-oriented snapshot contract through two transports: + +```bash +# One JSON document on stdout, then exit +codexbar dashboard +``` ```text +# Long-running HTTP endpoint GET /dashboard/v1/snapshot Authorization: Bearer YOUR_TOKEN ``` -The route is gated by a static bearer token and **fails closed**: without a configured token every request answers `401`. The token is only ever read from the `Authorization` header — a query-string parameter named `token` is never accepted. Every response on the dashboard route — including all `401`s and error responses — carries `Cache-Control: no-store`. +Both transports use the same producer and schema-v1 payload. The one-shot command starts no server and needs no token. + +The HTTP route is gated by a static bearer token and **fails closed**: without a configured token every request answers `401`. The token is only ever read from the `Authorization` header — a query-string parameter named `token` is never accepted. Every response on the dashboard route — including all `401`s and error responses — carries `Cache-Control: no-store`. On the default loopback bind, `/usage` and `/cost` are unchanged and unauthenticated. On a **non-loopback** bind the same token gates **all data routes**: `/usage`, `/cost`, and `/dashboard/v1/snapshot` each require `Authorization: Bearer YOUR_TOKEN`, so account data never leaves the machine unauthenticated. `/health` is always open (it carries only a status and version string, useful for liveness probes). +## One-shot command semantics + +- `codexbar dashboard` reads enabled providers from CodexBar config, emits them in stable order, and carries configured + ordering through each row's `display.sortKey`. +- Identity is always redacted. Provider failures stay in their rows without discarding healthy rows. +- A valid full, partial, empty, or all-error snapshot exits `0`. Command-wide setup or encoding failure writes a + diagnostic to stderr and exits non-zero without writing a substitute document to stdout. +- Stdout contains exactly one JSON document plus a trailing newline. `--pretty` changes formatting only; + `--json-output` controls optional logs on stderr. +- `--timeout ` accepts `0...86400` and defaults to `30`; `0` disables the command deadline. +- The one-shot payload reports `host.refreshIntervalSeconds` as `0` because it has no response cache. + `staleAfterSeconds` keeps the schema's 180-second minimum. + ## Configuring the token ```bash @@ -156,7 +178,7 @@ The snapshot is a stable display contract, not a raw dump of provider internals. - `generatedAt`: Snapshot generation timestamp. - `staleAfterSeconds`: Client-side staleness hint. - `host.codexBarVersion`: CodexBar version when available. -- `host.refreshIntervalSeconds`: Server response cache interval. +- `host.refreshIntervalSeconds`: HTTP response cache interval, or `0` for the one-shot command. - `providers[].id`: Provider identifier. - `providers[].name`: Provider display name. - `providers[].enabled`: Whether the provider is enabled in CodexBar config. From 0e00869385a4c1589368d616a4b4e8d6807d716d Mon Sep 17 00:00:00 2001 From: Umut Keltek Date: Wed, 29 Jul 2026 19:22:22 +0300 Subject: [PATCH 2/4] Fix dashboard pricing refresh policy --- Sources/CodexBarCLI/CLIDashboardCommand.swift | 5 ++++- Sources/CodexBarCLI/CLIServeCommand.swift | 7 ++++--- Tests/CodexBarTests/CLIServeTimeoutTests.swift | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index 53dfa9e7a1..baa6654e06 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -75,7 +75,7 @@ struct DashboardSnapshotProducer: Sendable { let snapshot = try await costFetcher.loadTokenSnapshot( provider: provider, forceRefresh: false, - refreshPricingInBackground: CodexBarCLI.serveCostRefreshesPricingInBackground) + refreshPricingInBackground: context.costRefreshesPricingInBackground) return CodexBarCLI.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) } catch { return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: error) @@ -87,6 +87,8 @@ struct DashboardSnapshotProducer: Sendable { } extension CodexBarCLI { + static let dashboardCostRefreshesPricingInBackground = false + static func runDashboard(_ values: ParsedValues) async { guard let timeout = decodeDashboardTimeout(from: values) else { exit( @@ -132,6 +134,7 @@ extension CodexBarCLI { requestTimeout: timeout), now: { ContinuousClock().now }, providerOperations: costOperations), + costRefreshesPricingInBackground: Self.dashboardCostRefreshesPricingInBackground, codexBarVersion: Self.currentVersion()) let result: DashboardSnapshotResult diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 7dcaab7b46..0eee66c36b 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -183,6 +183,7 @@ struct DashboardSnapshotContext: Sendable { let config: CodexBarConfig let usage: ServeUsageContext let costCollection: ServeCostCollectionContext + let costRefreshesPricingInBackground: Bool let codexBarVersion: String? } @@ -930,6 +931,7 @@ extension CodexBarCLI { requestDeadline: requestDeadline, now: { ContinuousClock().now }, providerOperations: runtime.costOperations), + costRefreshesPricingInBackground: Self.serveCostRefreshesPricingInBackground, codexBarVersion: runtime.healthVersion)) })) } @@ -1330,9 +1332,8 @@ extension CodexBarCLI { context: ServeCostCollectionContext, fetch: @Sendable @escaping (UsageProvider) async -> CostPayload) async -> [CostPayload] { - // Preserve the established scan order. Pricing refresh stays best-effort - // background work so network latency never consumes a provider deadline; - // consecutive scans can still overlap that bounded adjacent work. + // Preserve the established scan order. The injected fetch decides whether + // pricing refresh is awaited; provider deadlines still bound each row. var payload: [CostPayload] = [] for provider in providers { let deadline = Self.serveCostProviderDeadline( diff --git a/Tests/CodexBarTests/CLIServeTimeoutTests.swift b/Tests/CodexBarTests/CLIServeTimeoutTests.swift index 762c7d2637..5bd4367f69 100644 --- a/Tests/CodexBarTests/CLIServeTimeoutTests.swift +++ b/Tests/CodexBarTests/CLIServeTimeoutTests.swift @@ -5,7 +5,8 @@ import Testing struct CLIServeTimeoutTests { @Test - func `serve cost keeps pricing refresh outside the request deadline`() { + func `dashboard awaits pricing refresh while serve refreshes in background`() { + #expect(!CodexBarCLI.dashboardCostRefreshesPricingInBackground) #expect(CodexBarCLI.serveCostRefreshesPricingInBackground) } From ad3757868014eb0256dde6883e612182c774b2c8 Mon Sep 17 00:00:00 2001 From: Umut Keltek Date: Wed, 29 Jul 2026 21:17:11 +0300 Subject: [PATCH 3/4] Honor Cursor source policy in dashboard --- Sources/CodexBarCLI/CLICostCommand.swift | 2 +- Sources/CodexBarCLI/CLIDashboardCommand.swift | 14 +- Sources/CodexBarCLI/CLIServeCommand.swift | 56 +++++--- .../DashboardSnapshotBuilderTests.swift | 131 +++++++++++++++++- 4 files changed, 176 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 0497db6ea7..fdf6534c27 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -374,7 +374,7 @@ extension CodexBarCLI { /// Resolve the configured Cursor cookie settings (source + manual header) the same way the CLI /// usage path does, so Cursor cost honors Off/Manual instead of always auto-resolving a session. - /// Shared by `cost` and the serve `/cost` route. + /// Shared by `cost`, the serve `/cost` route, and dashboard snapshot collection. static func cursorCookieSettings( config: CodexBarConfig, providers: [UsageProvider]) throws -> ProviderSettingsSnapshot.CursorProviderSettings? diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index baa6654e06..875d74ea51 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -31,7 +31,7 @@ struct DashboardSnapshotResult { /// existing authenticated HTTP cache. struct DashboardSnapshotProducer: Sendable { let collectUsage: @Sendable ([UsageProvider]) async throws -> UsageCommandOutput - let collectCost: @Sendable ([UsageProvider]) async -> [CostPayload] + let collectCost: @Sendable ([UsageProvider], CodexBarConfig) async -> [CostPayload] let now: @Sendable () -> Date func collect( @@ -43,7 +43,9 @@ struct DashboardSnapshotProducer: Sendable { rawOverride: nil, enabled: config.enabledProviders()) let usageOutput = try await self.collectUsage(selection.asList) - let costPayloads = await self.collectCost(CodexBarCLI.costProviders(from: selection)) + let costPayloads = await self.collectCost( + CodexBarCLI.costProviders(from: selection), + config) let payload = DashboardSnapshotBuilder.makeSnapshot( usagePayloads: usageOutput.payload, @@ -65,16 +67,18 @@ struct DashboardSnapshotProducer: Sendable { selection: .custom(providers), context: context.usage) }, - collectCost: { providers in + collectCost: { providers, config in let costFetcher = CostUsageFetcher() - return await CodexBarCLI.serveCollectCostPayloads( + return await CodexBarCLI.collectConfiguredCostPayloads( providers: providers, + config: config, context: context.costCollection) - { provider in + { provider, cursorCookieHeaderOverride in do { let snapshot = try await costFetcher.loadTokenSnapshot( provider: provider, forceRefresh: false, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, refreshPricingInBackground: context.costRefreshesPricingInBackground) return CodexBarCLI.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) } catch { diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 0eee66c36b..e87fe07f58 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -1287,21 +1287,48 @@ extension CodexBarCLI { message: "cost is only supported for \(Self.costSupportedProviderNames())") } - // Cursor cost honors the same cookie policy here as the `cost` command: return a provider - // error when the source is Off and forward the Manual header for an enabled fetch. + let fetcher = CostUsageFetcher() + let payload = await Self.collectConfiguredCostPayloads( + providers: providers, + config: context.config, + context: context.collection) + { provider, cursorCookieHeaderOverride in + do { + let snapshot = try await fetcher.loadTokenSnapshot( + provider: provider, + forceRefresh: false, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + refreshPricingInBackground: Self.serveCostRefreshesPricingInBackground) + return Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) + } catch { + return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) + } + } + + return Self.serveJSON(payload) + } + + static func collectConfiguredCostPayloads( + providers: [UsageProvider], + config: CodexBarConfig, + context: ServeCostCollectionContext, + fetch: @Sendable @escaping (UsageProvider, String?) async -> CostPayload) async -> [CostPayload] + { + // Keep every dashboard transport aligned with the configured Cursor credential source. + // Policy failures remain row-local so other providers still render. let cursorCookieSettings: ProviderSettingsSnapshot.CursorProviderSettings? let cursorCookieSettingsError: Error? do { - cursorCookieSettings = try Self.cursorCookieSettings(config: context.config, providers: providers) + cursorCookieSettings = try Self.cursorCookieSettings(config: config, providers: providers) cursorCookieSettingsError = nil } catch { cursorCookieSettings = nil cursorCookieSettingsError = error } - let fetcher = CostUsageFetcher() - let payload = await Self.serveCollectCostPayloads( + + return await Self.serveCollectCostPayloads( providers: providers, - context: context.collection) + context: context) { provider in if let error = Self.cursorCostAvailabilityError( provider, @@ -1310,21 +1337,10 @@ extension CodexBarCLI { { return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) } - do { - let snapshot = try await fetcher.loadTokenSnapshot( - provider: provider, - forceRefresh: false, - cursorCookieHeaderOverride: Self.cursorCostHeaderOverride( - provider, - settings: cursorCookieSettings), - refreshPricingInBackground: Self.serveCostRefreshesPricingInBackground) - return Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) - } catch { - return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) - } + return await fetch( + provider, + Self.cursorCostHeaderOverride(provider, settings: cursorCookieSettings)) } - - return Self.serveJSON(payload) } static func serveCollectCostPayloads( diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift index 511de4fc41..57845bb447 100644 --- a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -4,6 +4,85 @@ import Testing @testable import CodexBarCLI struct DashboardSnapshotBuilderTests { + @Test + func `dashboard cost collection does not fetch Cursor when cookie source is off`() async { + let recorder = DashboardCostFetchRecorder() + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .cursor, enabled: true, cookieSource: .off), + ]) + + let payload = await CodexBarCLI.collectConfiguredCostPayloads( + providers: [.cursor], + config: config, + context: self.costCollectionContext()) + { provider, header in + await recorder.record(provider: provider, cursorCookieHeaderOverride: header) + return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: nil) + } + + #expect(await recorder.calls().isEmpty) + #expect(payload.count == 1) + #expect(payload[0].provider == "cursor") + #expect(payload[0].error?.message.contains("cookie source is set to Off") == true) + } + + @Test + func `dashboard cost collection forwards configured Cursor manual cookie`() async { + let recorder = DashboardCostFetchRecorder() + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .cursor, + enabled: true, + cookieHeader: " session=manual ", + cookieSource: .manual), + ]) + + let payload = await CodexBarCLI.collectConfiguredCostPayloads( + providers: [.cursor], + config: config, + context: self.costCollectionContext()) + { provider, header in + await recorder.record(provider: provider, cursorCookieHeaderOverride: header) + return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: nil) + } + + let calls = await recorder.calls() + #expect(calls.count == 1) + #expect(calls[0].provider == .cursor) + #expect(calls[0].cursorCookieHeaderOverride == "session=manual") + #expect(payload.count == 1) + #expect(payload[0].error == nil) + } + + @Test + func `dashboard producer forwards its config to cost collection`() async throws { + let recorder = DashboardCostConfigRecorder() + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .cursor, + enabled: true, + cookieHeader: "session=manual", + cookieSource: .manual), + ]) + let producer = DashboardSnapshotProducer( + collectUsage: { _ in UsageCommandOutput() }, + collectCost: { providers, config in + await recorder.record(providers: providers, config: config) + return [] + }, + now: { Date(timeIntervalSince1970: 1_800_000_000) }) + + _ = try await producer.collect( + config: config, + refreshInterval: 0, + codexBarVersion: "9.8.7") + + let call = try #require(await recorder.call()) + #expect(call.providers == [.cursor]) + #expect(call.cookieSource == .manual) + #expect(call.cookieHeader == "session=manual") + } + @Test func `producer keeps stable order redaction and partial errors`() async throws { let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) @@ -27,7 +106,7 @@ struct DashboardSnapshotBuilderTests { output.exitCode = .failure return output }, - collectCost: { _ in [] }, + collectCost: { _, _ in [] }, now: { generatedAt }) let config = CodexBarConfig(providers: [ ProviderConfig(id: .claude, enabled: true), @@ -53,6 +132,15 @@ struct DashboardSnapshotBuilderTests { #expect(object["generatedAt"] as? String == "2027-01-15T08:00:00Z") } + private func costCollectionContext() -> ServeCostCollectionContext { + ServeCostCollectionContext( + configFingerprint: "dashboard-cost-policy", + providerTimeout: nil, + requestDeadline: nil, + now: { ContinuousClock().now }, + providerOperations: CLIServeOperationCoordinator()) + } + @Test func `builds stable display-oriented dashboard snapshot`() throws { let generatedAt = Date(timeIntervalSince1970: 1_800_000_000) @@ -496,3 +584,44 @@ struct DashboardSnapshotBuilderTests { return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) } } + +private actor DashboardCostFetchRecorder { + struct Call: Sendable { + let provider: UsageProvider + let cursorCookieHeaderOverride: String? + } + + private var recordedCalls: [Call] = [] + + func record(provider: UsageProvider, cursorCookieHeaderOverride: String?) { + self.recordedCalls.append(Call( + provider: provider, + cursorCookieHeaderOverride: cursorCookieHeaderOverride)) + } + + func calls() -> [Call] { + self.recordedCalls + } +} + +private actor DashboardCostConfigRecorder { + struct Call: Sendable { + let providers: [UsageProvider] + let cookieSource: ProviderCookieSource? + let cookieHeader: String? + } + + private var recordedCall: Call? + + func record(providers: [UsageProvider], config: CodexBarConfig) { + let cursor = config.providerConfig(for: .cursor) + self.recordedCall = Call( + providers: providers, + cookieSource: cursor?.cookieSource, + cookieHeader: cursor?.cookieHeader) + } + + func call() -> Call? { + self.recordedCall + } +} From 5133b253257fe564bc407c1ecb1ed460aa297890 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 14:46:26 -0700 Subject: [PATCH 4/4] test: dedupe cliExecutableURL helper after branch update Co-Authored-By: Claude Fable 5 --- Tests/CodexBarTests/CLIEntryTests.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index 97b45989bf..681d7ee244 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -684,14 +684,6 @@ final class CLIEntryTests: XCTestCase { environment: [:])) } - private static var cliExecutableURL: URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent(".build/debug/CodexBarCLI") - } - private static func runCLI( arguments: [String], environment: [String: String] = [:]) throws -> (status: Int32, stdout: Data, stderr: Data)