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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/CLICostCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
188 changes: 188 additions & 0 deletions Sources/CodexBarCLI/CLIDashboardCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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], CodexBarConfig) 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),
config)

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, config in
let costFetcher = CostUsageFetcher()
return await CodexBarCLI.collectConfiguredCostPayloads(
providers: providers,
config: config,
context: context.costCollection)
{ 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 {
return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: error)
}
}
},
now: { Date() })
}
}

extension CodexBarCLI {
static let dashboardCostRefreshesPricingInBackground = false

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<UsageCommandOutput>()
let costOperations = CLIServeOperationCoordinator<CostPayload>()
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),
costRefreshesPricingInBackground: Self.dashboardCostRefreshesPricingInBackground,
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<UsageCommandOutput>,
costOperations: CLIServeOperationCoordinator<CostPayload>) async
{
await providerOperations.shutdown()
await costOperations.shutdown()
await ProviderCLISessionLifecycle.shutdownPersistentSessions()
TTYCommandRunner.terminateActiveProcessesForAppShutdown()
}
}
22 changes: 19 additions & 3 deletions Sources/CodexBarCLI/CLIEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" }) {
Expand All @@ -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":
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -141,7 +148,7 @@ enum CodexBarCLI {
defaultSubcommandName: "list")
}

private static func commandDescriptors() -> [CommandDescriptor] {
static func commandDescriptors() -> [CommandDescriptor] {
let cardsSignature = CommandSignature.describe(CardsOptions())
let usageSignature = CommandSignature.describe(UsageOptions())
let costSignature = CommandSignature.describe(CostOptions())
Expand Down Expand Up @@ -200,6 +207,7 @@ enum CodexBarCLI {
abstract: "Serve usage, cost, and dashboard JSON over HTTP",
discussion: nil,
signature: serveSignature),
Self.dashboardCommandDescriptor(),
CommandDescriptor(
name: "config",
abstract: "Config utilities",
Expand Down Expand Up @@ -261,6 +269,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",
Expand Down
32 changes: 32 additions & 0 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,36 @@ extension CodexBarCLI {
"""
}

static func dashboardHelp(version: String) -> String {
"""
CodexBar \(version)

Usage:
codexbar dashboard [--pretty] [--timeout <seconds>]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
[-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 <trace|verbose|debug|info|warning|error|critical>
--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)
Expand Down Expand Up @@ -398,6 +428,7 @@ extension CodexBarCLI {
[--days <days>] [--group-by project]
codexbar sessions [--json] [--pretty]
codexbar sessions focus <id>
codexbar dashboard [--pretty] [--timeout <seconds>]
codexbar serve [--host <host>] [--port <port>] [--refresh-interval <seconds>]
[--request-timeout <seconds>]
[--dashboard-token <token>] [--allow-plain-http]
Expand Down Expand Up @@ -437,6 +468,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
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCLI/CLIIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading