chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func runCacheClear(_ values: ParsedValues) {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let cookies = values.flags.contains("cookies")
|
||||
let cost = values.flags.contains("cost")
|
||||
let all = values.flags.contains("all")
|
||||
let rawProvider = values.options["provider"]?.last
|
||||
|
||||
let clearCookies = cookies || all
|
||||
let clearCost = cost || all
|
||||
|
||||
if !clearCookies, !clearCost {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Specify --cookies, --cost, or --all.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
if let error = Self.cacheClearProviderScopeError(rawProvider: rawProvider, clearCost: clearCost) {
|
||||
Self.exit(code: .failure, message: error, output: output, kind: .args)
|
||||
}
|
||||
|
||||
var results: [CacheClearResult] = []
|
||||
|
||||
if clearCookies {
|
||||
if let rawProvider {
|
||||
if let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] {
|
||||
let summary = CookieHeaderCache.clearAllScopesDetailed(provider: provider)
|
||||
results.append(CacheClearResult(
|
||||
cache: "cookies",
|
||||
provider: provider.rawValue,
|
||||
cleared: summary.clearedCount,
|
||||
error: Self.cookieClearError(failedCount: summary.failedCount)))
|
||||
} else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Unknown provider: \(rawProvider)",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
} else {
|
||||
let summary = CookieHeaderCache.clearAllDetailed()
|
||||
results.append(CacheClearResult(
|
||||
cache: "cookies",
|
||||
provider: nil,
|
||||
cleared: summary.clearedCount,
|
||||
error: Self.cookieClearError(failedCount: summary.failedCount)))
|
||||
}
|
||||
}
|
||||
|
||||
if clearCost {
|
||||
let fm = FileManager.default
|
||||
let cacheDir = Self.costUsageCacheDirectory(fileManager: fm)
|
||||
var cleared = 0
|
||||
var costError: String?
|
||||
if fm.fileExists(atPath: cacheDir.path) {
|
||||
do {
|
||||
try fm.removeItem(at: cacheDir)
|
||||
cleared = 1
|
||||
} catch {
|
||||
costError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
results.append(CacheClearResult(cache: "cost", provider: nil, cleared: cleared, error: costError))
|
||||
}
|
||||
|
||||
switch output.format {
|
||||
case .text:
|
||||
for result in results {
|
||||
let scope = result.provider ?? "all providers"
|
||||
if let error = result.error {
|
||||
print("\(result.cache): failed to clear (\(scope)) - \(error)")
|
||||
} else if result.cleared > 0 {
|
||||
print("\(result.cache): cleared (\(scope))")
|
||||
} else {
|
||||
print("\(result.cache): nothing to clear (\(scope))")
|
||||
}
|
||||
}
|
||||
case .json:
|
||||
Self.printJSON(results, pretty: output.pretty)
|
||||
}
|
||||
|
||||
let hasErrors = results.contains(where: { $0.error != nil })
|
||||
Self.exit(code: hasErrors ? .failure : .success, output: output, kind: .runtime)
|
||||
}
|
||||
|
||||
static func cacheClearProviderScopeError(rawProvider: String?, clearCost: Bool) -> String? {
|
||||
guard rawProvider != nil, clearCost else { return nil }
|
||||
return "--provider only scopes cookie caches. Use --cookies --provider <name>, or omit --provider."
|
||||
}
|
||||
|
||||
private static func cookieClearError(failedCount: Int) -> String? {
|
||||
guard failedCount > 0 else { return nil }
|
||||
return "Cookie cache cleanup failed for \(failedCount) operation\(failedCount == 1 ? "" : "s")"
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheOptions: 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?
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: text | json")
|
||||
var format: OutputFormat?
|
||||
|
||||
@Flag(name: .long("json"), help: "")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
|
||||
var jsonOnly: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
|
||||
@Flag(name: .long("cookies"), help: "Clear browser cookie caches")
|
||||
var cookies: Bool = false
|
||||
|
||||
@Flag(name: .long("cost"), help: "Clear cost usage caches")
|
||||
var cost: Bool = false
|
||||
|
||||
@Flag(name: .long("all"), help: "Clear all caches")
|
||||
var all: Bool = false
|
||||
|
||||
@Option(name: .long("provider"), help: "Clear cache for a specific provider only")
|
||||
var provider: String?
|
||||
}
|
||||
|
||||
private struct CacheClearResult: Encodable {
|
||||
let cache: String
|
||||
let provider: String?
|
||||
let cleared: Int
|
||||
var error: String?
|
||||
}
|
||||
|
||||
extension CodexBarCLI {
|
||||
/// Mirrors the cost usage cache directory used by the app (UsageStore.costUsageCacheDirectory).
|
||||
static func costUsageCacheDirectory(fileManager: FileManager = .default) -> URL {
|
||||
let root = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
return root
|
||||
.appendingPathComponent("CodexBar", isDirectory: true)
|
||||
.appendingPathComponent("cost-usage", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
struct CLICardsBriefRow: Sendable, Equatable {
|
||||
let provider: UsageProvider
|
||||
let providerName: String
|
||||
let sourceLabel: String
|
||||
let planBadge: String?
|
||||
let accountLabel: String?
|
||||
let metricLabel: String?
|
||||
let usedPercent: Double?
|
||||
let resetLabel: String?
|
||||
let resetAt: Date?
|
||||
}
|
||||
|
||||
private struct CLICardsBriefColumns {
|
||||
let provider: Int
|
||||
let usage: Int
|
||||
let reset: Int
|
||||
}
|
||||
|
||||
enum CLICardsBriefRenderer {
|
||||
private static let warningUsedThreshold = 85.0
|
||||
private static let tableBorderOverhead = 10
|
||||
private static let providerColumnMin = 20
|
||||
private static let providerColumnFloor = 8
|
||||
private static let providerColumnMax = 34
|
||||
private static let usageColumnMin = 22
|
||||
private static let usageColumnFloor = 9
|
||||
private static let usageColumnWidth = 28
|
||||
private static let usageBarMaxWidth = 22
|
||||
private static let resetColumnMin = 8
|
||||
private static let resetColumnFloor = 5
|
||||
private static let resetColumnMax = 10
|
||||
|
||||
static func makeRows(cards: [CLICardModel]) -> [CLICardsBriefRow] {
|
||||
cards.map { card in
|
||||
let metric = card.metrics.first
|
||||
let usedPercent = metric.map { max(0, min(100, 100 - $0.remainingPercent)) }
|
||||
let resetLabel = Self.briefResetLabel(metric?.resetText)
|
||||
return CLICardsBriefRow(
|
||||
provider: card.provider,
|
||||
providerName: card.title,
|
||||
sourceLabel: card.sourceLabel,
|
||||
planBadge: card.planBadge,
|
||||
accountLabel: card.accountLine,
|
||||
metricLabel: metric?.label,
|
||||
usedPercent: usedPercent,
|
||||
resetLabel: resetLabel,
|
||||
resetAt: metric?.resetAt)
|
||||
}
|
||||
}
|
||||
|
||||
static func render(
|
||||
rows: [CLICardsBriefRow],
|
||||
failures: [CLICardFailure],
|
||||
terminalWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool = false,
|
||||
now: Date = Date()) -> String
|
||||
{
|
||||
guard !rows.isEmpty else {
|
||||
return CLICardsRenderer.renderFailuresOnly(failures, useColor: useColor)
|
||||
}
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append(Self.titleLine(now: now, terminalWidth: terminalWidth, useColor: useColor, enhanced: enhanced))
|
||||
lines.append(contentsOf: Self.summaryLines(
|
||||
rows: rows,
|
||||
now: now,
|
||||
terminalWidth: terminalWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
lines.append("")
|
||||
lines.append(contentsOf: Self.tableLines(
|
||||
rows: rows,
|
||||
terminalWidth: terminalWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
|
||||
let warningLines = Self.warningLines(rows: rows, terminalWidth: terminalWidth, useColor: useColor)
|
||||
if !warningLines.isEmpty {
|
||||
lines.append("")
|
||||
lines.append(contentsOf: warningLines)
|
||||
}
|
||||
|
||||
if !failures.isEmpty {
|
||||
lines.append("")
|
||||
lines.append(CLICardsRenderer.renderFailureFooter(failures: failures, useColor: useColor))
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func titleLine(now: Date, terminalWidth: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
let left: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedAccentBold("codexbar • AI Usage & Limits")
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeAccentBold("codexbar • AI Usage & Limits")
|
||||
} else {
|
||||
"codexbar • AI Usage & Limits"
|
||||
}
|
||||
let timestamp = Self.timestampString(now: now)
|
||||
guard Self.visibleLength(left) + timestamp.count + 1 <= terminalWidth else {
|
||||
if Self.visibleLength(left) <= terminalWidth { return left }
|
||||
return Self.truncatePlain(TextParsing.stripANSICodes(left), width: terminalWidth)
|
||||
}
|
||||
let gap = max(1, terminalWidth - Self.visibleLength(left) - timestamp.count)
|
||||
let right: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedReadableMuted(timestamp)
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeSubtle(timestamp)
|
||||
} else {
|
||||
timestamp
|
||||
}
|
||||
return left + String(repeating: " ", count: gap) + right
|
||||
}
|
||||
|
||||
private static func summaryLines(
|
||||
rows: [CLICardsBriefRow],
|
||||
now: Date,
|
||||
terminalWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> [String]
|
||||
{
|
||||
var parts: [String] = []
|
||||
if let nextReset = Self.nextResetSummary(rows: rows, now: now) {
|
||||
parts.append("Next reset: \(nextReset)")
|
||||
}
|
||||
let text = parts.joined(separator: " • ")
|
||||
guard !text.isEmpty else { return [] }
|
||||
let lines = Self.wrapText(
|
||||
text,
|
||||
firstPrefix: "",
|
||||
continuationPrefix: " ",
|
||||
width: terminalWidth)
|
||||
if useColor, enhanced {
|
||||
return lines.map(CLIRenderer.colorizeEnhancedReadable)
|
||||
}
|
||||
if useColor {
|
||||
return lines.map(CLIRenderer.colorizeReadable)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func providerPlainLabel(_ row: CLICardsBriefRow) -> String {
|
||||
if let account = row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty {
|
||||
return "\(row.providerName) · \(account) · \(row.sourceLabel)"
|
||||
}
|
||||
if let plan = row.planBadge, !plan.isEmpty {
|
||||
return "\(row.providerName) · \(row.sourceLabel) · \(plan)"
|
||||
}
|
||||
return "\(row.providerName) · \(row.sourceLabel)"
|
||||
}
|
||||
|
||||
private static func tableLines(
|
||||
rows: [CLICardsBriefRow],
|
||||
terminalWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> [String]
|
||||
{
|
||||
let columns = Self.tableColumnWidths(
|
||||
rows: rows,
|
||||
terminalWidth: terminalWidth)
|
||||
|
||||
let top = Self.tableTop(
|
||||
providerWidth: columns.provider,
|
||||
usageWidth: columns.usage,
|
||||
resetWidth: columns.reset,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
let header = Self.tableHeaderRow(
|
||||
providerWidth: columns.provider,
|
||||
usageWidth: columns.usage,
|
||||
resetWidth: columns.reset,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
let divider = Self.tableDivider(
|
||||
providerWidth: columns.provider,
|
||||
usageWidth: columns.usage,
|
||||
resetWidth: columns.reset,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
|
||||
var lines = [top, header, divider]
|
||||
for row in rows {
|
||||
lines.append(Self.dataRow(
|
||||
row: row,
|
||||
columns: columns,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
}
|
||||
lines.append(Self.tableBottom(
|
||||
providerWidth: columns.provider,
|
||||
usageWidth: columns.usage,
|
||||
resetWidth: columns.reset,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func tableBorderLine(_ line: String, useColor: Bool, enhanced: Bool) -> String {
|
||||
guard useColor else { return line }
|
||||
if enhanced {
|
||||
return CLIRenderer.colorizeEnhancedBorder(line)
|
||||
}
|
||||
return CLIRenderer.colorizeCardBorder(line)
|
||||
}
|
||||
|
||||
private static func tableHeaderRow(
|
||||
providerWidth: Int,
|
||||
usageWidth: Int,
|
||||
resetWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let provider = Self.styledHeaderLabel("Provider", width: providerWidth, useColor: useColor, enhanced: enhanced)
|
||||
let usage = Self.styledHeaderLabel("Usage", width: usageWidth, useColor: useColor, enhanced: enhanced)
|
||||
let reset = Self.styledHeaderLabel(
|
||||
"Reset",
|
||||
width: resetWidth,
|
||||
alignRight: true,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
return "│ \(provider) │ \(usage) │ \(reset) │"
|
||||
}
|
||||
|
||||
private static func styledHeaderLabel(
|
||||
_ text: String,
|
||||
width: Int,
|
||||
alignRight: Bool = false,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let styled: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedReadable(text)
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeReadable(text)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
return Self.pad(styled, width: width, alignRight: alignRight)
|
||||
}
|
||||
|
||||
private static func tableTop(
|
||||
providerWidth: Int,
|
||||
usageWidth: Int,
|
||||
resetWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let line = "┌" + String(repeating: "─", count: providerWidth + 2)
|
||||
+ "┬" + String(repeating: "─", count: usageWidth + 2)
|
||||
+ "┬" + String(repeating: "─", count: resetWidth + 2) + "┐"
|
||||
return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func tableBottom(
|
||||
providerWidth: Int,
|
||||
usageWidth: Int,
|
||||
resetWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let line = "└" + String(repeating: "─", count: providerWidth + 2)
|
||||
+ "┴" + String(repeating: "─", count: usageWidth + 2)
|
||||
+ "┴" + String(repeating: "─", count: resetWidth + 2) + "┘"
|
||||
return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func tableDivider(
|
||||
providerWidth: Int,
|
||||
usageWidth: Int,
|
||||
resetWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let line = "├" + String(repeating: "─", count: providerWidth + 2)
|
||||
+ "┼" + String(repeating: "─", count: usageWidth + 2)
|
||||
+ "┼" + String(repeating: "─", count: resetWidth + 2) + "┤"
|
||||
return Self.tableBorderLine(line, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func styledProviderCell(
|
||||
row: CLICardsBriefRow,
|
||||
width: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
if row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
let fitted = Self.fitCell(Self.providerPlainLabel(row), width: width)
|
||||
if useColor, enhanced {
|
||||
return CLIRenderer.colorizeEnhancedReadable(fitted)
|
||||
}
|
||||
return useColor ? CLIRenderer.colorizeReadable(fitted) : fitted
|
||||
}
|
||||
guard useColor else {
|
||||
return self.plainProviderCell(row: row, width: width)
|
||||
}
|
||||
|
||||
let sourceVisibleWidth = row.sourceLabel.count + 2
|
||||
let prefixVisibleWidth = row.providerName.count + 1 + sourceVisibleWidth
|
||||
let plan: String? = row.planBadge.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let planPrefix = " · "
|
||||
let planWidth = plan.map { _ in max(0, width - prefixVisibleWidth - planPrefix.count) } ?? 0
|
||||
|
||||
let styled: String = if enhanced {
|
||||
CLIRenderer.colorizeEnhancedAccentBold(row.providerName)
|
||||
+ " "
|
||||
+ CLIRenderer.colorizeEnhancedBadge(row.sourceLabel)
|
||||
+ Self.styledProviderPlan(
|
||||
plan,
|
||||
prefix: planPrefix,
|
||||
width: planWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
} else {
|
||||
CLIRenderer.colorizeReadable(row.providerName)
|
||||
+ " "
|
||||
+ CLIRenderer.colorizeCardBadge(row.sourceLabel)
|
||||
+ Self.styledProviderPlan(
|
||||
plan,
|
||||
prefix: planPrefix,
|
||||
width: planWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
}
|
||||
return Self.fitCell(styled, width: width)
|
||||
}
|
||||
|
||||
private static func styledProviderPlan(
|
||||
_ plan: String?,
|
||||
prefix: String,
|
||||
width: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
guard let plan, width > 0 else { return "" }
|
||||
let text = prefix + Self.truncatePlain(plan, width: width)
|
||||
if useColor, enhanced {
|
||||
return CLIRenderer.colorizeEnhancedReadableMuted(text)
|
||||
}
|
||||
if useColor {
|
||||
return CLIRenderer.colorizeReadableMuted(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func plainProviderCell(row: CLICardsBriefRow, width: Int) -> String {
|
||||
if row.accountLabel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
return self.fitCell(self.providerPlainLabel(row), width: width)
|
||||
}
|
||||
guard
|
||||
let plan = row.planBadge,
|
||||
!plan.isEmpty
|
||||
else {
|
||||
return self.fitCell(self.providerPlainLabel(row), width: width)
|
||||
}
|
||||
|
||||
let prefix = "\(row.providerName) · \(row.sourceLabel) · "
|
||||
let planWidth = max(0, width - prefix.count)
|
||||
if planWidth > 0 {
|
||||
return Self.pad(prefix + Self.truncatePlain(plan, width: planWidth), width: width)
|
||||
}
|
||||
return Self.fitCell("\(row.providerName) · \(row.sourceLabel)", width: width)
|
||||
}
|
||||
|
||||
private static func styledResetCell(_ text: String, width: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
let styled: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedReadableMuted(text)
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeReadableMuted(text)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
return Self.fitCell(styled, width: width, alignRight: true)
|
||||
}
|
||||
|
||||
private static func dataRow(
|
||||
row: CLICardsBriefRow,
|
||||
columns: CLICardsBriefColumns,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let provider = Self.styledProviderCell(
|
||||
row: row,
|
||||
width: columns.provider,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
let usage: String
|
||||
if let used = row.usedPercent {
|
||||
let percent = String(format: "%.0f%%", used.rounded())
|
||||
let barWidth = max(4, min(Self.usageBarMaxWidth, columns.usage - Self.visibleLength(percent) - 1))
|
||||
let bar: String = if useColor, enhanced {
|
||||
CLIRenderer.gradientUsedBar(usedPercent: used, width: barWidth)
|
||||
} else {
|
||||
CLIRenderer.cardUsedBar(usedPercent: used, width: barWidth, useColor: useColor)
|
||||
}
|
||||
let coloredPercent: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedUsedPercent(percent, usedPercent: used)
|
||||
} else {
|
||||
CLIRenderer.colorizeCardUsedPercent(percent, usedPercent: used, useColor: useColor)
|
||||
}
|
||||
usage = Self.pad("\(coloredPercent) \(bar)", width: columns.usage)
|
||||
} else {
|
||||
usage = Self.pad("—", width: columns.usage)
|
||||
}
|
||||
let reset = Self.styledResetCell(
|
||||
row.resetLabel ?? "—",
|
||||
width: columns.reset,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
return "│ \(provider) │ \(usage) │ \(reset) │"
|
||||
}
|
||||
|
||||
private static func warningLines(
|
||||
rows: [CLICardsBriefRow],
|
||||
terminalWidth: Int,
|
||||
useColor: Bool) -> [String]
|
||||
{
|
||||
let warnings = rows.compactMap { row -> String? in
|
||||
guard let used = row.usedPercent, used >= Self.warningUsedThreshold else { return nil }
|
||||
let label = row.metricLabel ?? "Usage"
|
||||
return "\(row.providerName) \(label): \(Int(used.rounded()))% used"
|
||||
}
|
||||
guard !warnings.isEmpty else { return [] }
|
||||
let lines = Self.wrapText(
|
||||
warnings.joined(separator: "; "),
|
||||
firstPrefix: "⚠ Warnings: ",
|
||||
continuationPrefix: " ",
|
||||
width: terminalWidth)
|
||||
return useColor ? lines.map(CLIRenderer.colorizeWarning) : lines
|
||||
}
|
||||
|
||||
private static func wrapText(
|
||||
_ text: String,
|
||||
firstPrefix: String,
|
||||
continuationPrefix: String,
|
||||
width: Int) -> [String]
|
||||
{
|
||||
let lineWidth = max(16, width)
|
||||
var lines: [String] = []
|
||||
var line = firstPrefix
|
||||
var hasContent = false
|
||||
for word in text.split(separator: " ").map(String.init) {
|
||||
let separator = hasContent ? " " : ""
|
||||
if line.count + separator.count + word.count <= lineWidth {
|
||||
line += separator + word
|
||||
hasContent = true
|
||||
continue
|
||||
}
|
||||
if hasContent {
|
||||
lines.append(line)
|
||||
} else if !line.isEmpty {
|
||||
lines.append(Self.truncatePlain(line, width: lineWidth))
|
||||
}
|
||||
let available = max(1, lineWidth - continuationPrefix.count)
|
||||
line = continuationPrefix + Self.truncatePlain(word, width: available)
|
||||
hasContent = true
|
||||
}
|
||||
if hasContent {
|
||||
lines.append(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func nextResetSummary(rows: [CLICardsBriefRow], now: Date) -> String? {
|
||||
guard let (row, label, _) = rows.compactMap({ row -> (CLICardsBriefRow, String, Date)? in
|
||||
guard let reset = row.resetLabel, !reset.isEmpty, reset != "—" else { return nil }
|
||||
let sortDate = row.resetAt ?? Self.resetSortDate(reset, now: now)
|
||||
guard let sortDate else { return nil }
|
||||
return (row, reset, sortDate)
|
||||
}).min(by: { $0.2 < $1.2 })
|
||||
else { return nil }
|
||||
let separator = Self.resetDurationMinutes(label) == nil ? " · " : " in "
|
||||
return "\(row.providerName)\(separator)\(label)"
|
||||
}
|
||||
|
||||
private static func resetSortDate(_ label: String, now: Date) -> Date? {
|
||||
guard let minutes = resetDurationMinutes(label) else { return nil }
|
||||
return now.addingTimeInterval(TimeInterval(minutes * 60))
|
||||
}
|
||||
|
||||
private static func resetDurationMinutes(_ label: String) -> Int? {
|
||||
var minutes = 0
|
||||
var matched = false
|
||||
if let match = label.range(of: #"(\d+)d"#, options: .regularExpression) {
|
||||
matched = true
|
||||
minutes += (Int(label[match].dropLast()) ?? 0) * 24 * 60
|
||||
}
|
||||
if let match = label.range(of: #"(\d+)h"#, options: .regularExpression) {
|
||||
matched = true
|
||||
minutes += (Int(label[match].dropLast()) ?? 0) * 60
|
||||
}
|
||||
if let match = label.range(of: #"(\d+)m"#, options: .regularExpression) {
|
||||
matched = true
|
||||
minutes += Int(label[match].dropLast()) ?? 0
|
||||
}
|
||||
return matched ? minutes : nil
|
||||
}
|
||||
|
||||
private static func tableColumnWidths(
|
||||
rows: [CLICardsBriefRow],
|
||||
terminalWidth: Int) -> CLICardsBriefColumns
|
||||
{
|
||||
let providerContent = rows.map { Self.providerPlainLabel($0).count }.max() ?? Self.providerColumnMin
|
||||
let resetContent = rows.compactMap(\.resetLabel).map(\.count).max() ?? 6
|
||||
|
||||
var providerWidth = min(Self.providerColumnMax, max(Self.providerColumnMin, providerContent))
|
||||
var usageWidth = Self.usageColumnWidth
|
||||
var resetWidth = min(Self.resetColumnMax, max(Self.resetColumnMin, resetContent))
|
||||
|
||||
while providerWidth + usageWidth + resetWidth + Self.tableBorderOverhead > terminalWidth {
|
||||
if usageWidth > Self.usageColumnMin {
|
||||
usageWidth -= 1
|
||||
} else if providerWidth > Self.providerColumnMin {
|
||||
providerWidth -= 1
|
||||
} else if resetWidth > Self.resetColumnMin {
|
||||
resetWidth -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
while providerWidth + usageWidth + resetWidth + Self.tableBorderOverhead > terminalWidth {
|
||||
if providerWidth > Self.providerColumnFloor {
|
||||
providerWidth -= 1
|
||||
} else if usageWidth > Self.usageColumnFloor {
|
||||
usageWidth -= 1
|
||||
} else if resetWidth > Self.resetColumnFloor {
|
||||
resetWidth -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return CLICardsBriefColumns(provider: providerWidth, usage: usageWidth, reset: resetWidth)
|
||||
}
|
||||
|
||||
private static func briefResetLabel(_ resetText: String?) -> String? {
|
||||
guard var text = resetText?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if text.hasPrefix("⏳ ") {
|
||||
text = String(text.dropFirst(2))
|
||||
}
|
||||
if text.hasPrefix("Resets in ") {
|
||||
text = String(text.dropFirst("Resets in ".count))
|
||||
} else if text.hasPrefix("Resets ") {
|
||||
text = String(text.dropFirst("Resets ".count))
|
||||
}
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private static func timestampString(now: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm zzz"
|
||||
return formatter.string(from: now)
|
||||
}
|
||||
|
||||
private static func pad(_ text: String, width: Int, alignRight: Bool = false) -> String {
|
||||
let visible = Self.visibleLength(text)
|
||||
if visible >= width { return text }
|
||||
let padding = String(repeating: " ", count: width - visible)
|
||||
return alignRight ? padding + text : text + padding
|
||||
}
|
||||
|
||||
private static func fitCell(_ text: String, width: Int, alignRight: Bool = false) -> String {
|
||||
let visible = Self.visibleLength(text)
|
||||
if visible <= width {
|
||||
return Self.pad(text, width: width, alignRight: alignRight)
|
||||
}
|
||||
let plain = TextParsing.stripANSICodes(text)
|
||||
let clipped = Self.truncatePlain(plain, width: width)
|
||||
return alignRight ? Self.pad(clipped, width: width, alignRight: true) : clipped
|
||||
}
|
||||
|
||||
private static func truncatePlain(_ text: String, width: Int) -> String {
|
||||
guard width > 0 else { return "" }
|
||||
if text.count <= width { return text }
|
||||
guard width > 1 else { return String(text.prefix(width)) }
|
||||
return String(text.prefix(width - 1)) + "…"
|
||||
}
|
||||
|
||||
private static func visibleLength(_ text: String) -> Int {
|
||||
TextParsing.stripANSICodes(text).count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct CardsOptions: CommanderParsable {
|
||||
private static let sourceHelp: String = {
|
||||
#if os(macOS)
|
||||
"Data source: auto | web | cli | oauth | api (auto behavior is provider-specific)"
|
||||
#else
|
||||
"Data source: auto | web | cli | oauth | api (web/auto are macOS only for web-capable providers)"
|
||||
#endif
|
||||
}()
|
||||
|
||||
@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?
|
||||
|
||||
@Option(
|
||||
name: .long("provider"),
|
||||
help: ProviderHelp.optionHelp)
|
||||
var provider: ProviderSelection?
|
||||
|
||||
@Option(name: .long("account"), help: "Token account label to use (from config.json)")
|
||||
var account: String?
|
||||
|
||||
@Option(name: .long("account-index"), help: "Token account index (1-based)")
|
||||
var accountIndex: Int?
|
||||
|
||||
@Flag(name: .long("all-accounts"), help: "Fetch all token accounts, or all visible Codex accounts")
|
||||
var allAccounts: Bool = false
|
||||
|
||||
@Flag(name: .long("no-credits"), help: "Skip Codex credits line")
|
||||
var noCredits: Bool = false
|
||||
|
||||
@Flag(name: .long("no-color"), help: "Disable ANSI colors in text output")
|
||||
var noColor: Bool = false
|
||||
|
||||
@Flag(name: .long("status"), help: "Fetch and include provider status")
|
||||
var status: Bool = false
|
||||
|
||||
@Flag(name: .long("web"), help: "Alias for --source web")
|
||||
var web: Bool = false
|
||||
|
||||
@Option(name: .long("source"), help: Self.sourceHelp)
|
||||
var source: String?
|
||||
|
||||
@Option(name: .long("web-timeout"), help: "Web fetch timeout (seconds; source=auto or web)")
|
||||
var webTimeout: Double?
|
||||
|
||||
@Flag(name: .long("web-debug-dump-html"), help: "Dump HTML snapshots to /tmp when Codex dashboard data is missing")
|
||||
var webDebugDumpHtml: Bool = false
|
||||
|
||||
@Flag(name: .long("antigravity-plan-debug"), help: "Emit Antigravity planInfo fields (debug)")
|
||||
var antigravityPlanDebug: Bool = false
|
||||
|
||||
@Flag(name: .long("augment-debug"), help: "Emit Augment API responses (debug)")
|
||||
var augmentDebug: Bool = false
|
||||
|
||||
@Flag(name: .long("brief"), help: "Compact table layout instead of the card grid")
|
||||
var brief: Bool = false
|
||||
}
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func runCards(_ values: ParsedValues) async {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = Self.loadConfig(output: output)
|
||||
let provider = Self.decodeProvider(from: values, config: config)
|
||||
let includeCredits = !values.flags.contains("noCredits")
|
||||
let includeStatus = values.flags.contains("status")
|
||||
let sourceModeRaw = values.options["source"]?.last
|
||||
let parsedSourceMode = Self.decodeSourceMode(from: values)
|
||||
if sourceModeRaw != nil, parsedSourceMode == nil {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: --source must be auto|web|cli|oauth|api.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
let antigravityPlanDebug = values.flags.contains("antigravityPlanDebug")
|
||||
let augmentDebug = values.flags.contains("augmentDebug")
|
||||
let webDebugDumpHTML = values.flags.contains("webDebugDumpHtml")
|
||||
let webTimeout: TimeInterval
|
||||
do {
|
||||
webTimeout = try Self.decodeWebTimeout(from: values) ?? 60
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args)
|
||||
}
|
||||
let verbose = values.flags.contains("verbose")
|
||||
let noColor = values.flags.contains("noColor")
|
||||
let useColor = Self.shouldUseColor(noColor: noColor, format: .text)
|
||||
let brief = values.flags.contains("brief")
|
||||
let resetStyle = Self.resetTimeDisplayStyleFromDefaults()
|
||||
let weeklyWorkDays = Self.weeklyProgressWorkDaysFromDefaults()
|
||||
let providerList = provider.asList
|
||||
|
||||
let tokenSelection: TokenAccountCLISelection
|
||||
do {
|
||||
tokenSelection = try Self.decodeTokenAccountSelection(from: values)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args)
|
||||
}
|
||||
|
||||
if tokenSelection.allAccounts, tokenSelection.label != nil || tokenSelection.index != nil {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: --all-accounts cannot be combined with --account or --account-index.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
|
||||
if tokenSelection.usesOverride {
|
||||
guard providerList.count == 1 else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: account selection requires a single provider.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
let supportsAllCodexAccounts = providerList[0] == .codex
|
||||
&& tokenSelection.allAccounts
|
||||
&& tokenSelection.label == nil
|
||||
&& tokenSelection.index == nil
|
||||
guard supportsAllCodexAccounts || TokenAccountSupportCatalog.support(for: providerList[0]) != nil else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: \(providerList[0].rawValue) does not support token accounts.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
}
|
||||
|
||||
let browserDetection = BrowserDetection()
|
||||
let fetcher = UsageFetcher()
|
||||
let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection)
|
||||
let tokenContext: TokenAccountCLIContext
|
||||
do {
|
||||
tokenContext = try TokenAccountCLIContext(
|
||||
selection: tokenSelection,
|
||||
config: config,
|
||||
verbose: verbose)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config)
|
||||
}
|
||||
|
||||
var cards: [CLICardModel] = []
|
||||
var failures: [CLICardFailure] = []
|
||||
var exitCode: ExitCode = .success
|
||||
let command = UsageCommandContext(
|
||||
format: .text,
|
||||
includeCredits: includeCredits,
|
||||
sourceModeOverride: parsedSourceMode,
|
||||
antigravityPlanDebug: antigravityPlanDebug,
|
||||
augmentDebug: augmentDebug,
|
||||
webDebugDumpHTML: webDebugDumpHTML,
|
||||
webTimeout: webTimeout,
|
||||
verbose: verbose,
|
||||
useColor: useColor,
|
||||
resetStyle: resetStyle,
|
||||
weeklyWorkDays: weeklyWorkDays,
|
||||
jsonOnly: output.jsonOnly,
|
||||
includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex],
|
||||
fetcher: fetcher,
|
||||
claudeFetcher: claudeFetcher,
|
||||
browserDetection: browserDetection,
|
||||
cardsLayout: true)
|
||||
|
||||
for provider in providerList {
|
||||
let status = includeStatus ? await Self.fetchStatus(for: provider) : nil
|
||||
let result = await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await Self.fetchUsageOutputs(
|
||||
provider: provider,
|
||||
status: status,
|
||||
tokenContext: tokenContext,
|
||||
command: command)
|
||||
}
|
||||
if result.exitCode != .success {
|
||||
exitCode = result.exitCode
|
||||
}
|
||||
cards.append(contentsOf: result.cards)
|
||||
failures.append(contentsOf: result.cardFailures)
|
||||
}
|
||||
|
||||
let rendered: String
|
||||
let enhanced = CLITerminalCapabilities.supportsEnhancedCards(useColor: useColor)
|
||||
if brief {
|
||||
let rows = CLICardsBriefRenderer.makeRows(cards: cards)
|
||||
rendered = CLICardsBriefRenderer.render(
|
||||
rows: rows,
|
||||
failures: failures,
|
||||
terminalWidth: CLICardsRenderer.terminalColumnCount(),
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
} else {
|
||||
rendered = CLICardsRenderer.render(
|
||||
cards: cards,
|
||||
failures: failures,
|
||||
terminalWidth: CLICardsRenderer.terminalColumnCount(),
|
||||
useColor: useColor,
|
||||
enhanced: enhanced)
|
||||
}
|
||||
if !rendered.isEmpty {
|
||||
print(rendered)
|
||||
}
|
||||
|
||||
Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
#if canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
struct CLICardMetric: Sendable, Equatable {
|
||||
let label: String
|
||||
let remainingPercent: Double
|
||||
let resetText: String?
|
||||
let resetAt: Date?
|
||||
let detailText: String?
|
||||
|
||||
init(
|
||||
label: String,
|
||||
remainingPercent: Double,
|
||||
resetText: String?,
|
||||
resetAt: Date? = nil,
|
||||
detailText: String? = nil)
|
||||
{
|
||||
self.label = label
|
||||
self.remainingPercent = remainingPercent
|
||||
self.resetText = resetText
|
||||
self.resetAt = resetAt
|
||||
self.detailText = detailText
|
||||
}
|
||||
}
|
||||
|
||||
struct CLICardModel: Sendable, Equatable {
|
||||
let provider: UsageProvider
|
||||
let title: String
|
||||
let sourceLabel: String
|
||||
let planBadge: String?
|
||||
let accountLine: String?
|
||||
let infoLines: [String]
|
||||
let metrics: [CLICardMetric]
|
||||
let extraLines: [String]
|
||||
let statusLine: String?
|
||||
}
|
||||
|
||||
struct CLICardFailure: Sendable, Equatable {
|
||||
let provider: UsageProvider
|
||||
let accountLabel: String?
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct CLICardBuildInput: Sendable {
|
||||
let provider: UsageProvider
|
||||
let snapshot: UsageSnapshot
|
||||
let credits: CreditsSnapshot?
|
||||
let source: String
|
||||
let status: ProviderStatusPayload?
|
||||
let notes: [String]
|
||||
let useColor: Bool
|
||||
let resetStyle: ResetTimeDisplayStyle
|
||||
let weeklyWorkDays: Int?
|
||||
let now: Date
|
||||
}
|
||||
|
||||
enum CLICardsRenderer {
|
||||
static let minCardWidth = 38
|
||||
static let maxCardWidth = 42
|
||||
static let cardGap = 2
|
||||
|
||||
static func terminalColumnCount() -> Int {
|
||||
if let value = terminalColumnCountFromTTY(), value > 0 {
|
||||
return value
|
||||
}
|
||||
if let columns = ProcessInfo.processInfo.environment["COLUMNS"],
|
||||
let value = Int(columns.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
value > 0
|
||||
{
|
||||
return value
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
private static func terminalColumnCountFromTTY(fileDescriptor: Int32 = STDOUT_FILENO) -> Int? {
|
||||
guard isatty(fileDescriptor) == 1 else { return nil }
|
||||
var windowSize = winsize(ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0)
|
||||
guard ioctl(fileDescriptor, UInt(TIOCGWINSZ), &windowSize) == 0 else { return nil }
|
||||
let columns = Int(windowSize.ws_col)
|
||||
return columns > 0 ? columns : nil
|
||||
}
|
||||
|
||||
static func columnCount(terminalWidth: Int, minCardWidth: Int = Self.minCardWidth) -> Int {
|
||||
let usable = max(minCardWidth, terminalWidth)
|
||||
return max(1, (usable + Self.cardGap) / (minCardWidth + Self.cardGap))
|
||||
}
|
||||
|
||||
static func cardWidth(terminalWidth: Int, columns: Int) -> Int {
|
||||
let totalGaps = (columns - 1) * Self.cardGap
|
||||
let availableWidth = max(1, (terminalWidth - totalGaps) / columns)
|
||||
return min(Self.maxCardWidth, availableWidth)
|
||||
}
|
||||
|
||||
static func makeCard(_ input: CLICardBuildInput) -> CLICardModel {
|
||||
let provider = input.provider
|
||||
let snapshot = input.snapshot
|
||||
let displayName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
|
||||
let context = RenderContext(
|
||||
header: displayName,
|
||||
status: input.status,
|
||||
useColor: input.useColor,
|
||||
resetStyle: input.resetStyle,
|
||||
weeklyWorkDays: input.weeklyWorkDays,
|
||||
notes: input.notes)
|
||||
let infoLines = CLIRenderer.collectCardInfoLines(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
credits: input.credits,
|
||||
notes: input.notes,
|
||||
useColor: input.useColor,
|
||||
now: input.now)
|
||||
let metrics = CLIRenderer.collectCardMetrics(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
resetStyle: input.resetStyle,
|
||||
now: input.now)
|
||||
let extraLines = CLIRenderer.collectCardExtraLines(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
credits: input.credits,
|
||||
context: context,
|
||||
now: input.now)
|
||||
let statusLine: String?
|
||||
if let status = input.status {
|
||||
let line = "Status: \(status.indicator.label)\(status.descriptionSuffix)"
|
||||
statusLine = CLIRenderer.colorizeStatusLine(line, indicator: status.indicator, useColor: input.useColor)
|
||||
} else {
|
||||
statusLine = nil
|
||||
}
|
||||
return CLICardModel(
|
||||
provider: provider,
|
||||
title: displayName,
|
||||
sourceLabel: Self.normalizedSourceLabel(input.source),
|
||||
planBadge: CLIRenderer.planBadgeText(provider: provider, snapshot: snapshot),
|
||||
accountLine: snapshot.accountEmail(for: provider),
|
||||
infoLines: infoLines,
|
||||
metrics: metrics,
|
||||
extraLines: extraLines,
|
||||
statusLine: statusLine)
|
||||
}
|
||||
|
||||
static func render(
|
||||
cards: [CLICardModel],
|
||||
failures: [CLICardFailure],
|
||||
terminalWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool = false) -> String
|
||||
{
|
||||
guard !cards.isEmpty else {
|
||||
return self.renderFailuresOnly(failures, useColor: useColor)
|
||||
}
|
||||
|
||||
let columns = Self.columnCount(terminalWidth: terminalWidth)
|
||||
let width = Self.cardWidth(terminalWidth: terminalWidth, columns: columns)
|
||||
var chunks: [String] = []
|
||||
|
||||
for rowStart in stride(from: 0, to: cards.count, by: columns) {
|
||||
let rowCards = Array(cards[rowStart..<min(rowStart + columns, cards.count)])
|
||||
let rendered = rowCards.map { Self.renderCard($0, width: width, useColor: useColor, enhanced: enhanced) }
|
||||
let rowHeight = rendered.map(\.count).max() ?? 0
|
||||
for lineIndex in 0..<rowHeight {
|
||||
let parts = rendered.map { lines -> String in
|
||||
if lineIndex < lines.count - 1 {
|
||||
return lines[lineIndex]
|
||||
}
|
||||
if lineIndex == rowHeight - 1, let bottom = lines.last {
|
||||
return bottom
|
||||
}
|
||||
return Self.emptyCardLine(width: width, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
chunks.append(parts.joined(separator: String(repeating: " ", count: Self.cardGap)))
|
||||
}
|
||||
if rowStart + columns < cards.count {
|
||||
chunks.append("")
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.isEmpty {
|
||||
if !chunks.isEmpty {
|
||||
chunks.append("")
|
||||
}
|
||||
chunks.append(Self.renderFailureFooter(failures: failures, useColor: useColor))
|
||||
}
|
||||
|
||||
return chunks.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func renderCard(_ card: CLICardModel, width: Int, useColor: Bool, enhanced: Bool = false) -> [String] {
|
||||
let innerWidth = max(12, width - 4)
|
||||
var lines: [String] = []
|
||||
lines.append(Self.boxLine(kind: .top, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
lines.append(Self.headerLine(card: card, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
|
||||
if let account = card.accountLine?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty {
|
||||
let accountText = "@ \(account)"
|
||||
lines.append(Self.contentLine(
|
||||
accountText,
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced,
|
||||
style: .subtle))
|
||||
}
|
||||
|
||||
lines.append(Self.separatorLine(innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
|
||||
for infoLine in card.infoLines {
|
||||
lines.append(Self.detailLine(
|
||||
infoLine,
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
}
|
||||
|
||||
if !card.metrics.isEmpty, !card.infoLines.isEmpty {
|
||||
lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
}
|
||||
|
||||
for (index, metric) in card.metrics.enumerated() {
|
||||
if index > 0 {
|
||||
lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
}
|
||||
lines.append(Self.metricLabelLine(
|
||||
metric: metric,
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
lines.append(Self.metricBarLine(
|
||||
metric: metric,
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced))
|
||||
if let resetText = metric.resetText {
|
||||
lines.append(Self.contentLine(
|
||||
resetText,
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced,
|
||||
style: .subtle))
|
||||
}
|
||||
if let detailText = metric.detailText {
|
||||
lines.append(Self.contentLine(
|
||||
detailText,
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced,
|
||||
style: .subtle))
|
||||
}
|
||||
}
|
||||
|
||||
for extraLine in card.extraLines {
|
||||
lines.append(Self.detailLine(extraLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
}
|
||||
|
||||
if let statusLine = card.statusLine {
|
||||
lines.append(Self.contentLine(statusLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
}
|
||||
|
||||
lines.append(Self.boxLine(kind: .bottom, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
|
||||
return lines
|
||||
}
|
||||
|
||||
private enum BoxLineKind {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
private enum ContentStyle: Equatable {
|
||||
case normal
|
||||
case subtle
|
||||
case border
|
||||
}
|
||||
|
||||
private static func boxLine(kind: BoxLineKind, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
let chars = switch kind {
|
||||
case .top: ("╭", "╮")
|
||||
case .bottom: ("╰", "╯")
|
||||
}
|
||||
let line = chars.0 + String(repeating: "─", count: innerWidth + 2) + chars.1
|
||||
return Self.styleBorder(line, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func headerLine(card: CLICardModel, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
let title: String
|
||||
let badge: String
|
||||
if useColor, enhanced {
|
||||
title = CLIRenderer.colorizeEnhancedAccentBold(card.title)
|
||||
badge = CLIRenderer.colorizeEnhancedBadge(card.sourceLabel)
|
||||
} else if useColor {
|
||||
title = CLIRenderer.colorizeAccentBold(card.title)
|
||||
badge = CLIRenderer.colorizeCardBadge(card.sourceLabel)
|
||||
} else {
|
||||
title = card.title
|
||||
badge = "[\(card.sourceLabel)]"
|
||||
}
|
||||
let left = "\(title) \(badge)"
|
||||
let leftVisible = Self.visibleLength(left)
|
||||
let rawPlanText = card.planBadge.map { "PLAN \($0)" } ?? ""
|
||||
let maxPlanWidth = max(0, innerWidth - leftVisible - 1)
|
||||
let planText = maxPlanWidth >= 8 ? Self.truncatePlain(rawPlanText, width: maxPlanWidth) : ""
|
||||
let planVisible = Self.visibleLength(planText)
|
||||
let gap = max(1, innerWidth - leftVisible - planVisible)
|
||||
let plan = Self.planPill(text: planText, useColor: useColor, enhanced: enhanced)
|
||||
let content = left + String(repeating: " ", count: gap) + plan
|
||||
return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func planPill(text: String, useColor: Bool, enhanced: Bool) -> String {
|
||||
guard !text.isEmpty else { return "" }
|
||||
let pieces = text.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
guard pieces.count == 2 else {
|
||||
return useColor ? CLIRenderer.colorizeCardPlanBox(text) : text
|
||||
}
|
||||
if useColor, enhanced {
|
||||
return CLIRenderer.colorizeEnhancedPlanLabel(pieces[0])
|
||||
+ " "
|
||||
+ CLIRenderer.colorizeEnhancedPlanValue(pieces[1])
|
||||
}
|
||||
if useColor {
|
||||
return CLIRenderer.colorizeCardPlanBox(pieces[0])
|
||||
+ " "
|
||||
+ CLIRenderer.colorizeWarning(pieces[1])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func separatorLine(innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
self.sideBorder(
|
||||
String(repeating: "─", count: innerWidth),
|
||||
innerWidth: innerWidth,
|
||||
useColor: useColor,
|
||||
enhanced: enhanced,
|
||||
contentStyle: .border)
|
||||
}
|
||||
|
||||
private static func metricLabelLine(
|
||||
metric: CLICardMetric,
|
||||
innerWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let percentText = UsageFormatter.usageLine(
|
||||
remaining: metric.remainingPercent,
|
||||
used: 100 - metric.remainingPercent,
|
||||
showUsed: false)
|
||||
let coloredPercent: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedRemainingPercent(percentText, remainingPercent: metric.remainingPercent)
|
||||
} else {
|
||||
CLIRenderer.colorizeCardPercent(
|
||||
percentText,
|
||||
remainingPercent: metric.remainingPercent,
|
||||
useColor: useColor)
|
||||
}
|
||||
let label: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedReadable(metric.label)
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeReadable(metric.label)
|
||||
} else {
|
||||
metric.label
|
||||
}
|
||||
let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(coloredPercent))
|
||||
let content = label + String(repeating: " ", count: gap) + coloredPercent
|
||||
return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func metricBarLine(
|
||||
metric: CLICardMetric,
|
||||
innerWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool) -> String
|
||||
{
|
||||
let barWidth = max(4, innerWidth - 4)
|
||||
let bar: String = if useColor, enhanced {
|
||||
CLIRenderer.gradientRemainingTrackBar(remainingPercent: metric.remainingPercent, width: barWidth)
|
||||
} else {
|
||||
CLIRenderer.cardBlockBar(
|
||||
remainingPercent: metric.remainingPercent,
|
||||
width: barWidth,
|
||||
useColor: useColor)
|
||||
}
|
||||
return Self.sideBorder("[ \(bar) ]", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func detailLine(_ content: String, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
let normalized = Self.normalizeGlyphs(content)
|
||||
let plain = TextParsing.stripANSICodes(normalized)
|
||||
let parts = plain.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2 else {
|
||||
return Self.contentLine(normalized, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
let rawLabel = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + ":"
|
||||
let rawValue = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let label: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedReadable(rawLabel)
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeReadable(rawLabel)
|
||||
} else {
|
||||
rawLabel
|
||||
}
|
||||
let value: String = if useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedGood(rawValue)
|
||||
} else if useColor {
|
||||
CLIRenderer.colorizeAccent(rawValue)
|
||||
} else {
|
||||
rawValue
|
||||
}
|
||||
let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(value))
|
||||
let line = label + String(repeating: " ", count: gap) + value
|
||||
return Self.sideBorder(line, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func contentLine(
|
||||
_ content: String,
|
||||
innerWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool,
|
||||
style: ContentStyle = .normal) -> String
|
||||
{
|
||||
let normalized = Self.normalizeGlyphs(content)
|
||||
let stripped = TextParsing.stripANSICodes(normalized)
|
||||
let clipped = stripped.count <= innerWidth
|
||||
? normalized
|
||||
: (innerWidth <= 1 ? String(stripped.prefix(innerWidth)) : String(stripped.prefix(innerWidth - 1)) + "…")
|
||||
let display: String = if style == .subtle, useColor, enhanced {
|
||||
CLIRenderer.colorizeEnhancedSubtle(TextParsing.stripANSICodes(clipped))
|
||||
} else if style == .subtle, useColor {
|
||||
CLIRenderer.colorizeSubtle(TextParsing.stripANSICodes(clipped))
|
||||
} else {
|
||||
clipped
|
||||
}
|
||||
return Self.sideBorder(display, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func sideBorder(
|
||||
_ content: String,
|
||||
innerWidth: Int,
|
||||
useColor: Bool,
|
||||
enhanced: Bool,
|
||||
contentStyle: ContentStyle = .normal) -> String
|
||||
{
|
||||
let fitted = Self.fitContent(content, width: innerWidth)
|
||||
let padding = max(0, innerWidth - Self.visibleLength(fitted))
|
||||
let padded = fitted + String(repeating: " ", count: padding)
|
||||
let visible = "│ \(padded) │"
|
||||
guard useColor else { return visible }
|
||||
let left = Self.styleBorder("│ ", useColor: useColor, enhanced: enhanced)
|
||||
let right = Self.styleBorder(" │", useColor: useColor, enhanced: enhanced)
|
||||
let styledContent: String = if contentStyle == .border {
|
||||
Self.styleBorder(padded, useColor: useColor, enhanced: enhanced)
|
||||
} else {
|
||||
padded
|
||||
}
|
||||
return left + styledContent + right
|
||||
}
|
||||
|
||||
private static func styleBorder(_ text: String, useColor: Bool, enhanced: Bool) -> String {
|
||||
guard useColor else { return text }
|
||||
if enhanced {
|
||||
return CLIRenderer.colorizeEnhancedBorder(text)
|
||||
}
|
||||
return CLIRenderer.colorizeCardBorder(text)
|
||||
}
|
||||
|
||||
private static func emptyCardLine(width: Int, useColor: Bool, enhanced: Bool) -> String {
|
||||
let innerWidth = max(12, width - 4)
|
||||
return Self.sideBorder("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
|
||||
}
|
||||
|
||||
private static func visibleLength(_ text: String) -> Int {
|
||||
TextParsing.stripANSICodes(self.normalizeGlyphs(text)).count
|
||||
}
|
||||
|
||||
private static func truncatePlain(_ text: String, width: Int) -> String {
|
||||
guard width > 0 else { return "" }
|
||||
guard text.count > width else { return text }
|
||||
if width <= 1 { return String(text.prefix(width)) }
|
||||
return String(text.prefix(width - 1)) + "…"
|
||||
}
|
||||
|
||||
private static func fitContent(_ text: String, width: Int) -> String {
|
||||
guard self.visibleLength(text) > width else { return text }
|
||||
return self.truncatePlain(TextParsing.stripANSICodes(text), width: width)
|
||||
}
|
||||
|
||||
private static func normalizeGlyphs(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "👤", with: "@")
|
||||
.replacingOccurrences(of: "⏳ Resets in ", with: "Reset in ")
|
||||
.replacingOccurrences(of: "⏳ Resets ", with: "Reset ")
|
||||
.replacingOccurrences(of: "⏳ ", with: "Reset ")
|
||||
}
|
||||
|
||||
static func renderFailureFooter(failures: [CLICardFailure], useColor: Bool) -> String {
|
||||
var lines = ["Failed providers:"]
|
||||
for failure in failures {
|
||||
let name = ProviderDescriptorRegistry.descriptor(for: failure.provider).metadata.displayName
|
||||
if let account = failure.accountLabel, !account.isEmpty {
|
||||
lines.append(" - \(name) (\(account)): \(failure.message)")
|
||||
} else {
|
||||
lines.append(" - \(name): \(failure.message)")
|
||||
}
|
||||
}
|
||||
let text = lines.joined(separator: "\n")
|
||||
guard useColor else { return text }
|
||||
return CLIRenderer.colorizeError(text)
|
||||
}
|
||||
|
||||
static func renderFailuresOnly(_ failures: [CLICardFailure], useColor: Bool) -> String {
|
||||
guard !failures.isEmpty else { return "" }
|
||||
return self.renderFailureFooter(failures: failures, useColor: useColor)
|
||||
}
|
||||
|
||||
private static func normalizedSourceLabel(_ source: String) -> String {
|
||||
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "auto" }
|
||||
if trimmed.contains("oauth") { return "oauth" }
|
||||
if trimmed.contains("web") || trimmed.contains("openai-web") { return "web" }
|
||||
if trimmed.contains("api") { return "api" }
|
||||
if trimmed.contains("cli") { return "cli" }
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func runConfigValidate(_ values: ParsedValues) {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = Self.loadConfig(output: output)
|
||||
let issues = CodexBarConfigValidator.validate(config)
|
||||
let hasErrors = issues.contains(where: { $0.severity == .error })
|
||||
|
||||
switch output.format {
|
||||
case .text:
|
||||
if issues.isEmpty {
|
||||
print("Config: OK")
|
||||
} else {
|
||||
for issue in issues {
|
||||
let provider = issue.provider?.rawValue ?? "config"
|
||||
let field = issue.field ?? ""
|
||||
let prefix = "[\(issue.severity.rawValue.uppercased())]"
|
||||
let suffix = field.isEmpty ? "" : " (\(field))"
|
||||
print("\(prefix) \(provider)\(suffix): \(issue.message)")
|
||||
}
|
||||
}
|
||||
case .json:
|
||||
Self.printJSON(issues, pretty: output.pretty)
|
||||
}
|
||||
|
||||
Self.exit(code: hasErrors ? .failure : .success, output: output, kind: .config)
|
||||
}
|
||||
|
||||
static func runConfigDump(_ values: ParsedValues) {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = Self.loadConfig(output: output)
|
||||
Self.printJSON(config, pretty: output.pretty)
|
||||
Self.exit(code: .success, output: output, kind: .config)
|
||||
}
|
||||
|
||||
static func runConfigProviders(_ values: ParsedValues) {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = Self.loadConfig(output: output)
|
||||
let results = Self.configProviderStatuses(config)
|
||||
|
||||
switch output.format {
|
||||
case .text:
|
||||
for result in results {
|
||||
let state = result.enabled ? "enabled" : "disabled"
|
||||
let marker = result.defaultEnabled ? " default" : ""
|
||||
print("\(result.provider): \(state)\(marker) (\(result.displayName))")
|
||||
}
|
||||
case .json:
|
||||
Self.printJSON(results, pretty: output.pretty)
|
||||
}
|
||||
|
||||
Self.exit(code: .success, output: output, kind: .config)
|
||||
}
|
||||
|
||||
static func runConfigSetProviderEnabled(_ values: ParsedValues, enabled: Bool) {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
guard let rawProvider = values.options["provider"]?.last,
|
||||
let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()]
|
||||
else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Unknown or missing provider. Use --provider <name>.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
|
||||
let store = CodexBarConfigStore()
|
||||
var config = Self.loadConfig(output: output)
|
||||
config = Self.configSettingProviderEnabled(config, provider: provider, enabled: enabled)
|
||||
|
||||
do {
|
||||
try store.save(config)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .config)
|
||||
}
|
||||
|
||||
let metadata = ProviderDescriptorRegistry.descriptor(for: provider).metadata
|
||||
let result = ConfigProviderToggleResult(
|
||||
provider: provider.rawValue,
|
||||
displayName: metadata.displayName,
|
||||
enabled: enabled,
|
||||
configPath: store.fileURL.path)
|
||||
|
||||
switch output.format {
|
||||
case .text:
|
||||
let state = enabled ? "enabled" : "disabled"
|
||||
print("Config: \(state) \(metadata.displayName)")
|
||||
case .json:
|
||||
Self.printJSON(result, pretty: output.pretty)
|
||||
}
|
||||
|
||||
Self.exit(code: .success, output: output, kind: .config)
|
||||
}
|
||||
|
||||
static func runConfigSetAPIKey(_ values: ParsedValues) {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
|
||||
guard let rawProvider = values.options["provider"]?.last,
|
||||
let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()]
|
||||
else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Unknown or missing provider. Use --provider <name>.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
guard ProviderConfigEnvironment.supportsAPIKeyOverride(for: provider) else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "\(rawProvider) does not support config API keys.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
|
||||
let apiKey: String
|
||||
do {
|
||||
apiKey = try Self.resolveConfigAPIKeyInput(
|
||||
apiKey: values.options["apiKey"]?.last,
|
||||
readFromStdin: values.flags.contains("stdin"))
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .args)
|
||||
}
|
||||
|
||||
let enableProvider = !values.flags.contains("noEnable")
|
||||
let store = CodexBarConfigStore()
|
||||
var config = Self.loadConfig(output: output)
|
||||
let accountOptions: ConfigAPIKeyAccountOptions?
|
||||
do {
|
||||
accountOptions = try Self.resolveConfigAPIKeyAccountOptions(
|
||||
provider: provider,
|
||||
label: values.options["label"]?.last,
|
||||
usageScope: values.options["usageScope"]?.last,
|
||||
organizationID: values.options["organizationId"]?.last,
|
||||
workspaceID: values.options["workspaceId"]?.last)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .args)
|
||||
}
|
||||
config = Self.configSettingAPIKey(
|
||||
config,
|
||||
provider: provider,
|
||||
apiKey: apiKey,
|
||||
enableProvider: enableProvider,
|
||||
accountOptions: accountOptions)
|
||||
|
||||
do {
|
||||
try store.save(config)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .config)
|
||||
}
|
||||
|
||||
let result = ConfigSetAPIKeyResult(
|
||||
provider: provider.rawValue,
|
||||
enabled: config.providerConfig(for: provider)?.enabled ?? false,
|
||||
configPath: store.fileURL.path)
|
||||
|
||||
switch output.format {
|
||||
case .text:
|
||||
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
|
||||
let suffix = result.enabled ? " and enabled" : ""
|
||||
let action = accountOptions == nil ? "stored API key" : "stored team token account"
|
||||
print("Config: \(action) for \(name)\(suffix)")
|
||||
case .json:
|
||||
Self.printJSON(result, pretty: output.pretty)
|
||||
}
|
||||
|
||||
Self.exit(code: .success, output: output, kind: .config)
|
||||
}
|
||||
|
||||
static func resolveConfigAPIKeyInput(apiKey: String?, readFromStdin: Bool) throws -> String {
|
||||
if apiKey != nil, readFromStdin {
|
||||
throw CLIArgumentError("Use either --api-key or --stdin, not both.")
|
||||
}
|
||||
|
||||
let raw: String? = if readFromStdin {
|
||||
String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8)
|
||||
} else {
|
||||
apiKey
|
||||
}
|
||||
|
||||
guard let value = Self.cleanConfigSecret(raw) else {
|
||||
throw CLIArgumentError("Missing API key. Pass --api-key <key> or pipe it with --stdin.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func configSettingAPIKey(
|
||||
_ config: CodexBarConfig,
|
||||
provider: UsageProvider,
|
||||
apiKey: String,
|
||||
enableProvider: Bool,
|
||||
accountOptions: ConfigAPIKeyAccountOptions? = nil) -> CodexBarConfig
|
||||
{
|
||||
var updated = config.normalized()
|
||||
var providerConfig = updated.providerConfig(for: provider) ?? ProviderConfig(id: provider)
|
||||
if let accountOptions {
|
||||
let existing = providerConfig.tokenAccounts
|
||||
let accounts = existing?.accounts ?? []
|
||||
let account = ProviderTokenAccount(
|
||||
id: UUID(),
|
||||
label: accountOptions.label,
|
||||
token: apiKey,
|
||||
addedAt: Date().timeIntervalSince1970,
|
||||
lastUsed: nil,
|
||||
usageScope: accountOptions.usageScope.rawValue,
|
||||
organizationID: accountOptions.organizationID,
|
||||
workspaceID: accountOptions.workspaceID)
|
||||
providerConfig.tokenAccounts = ProviderTokenAccountData(
|
||||
version: existing?.version ?? 1,
|
||||
accounts: accounts + [account],
|
||||
activeIndex: accounts.count)
|
||||
providerConfig.apiKey = nil
|
||||
if enableProvider {
|
||||
providerConfig.enabled = true
|
||||
}
|
||||
updated.setProviderConfig(providerConfig)
|
||||
return updated
|
||||
}
|
||||
providerConfig.apiKey = apiKey
|
||||
if enableProvider {
|
||||
providerConfig.enabled = true
|
||||
}
|
||||
updated.setProviderConfig(providerConfig)
|
||||
return updated
|
||||
}
|
||||
|
||||
static func resolveConfigAPIKeyAccountOptions(
|
||||
provider: UsageProvider,
|
||||
label: String?,
|
||||
usageScope: String?,
|
||||
organizationID: String?,
|
||||
workspaceID: String?) throws -> ConfigAPIKeyAccountOptions?
|
||||
{
|
||||
let cleanedLabel = Self.cleanConfigValue(label)
|
||||
let cleanedScope = Self.cleanConfigValue(usageScope)
|
||||
let cleanedOrganizationID = try Self.cleanSingleLineConfigValue(
|
||||
organizationID,
|
||||
fieldName: "organization-id")
|
||||
let cleanedWorkspaceID = try Self.cleanSingleLineConfigValue(
|
||||
workspaceID,
|
||||
fieldName: "workspace-id")
|
||||
let hasAccountOptions = cleanedLabel != nil ||
|
||||
cleanedScope != nil ||
|
||||
cleanedOrganizationID != nil ||
|
||||
cleanedWorkspaceID != nil
|
||||
guard hasAccountOptions else { return nil }
|
||||
|
||||
guard provider == .zai else {
|
||||
throw CLIArgumentError("Token-account options are only supported for --provider zai.")
|
||||
}
|
||||
|
||||
guard cleanedScope?.lowercased() == ZaiUsageScope.team.rawValue else {
|
||||
throw CLIArgumentError("Use --usage-scope team for z.ai team accounts, or omit account options.")
|
||||
}
|
||||
guard let organizationID = cleanedOrganizationID else {
|
||||
throw CLIArgumentError("Missing --organization-id for z.ai team usage.")
|
||||
}
|
||||
guard let workspaceID = cleanedWorkspaceID else {
|
||||
throw CLIArgumentError("Missing --workspace-id for z.ai team usage.")
|
||||
}
|
||||
|
||||
return ConfigAPIKeyAccountOptions(
|
||||
label: cleanedLabel ?? "Team",
|
||||
usageScope: .team,
|
||||
organizationID: organizationID,
|
||||
workspaceID: workspaceID)
|
||||
}
|
||||
|
||||
static func configSettingProviderEnabled(
|
||||
_ config: CodexBarConfig,
|
||||
provider: UsageProvider,
|
||||
enabled: Bool) -> CodexBarConfig
|
||||
{
|
||||
var updated = config.normalized()
|
||||
var providerConfig = updated.providerConfig(for: provider) ?? ProviderConfig(id: provider)
|
||||
providerConfig.enabled = enabled
|
||||
updated.setProviderConfig(providerConfig)
|
||||
return updated
|
||||
}
|
||||
|
||||
static func configProviderStatuses(_ config: CodexBarConfig) -> [ConfigProviderStatusResult] {
|
||||
let metadata = ProviderDescriptorRegistry.metadata
|
||||
return config.normalized().providers.map { providerConfig in
|
||||
let meta = metadata[providerConfig.id]
|
||||
let defaultEnabled = meta?.defaultEnabled ?? false
|
||||
return ConfigProviderStatusResult(
|
||||
provider: providerConfig.id.rawValue,
|
||||
displayName: meta?.displayName ?? providerConfig.id.rawValue,
|
||||
enabled: providerConfig.enabled ?? defaultEnabled,
|
||||
defaultEnabled: defaultEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cleanConfigSecret(_ raw: String?) -> String? {
|
||||
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
|
||||
(value.hasPrefix("'") && value.hasSuffix("'"))
|
||||
{
|
||||
value = String(value.dropFirst().dropLast())
|
||||
}
|
||||
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private static func cleanConfigValue(_ raw: String?) -> String? {
|
||||
guard let value = self.cleanConfigSecret(raw) else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func cleanSingleLineConfigValue(_ raw: String?, fieldName: String) throws -> String? {
|
||||
guard let value = self.cleanConfigValue(raw) else { return nil }
|
||||
guard !value.contains(where: \.isNewline) else {
|
||||
throw CLIArgumentError("--\(fieldName) must be a single line.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigAPIKeyAccountOptions: Equatable {
|
||||
let label: String
|
||||
let usageScope: ZaiUsageScope
|
||||
let organizationID: String
|
||||
let workspaceID: String
|
||||
}
|
||||
|
||||
struct ConfigOptions: 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?
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: text | json")
|
||||
var format: OutputFormat?
|
||||
|
||||
@Flag(name: .long("json"), help: "")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
|
||||
var jsonOnly: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
}
|
||||
|
||||
struct ConfigSetAPIKeyOptions: 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?
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: text | json")
|
||||
var format: OutputFormat?
|
||||
|
||||
@Flag(name: .long("json"), help: "")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
|
||||
var jsonOnly: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
|
||||
@Option(name: .long("provider"), help: ProviderHelp.optionHelp)
|
||||
var provider: String?
|
||||
|
||||
@Option(name: .long("api-key"), help: "API key to store")
|
||||
var apiKey: String?
|
||||
|
||||
@Flag(name: .long("stdin"), help: "Read API key from stdin")
|
||||
var stdin: Bool = false
|
||||
|
||||
@Flag(name: .long("no-enable"), help: "Store the key without enabling the provider")
|
||||
var noEnable: Bool = false
|
||||
|
||||
@Option(name: .long("label"), help: "Token-account label (z.ai team mode)")
|
||||
var label: String?
|
||||
|
||||
@Option(name: .long("usage-scope"), help: "Token-account usage scope (z.ai: team)")
|
||||
var usageScope: String?
|
||||
|
||||
@Option(name: .long("organization-id"), help: "z.ai BigModel organization ID for team usage")
|
||||
var organizationId: String?
|
||||
|
||||
@Option(name: .long("workspace-id"), help: "z.ai BigModel project ID for team usage")
|
||||
var workspaceId: String?
|
||||
}
|
||||
|
||||
struct ConfigProviderToggleOptions: 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?
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: text | json")
|
||||
var format: OutputFormat?
|
||||
|
||||
@Flag(name: .long("json"), help: "")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
|
||||
var jsonOnly: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
|
||||
@Option(name: .long("provider"), help: ProviderHelp.optionHelp)
|
||||
var provider: String?
|
||||
}
|
||||
|
||||
private struct ConfigSetAPIKeyResult: Encodable {
|
||||
let provider: String
|
||||
let enabled: Bool
|
||||
let configPath: String
|
||||
}
|
||||
|
||||
struct ConfigProviderStatusResult: Encodable, Equatable {
|
||||
let provider: String
|
||||
let displayName: String
|
||||
let enabled: Bool
|
||||
let defaultEnabled: Bool
|
||||
}
|
||||
|
||||
private struct ConfigProviderToggleResult: Encodable {
|
||||
let provider: String
|
||||
let displayName: String
|
||||
let enabled: Bool
|
||||
let configPath: String
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
private static let costSupportedProviders: Set<UsageProvider> = [.claude, .codex]
|
||||
|
||||
static func runCost(_ values: ParsedValues) async {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = CodexBarCLI.loadConfig(output: output)
|
||||
let selection = CodexBarCLI.decodeProvider(from: values, config: config)
|
||||
let providers = Self.costProviders(from: selection)
|
||||
let unsupported = selection.asList.filter { !Self.costSupportedProviders.contains($0) }
|
||||
if !unsupported.isEmpty {
|
||||
let names = unsupported
|
||||
.map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName }
|
||||
.sorted()
|
||||
.joined(separator: ", ")
|
||||
if !output.jsonOnly {
|
||||
Self.writeStderr("Skipping providers without local cost usage: \(names)\n")
|
||||
}
|
||||
}
|
||||
guard !providers.isEmpty else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: cost is only supported for Claude and Codex.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
|
||||
let format = output.format
|
||||
let forceRefresh = values.flags.contains("refresh")
|
||||
let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format)
|
||||
let historyDays = Self.decodeCostHistoryDays(from: values)
|
||||
let groupBy = Self.decodeCostGroupBy(from: values)
|
||||
if groupBy == .project {
|
||||
let unsupportedProjectProviders = providers.filter { $0 != .codex }
|
||||
if !unsupportedProjectProviders.isEmpty, !output.jsonOnly {
|
||||
let names = unsupportedProjectProviders
|
||||
.map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName }
|
||||
.sorted()
|
||||
.joined(separator: ", ")
|
||||
Self.writeStderr("Skipping project grouping for providers without Codex project data: \(names)\n")
|
||||
}
|
||||
}
|
||||
|
||||
let fetcher = CostUsageFetcher()
|
||||
var sections: [String] = []
|
||||
var payload: [CostPayload] = []
|
||||
var exitCode: ExitCode = .success
|
||||
|
||||
for provider in providers where groupBy != .project || provider == .codex || format == .json {
|
||||
do {
|
||||
// Cost usage is local-only; it does not require web/CLI provider fetches.
|
||||
let snapshot = try await fetcher.loadTokenSnapshot(
|
||||
provider: provider,
|
||||
forceRefresh: forceRefresh,
|
||||
historyDays: historyDays,
|
||||
refreshPricingInBackground: false)
|
||||
switch format {
|
||||
case .text:
|
||||
sections.append(Self.renderCostText(
|
||||
provider: provider,
|
||||
snapshot: snapshot,
|
||||
groupBy: groupBy,
|
||||
useColor: useColor))
|
||||
case .json:
|
||||
payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil))
|
||||
}
|
||||
} catch {
|
||||
exitCode = Self.mapError(error)
|
||||
if format == .json {
|
||||
payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error))
|
||||
} else if !output.jsonOnly {
|
||||
Self.writeStderr("Error: \(error.localizedDescription)\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch format {
|
||||
case .text:
|
||||
if !sections.isEmpty {
|
||||
print(sections.joined(separator: "\n\n"))
|
||||
}
|
||||
case .json:
|
||||
if !payload.isEmpty {
|
||||
Self.printJSON(payload, pretty: output.pretty)
|
||||
}
|
||||
}
|
||||
|
||||
Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider)
|
||||
}
|
||||
|
||||
enum CostGroupBy: String {
|
||||
case none
|
||||
case project
|
||||
}
|
||||
|
||||
static func renderCostText(
|
||||
provider: UsageProvider,
|
||||
snapshot: CostUsageTokenSnapshot,
|
||||
groupBy: CostGroupBy = .none,
|
||||
useColor: Bool) -> String
|
||||
{
|
||||
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
|
||||
let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor)
|
||||
if groupBy == .project, provider == .codex {
|
||||
return Self.renderProjectCostText(header: header, snapshot: snapshot)
|
||||
}
|
||||
|
||||
let todayCost = snapshot.sessionCostUSD
|
||||
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
|
||||
let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
|
||||
let todayLine = todayTokens.map { "Today: \(todayCost) · \($0) tokens" } ?? "Today: \(todayCost)"
|
||||
|
||||
let monthCost = snapshot.last30DaysCostUSD
|
||||
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
|
||||
let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) }
|
||||
let historyLabel = snapshot.historyLabel
|
||||
?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days")
|
||||
let monthLine = monthTokens.map {
|
||||
"\(historyLabel): \(monthCost) · \($0) tokens"
|
||||
} ?? "\(historyLabel): \(monthCost)"
|
||||
|
||||
let hintLine = UsageFormatter.costEstimateHint(provider: provider)
|
||||
return [header, todayLine, monthLine, hintLine].joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func renderProjectCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String {
|
||||
let historyLabel = snapshot.historyLabel
|
||||
?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days")
|
||||
var lines = [header, "Projects (\(historyLabel)):"]
|
||||
guard !snapshot.projects.isEmpty else {
|
||||
lines.append("—")
|
||||
lines.append(UsageFormatter.costEstimateHint(provider: .codex))
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
for project in snapshot.projects {
|
||||
let cost = project.totalCostUSD
|
||||
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
|
||||
let tokens = project.totalTokens.map { UsageFormatter.tokenCountString($0) }
|
||||
let summary = tokens.map { "\(cost) · \($0) tokens" } ?? cost
|
||||
lines.append("\(project.name): \(summary)")
|
||||
if let path = project.path {
|
||||
lines.append(" \(path)")
|
||||
}
|
||||
for source in project.sources {
|
||||
let sourceCost = source.totalCostUSD
|
||||
.map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—"
|
||||
let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) }
|
||||
let sourceSummary = sourceTokens.map { "\(sourceCost) · \($0) tokens" } ?? sourceCost
|
||||
lines.append(" - \(source.name): \(sourceSummary)")
|
||||
if let path = source.path {
|
||||
lines.append(" \(path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.append(UsageFormatter.costEstimateHint(provider: .codex))
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func costHeaderLine(_ header: String, useColor: Bool) -> String {
|
||||
guard useColor else { return header }
|
||||
return "\u{001B}[1;36m\(header)\u{001B}[0m"
|
||||
}
|
||||
|
||||
static func costProviders(from selection: ProviderSelection) -> [UsageProvider] {
|
||||
selection.asList.filter { Self.costSupportedProviders.contains($0) }
|
||||
}
|
||||
|
||||
static func makeCostPayload(
|
||||
provider: UsageProvider,
|
||||
snapshot: CostUsageTokenSnapshot?,
|
||||
error: Error?) -> CostPayload
|
||||
{
|
||||
let daily = snapshot?.daily.map(Self.costDailyPayload(from:)) ?? []
|
||||
let projects = provider == .codex
|
||||
? snapshot?.projects.map { project in
|
||||
CostProjectPayload(
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
totalTokens: project.totalTokens,
|
||||
totalCostUSD: project.totalCostUSD,
|
||||
daily: project.daily.map(Self.costDailyPayload(from:)),
|
||||
modelBreakdowns: project.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:)),
|
||||
sources: project.sources.map { source in
|
||||
CostProjectSourcePayload(
|
||||
name: source.name,
|
||||
path: source.path,
|
||||
totalTokens: source.totalTokens,
|
||||
totalCostUSD: source.totalCostUSD,
|
||||
daily: source.daily.map(Self.costDailyPayload(from:)),
|
||||
modelBreakdowns: source.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:)))
|
||||
})
|
||||
} ?? []
|
||||
: []
|
||||
|
||||
return CostPayload(
|
||||
provider: provider.rawValue,
|
||||
source: "local",
|
||||
updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()),
|
||||
currencyCode: snapshot?.currencyCode,
|
||||
sessionTokens: snapshot?.sessionTokens,
|
||||
sessionCostUSD: snapshot?.sessionCostUSD,
|
||||
historyDays: snapshot?.historyDays,
|
||||
last30DaysTokens: snapshot?.last30DaysTokens,
|
||||
last30DaysCostUSD: snapshot?.last30DaysCostUSD,
|
||||
daily: daily,
|
||||
projects: projects,
|
||||
totals: snapshot.flatMap(Self.costTotals(from:)),
|
||||
error: error.map { Self.makeErrorPayload($0) })
|
||||
}
|
||||
|
||||
private static func costDailyPayload(from entry: CostUsageDailyReport.Entry) -> CostDailyEntryPayload {
|
||||
CostDailyEntryPayload(
|
||||
date: entry.date,
|
||||
inputTokens: entry.inputTokens,
|
||||
outputTokens: entry.outputTokens,
|
||||
cacheReadTokens: entry.cacheReadTokens,
|
||||
cacheCreationTokens: entry.cacheCreationTokens,
|
||||
totalTokens: entry.totalTokens,
|
||||
costUSD: entry.costUSD,
|
||||
modelsUsed: entry.modelsUsed,
|
||||
modelBreakdowns: entry.modelBreakdowns?.map(self.costModelBreakdownPayload(from:)))
|
||||
}
|
||||
|
||||
private static func costModelBreakdownPayload(
|
||||
from breakdown: CostUsageDailyReport.ModelBreakdown) -> CostModelBreakdownPayload
|
||||
{
|
||||
CostModelBreakdownPayload(
|
||||
modelName: breakdown.modelName,
|
||||
costUSD: breakdown.costUSD,
|
||||
totalTokens: breakdown.totalTokens)
|
||||
}
|
||||
|
||||
private static func costTotals(from snapshot: CostUsageTokenSnapshot) -> CostTotalsPayload? {
|
||||
let entries = snapshot.daily
|
||||
guard !entries.isEmpty else {
|
||||
guard snapshot.last30DaysTokens != nil || snapshot.last30DaysCostUSD != nil else { return nil }
|
||||
return CostTotalsPayload(
|
||||
totalInputTokens: nil,
|
||||
totalOutputTokens: nil,
|
||||
cacheReadTokens: nil,
|
||||
cacheCreationTokens: nil,
|
||||
totalTokens: snapshot.last30DaysTokens,
|
||||
totalCostUSD: snapshot.last30DaysCostUSD)
|
||||
}
|
||||
|
||||
var totalInput = 0
|
||||
var totalOutput = 0
|
||||
var totalCacheRead = 0
|
||||
var totalCacheCreation = 0
|
||||
var totalTokens = 0
|
||||
var totalCost = 0.0
|
||||
var sawInput = false
|
||||
var sawOutput = false
|
||||
var sawCacheRead = false
|
||||
var sawCacheCreation = false
|
||||
var sawTokens = false
|
||||
var sawCost = false
|
||||
|
||||
for entry in entries {
|
||||
if let input = entry.inputTokens {
|
||||
totalInput += input
|
||||
sawInput = true
|
||||
}
|
||||
if let output = entry.outputTokens {
|
||||
totalOutput += output
|
||||
sawOutput = true
|
||||
}
|
||||
if let cacheRead = entry.cacheReadTokens {
|
||||
totalCacheRead += cacheRead
|
||||
sawCacheRead = true
|
||||
}
|
||||
if let cacheCreation = entry.cacheCreationTokens {
|
||||
totalCacheCreation += cacheCreation
|
||||
sawCacheCreation = true
|
||||
}
|
||||
if let tokens = entry.totalTokens {
|
||||
totalTokens += tokens
|
||||
sawTokens = true
|
||||
}
|
||||
if let cost = entry.costUSD {
|
||||
totalCost += cost
|
||||
sawCost = true
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer totals derived from daily rows; fall back to snapshot aggregates when rows omit fields.
|
||||
return CostTotalsPayload(
|
||||
totalInputTokens: sawInput ? totalInput : nil,
|
||||
totalOutputTokens: sawOutput ? totalOutput : nil,
|
||||
cacheReadTokens: sawCacheRead ? totalCacheRead : nil,
|
||||
cacheCreationTokens: sawCacheCreation ? totalCacheCreation : nil,
|
||||
totalTokens: sawTokens ? totalTokens : snapshot.last30DaysTokens,
|
||||
totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD)
|
||||
}
|
||||
|
||||
private static func decodeCostHistoryDays(from values: ParsedValues) -> Int {
|
||||
guard let raw = values.options["days"]?.last,
|
||||
let parsed = Int(raw)
|
||||
else { return 30 }
|
||||
return max(1, min(365, parsed))
|
||||
}
|
||||
|
||||
private static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy {
|
||||
guard let raw = values.options["groupBy"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else { return .none }
|
||||
return CostGroupBy(rawValue: raw.lowercased()) ?? .none
|
||||
}
|
||||
}
|
||||
|
||||
struct CostOptions: 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?
|
||||
|
||||
@Option(
|
||||
name: .long("provider"),
|
||||
help: ProviderHelp.optionHelp)
|
||||
var provider: ProviderSelection?
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: text | json")
|
||||
var format: OutputFormat?
|
||||
|
||||
@Flag(name: .long("json"), help: "")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
|
||||
var jsonOnly: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
|
||||
@Flag(name: .long("no-color"), help: "Disable ANSI colors in text output")
|
||||
var noColor: Bool = false
|
||||
|
||||
@Flag(name: .long("refresh"), help: "Force refresh by ignoring cached scans")
|
||||
var refresh: Bool = false
|
||||
|
||||
@Option(name: .long("days"), help: "Cost history window in days (1...365)")
|
||||
var days: Int?
|
||||
|
||||
@Option(name: .long("group-by"), help: "Group text output by: project")
|
||||
var groupBy: String?
|
||||
}
|
||||
|
||||
struct CostPayload: Encodable, Sendable {
|
||||
let provider: String
|
||||
let source: String
|
||||
let updatedAt: Date?
|
||||
let currencyCode: String?
|
||||
let sessionTokens: Int?
|
||||
let sessionCostUSD: Double?
|
||||
let historyDays: Int?
|
||||
let last30DaysTokens: Int?
|
||||
let last30DaysCostUSD: Double?
|
||||
let daily: [CostDailyEntryPayload]
|
||||
let projects: [CostProjectPayload]
|
||||
let totals: CostTotalsPayload?
|
||||
let error: ProviderErrorPayload?
|
||||
|
||||
init(
|
||||
provider: String,
|
||||
source: String,
|
||||
updatedAt: Date?,
|
||||
currencyCode: String? = nil,
|
||||
sessionTokens: Int?,
|
||||
sessionCostUSD: Double?,
|
||||
historyDays: Int?,
|
||||
last30DaysTokens: Int?,
|
||||
last30DaysCostUSD: Double?,
|
||||
daily: [CostDailyEntryPayload],
|
||||
projects: [CostProjectPayload] = [],
|
||||
totals: CostTotalsPayload?,
|
||||
error: ProviderErrorPayload?)
|
||||
{
|
||||
self.provider = provider
|
||||
self.source = source
|
||||
self.updatedAt = updatedAt
|
||||
self.currencyCode = currencyCode
|
||||
self.sessionTokens = sessionTokens
|
||||
self.sessionCostUSD = sessionCostUSD
|
||||
self.historyDays = historyDays
|
||||
self.last30DaysTokens = last30DaysTokens
|
||||
self.last30DaysCostUSD = last30DaysCostUSD
|
||||
self.daily = daily
|
||||
self.projects = projects
|
||||
self.totals = totals
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
struct CostDailyEntryPayload: Encodable, Sendable {
|
||||
let date: String
|
||||
let inputTokens: Int?
|
||||
let outputTokens: Int?
|
||||
let cacheReadTokens: Int?
|
||||
let cacheCreationTokens: Int?
|
||||
let totalTokens: Int?
|
||||
let costUSD: Double?
|
||||
let modelsUsed: [String]?
|
||||
let modelBreakdowns: [CostModelBreakdownPayload]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case date
|
||||
case inputTokens
|
||||
case outputTokens
|
||||
case cacheReadTokens
|
||||
case cacheCreationTokens
|
||||
case totalTokens
|
||||
case costUSD = "totalCost"
|
||||
case modelsUsed
|
||||
case modelBreakdowns
|
||||
}
|
||||
}
|
||||
|
||||
struct CostModelBreakdownPayload: Encodable, Sendable {
|
||||
let modelName: String
|
||||
let costUSD: Double?
|
||||
let totalTokens: Int?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case modelName
|
||||
case costUSD = "cost"
|
||||
case totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
struct CostProjectPayload: Encodable, Sendable {
|
||||
let name: String
|
||||
let path: String?
|
||||
let totalTokens: Int?
|
||||
let totalCostUSD: Double?
|
||||
let daily: [CostDailyEntryPayload]
|
||||
let modelBreakdowns: [CostModelBreakdownPayload]?
|
||||
let sources: [CostProjectSourcePayload]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case path
|
||||
case totalTokens
|
||||
case totalCostUSD = "totalCost"
|
||||
case daily
|
||||
case modelBreakdowns
|
||||
case sources
|
||||
}
|
||||
|
||||
init(
|
||||
name: String,
|
||||
path: String?,
|
||||
totalTokens: Int?,
|
||||
totalCostUSD: Double?,
|
||||
daily: [CostDailyEntryPayload],
|
||||
modelBreakdowns: [CostModelBreakdownPayload]?,
|
||||
sources: [CostProjectSourcePayload] = [])
|
||||
{
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.totalTokens = totalTokens
|
||||
self.totalCostUSD = totalCostUSD
|
||||
self.daily = daily
|
||||
self.modelBreakdowns = modelBreakdowns
|
||||
self.sources = sources
|
||||
}
|
||||
}
|
||||
|
||||
struct CostProjectSourcePayload: Encodable, Sendable {
|
||||
let name: String
|
||||
let path: String?
|
||||
let totalTokens: Int?
|
||||
let totalCostUSD: Double?
|
||||
let daily: [CostDailyEntryPayload]
|
||||
let modelBreakdowns: [CostModelBreakdownPayload]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case path
|
||||
case totalTokens
|
||||
case totalCostUSD = "totalCost"
|
||||
case daily
|
||||
case modelBreakdowns
|
||||
}
|
||||
}
|
||||
|
||||
struct CostTotalsPayload: Encodable, Sendable {
|
||||
let totalInputTokens: Int?
|
||||
let totalOutputTokens: Int?
|
||||
let cacheReadTokens: Int?
|
||||
let cacheCreationTokens: Int?
|
||||
let totalTokens: Int?
|
||||
let totalCostUSD: Double?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case totalInputTokens = "inputTokens"
|
||||
case totalOutputTokens = "outputTokens"
|
||||
case cacheReadTokens
|
||||
case cacheCreationTokens
|
||||
case totalTokens
|
||||
case totalCostUSD = "totalCost"
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally empty.
|
||||
@@ -0,0 +1,341 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func runDiagnose(_ values: ParsedValues) async {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = Self.loadConfig(output: output)
|
||||
|
||||
let format = Self.decodeFormat(from: values)
|
||||
guard format == .json else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: only JSON format is supported for diagnose",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
|
||||
let providerSelection: ProviderSelection
|
||||
if let rawProvider = values.options["provider"]?.last {
|
||||
guard let parsed = ProviderSelection(argument: rawProvider) else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: unknown provider '\(rawProvider)'",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
providerSelection = parsed
|
||||
} else {
|
||||
providerSelection = Self.providerSelection(rawOverride: nil, enabled: config.enabledProviders())
|
||||
}
|
||||
|
||||
let providers = providerSelection.asList
|
||||
let pretty = values.flags.contains("pretty")
|
||||
let verbose = values.flags.contains("verbose")
|
||||
let outputPath = values.options["output"]?.last
|
||||
let browserDetection = BrowserDetection()
|
||||
let baseFetcher = UsageFetcher()
|
||||
|
||||
let tokenSelection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false)
|
||||
let tokenContext: TokenAccountCLIContext
|
||||
do {
|
||||
tokenContext = try TokenAccountCLIContext(
|
||||
selection: tokenSelection,
|
||||
config: config,
|
||||
verbose: verbose)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config)
|
||||
}
|
||||
|
||||
var diagnostics: [ProviderDiagnosticExport] = []
|
||||
diagnostics.reserveCapacity(providers.count)
|
||||
for provider in providers {
|
||||
await diagnostics.append(Self.makeDiagnosticExport(
|
||||
provider: provider,
|
||||
tokenContext: tokenContext,
|
||||
baseFetcher: baseFetcher,
|
||||
browserDetection: browserDetection,
|
||||
verbose: verbose))
|
||||
}
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = pretty ? [.prettyPrinted, .sortedKeys] : .sortedKeys
|
||||
|
||||
do {
|
||||
let data: Data = if diagnostics.count == 1, let diagnostic = diagnostics.first {
|
||||
try encoder.encode(diagnostic)
|
||||
} else {
|
||||
try encoder.encode(ProviderDiagnosticBatchExport(
|
||||
timestamp: Date(),
|
||||
diagnostics: diagnostics))
|
||||
}
|
||||
var jsonString = String(data: data, encoding: .utf8) ?? "{}"
|
||||
jsonString = LogRedactor.redact(jsonString)
|
||||
if let outputPath, !outputPath.isEmpty {
|
||||
try Self.writeDiagnosticExport(jsonString, to: outputPath)
|
||||
} else {
|
||||
print(jsonString)
|
||||
}
|
||||
} catch {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error encoding diagnostic: \(error.localizedDescription)",
|
||||
output: output,
|
||||
kind: .runtime)
|
||||
}
|
||||
|
||||
Self.exit(code: .success, output: output, kind: .runtime)
|
||||
}
|
||||
|
||||
static func writeDiagnosticExport(_ jsonString: String, to path: String) throws {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let parent = url.deletingLastPathComponent()
|
||||
if !parent.path.isEmpty {
|
||||
try FileManager.default.createDirectory(
|
||||
at: parent,
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
try jsonString.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
extension CodexBarCLI {
|
||||
private static func makeDiagnosticExport(
|
||||
provider: UsageProvider,
|
||||
tokenContext: TokenAccountCLIContext,
|
||||
baseFetcher: UsageFetcher,
|
||||
browserDetection: BrowserDetection,
|
||||
verbose: Bool) async -> ProviderDiagnosticExport
|
||||
{
|
||||
let account = ((try? tokenContext.resolvedAccounts(for: provider)) ?? []).first
|
||||
let env = tokenContext.environment(
|
||||
base: ProcessInfo.processInfo.environment,
|
||||
provider: provider,
|
||||
account: account,
|
||||
codexActiveSourceOverride: nil)
|
||||
let settings = tokenContext.settingsSnapshot(
|
||||
for: provider,
|
||||
account: account,
|
||||
codexActiveSourceOverride: nil)
|
||||
let preferredSourceMode = tokenContext.preferredSourceMode(for: provider)
|
||||
let sourceMode = tokenContext.effectiveSourceMode(
|
||||
base: preferredSourceMode,
|
||||
provider: provider,
|
||||
account: account)
|
||||
let fetcher = tokenContext.fetcher(base: baseFetcher, provider: provider, env: env)
|
||||
let fetchContext = ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: true,
|
||||
includeOptionalUsage: true,
|
||||
webTimeout: 60,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: verbose,
|
||||
env: env,
|
||||
settings: settings,
|
||||
fetcher: fetcher,
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection,
|
||||
selectedTokenAccountID: account?.id,
|
||||
tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account),
|
||||
providerManualTokenUpdater: tokenContext.manualTokenUpdater())
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
|
||||
let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext)
|
||||
return ProviderDiagnosticExportBuilder.build(.init(
|
||||
provider: provider,
|
||||
descriptor: descriptor,
|
||||
outcome: outcome,
|
||||
sourceMode: sourceMode,
|
||||
settings: settings,
|
||||
auth: Self.diagnosticAuthSummary(
|
||||
provider: provider,
|
||||
account: account,
|
||||
config: tokenContext.config.providerConfig(for: provider),
|
||||
environment: env,
|
||||
settings: settings),
|
||||
appVersion: Self.currentVersion()))
|
||||
}
|
||||
|
||||
static func diagnosticAuthSummary(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?,
|
||||
environment: [String: String],
|
||||
settings: ProviderSettingsSnapshot?) -> ProviderDiagnosticAuthSummary
|
||||
{
|
||||
if provider == .minimax {
|
||||
let authMode = self.resolveMiniMaxAuthMode(environment: environment, settings: settings)
|
||||
return ProviderDiagnosticAuthSummary(
|
||||
configured: authMode.usesAPIToken || authMode.usesCookie,
|
||||
modes: authMode == .none ? [] : [authMode.description])
|
||||
}
|
||||
|
||||
var modes: [String] = []
|
||||
if account != nil {
|
||||
modes.append("tokenAccount")
|
||||
}
|
||||
let hasConfigAPIAuth = if provider == .bedrock {
|
||||
config?.sanitizedAPIKey != nil && config?.sanitizedSecretKey != nil
|
||||
} else {
|
||||
config?.sanitizedAPIKey != nil || config?.sanitizedSecretKey != nil
|
||||
}
|
||||
if hasConfigAPIAuth {
|
||||
modes.append("api")
|
||||
}
|
||||
if Self.environmentAPIAuthConfigured(provider: provider, environment: environment), !modes.contains("api") {
|
||||
modes.append("api")
|
||||
}
|
||||
if config?.sanitizedCookieHeader != nil {
|
||||
modes.append("web")
|
||||
}
|
||||
if Self.environmentWebAuthConfigured(provider: provider, environment: environment), !modes.contains("web") {
|
||||
modes.append("web")
|
||||
}
|
||||
return ProviderDiagnosticAuthSummary(
|
||||
configured: !modes.isEmpty,
|
||||
modes: modes)
|
||||
}
|
||||
|
||||
private static func environmentAPIAuthConfigured(
|
||||
provider: UsageProvider,
|
||||
environment: [String: String]) -> Bool
|
||||
{
|
||||
self.environmentCoreAPIAuthConfigured(provider: provider, environment: environment) ||
|
||||
self.environmentExtendedAPIAuthConfigured(provider: provider, environment: environment)
|
||||
}
|
||||
|
||||
private static func environmentCoreAPIAuthConfigured(
|
||||
provider: UsageProvider,
|
||||
environment: [String: String]) -> Bool
|
||||
{
|
||||
switch provider {
|
||||
case .alibaba:
|
||||
AlibabaCodingPlanSettingsReader.apiToken(environment: environment) != nil
|
||||
case .azureopenai:
|
||||
AzureOpenAISettingsReader.apiKey(environment: environment) != nil
|
||||
case .bedrock:
|
||||
BedrockSettingsReader.hasCredentials(environment: environment)
|
||||
case .claude:
|
||||
ClaudeAdminAPISettingsReader.apiKey(environment: environment) != nil
|
||||
case .codebuff:
|
||||
CodebuffSettingsReader.apiKey(environment: environment) != nil
|
||||
case .chutes:
|
||||
ChutesSettingsReader.apiKey(environment: environment) != nil
|
||||
case .crof:
|
||||
CrofSettingsReader.apiKey(environment: environment) != nil
|
||||
case .crossmodel:
|
||||
CrossModelSettingsReader.apiToken(environment: environment) != nil
|
||||
case .deepgram:
|
||||
DeepgramSettingsReader.apiKey(environment: environment) != nil
|
||||
case .deepseek:
|
||||
DeepSeekSettingsReader.apiKey(environment: environment) != nil
|
||||
case .doubao:
|
||||
DoubaoSettingsReader.apiKey(environment: environment) != nil
|
||||
case .elevenlabs:
|
||||
ElevenLabsSettingsReader.apiKey(environment: environment) != nil
|
||||
case .groq:
|
||||
GroqSettingsReader.apiKey(environment: environment) != nil
|
||||
case .kilo:
|
||||
KiloSettingsReader.apiKey(environment: environment) != nil
|
||||
case .factory:
|
||||
FactorySettingsReader.apiKey(environment: environment) != nil
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func environmentExtendedAPIAuthConfigured(
|
||||
provider: UsageProvider,
|
||||
environment: [String: String]) -> Bool
|
||||
{
|
||||
switch provider {
|
||||
case .kimi:
|
||||
KimiSettingsReader.apiKey(environment: environment) != nil
|
||||
case .kimik2:
|
||||
KimiK2SettingsReader.apiKey(environment: environment) != nil
|
||||
case .llmproxy:
|
||||
LLMProxySettingsReader.apiKey(environment: environment) != nil
|
||||
case .clawrouter:
|
||||
ClawRouterSettingsReader.apiKey(environment: environment) != nil
|
||||
case .sub2api:
|
||||
Sub2APISettingsReader.apiKey(environment: environment) != nil
|
||||
case .moonshot:
|
||||
MoonshotSettingsReader.apiKey(environment: environment) != nil
|
||||
case .ollama:
|
||||
OllamaAPISettingsReader.apiKey(environment: environment) != nil
|
||||
case .openai:
|
||||
OpenAIAPISettingsReader.apiKey(environment: environment) != nil
|
||||
case .openrouter:
|
||||
OpenRouterSettingsReader.apiToken(environment: environment) != nil
|
||||
case .stepfun:
|
||||
StepFunSettingsReader.token(environment: environment) != nil
|
||||
case .synthetic:
|
||||
SyntheticSettingsReader.apiKey(environment: environment) != nil
|
||||
case .venice:
|
||||
VeniceSettingsReader.apiKey(environment: environment) != nil
|
||||
case .warp:
|
||||
WarpSettingsReader.apiKey(environment: environment) != nil
|
||||
case .zai:
|
||||
ZaiSettingsReader.apiToken(environment: environment) != nil
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func environmentWebAuthConfigured(
|
||||
provider: UsageProvider,
|
||||
environment: [String: String]) -> Bool
|
||||
{
|
||||
switch provider {
|
||||
case .alibabatokenplan:
|
||||
AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment) != nil
|
||||
case .kimi:
|
||||
KimiSettingsReader.authToken(environment: environment) != nil
|
||||
case .manus:
|
||||
ManusSettingsReader.sessionToken(environment: environment) != nil
|
||||
case .perplexity:
|
||||
PerplexitySettingsReader.sessionToken(environment: environment) != nil
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveMiniMaxAuthMode(
|
||||
environment: [String: String],
|
||||
settings: ProviderSettingsSnapshot?) -> MiniMaxAuthMode
|
||||
{
|
||||
let apiToken = ProviderTokenResolver.minimaxToken(environment: environment)
|
||||
let envCookieHeader = ProviderTokenResolver.minimaxCookie(environment: environment)
|
||||
let settingsCookieHeader = CookieHeaderNormalizer.normalize(settings?.minimax?.manualCookieHeader)
|
||||
let cookieHeader = envCookieHeader ?? settingsCookieHeader
|
||||
return MiniMaxAuthMode.resolve(apiToken: apiToken, cookieHeader: cookieHeader)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension CodexBarCLI {
|
||||
static func _diagnosticAuthSummaryForTesting(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?,
|
||||
environment: [String: String],
|
||||
settings: ProviderSettingsSnapshot?) -> ProviderDiagnosticAuthSummary
|
||||
{
|
||||
self.diagnosticAuthSummary(
|
||||
provider: provider,
|
||||
account: account,
|
||||
config: config,
|
||||
environment: environment,
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
static func _resolveMiniMaxAuthModeForTesting(
|
||||
environment: [String: String],
|
||||
settings: ProviderSettingsSnapshot?) -> MiniMaxAuthMode
|
||||
{
|
||||
self.resolveMiniMaxAuthMode(environment: environment, settings: settings)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,226 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
@main
|
||||
enum CodexBarCLI {
|
||||
static func main() async {
|
||||
let rawArgv = Array(CommandLine.arguments.dropFirst())
|
||||
let argv = Self.effectiveArgv(rawArgv)
|
||||
let outputPreferences = CLIOutputPreferences.from(argv: argv)
|
||||
|
||||
// Fast path: global help/version before building descriptors.
|
||||
if let helpIndex = argv.firstIndex(where: { $0 == "-h" || $0 == "--help" }) {
|
||||
let command = helpIndex == 0 ? argv.dropFirst().first : argv.first
|
||||
Self.printHelp(for: command)
|
||||
}
|
||||
if argv.contains("-V") || argv.contains("--version") {
|
||||
Self.printVersion()
|
||||
}
|
||||
|
||||
let program = Program(descriptors: Self.commandDescriptors())
|
||||
|
||||
do {
|
||||
let invocation = try program.resolve(argv: argv)
|
||||
Self.bootstrapLogging(path: invocation.path, values: invocation.parsedValues)
|
||||
switch invocation.path {
|
||||
case ["cards"]:
|
||||
let signalMonitor = CLITerminationSignalMonitor { signalNumber in
|
||||
CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber)
|
||||
}
|
||||
defer { signalMonitor.cancel() }
|
||||
await self.runCards(invocation.parsedValues)
|
||||
case ["usage"]:
|
||||
let signalMonitor = CLITerminationSignalMonitor { signalNumber in
|
||||
CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber)
|
||||
}
|
||||
defer { signalMonitor.cancel() }
|
||||
await self.runUsage(invocation.parsedValues)
|
||||
case ["cost"]:
|
||||
await self.runCost(invocation.parsedValues)
|
||||
case ["sessions", "list"]:
|
||||
await self.runSessions(invocation.parsedValues)
|
||||
case ["sessions", "focus"]:
|
||||
await self.runSessionsFocus(invocation.parsedValues)
|
||||
case ["serve"]:
|
||||
await self.runServe(invocation.parsedValues)
|
||||
case ["config", "validate"]:
|
||||
self.runConfigValidate(invocation.parsedValues)
|
||||
case ["config", "dump"]:
|
||||
self.runConfigDump(invocation.parsedValues)
|
||||
case ["config", "providers"]:
|
||||
self.runConfigProviders(invocation.parsedValues)
|
||||
case ["config", "enable"]:
|
||||
self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: true)
|
||||
case ["config", "disable"]:
|
||||
self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: false)
|
||||
case ["config", "set-api-key"]:
|
||||
self.runConfigSetAPIKey(invocation.parsedValues)
|
||||
case ["cache", "clear"]:
|
||||
self.runCacheClear(invocation.parsedValues)
|
||||
case ["diagnose"]:
|
||||
let signalMonitor = CLITerminationSignalMonitor { signalNumber in
|
||||
CLITerminationSignalMonitor.terminateActiveHelpersAndReraise(signalNumber)
|
||||
}
|
||||
defer { signalMonitor.cancel() }
|
||||
await self.runDiagnose(invocation.parsedValues)
|
||||
default:
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Unknown command",
|
||||
output: outputPreferences,
|
||||
kind: .args)
|
||||
}
|
||||
} catch let error as CommanderProgramError {
|
||||
Self.exit(code: .failure, message: error.description, output: outputPreferences, kind: .args)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: error.localizedDescription, output: outputPreferences, kind: .runtime)
|
||||
}
|
||||
}
|
||||
|
||||
private static func commandDescriptors() -> [CommandDescriptor] {
|
||||
let cardsSignature = CommandSignature.describe(CardsOptions())
|
||||
let usageSignature = CommandSignature.describe(UsageOptions())
|
||||
let costSignature = CommandSignature.describe(CostOptions())
|
||||
let sessionsSignature = CommandSignature.describe(SessionsOptions())
|
||||
let sessionsFocusSignature = CommandSignature.describe(SessionsFocusOptions())
|
||||
let serveSignature = CommandSignature.describe(ServeOptions())
|
||||
let configSignature = CommandSignature.describe(ConfigOptions())
|
||||
let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions())
|
||||
let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions())
|
||||
let cacheSignature = CommandSignature.describe(CacheOptions())
|
||||
let diagnoseSignature = CommandSignature.describe(DiagnoseOptions())
|
||||
|
||||
return [
|
||||
CommandDescriptor(
|
||||
name: "cards",
|
||||
abstract: "Print usage as a terminal card grid",
|
||||
discussion: nil,
|
||||
signature: cardsSignature),
|
||||
CommandDescriptor(
|
||||
name: "usage",
|
||||
abstract: "Print usage as text or JSON",
|
||||
discussion: nil,
|
||||
signature: usageSignature),
|
||||
CommandDescriptor(
|
||||
name: "cost",
|
||||
abstract: "Print local cost usage as text or JSON",
|
||||
discussion: nil,
|
||||
signature: costSignature),
|
||||
CommandDescriptor(
|
||||
name: "sessions",
|
||||
abstract: "List live Codex and Claude Code sessions",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [
|
||||
CommandDescriptor(
|
||||
name: "list",
|
||||
abstract: "List live Codex and Claude Code sessions",
|
||||
discussion: nil,
|
||||
signature: sessionsSignature),
|
||||
CommandDescriptor(
|
||||
name: "focus",
|
||||
abstract: "Focus the window for a session",
|
||||
discussion: nil,
|
||||
signature: sessionsFocusSignature),
|
||||
],
|
||||
defaultSubcommandName: "list"),
|
||||
CommandDescriptor(
|
||||
name: "serve",
|
||||
abstract: "Serve usage and cost JSON over localhost HTTP",
|
||||
discussion: nil,
|
||||
signature: serveSignature),
|
||||
CommandDescriptor(
|
||||
name: "config",
|
||||
abstract: "Config utilities",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [
|
||||
CommandDescriptor(
|
||||
name: "validate",
|
||||
abstract: "Validate config file",
|
||||
discussion: nil,
|
||||
signature: configSignature),
|
||||
CommandDescriptor(
|
||||
name: "dump",
|
||||
abstract: "Print normalized config JSON",
|
||||
discussion: nil,
|
||||
signature: configSignature),
|
||||
CommandDescriptor(
|
||||
name: "providers",
|
||||
abstract: "List provider enablement",
|
||||
discussion: nil,
|
||||
signature: configSignature),
|
||||
CommandDescriptor(
|
||||
name: "enable",
|
||||
abstract: "Enable a provider",
|
||||
discussion: nil,
|
||||
signature: configProviderToggleSignature),
|
||||
CommandDescriptor(
|
||||
name: "disable",
|
||||
abstract: "Disable a provider",
|
||||
discussion: nil,
|
||||
signature: configProviderToggleSignature),
|
||||
CommandDescriptor(
|
||||
name: "set-api-key",
|
||||
abstract: "Store a provider API key",
|
||||
discussion: nil,
|
||||
signature: configSetAPIKeySignature),
|
||||
],
|
||||
defaultSubcommandName: "validate"),
|
||||
CommandDescriptor(
|
||||
name: "cache",
|
||||
abstract: "Cache management",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [
|
||||
CommandDescriptor(
|
||||
name: "clear",
|
||||
abstract: "Clear cached data (cookies, cost, or all)",
|
||||
discussion: nil,
|
||||
signature: cacheSignature),
|
||||
],
|
||||
defaultSubcommandName: "clear"),
|
||||
CommandDescriptor(
|
||||
name: "diagnose",
|
||||
abstract: "Run provider diagnostic and emit safe JSON export",
|
||||
discussion: nil,
|
||||
signature: diagnoseSignature),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func bootstrapLogging(path: [String], values: ParsedValues) {
|
||||
CodexBarLog.bootstrapIfNeeded(self.loggingConfiguration(path: path, values: values))
|
||||
}
|
||||
|
||||
static func loggingConfiguration(path: [String], values: ParsedValues) -> CodexBarLog.Configuration {
|
||||
let isJSON = values.flags.contains("jsonOutput") || values.flags.contains("jsonOnly")
|
||||
let verbose = values.flags.contains("verbose")
|
||||
let rawLevel = values.options["logLevel"]?.last
|
||||
let level = Self.resolvedLogLevel(verbose: verbose, rawLevel: rawLevel)
|
||||
let destination: CodexBarLog.Destination = path == ["diagnose"] ? .discard : .stderr
|
||||
return .init(destination: destination, level: level, json: isJSON)
|
||||
}
|
||||
|
||||
static func resolvedLogLevel(verbose: Bool, rawLevel: String?) -> CodexBarLog.Level {
|
||||
CodexBarLog.parseLevel(rawLevel) ?? (verbose ? .debug : .error)
|
||||
}
|
||||
|
||||
static func effectiveArgv(_ argv: [String]) -> [String] {
|
||||
guard let first = argv.first else { return ["usage"] }
|
||||
if first.hasPrefix("-") { return ["usage"] + argv }
|
||||
return argv
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
enum CLIErrorKind: String, Encodable, Sendable {
|
||||
case args
|
||||
case config
|
||||
case provider
|
||||
case runtime
|
||||
}
|
||||
|
||||
struct ProviderErrorPayload: Encodable, Sendable {
|
||||
let code: Int32
|
||||
let message: String
|
||||
let kind: CLIErrorKind?
|
||||
}
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func makeErrorPayload(_ error: Error, kind: CLIErrorKind? = nil) -> ProviderErrorPayload {
|
||||
ProviderErrorPayload(
|
||||
code: self.mapError(error).rawValue,
|
||||
message: error.localizedDescription,
|
||||
kind: kind)
|
||||
}
|
||||
|
||||
static func makeErrorPayload(code: ExitCode, message: String, kind: CLIErrorKind? = nil) -> ProviderErrorPayload {
|
||||
ProviderErrorPayload(code: code.rawValue, message: message, kind: kind)
|
||||
}
|
||||
|
||||
static func makeCLIErrorPayload(
|
||||
message: String,
|
||||
code: ExitCode,
|
||||
kind: CLIErrorKind,
|
||||
pretty: Bool) -> String?
|
||||
{
|
||||
let payload = ProviderPayload(
|
||||
providerID: "cli",
|
||||
account: nil,
|
||||
version: nil,
|
||||
source: "cli",
|
||||
status: nil,
|
||||
usage: nil,
|
||||
credits: nil,
|
||||
antigravityPlanInfo: nil,
|
||||
openaiDashboard: nil,
|
||||
error: ProviderErrorPayload(code: code.rawValue, message: message, kind: kind))
|
||||
return self.encodeJSON([payload], pretty: pretty)
|
||||
}
|
||||
|
||||
static func makeProviderErrorPayload(
|
||||
provider: UsageProvider,
|
||||
account: String?,
|
||||
cacheAccountKey: String? = nil,
|
||||
source: String,
|
||||
status: ProviderStatusPayload?,
|
||||
error: Error,
|
||||
kind: CLIErrorKind = .provider) -> ProviderPayload
|
||||
{
|
||||
ProviderPayload(
|
||||
provider: provider,
|
||||
account: account,
|
||||
cacheAccountKey: cacheAccountKey,
|
||||
version: nil,
|
||||
source: source,
|
||||
status: status,
|
||||
usage: nil,
|
||||
credits: nil,
|
||||
antigravityPlanInfo: nil,
|
||||
openaiDashboard: nil,
|
||||
error: self.makeErrorPayload(error, kind: kind))
|
||||
}
|
||||
|
||||
static func encodeJSON(_ payload: some Encodable, pretty: Bool) -> String? {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = pretty ? [.prettyPrinted, .sortedKeys] : []
|
||||
guard let data = try? encoder.encode(payload) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func printJSON(_ payload: some Encodable, pretty: Bool) {
|
||||
if let output = self.encodeJSON(payload, pretty: pretty) {
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
|
||||
static func exit(
|
||||
code: ExitCode,
|
||||
message: String? = nil,
|
||||
output: CLIOutputPreferences? = nil,
|
||||
kind: CLIErrorKind = .runtime) -> Never
|
||||
{
|
||||
if self.shouldPrintExitError(code: code, message: message) {
|
||||
if let output, output.usesJSONOutput {
|
||||
let payload = self.makeCLIErrorPayload(
|
||||
message: message ?? "",
|
||||
code: code,
|
||||
kind: kind,
|
||||
pretty: output.pretty)
|
||||
if let payload {
|
||||
print(payload)
|
||||
}
|
||||
} else if let message {
|
||||
self.writeStderr("\(message)\n")
|
||||
}
|
||||
}
|
||||
platformExit(code.rawValue)
|
||||
}
|
||||
|
||||
static func shouldPrintExitError(code: ExitCode, message: String?) -> Bool {
|
||||
code != .success && message != nil
|
||||
}
|
||||
|
||||
static func printError(_ error: Error, output: CLIOutputPreferences, kind: CLIErrorKind = .runtime) {
|
||||
if output.usesJSONOutput {
|
||||
let payload = ProviderPayload(
|
||||
providerID: "cli",
|
||||
account: nil,
|
||||
version: nil,
|
||||
source: "cli",
|
||||
status: nil,
|
||||
usage: nil,
|
||||
credits: nil,
|
||||
antigravityPlanInfo: nil,
|
||||
openaiDashboard: nil,
|
||||
error: self.makeErrorPayload(error, kind: kind))
|
||||
self.printJSON([payload], pretty: output.pretty)
|
||||
} else {
|
||||
self.writeStderr("Error: \(error.localizedDescription)\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
enum ExitCode: Int32 {
|
||||
case success = 0
|
||||
case failure = 1
|
||||
case binaryNotFound = 2
|
||||
case parseError = 3
|
||||
case timeout = 4
|
||||
|
||||
init(_ rawValue: Int) {
|
||||
self = ExitCode(rawValue: Int32(rawValue)) ?? .failure
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func cardsHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar cards [--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
|
||||
[--provider \(ProviderHelp.list)]
|
||||
[--account <label>] [--account-index <index>] [--all-accounts]
|
||||
[--no-credits] [--no-color] [--status] [--source <auto|web|cli|oauth|api>]
|
||||
[--web-timeout <seconds>] [--web-debug-dump-html] [--antigravity-plan-debug] [--augment-debug]
|
||||
[--brief]
|
||||
|
||||
Description:
|
||||
Print a one-shot usage snapshot as a responsive card grid in the terminal.
|
||||
Honors enabled providers from config and reuses the same fetch flags as codexbar usage.
|
||||
Failed providers are summarized in a footer instead of error cards.
|
||||
Use --brief for a compact table layout (Provider / Usage / Reset).
|
||||
Stdout is always the rendered card/table text; --json-output only affects stderr logs.
|
||||
|
||||
Global flags:
|
||||
-h, --help Show help
|
||||
-V, --version Show version
|
||||
-v, --verbose Enable verbose logging
|
||||
--no-color Disable ANSI colors in text output
|
||||
--log-level <trace|verbose|debug|info|warning|error|critical>
|
||||
--json-output Emit machine-readable logs (JSONL) to stderr
|
||||
|
||||
Examples:
|
||||
codexbar cards
|
||||
codexbar cards --provider codex
|
||||
codexbar cards --provider all --status
|
||||
codexbar cards --brief
|
||||
codexbar cards --no-color
|
||||
"""
|
||||
}
|
||||
|
||||
static func usageHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar usage [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
|
||||
[--provider \(ProviderHelp.list)]
|
||||
[--account <label>] [--account-index <index>] [--all-accounts]
|
||||
[--no-credits] [--no-color] [--pretty] [--status] [--source <auto|web|cli|oauth|api>]
|
||||
[--web-timeout <seconds>] [--web-debug-dump-html] [--antigravity-plan-debug] [--augment-debug]
|
||||
|
||||
Description:
|
||||
Print usage from enabled providers as text (default) or JSON. Honors your in-app toggles.
|
||||
Output format: use --json (or --format json) for JSON on stdout; use --json-output for JSON logs on stderr.
|
||||
Source behavior is provider-specific:
|
||||
- Codex: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown).
|
||||
Auto falls back to Codex CLI only when cookies are missing.
|
||||
- Claude: claude.ai API.
|
||||
Auto falls back to Claude CLI only when cookies are missing.
|
||||
- Kilo: app.kilo.ai API.
|
||||
Auto falls back to Kilo CLI when API credentials are missing or unauthorized.
|
||||
Token accounts are loaded from the resolved CodexBar config file.
|
||||
Use --account or --account-index to select a specific token account.
|
||||
Use --all-accounts to fetch every token account, or every visible Codex account for Codex.
|
||||
Account selection requires a single provider.
|
||||
|
||||
Global flags:
|
||||
-h, --help Show help
|
||||
-V, --version Show version
|
||||
-v, --verbose Enable verbose logging
|
||||
--no-color Disable ANSI colors in text output
|
||||
--log-level <trace|verbose|debug|info|warning|error|critical>
|
||||
--json-output Emit machine-readable logs (JSONL) to stderr
|
||||
|
||||
Examples:
|
||||
codexbar usage
|
||||
codexbar usage --provider claude
|
||||
codexbar usage --provider gemini
|
||||
codexbar usage --format json --provider all --pretty
|
||||
codexbar usage --provider all --json
|
||||
codexbar usage --status
|
||||
codexbar usage --provider codex --source web --format json --pretty
|
||||
"""
|
||||
}
|
||||
|
||||
static func costHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar cost [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
|
||||
[--provider \(ProviderHelp.list)]
|
||||
[--no-color] [--pretty] [--refresh] [--days <days>] [--group-by project]
|
||||
|
||||
Description:
|
||||
Print local token cost usage from Claude/Codex native logs plus supported pi sessions.
|
||||
This does not require web or CLI access and uses cached scan results unless --refresh is provided.
|
||||
|
||||
Examples:
|
||||
codexbar cost
|
||||
codexbar cost --provider codex --group-by project
|
||||
codexbar cost --provider claude --format json --pretty
|
||||
"""
|
||||
}
|
||||
|
||||
static func sessionsHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar sessions [--json] [--pretty]
|
||||
codexbar sessions focus <id>
|
||||
|
||||
Description:
|
||||
List live local Codex and Claude Code agent sessions.
|
||||
JSON uses stable AgentSession field names and ISO-8601 dates.
|
||||
Focus activates the owning terminal or desktop app on macOS.
|
||||
|
||||
Examples:
|
||||
codexbar sessions
|
||||
codexbar sessions --json
|
||||
codexbar sessions focus 019f3497-73bf-7df3-a173-4f67d968914a
|
||||
"""
|
||||
}
|
||||
|
||||
static func serveHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar serve [--port <port>] [--refresh-interval <seconds>]
|
||||
[--request-timeout <seconds>]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
|
||||
[-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.
|
||||
|
||||
Endpoints:
|
||||
GET /health
|
||||
GET /usage
|
||||
GET /usage?provider=claude
|
||||
GET /usage?provider=all
|
||||
GET /cost
|
||||
GET /cost?provider=codex
|
||||
|
||||
Examples:
|
||||
codexbar serve
|
||||
codexbar serve --port 8080 --refresh-interval 60 --request-timeout 30
|
||||
curl http://127.0.0.1:8080/usage?provider=all
|
||||
"""
|
||||
}
|
||||
|
||||
static func configHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar config validate [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
|
||||
[-v|--verbose]
|
||||
[--pretty]
|
||||
codexbar config dump [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
|
||||
[-v|--verbose]
|
||||
[--pretty]
|
||||
codexbar config providers [--format text|json] [--json] [--json-only] [--pretty]
|
||||
codexbar config enable --provider <name> [--format text|json] [--json] [--json-only] [--pretty]
|
||||
codexbar config disable --provider <name> [--format text|json] [--json] [--json-only] [--pretty]
|
||||
codexbar config set-api-key --provider <name> (--api-key <key>|--stdin)
|
||||
[--label <label>] [--usage-scope team]
|
||||
[--organization-id <org>] [--workspace-id <project>]
|
||||
[--no-enable]
|
||||
[--format text|json] [--json] [--json-only] [--pretty]
|
||||
|
||||
Description:
|
||||
Validate or print the CodexBar config file (default: validate).
|
||||
providers lists persistent provider enablement.
|
||||
enable/disable updates the same provider toggle used by Settings.
|
||||
set-api-key stores a provider API key in the resolved config file and enables that provider by default.
|
||||
For z.ai team usage, add --usage-scope team with BigModel organization and project IDs; this stores
|
||||
the key as a token account instead of a provider-level personal key.
|
||||
|
||||
Examples:
|
||||
codexbar config validate --format json --pretty
|
||||
codexbar config dump --pretty
|
||||
codexbar config providers
|
||||
codexbar config enable --provider grok
|
||||
codexbar config disable --provider cursor
|
||||
printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin
|
||||
printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin \\
|
||||
--label Team --usage-scope team --organization-id org_... --workspace-id proj_...
|
||||
"""
|
||||
}
|
||||
|
||||
static func cacheHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar cache clear <--cookies|--cost|--all>
|
||||
[--provider <name>]
|
||||
[--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
|
||||
[-v|--verbose]
|
||||
[--pretty]
|
||||
|
||||
Description:
|
||||
Clear cached data. Use --cookies to clear browser cookie caches (stored in Keychain),
|
||||
--cost to clear cost usage scan caches, or --all for both.
|
||||
Optionally specify --provider with --cookies to clear cookies for a single provider only.
|
||||
|
||||
Examples:
|
||||
codexbar cache clear --cookies
|
||||
codexbar cache clear --cookies --provider claude
|
||||
codexbar cache clear --cost
|
||||
codexbar cache clear --all
|
||||
codexbar cache clear --all --format json --pretty
|
||||
"""
|
||||
}
|
||||
|
||||
static func diagnoseHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar diagnose --provider <name|all> --format json
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
|
||||
[-v|--verbose]
|
||||
[--redact] [--output <path>]
|
||||
[--pretty]
|
||||
|
||||
Description:
|
||||
Run provider diagnostic fetches and print a safe JSON export for issue reporting.
|
||||
The export is redacted and omits raw API tokens, cookies, auth headers, emails,
|
||||
account IDs, org IDs, raw responses, and billing-history records.
|
||||
|
||||
Examples:
|
||||
codexbar diagnose --provider minimax --format json --redact --output diagnostic.json
|
||||
codexbar diagnose --provider minimax --format json --pretty
|
||||
codexbar diagnose --provider claude --format json --pretty
|
||||
codexbar diagnose --provider all --format json
|
||||
"""
|
||||
}
|
||||
|
||||
static func rootHelp(version: String) -> String {
|
||||
"""
|
||||
CodexBar \(version)
|
||||
|
||||
Usage:
|
||||
codexbar [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
|
||||
[--provider \(ProviderHelp.list)]
|
||||
[--account <label>] [--account-index <index>] [--all-accounts]
|
||||
[--no-credits] [--no-color] [--pretty] [--status] [--source <auto|web|cli|oauth|api>]
|
||||
[--web-timeout <seconds>] [--web-debug-dump-html] [--antigravity-plan-debug] [--augment-debug]
|
||||
codexbar cards [--provider \(ProviderHelp.list)] [--brief] [--no-color] [--status]
|
||||
codexbar cost [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
|
||||
[--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh]
|
||||
[--days <days>] [--group-by project]
|
||||
codexbar sessions [--json] [--pretty]
|
||||
codexbar sessions focus <id>
|
||||
codexbar serve [--port <port>] [--refresh-interval <seconds>]
|
||||
[--request-timeout <seconds>]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
|
||||
codexbar config <validate|dump|providers> [--format text|json]
|
||||
[--json]
|
||||
[--json-only]
|
||||
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
|
||||
[-v|--verbose]
|
||||
[--pretty]
|
||||
codexbar config enable --provider <name>
|
||||
codexbar config disable --provider <name>
|
||||
codexbar config set-api-key --provider <name> (--api-key <key>|--stdin)
|
||||
codexbar config set-api-key --provider zai --stdin --usage-scope team
|
||||
--organization-id <org> --workspace-id <project>
|
||||
codexbar cache clear <--cookies|--cost|--all> [--provider <name>]
|
||||
codexbar diagnose --provider <name|all> --format json [--redact] [--output <path>] [--pretty]
|
||||
|
||||
Global flags:
|
||||
-h, --help Show help
|
||||
-V, --version Show version
|
||||
-v, --verbose Enable verbose logging
|
||||
--no-color Disable ANSI colors in text output
|
||||
--log-level <trace|verbose|debug|info|warning|error|critical>
|
||||
--json-output Emit machine-readable logs (JSONL) to stderr
|
||||
|
||||
Examples:
|
||||
codexbar
|
||||
codexbar --format json --provider all --pretty
|
||||
codexbar --provider all --json
|
||||
codexbar --provider gemini
|
||||
codexbar cards --provider all --status
|
||||
codexbar cards --brief
|
||||
codexbar cost --provider claude --format json --pretty
|
||||
codexbar sessions --json
|
||||
codexbar serve --port 8080
|
||||
codexbar config validate --format json --pretty
|
||||
codexbar config enable --provider grok
|
||||
codexbar config set-api-key --provider elevenlabs --stdin
|
||||
codexbar cache clear --cookies
|
||||
codexbar diagnose --provider minimax --format json --redact --output diagnostic.json
|
||||
codexbar diagnose --provider minimax --format json --pretty
|
||||
codexbar diagnose --provider all --format json
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func decodeProvider(from values: ParsedValues, config: CodexBarConfig) -> ProviderSelection {
|
||||
let rawOverride = values.options["provider"]?.last
|
||||
return Self.providerSelection(rawOverride: rawOverride, enabled: config.enabledProviders())
|
||||
}
|
||||
|
||||
static func providerSelection(rawOverride: String?, enabled: [UsageProvider]) -> ProviderSelection {
|
||||
if let rawOverride, let parsed = ProviderSelection(argument: rawOverride) {
|
||||
return parsed
|
||||
}
|
||||
if enabled.count == 2 {
|
||||
let enabledSet = Set(enabled)
|
||||
let primary = Set(ProviderDescriptorRegistry.all.filter(\ .metadata.isPrimaryProvider).map(\ .id))
|
||||
if !primary.isEmpty, enabledSet == primary {
|
||||
return .both
|
||||
}
|
||||
return .custom(enabled)
|
||||
}
|
||||
if enabled.count >= 3 { return .custom(enabled) }
|
||||
if let first = enabled.first { return ProviderSelection(provider: first) }
|
||||
return .custom([])
|
||||
}
|
||||
|
||||
static func decodeFormat(from values: ParsedValues) -> OutputFormat {
|
||||
if let raw = values.options["format"]?.last, let parsed = OutputFormat(argument: raw) {
|
||||
return parsed
|
||||
}
|
||||
if values.flags.contains("jsonShortcut") || values.flags.contains("json") || values.flags.contains("jsonOnly") {
|
||||
return .json
|
||||
}
|
||||
return .text
|
||||
}
|
||||
|
||||
static func decodeTokenAccountSelection(from values: ParsedValues) throws -> TokenAccountCLISelection {
|
||||
let label = values.options["account"]?.last
|
||||
let rawIndex = values.options["accountIndex"]?.last
|
||||
var index: Int?
|
||||
if let rawIndex {
|
||||
guard let parsed = Int(rawIndex), parsed > 0 else {
|
||||
throw CLIArgumentError("--account-index must be a positive integer.")
|
||||
}
|
||||
index = parsed - 1
|
||||
}
|
||||
let allAccounts = values.flags.contains("allAccounts")
|
||||
return TokenAccountCLISelection(label: label, index: index, allAccounts: allAccounts)
|
||||
}
|
||||
|
||||
static func shouldUseColor(noColor: Bool, format: OutputFormat) -> Bool {
|
||||
guard format == .text else { return false }
|
||||
if noColor { return false }
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if env["TERM"]?.lowercased() == "dumb" { return false }
|
||||
return isatty(STDOUT_FILENO) == 1
|
||||
}
|
||||
|
||||
static func detectVersion(for provider: UsageProvider, browserDetection: BrowserDetection) -> String? {
|
||||
ProviderDescriptorRegistry.descriptor(for: provider).cli.versionDetector?(browserDetection)
|
||||
}
|
||||
|
||||
static func normalizeVersion(raw: String?) -> String? {
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
if let match = raw.range(of: #"(\d+(?:\.\d+)+)"#, options: .regularExpression) {
|
||||
return String(raw[match]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func makeHeader(provider: UsageProvider, version: String?, source: String) -> String {
|
||||
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
|
||||
if let version, !version.isEmpty {
|
||||
return "\(name) \(version) (\(source))"
|
||||
}
|
||||
return "\(name) (\(source))"
|
||||
}
|
||||
|
||||
static func printFetchAttempts(provider: UsageProvider, attempts: [ProviderFetchAttempt]) {
|
||||
guard !attempts.isEmpty else { return }
|
||||
self.writeStderr("[\(provider.rawValue)] fetch strategies:\n")
|
||||
for attempt in attempts {
|
||||
let kindLabel = Self.fetchKindLabel(attempt.kind)
|
||||
var line = " - \(attempt.strategyID) (\(kindLabel))"
|
||||
line += attempt.wasAvailable ? " available" : " unavailable"
|
||||
if let error = attempt.errorDescription, !error.isEmpty {
|
||||
line += " error=\(error)"
|
||||
}
|
||||
self.writeStderr("\(line)\n")
|
||||
}
|
||||
}
|
||||
|
||||
static func usageTextNotes(
|
||||
provider: UsageProvider,
|
||||
sourceMode: ProviderSourceMode,
|
||||
resolvedSourceLabel: String) -> [String]
|
||||
{
|
||||
guard provider == .kilo,
|
||||
sourceMode == .auto,
|
||||
resolvedSourceLabel.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "cli"
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return ["Using CLI fallback"]
|
||||
}
|
||||
|
||||
static func kiloAutoFallbackSummary(
|
||||
provider: UsageProvider,
|
||||
sourceMode: ProviderSourceMode,
|
||||
attempts: [ProviderFetchAttempt]) -> String?
|
||||
{
|
||||
guard provider == .kilo, sourceMode == .auto, !attempts.isEmpty else { return nil }
|
||||
let parts = attempts.map { attempt in
|
||||
let label = Self.fetchKindLabel(attempt.kind)
|
||||
let message = attempt.errorDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !message.isEmpty {
|
||||
return "\(label): \(message)"
|
||||
}
|
||||
return "\(label): \(attempt.wasAvailable ? "success" : "unavailable")"
|
||||
}
|
||||
guard !parts.isEmpty else { return nil }
|
||||
return "Kilo auto fallback attempts: " + parts.joined(separator: " -> ")
|
||||
}
|
||||
|
||||
private static func fetchKindLabel(_ kind: ProviderFetchKind) -> String {
|
||||
switch kind {
|
||||
case .cli: "cli"
|
||||
case .web: "web"
|
||||
case .oauth: "oauth"
|
||||
case .apiToken: "api"
|
||||
case .localProbe: "local"
|
||||
case .webDashboard: "web"
|
||||
}
|
||||
}
|
||||
|
||||
static func fetchStatus(for provider: UsageProvider) async -> ProviderStatusPayload? {
|
||||
let urlString = ProviderDescriptorRegistry.descriptor(for: provider).metadata.statusPageURL
|
||||
guard let urlString,
|
||||
let baseURL = URL(string: urlString) else { return nil }
|
||||
do {
|
||||
return try await StatusFetcher.fetch(from: baseURL)
|
||||
} catch {
|
||||
return ProviderStatusPayload(
|
||||
indicator: .unknown,
|
||||
description: error.localizedDescription,
|
||||
updatedAt: nil,
|
||||
url: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
static func resetTimeDisplayStyleFromDefaults() -> ResetTimeDisplayStyle {
|
||||
let domains = [
|
||||
"com.steipete.codexbar",
|
||||
"com.steipete.codexbar.debug",
|
||||
]
|
||||
for domain in domains {
|
||||
if let value = UserDefaults(suiteName: domain)?.object(forKey: "resetTimesShowAbsolute") as? Bool {
|
||||
return value ? .absolute : .countdown
|
||||
}
|
||||
}
|
||||
let fallback = UserDefaults.standard.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false
|
||||
return fallback ? .absolute : .countdown
|
||||
}
|
||||
|
||||
static func weeklyProgressWorkDaysFromDefaults() -> Int? {
|
||||
let domains = [
|
||||
"com.steipete.codexbar",
|
||||
"com.steipete.codexbar.debug",
|
||||
]
|
||||
for domain in domains {
|
||||
if let value = UserDefaults(suiteName: domain)?.object(forKey: "weeklyProgressWorkDays") as? Int {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return UserDefaults.standard.object(forKey: "weeklyProgressWorkDays") as? Int
|
||||
}
|
||||
|
||||
static func fetchProviderUsage(
|
||||
provider: UsageProvider,
|
||||
context: ProviderFetchContext) async -> ProviderFetchOutcome
|
||||
{
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
|
||||
if !descriptor.fetchPlan.sourceModes.contains(context.sourceMode) {
|
||||
let error = SourceSelectionError.unsupported(
|
||||
provider: descriptor.cli.name,
|
||||
source: context.sourceMode)
|
||||
return ProviderFetchOutcome(result: .failure(error), attempts: [])
|
||||
}
|
||||
return await descriptor.fetchOutcome(context: context)
|
||||
}
|
||||
|
||||
private enum SourceSelectionError: LocalizedError {
|
||||
case unsupported(provider: String, source: ProviderSourceMode)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unsupported(provider, source):
|
||||
"Source '\(source.rawValue)' is not supported for \(provider)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func loadOpenAIDashboardIfAvailable(
|
||||
usage: UsageSnapshot,
|
||||
sourceLabel: String,
|
||||
context: ProviderFetchContext) -> OpenAIDashboardSnapshot?
|
||||
{
|
||||
guard let cache = OpenAIDashboardCacheStore.load() else { return nil }
|
||||
let snapshot: OpenAIDashboardSnapshot = if cache.snapshot.dailyBreakdown.isEmpty,
|
||||
!cache.snapshot.creditEvents.isEmpty
|
||||
{
|
||||
OpenAIDashboardSnapshot(
|
||||
signedInEmail: cache.snapshot.signedInEmail,
|
||||
codeReviewRemainingPercent: cache.snapshot.codeReviewRemainingPercent,
|
||||
codeReviewLimit: cache.snapshot.codeReviewLimit,
|
||||
creditEvents: cache.snapshot.creditEvents,
|
||||
dailyBreakdown: OpenAIDashboardSnapshot.makeDailyBreakdown(
|
||||
from: cache.snapshot.creditEvents,
|
||||
maxDays: 30),
|
||||
usageBreakdown: cache.snapshot.usageBreakdown,
|
||||
creditsPurchaseURL: cache.snapshot.creditsPurchaseURL,
|
||||
updatedAt: cache.snapshot.updatedAt)
|
||||
} else {
|
||||
cache.snapshot
|
||||
}
|
||||
|
||||
let input = CodexCLIDashboardAuthorityContext.makeCachedDashboardInput(
|
||||
dashboard: snapshot,
|
||||
cachedAccountEmail: cache.accountEmail,
|
||||
usage: usage,
|
||||
sourceLabel: sourceLabel,
|
||||
context: context)
|
||||
let decision = CodexDashboardAuthority.evaluate(input)
|
||||
if decision.allowedEffects.contains(.cachedDashboardReuse) {
|
||||
return snapshot
|
||||
}
|
||||
if decision.cleanup.contains(.dashboardCache) {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func decodeWebTimeout(from values: ParsedValues) throws -> TimeInterval? {
|
||||
guard let raw = values.options["webTimeout"]?.last else { return nil }
|
||||
guard let seconds = Double(raw),
|
||||
seconds.isFinite,
|
||||
seconds >= 0,
|
||||
seconds <= TimeInterval(Int64.max)
|
||||
else {
|
||||
throw CLIArgumentError("--web-timeout must be a finite, nonnegative number within the supported range.")
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
static func decodeSourceMode(from values: ParsedValues) -> ProviderSourceMode? {
|
||||
if values.flags.contains("web") {
|
||||
return .web
|
||||
}
|
||||
guard let raw = values.options["source"]?.last?.lowercased() else { return nil }
|
||||
return ProviderSourceMode(rawValue: raw)
|
||||
}
|
||||
|
||||
static func renderOpenAIWebDashboardText(_ dash: OpenAIDashboardSnapshot) -> String {
|
||||
var lines: [String] = []
|
||||
if let email = dash.signedInEmail, !email.isEmpty {
|
||||
lines.append("Web session: \(email)")
|
||||
}
|
||||
if let remaining = dash.codeReviewRemainingPercent {
|
||||
let percent = Int(remaining.rounded())
|
||||
if let limit = dash.codeReviewLimit,
|
||||
let reset = UsageFormatter.resetLine(for: limit, style: .countdown)
|
||||
{
|
||||
lines.append("Code review: \(percent)% remaining (\(reset))")
|
||||
} else {
|
||||
lines.append("Code review: \(percent)% remaining")
|
||||
}
|
||||
}
|
||||
if let first = dash.creditEvents.first {
|
||||
let day = first.date.formatted(date: .abbreviated, time: .omitted)
|
||||
lines.append("Web history: \(dash.creditEvents.count) events (latest \(day))")
|
||||
} else {
|
||||
lines.append("Web history: none")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
static func mapError(_ error: Error) -> ExitCode {
|
||||
switch error {
|
||||
case TTYCommandRunner.Error.binaryNotFound,
|
||||
CodexStatusProbeError.codexNotInstalled,
|
||||
ClaudeUsageError.claudeNotInstalled,
|
||||
GeminiStatusProbeError.geminiNotInstalled:
|
||||
ExitCode(2)
|
||||
case CodexStatusProbeError.timedOut,
|
||||
TTYCommandRunner.Error.timedOut,
|
||||
GeminiStatusProbeError.timedOut,
|
||||
ClaudeWebFetchStrategyError.timedOut,
|
||||
CostUsageError.timedOut:
|
||||
ExitCode(4)
|
||||
case ClaudeUsageError.parseFailed,
|
||||
ClaudeUsageError.oauthFailed,
|
||||
CostUsageError.unsupportedProvider,
|
||||
UsageError.decodeFailed,
|
||||
UsageError.noRateLimitsFound,
|
||||
GeminiStatusProbeError.parseFailed,
|
||||
GeminiStatusProbeError.consumerTierDeprecated:
|
||||
ExitCode(3)
|
||||
default:
|
||||
.failure
|
||||
}
|
||||
}
|
||||
|
||||
static func printAntigravityPlanInfo(_ info: AntigravityPlanInfoSummary) {
|
||||
let fields: [(String, String?)] = [
|
||||
("planName", info.planName),
|
||||
("planDisplayName", info.planDisplayName),
|
||||
("displayName", info.displayName),
|
||||
("productName", info.productName),
|
||||
("planShortName", info.planShortName),
|
||||
]
|
||||
self.writeStderr("Antigravity plan info:\n")
|
||||
for (label, value) in fields {
|
||||
guard let value, !value.isEmpty else { continue }
|
||||
self.writeStderr(" \(label): \(value)\n")
|
||||
}
|
||||
}
|
||||
|
||||
static func loadConfig(output: CLIOutputPreferences) -> CodexBarConfig {
|
||||
let store = CodexBarConfigStore()
|
||||
do {
|
||||
if let existing = try store.load() {
|
||||
return existing
|
||||
}
|
||||
return CodexBarConfig.makeDefault()
|
||||
} catch {
|
||||
if output.usesJSONOutput {
|
||||
let payload = ProviderPayload(
|
||||
providerID: "cli",
|
||||
account: nil,
|
||||
version: nil,
|
||||
source: "cli",
|
||||
status: nil,
|
||||
usage: nil,
|
||||
credits: nil,
|
||||
antigravityPlanInfo: nil,
|
||||
openaiDashboard: nil,
|
||||
error: self.makeErrorPayload(code: .failure, message: error.localizedDescription, kind: .config))
|
||||
self.printJSON([payload], pretty: output.pretty)
|
||||
} else {
|
||||
self.writeStderr("Error: \(error.localizedDescription)\n")
|
||||
}
|
||||
Self.platformExit(ExitCode.failure.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CLIArgumentError: LocalizedError {
|
||||
let message: String
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
self.message
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension CodexBarCLI {
|
||||
static func _usageSignatureForTesting() -> CommandSignature {
|
||||
CommandSignature.describe(UsageOptions())
|
||||
}
|
||||
|
||||
static func _costSignatureForTesting() -> CommandSignature {
|
||||
CommandSignature.describe(CostOptions())
|
||||
}
|
||||
|
||||
static func _cacheSignatureForTesting() -> CommandSignature {
|
||||
CommandSignature.describe(CacheOptions())
|
||||
}
|
||||
|
||||
static func _diagnoseSignatureForTesting() -> CommandSignature {
|
||||
CommandSignature.describe(DiagnoseOptions())
|
||||
}
|
||||
|
||||
static func _configSetAPIKeySignatureForTesting() -> CommandSignature {
|
||||
CommandSignature.describe(ConfigSetAPIKeyOptions())
|
||||
}
|
||||
|
||||
static func _configProviderToggleSignatureForTesting() -> CommandSignature {
|
||||
CommandSignature.describe(ConfigProviderToggleOptions())
|
||||
}
|
||||
|
||||
static func _decodeFormatForTesting(from values: ParsedValues) -> OutputFormat {
|
||||
self.decodeFormat(from: values)
|
||||
}
|
||||
|
||||
static func _decodeWebTimeoutForTesting(from values: ParsedValues) throws -> TimeInterval? {
|
||||
try self.decodeWebTimeout(from: values)
|
||||
}
|
||||
|
||||
static func _decodeSourceModeForTesting(from values: ParsedValues) -> ProviderSourceMode? {
|
||||
self.decodeSourceMode(from: values)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,127 @@
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func writeStderr(_ string: String) {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
FileHandle.standardError.write(data)
|
||||
}
|
||||
|
||||
static func printVersion() -> Never {
|
||||
if let version = currentVersion() {
|
||||
print("CodexBar \(version)")
|
||||
} else {
|
||||
print("CodexBar")
|
||||
}
|
||||
Self.platformExit(0)
|
||||
}
|
||||
|
||||
static func printHelp(for command: String?) -> Never {
|
||||
let version = self.currentVersion() ?? "unknown"
|
||||
switch command {
|
||||
case "cards":
|
||||
print(Self.cardsHelp(version: version))
|
||||
case "usage":
|
||||
print(Self.usageHelp(version: version))
|
||||
case "cost":
|
||||
print(Self.costHelp(version: version))
|
||||
case "sessions", "focus":
|
||||
print(Self.sessionsHelp(version: version))
|
||||
case "serve":
|
||||
print(Self.serveHelp(version: version))
|
||||
case "config", "validate", "dump":
|
||||
print(Self.configHelp(version: version))
|
||||
case "cache", "clear":
|
||||
print(Self.cacheHelp(version: version))
|
||||
case "diagnose":
|
||||
print(Self.diagnoseHelp(version: version))
|
||||
default:
|
||||
print(Self.rootHelp(version: version))
|
||||
}
|
||||
Self.platformExit(0)
|
||||
}
|
||||
|
||||
static func currentVersion(
|
||||
bundle: Bundle = .main,
|
||||
executablePath: String? = CommandLine.arguments.first) -> String?
|
||||
{
|
||||
if let version = self.currentVersion(bundleVersion: nil, executablePath: executablePath) {
|
||||
return version
|
||||
}
|
||||
return self.currentVersion(
|
||||
bundleVersion: bundle.infoDictionary?["CFBundleShortVersionString"] as? String,
|
||||
executablePath: nil)
|
||||
}
|
||||
|
||||
static func currentVersion(bundleVersion: String?, executablePath: String?) -> String? {
|
||||
if let executablePath, !executablePath.isEmpty {
|
||||
let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath()
|
||||
if let version = Self.adjacentVersionFileVersion(for: executableURL) {
|
||||
return version
|
||||
}
|
||||
if let version = Self.containingAppVersion(for: executableURL) {
|
||||
return version
|
||||
}
|
||||
}
|
||||
return Self.normalizedBundleVersion(bundleVersion)
|
||||
}
|
||||
|
||||
static func containingAppVersion(for executableURL: URL) -> String? {
|
||||
var currentURL = executableURL.deletingLastPathComponent()
|
||||
let fileManager = FileManager.default
|
||||
|
||||
while currentURL.path != currentURL.deletingLastPathComponent().path {
|
||||
if currentURL.pathExtension == "app" {
|
||||
let infoURL = currentURL
|
||||
.appendingPathComponent("Contents")
|
||||
.appendingPathComponent("Info.plist")
|
||||
guard let data = fileManager.contents(atPath: infoURL.path),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
|
||||
else { return nil }
|
||||
return plist["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
currentURL.deleteLastPathComponent()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func adjacentVersionFileVersion(for executableURL: URL) -> String? {
|
||||
let versionURL = executableURL
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("VERSION")
|
||||
guard let raw = try? String(contentsOf: versionURL, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.hasPrefix("v"), trimmed.dropFirst().first?.isNumber == true {
|
||||
return String(trimmed.dropFirst())
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func normalizedBundleVersion(_ raw: String?) -> String? {
|
||||
guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty,
|
||||
trimmed != "CodexBar"
|
||||
else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func platformExit(_ code: Int32) -> Never {
|
||||
#if canImport(Darwin)
|
||||
Darwin.exit(code)
|
||||
#elseif canImport(Glibc)
|
||||
Glibc.exit(code)
|
||||
#elseif canImport(Musl)
|
||||
Musl.exit(code)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import Foundation
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
|
||||
private let requestReadTimeoutMilliseconds: Int32 = 5000
|
||||
|
||||
struct CLILocalHTTPRequest {
|
||||
let method: String
|
||||
let target: String
|
||||
let host: String
|
||||
let path: String
|
||||
let queryItems: [String: String]
|
||||
|
||||
static func parse(_ data: Data) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError> {
|
||||
guard let raw = String(data: data, encoding: .utf8),
|
||||
let firstLine = raw.components(separatedBy: "\r\n").first
|
||||
else {
|
||||
return .failure(.invalidRequest)
|
||||
}
|
||||
|
||||
let parts = firstLine.split(separator: " ")
|
||||
guard parts.count >= 3 else { return .failure(.invalidRequest) }
|
||||
|
||||
let method = String(parts[0]).uppercased()
|
||||
let target = String(parts[1])
|
||||
guard target.hasPrefix("/") else { return .failure(.invalidRequest) }
|
||||
|
||||
let headerResult = Self.parseHeaders(raw)
|
||||
let host: String
|
||||
switch headerResult {
|
||||
case let .success(headers):
|
||||
let hosts = headers.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) }
|
||||
host = candidate
|
||||
case let .failure(error):
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
let components = URLComponents(string: "http://localhost\(target)")
|
||||
let path = components?.path ?? target
|
||||
var queryItems: [String: String] = [:]
|
||||
for item in components?.queryItems ?? [] {
|
||||
if let value = item.value {
|
||||
queryItems[item.name] = value
|
||||
}
|
||||
}
|
||||
|
||||
return .success(CLILocalHTTPRequest(
|
||||
method: method,
|
||||
target: target,
|
||||
host: host,
|
||||
path: path,
|
||||
queryItems: queryItems))
|
||||
}
|
||||
|
||||
private static func parseHeaders(_ raw: String) -> Result<[(String, String)], CLILocalHTTPRequestParseError> {
|
||||
let lines = raw.components(separatedBy: "\r\n")
|
||||
var headers: [(String, String)] = []
|
||||
|
||||
for line in lines.dropFirst() {
|
||||
if line.isEmpty { break }
|
||||
guard let separator = line.firstIndex(of: ":") else {
|
||||
return .failure(.invalidRequest)
|
||||
}
|
||||
let name = String(line[..<separator]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = String(line[line.index(after: separator)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return .failure(.invalidRequest) }
|
||||
headers.append((name, value))
|
||||
}
|
||||
|
||||
return .success(headers)
|
||||
}
|
||||
|
||||
private static func isAllowedLoopbackHost(_ host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, !trimmed.contains(",") else { return false }
|
||||
|
||||
let hostWithoutPort: String
|
||||
if trimmed.hasPrefix("[") {
|
||||
guard let closingBracket = trimmed.firstIndex(of: "]") else { return false }
|
||||
hostWithoutPort = String(trimmed[...closingBracket])
|
||||
let remainder = trimmed[trimmed.index(after: closingBracket)...]
|
||||
guard remainder.isEmpty || Self.isValidPortSuffix(String(remainder)) else { return false }
|
||||
} else {
|
||||
let segments = trimmed.split(separator: ":", omittingEmptySubsequences: false)
|
||||
switch segments.count {
|
||||
case 1:
|
||||
hostWithoutPort = String(segments[0])
|
||||
case 2:
|
||||
guard Self.isValidPort(String(segments[1])) else { return false }
|
||||
hostWithoutPort = String(segments[0])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
switch hostWithoutPort.lowercased() {
|
||||
case "127.0.0.1", "localhost", "localhost.", "[::1]":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func isValidPortSuffix(_ raw: String) -> Bool {
|
||||
guard raw.hasPrefix(":") else { return false }
|
||||
return self.isValidPort(String(raw.dropFirst()))
|
||||
}
|
||||
|
||||
private static func isValidPort(_ raw: String) -> Bool {
|
||||
guard let port = Int(raw), port > 0, port <= Int(UInt16.max) else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
enum CLILocalHTTPRequestParseError: Error, Equatable {
|
||||
case invalidRequest
|
||||
case missingHost
|
||||
case duplicateHost
|
||||
case disallowedHost
|
||||
}
|
||||
|
||||
enum CLIHTTPStatus {
|
||||
case ok
|
||||
case badRequest
|
||||
case forbidden
|
||||
case notFound
|
||||
case methodNotAllowed
|
||||
case internalServerError
|
||||
case gatewayTimeout
|
||||
var code: Int {
|
||||
switch self {
|
||||
case .ok: 200
|
||||
case .badRequest: 400
|
||||
case .forbidden: 403
|
||||
case .notFound: 404
|
||||
case .methodNotAllowed: 405
|
||||
case .internalServerError: 500
|
||||
case .gatewayTimeout: 504
|
||||
}
|
||||
}
|
||||
|
||||
var reason: String {
|
||||
switch self {
|
||||
case .ok: "OK"
|
||||
case .badRequest: "Bad Request"
|
||||
case .forbidden: "Forbidden"
|
||||
case .notFound: "Not Found"
|
||||
case .methodNotAllowed: "Method Not Allowed"
|
||||
case .internalServerError: "Internal Server Error"
|
||||
case .gatewayTimeout: "Gateway Timeout"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CLILocalHTTPResponse {
|
||||
let status: CLIHTTPStatus
|
||||
let body: Data
|
||||
let contentType: String
|
||||
let usageCacheKeys: [String?]?
|
||||
|
||||
init(
|
||||
status: CLIHTTPStatus,
|
||||
body: Data,
|
||||
contentType: String = "application/json; charset=utf-8",
|
||||
usageCacheKeys: [String?]? = nil)
|
||||
{
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.contentType = contentType
|
||||
self.usageCacheKeys = usageCacheKeys
|
||||
}
|
||||
|
||||
var serialized: Data {
|
||||
var headers = "HTTP/1.1 \(self.status.code) \(self.status.reason)\r\n"
|
||||
headers += "Content-Type: \(self.contentType)\r\n"
|
||||
headers += "Content-Length: \(self.body.count)\r\n"
|
||||
headers += "Connection: close\r\n"
|
||||
headers += "\r\n"
|
||||
|
||||
var data = Data(headers.utf8)
|
||||
data.append(self.body)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
final class CLILocalHTTPServer: @unchecked Sendable {
|
||||
typealias Handler = @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse
|
||||
|
||||
private let host: String
|
||||
private let port: UInt16
|
||||
private let handler: Handler
|
||||
private let stateLock = NSLock()
|
||||
private var listeningFD: Int32?
|
||||
private var stopRequested = false
|
||||
|
||||
init(host: String, port: UInt16, handler: @escaping Handler) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.stateLock.lock()
|
||||
self.stopRequested = true
|
||||
self.stateLock.unlock()
|
||||
}
|
||||
|
||||
func run(onListening: @Sendable () -> Void = {}) async throws {
|
||||
ignoreSIGPIPE()
|
||||
|
||||
#if canImport(Darwin)
|
||||
let streamType = SOCK_STREAM
|
||||
#elseif canImport(Glibc)
|
||||
let streamType = Int32(SOCK_STREAM.rawValue)
|
||||
#elseif canImport(Musl)
|
||||
let streamType = Int32(SOCK_STREAM)
|
||||
#endif
|
||||
|
||||
let serverFD = socket(AF_INET, streamType, 0)
|
||||
guard serverFD >= 0 else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
var ownsServerFD = true
|
||||
defer {
|
||||
if ownsServerFD {
|
||||
closeSocket(serverFD)
|
||||
}
|
||||
}
|
||||
|
||||
var reuse: Int32 = 1
|
||||
setsockopt(
|
||||
serverFD,
|
||||
SOL_SOCKET,
|
||||
SO_REUSEADDR,
|
||||
&reuse,
|
||||
socklen_t(MemoryLayout<Int32>.size))
|
||||
|
||||
var address = sockaddr_in()
|
||||
#if canImport(Darwin)
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
#endif
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_port = self.port.bigEndian
|
||||
guard inet_pton(AF_INET, self.host, &address.sin_addr) == 1 else {
|
||||
throw POSIXError(.EADDRNOTAVAIL)
|
||||
}
|
||||
|
||||
let bound = withUnsafePointer(to: &address) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
|
||||
bind(serverFD, socketAddress, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bound == 0 else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
|
||||
guard listen(serverFD, 16) == 0 else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
guard self.installListeningFD(serverFD) else {
|
||||
return
|
||||
}
|
||||
ownsServerFD = false
|
||||
defer {
|
||||
if self.releaseListeningFD(serverFD) {
|
||||
closeSocket(serverFD)
|
||||
}
|
||||
}
|
||||
onListening()
|
||||
|
||||
while !self.isStopRequested {
|
||||
guard waitForReadable(serverFD, timeoutMilliseconds: 250) else {
|
||||
continue
|
||||
}
|
||||
var clientAddress = sockaddr()
|
||||
var clientLength = socklen_t(MemoryLayout<sockaddr>.size)
|
||||
let clientFD = accept(serverFD, &clientAddress, &clientLength)
|
||||
guard clientFD >= 0 else {
|
||||
if self.isStopRequested { return }
|
||||
if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK || errno == ECONNABORTED {
|
||||
continue
|
||||
}
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
let handler = self.handler
|
||||
Task {
|
||||
defer { closeSocket(clientFD) }
|
||||
await handleClient(clientFD, handler: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isStopRequested: Bool {
|
||||
self.stateLock.lock()
|
||||
let value = self.stopRequested
|
||||
self.stateLock.unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
private func installListeningFD(_ fd: Int32) -> Bool {
|
||||
self.stateLock.lock()
|
||||
defer { self.stateLock.unlock() }
|
||||
guard !self.stopRequested else { return false }
|
||||
self.listeningFD = fd
|
||||
return true
|
||||
}
|
||||
|
||||
private func releaseListeningFD(_ fd: Int32) -> Bool {
|
||||
self.stateLock.lock()
|
||||
defer { self.stateLock.unlock() }
|
||||
guard self.listeningFD == fd else { return false }
|
||||
self.listeningFD = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleClient(
|
||||
_ clientFD: Int32,
|
||||
handler: @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse) async
|
||||
{
|
||||
let request: CLILocalHTTPRequest
|
||||
switch readRequest(clientFD) {
|
||||
case let .success(parsedRequest):
|
||||
request = parsedRequest
|
||||
case .failure(.disallowedHost):
|
||||
sendResponse(
|
||||
CLILocalHTTPResponse(
|
||||
status: .forbidden,
|
||||
body: Data(#"{"error":"forbidden host"}"#.utf8)),
|
||||
to: clientFD)
|
||||
return
|
||||
case .failure:
|
||||
sendResponse(
|
||||
CLILocalHTTPResponse(
|
||||
status: .badRequest,
|
||||
body: Data(#"{"error":"invalid request"}"#.utf8)),
|
||||
to: clientFD)
|
||||
return
|
||||
}
|
||||
|
||||
let response = await handler(request)
|
||||
sendResponse(response, to: clientFD)
|
||||
}
|
||||
|
||||
private func readRequest(_ fd: Int32) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError> {
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let bufferSize = buffer.count
|
||||
var sawHeaderEnd = false
|
||||
|
||||
while data.count < 16384 {
|
||||
guard waitForReadable(fd, timeoutMilliseconds: requestReadTimeoutMilliseconds) else {
|
||||
return .failure(.invalidRequest)
|
||||
}
|
||||
let count = buffer.withUnsafeMutableBytes { rawBuffer in
|
||||
recv(fd, rawBuffer.baseAddress, bufferSize, 0)
|
||||
}
|
||||
guard count > 0 else { break }
|
||||
data.append(buffer, count: count)
|
||||
if data.range(of: Data("\r\n\r\n".utf8)) != nil {
|
||||
sawHeaderEnd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard sawHeaderEnd else { return .failure(.invalidRequest) }
|
||||
return CLILocalHTTPRequest.parse(data)
|
||||
}
|
||||
|
||||
private func sendResponse(_ response: CLILocalHTTPResponse, to fd: Int32) {
|
||||
let data = response.serialized
|
||||
data.withUnsafeBytes { rawBuffer in
|
||||
guard let base = rawBuffer.baseAddress else { return }
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let count = send(fd, base.advanced(by: sent), data.count - sent, sendNoSignalFlags())
|
||||
guard count > 0 else { break }
|
||||
sent += count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForReadable(_ fd: Int32, timeoutMilliseconds: Int32) -> Bool {
|
||||
var pollFD = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
while true {
|
||||
let result = poll(&pollFD, 1, timeoutMilliseconds)
|
||||
if result > 0 {
|
||||
return (pollFD.revents & Int16(POLLIN)) != 0
|
||||
}
|
||||
if result == -1, errno == EINTR {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func sendNoSignalFlags() -> Int32 {
|
||||
#if canImport(Darwin)
|
||||
0
|
||||
#else
|
||||
Int32(MSG_NOSIGNAL)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func ignoreSIGPIPE() {
|
||||
#if canImport(Darwin)
|
||||
_ = Darwin.signal(SIGPIPE, SIG_IGN)
|
||||
#elseif canImport(Glibc)
|
||||
_ = Glibc.signal(SIGPIPE, SIG_IGN)
|
||||
#elseif canImport(Musl)
|
||||
_ = Musl.signal(SIGPIPE, SIG_IGN)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func closeSocket(_ fd: Int32) {
|
||||
#if canImport(Darwin)
|
||||
Darwin.close(fd)
|
||||
#elseif canImport(Glibc)
|
||||
Glibc.close(fd)
|
||||
#elseif canImport(Musl)
|
||||
Musl.close(fd)
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
// MARK: - Options & parsing helpers
|
||||
|
||||
struct UsageOptions: CommanderParsable {
|
||||
private static let sourceHelp: String = {
|
||||
#if os(macOS)
|
||||
"Data source: auto | web | cli | oauth | api (auto behavior is provider-specific)"
|
||||
#else
|
||||
"Data source: auto | web | cli | oauth | api (web/auto are macOS only for web-capable providers)"
|
||||
#endif
|
||||
}()
|
||||
|
||||
@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?
|
||||
|
||||
@Option(
|
||||
name: .long("provider"),
|
||||
help: ProviderHelp.optionHelp)
|
||||
var provider: ProviderSelection?
|
||||
|
||||
@Option(name: .long("account"), help: "Token account label to use (from config.json)")
|
||||
var account: String?
|
||||
|
||||
@Option(name: .long("account-index"), help: "Token account index (1-based)")
|
||||
var accountIndex: Int?
|
||||
|
||||
@Flag(name: .long("all-accounts"), help: "Fetch all token accounts, or all visible Codex accounts")
|
||||
var allAccounts: Bool = false
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: text | json")
|
||||
var format: OutputFormat?
|
||||
|
||||
@Flag(name: .long("json"), help: "")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)")
|
||||
var jsonOnly: Bool = false
|
||||
|
||||
@Flag(name: .long("no-credits"), help: "Skip Codex credits line")
|
||||
var noCredits: Bool = false
|
||||
|
||||
@Flag(name: .long("no-color"), help: "Disable ANSI colors in text output")
|
||||
var noColor: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
|
||||
@Flag(name: .long("status"), help: "Fetch and include provider status")
|
||||
var status: Bool = false
|
||||
|
||||
@Flag(name: .long("web"), help: "Alias for --source web")
|
||||
var web: Bool = false
|
||||
|
||||
@Option(name: .long("source"), help: Self.sourceHelp)
|
||||
var source: String?
|
||||
|
||||
@Option(name: .long("web-timeout"), help: "Web fetch timeout (seconds; source=auto or web)")
|
||||
var webTimeout: Double?
|
||||
|
||||
@Flag(name: .long("web-debug-dump-html"), help: "Dump HTML snapshots to /tmp when Codex dashboard data is missing")
|
||||
var webDebugDumpHtml: Bool = false
|
||||
|
||||
@Flag(name: .long("antigravity-plan-debug"), help: "Emit Antigravity planInfo fields (debug)")
|
||||
var antigravityPlanDebug: Bool = false
|
||||
|
||||
@Flag(name: .long("augment-debug"), help: "Emit Augment API responses (debug)")
|
||||
var augmentDebug: Bool = false
|
||||
}
|
||||
|
||||
enum ProviderSelection: ExpressibleFromArgument {
|
||||
case single(UsageProvider)
|
||||
case both
|
||||
case all
|
||||
case custom([UsageProvider])
|
||||
|
||||
init?(argument: String) {
|
||||
let normalized = argument.lowercased()
|
||||
switch normalized {
|
||||
case "both":
|
||||
self = .both
|
||||
case "all":
|
||||
self = .all
|
||||
default:
|
||||
if let provider = ProviderDescriptorRegistry.cliNameMap[normalized] {
|
||||
self = .single(provider)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(provider: UsageProvider) {
|
||||
self = .single(provider)
|
||||
}
|
||||
|
||||
var asList: [UsageProvider] {
|
||||
switch self {
|
||||
case let .single(provider):
|
||||
return [provider]
|
||||
case .both:
|
||||
let primary = ProviderDescriptorRegistry.all.filter(\ .metadata.isPrimaryProvider)
|
||||
if !primary.isEmpty {
|
||||
return primary.map(\ .id)
|
||||
}
|
||||
return ProviderDescriptorRegistry.all.prefix(2).map(\ .id)
|
||||
case .all:
|
||||
return ProviderDescriptorRegistry.all.map(\ .id)
|
||||
case let .custom(providers):
|
||||
return providers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OutputFormat: String, ExpressibleFromArgument {
|
||||
case text
|
||||
case json
|
||||
|
||||
init?(argument: String) {
|
||||
switch argument.lowercased() {
|
||||
case "text": self = .text
|
||||
case "json": self = .json
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderHelp {
|
||||
static var list: String {
|
||||
let names = ProviderDescriptorRegistry.all.map(\ .cli.name)
|
||||
return (names + ["both", "all"]).joined(separator: "|")
|
||||
}
|
||||
|
||||
static var optionHelp: String {
|
||||
"Provider to query: \(self.list)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct CLIOutputPreferences {
|
||||
let format: OutputFormat
|
||||
let jsonOnly: Bool
|
||||
let pretty: Bool
|
||||
|
||||
var usesJSONOutput: Bool {
|
||||
self.jsonOnly || self.format == .json
|
||||
}
|
||||
|
||||
static func from(values: ParsedValues) -> CLIOutputPreferences {
|
||||
let jsonOnly = values.flags.contains("jsonOnly")
|
||||
let format = CodexBarCLI.decodeFormat(from: values)
|
||||
let pretty = values.flags.contains("pretty")
|
||||
return CLIOutputPreferences(format: format, jsonOnly: jsonOnly, pretty: pretty)
|
||||
}
|
||||
|
||||
static func from(argv: [String]) -> CLIOutputPreferences {
|
||||
var jsonOnly = false
|
||||
var pretty = false
|
||||
var format: OutputFormat = .text
|
||||
|
||||
var index = 0
|
||||
while index < argv.count {
|
||||
let arg = argv[index]
|
||||
switch arg {
|
||||
case "--json-only":
|
||||
jsonOnly = true
|
||||
format = .json
|
||||
case "--json":
|
||||
format = .json
|
||||
case "--pretty":
|
||||
pretty = true
|
||||
case "--format":
|
||||
let next = index + 1
|
||||
if next < argv.count, let parsed = OutputFormat(argument: argv[next]) {
|
||||
format = parsed
|
||||
index += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
return CLIOutputPreferences(format: format, jsonOnly: jsonOnly, pretty: pretty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
struct ProviderPayload: Encodable {
|
||||
let provider: String
|
||||
let account: String?
|
||||
let cacheAccountKey: String?
|
||||
let version: String?
|
||||
let source: String
|
||||
let status: ProviderStatusPayload?
|
||||
let usage: UsageSnapshot?
|
||||
let credits: CreditsSnapshot?
|
||||
let antigravityPlanInfo: AntigravityPlanInfoSummary?
|
||||
let openaiDashboard: OpenAIDashboardSnapshot?
|
||||
let error: ProviderErrorPayload?
|
||||
let pace: ProviderPacePayload?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case provider
|
||||
case account
|
||||
case version
|
||||
case source
|
||||
case status
|
||||
case usage
|
||||
case credits
|
||||
case antigravityPlanInfo
|
||||
case openaiDashboard
|
||||
case error
|
||||
case pace
|
||||
}
|
||||
|
||||
init(
|
||||
provider: UsageProvider,
|
||||
account: String?,
|
||||
cacheAccountKey: String? = nil,
|
||||
version: String?,
|
||||
source: String,
|
||||
status: ProviderStatusPayload?,
|
||||
usage: UsageSnapshot?,
|
||||
credits: CreditsSnapshot?,
|
||||
antigravityPlanInfo: AntigravityPlanInfoSummary?,
|
||||
openaiDashboard: OpenAIDashboardSnapshot?,
|
||||
error: ProviderErrorPayload?,
|
||||
pace: ProviderPacePayload? = nil)
|
||||
{
|
||||
self.provider = provider.rawValue
|
||||
self.account = account
|
||||
self.cacheAccountKey = cacheAccountKey
|
||||
self.version = version
|
||||
self.source = source
|
||||
self.status = status
|
||||
self.usage = usage
|
||||
self.credits = credits
|
||||
self.antigravityPlanInfo = antigravityPlanInfo
|
||||
self.openaiDashboard = openaiDashboard
|
||||
self.error = error
|
||||
self.pace = pace
|
||||
}
|
||||
|
||||
init(
|
||||
providerID: String,
|
||||
account: String?,
|
||||
cacheAccountKey: String? = nil,
|
||||
version: String?,
|
||||
source: String,
|
||||
status: ProviderStatusPayload?,
|
||||
usage: UsageSnapshot?,
|
||||
credits: CreditsSnapshot?,
|
||||
antigravityPlanInfo: AntigravityPlanInfoSummary?,
|
||||
openaiDashboard: OpenAIDashboardSnapshot?,
|
||||
error: ProviderErrorPayload?,
|
||||
pace: ProviderPacePayload? = nil)
|
||||
{
|
||||
self.provider = providerID
|
||||
self.account = account
|
||||
self.cacheAccountKey = cacheAccountKey
|
||||
self.version = version
|
||||
self.source = source
|
||||
self.status = status
|
||||
self.usage = usage
|
||||
self.credits = credits
|
||||
self.antigravityPlanInfo = antigravityPlanInfo
|
||||
self.openaiDashboard = openaiDashboard
|
||||
self.error = error
|
||||
self.pace = pace
|
||||
}
|
||||
}
|
||||
|
||||
struct ProviderPacePayload: Encodable {
|
||||
let primary: PacePayload?
|
||||
let secondary: PacePayload?
|
||||
}
|
||||
|
||||
struct PacePayload: Encodable {
|
||||
let stage: String
|
||||
/// Rounded (used − expected); positive = deficit, negative = reserve.
|
||||
let deltaPercent: Double
|
||||
let expectedUsedPercent: Double
|
||||
let willLastToReset: Bool
|
||||
let etaSeconds: TimeInterval?
|
||||
/// Always absent in CLI output; kept for schema parity.
|
||||
let runOutProbability: Double?
|
||||
let summary: String
|
||||
}
|
||||
|
||||
struct ProviderStatusPayload: Encodable {
|
||||
let indicator: ProviderStatusIndicator
|
||||
let description: String?
|
||||
let updatedAt: Date?
|
||||
let url: String
|
||||
|
||||
enum ProviderStatusIndicator: String, Encodable {
|
||||
case none
|
||||
case minor
|
||||
case major
|
||||
case critical
|
||||
case maintenance
|
||||
case unknown
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .none: "Operational"
|
||||
case .minor: "Partial outage"
|
||||
case .major: "Major outage"
|
||||
case .critical: "Critical issue"
|
||||
case .maintenance: "Maintenance"
|
||||
case .unknown: "Status unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var descriptionSuffix: String {
|
||||
guard let description, !description.isEmpty else { return "" }
|
||||
return " – \(description)"
|
||||
}
|
||||
}
|
||||
|
||||
enum StatusFetcher {
|
||||
static func fetch(from baseURL: URL) async throws -> ProviderStatusPayload {
|
||||
let apiURL = baseURL.appendingPathComponent("api/v2/status.json")
|
||||
var request = URLRequest(url: apiURL)
|
||||
request.timeoutInterval = 10
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
struct Response: Decodable {
|
||||
struct Status: Decodable {
|
||||
let indicator: String
|
||||
let description: String?
|
||||
}
|
||||
|
||||
struct Page: Decodable {
|
||||
let updatedAt: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
|
||||
let page: Page?
|
||||
let status: Status
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let raw = try container.decode(String.self)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: raw) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
if let date = formatter.date(from: raw) { return date }
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date")
|
||||
}
|
||||
|
||||
let response = try decoder.decode(Response.self, from: data)
|
||||
let indicator = ProviderStatusPayload.ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown
|
||||
return ProviderStatusPayload(
|
||||
indicator: indicator,
|
||||
description: response.status.description,
|
||||
updatedAt: response.page?.updatedAt,
|
||||
url: baseURL.absoluteString)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
import Foundation
|
||||
|
||||
/// Owns at most one running source operation for each logical serve key.
|
||||
///
|
||||
/// Cancellation is advisory in Swift. Timed-out work therefore remains owned
|
||||
/// until its source body actually exits. A config successor can wait behind it,
|
||||
/// but can never overlap it.
|
||||
actor CLIServeOperationCoordinator<Value: Sendable> {
|
||||
typealias Instant = ContinuousClock.Instant
|
||||
typealias Now = @Sendable () -> Instant
|
||||
typealias SleepUntil = @Sendable (Instant) async throws -> Void
|
||||
|
||||
struct Snapshot: Equatable, Sendable {
|
||||
let operationCount: Int
|
||||
let waiterCount: Int
|
||||
let timerCount: Int
|
||||
let isShutDown: Bool
|
||||
}
|
||||
|
||||
private struct Waiter {
|
||||
let id: UUID
|
||||
let continuation: CheckedContinuation<Value, Never>
|
||||
let timeoutValue: Value
|
||||
}
|
||||
|
||||
private enum OperationPhase {
|
||||
case running
|
||||
case accepting
|
||||
case timedOut
|
||||
}
|
||||
|
||||
private struct Operation {
|
||||
let generation: UInt64
|
||||
let fingerprint: String
|
||||
var deadline: Instant?
|
||||
let makeValue: @Sendable () async -> Value
|
||||
let acceptValue: @Sendable (Value) async -> Value
|
||||
var sourceTask: Task<Void, Never>?
|
||||
var deadlineTask: Task<Void, Never>?
|
||||
var waiters: [Waiter]
|
||||
var phase: OperationPhase
|
||||
}
|
||||
|
||||
private struct Slot {
|
||||
var active: Operation?
|
||||
var pending: Operation?
|
||||
}
|
||||
|
||||
private struct OperationRequest: Sendable {
|
||||
let key: String
|
||||
let fingerprint: String
|
||||
let deadline: Instant?
|
||||
let timeoutValue: Value
|
||||
let makeValue: @Sendable () async -> Value
|
||||
let acceptValue: @Sendable (Value) async -> Value
|
||||
}
|
||||
|
||||
private enum WaitTarget {
|
||||
case active
|
||||
case pending
|
||||
}
|
||||
|
||||
private let now: Now
|
||||
private let sleepUntil: SleepUntil
|
||||
// Callers validate route keys and provider keys come from UsageProvider, so
|
||||
// retained canceled work has a small, closed key space.
|
||||
private var slots: [String: Slot] = [:]
|
||||
private var nextGeneration: UInt64 = 0
|
||||
private var isShutDown = false
|
||||
|
||||
init(
|
||||
now: @escaping Now = { ContinuousClock().now },
|
||||
sleepUntil: @escaping SleepUntil = { deadline in
|
||||
try await ContinuousClock().sleep(until: deadline)
|
||||
})
|
||||
{
|
||||
self.now = now
|
||||
self.sleepUntil = sleepUntil
|
||||
}
|
||||
|
||||
func value(
|
||||
for key: String,
|
||||
fingerprint: String,
|
||||
deadline: Instant?,
|
||||
timeoutValue: Value,
|
||||
accept: @Sendable @escaping (Value) async -> Value = { $0 },
|
||||
operation makeValue: @Sendable @escaping () async -> Value) async -> Value
|
||||
{
|
||||
let request = OperationRequest(
|
||||
key: key,
|
||||
fingerprint: fingerprint,
|
||||
deadline: deadline,
|
||||
timeoutValue: timeoutValue,
|
||||
makeValue: makeValue,
|
||||
acceptValue: accept)
|
||||
guard !self.isShutDown, !self.isExpired(deadline) else { return timeoutValue }
|
||||
|
||||
self.expireOverdueOperations(for: key)
|
||||
guard let slot = self.slots[key], let active = slot.active else {
|
||||
return await self.installActive(request)
|
||||
}
|
||||
|
||||
if active.fingerprint == fingerprint, active.phase != .timedOut {
|
||||
guard self.deadlinesAreCompatible(active.deadline, deadline) else { return timeoutValue }
|
||||
guard self.canJoinAccepting(active, deadline: deadline) else { return timeoutValue }
|
||||
return await self.awaitValue(for: request, target: .active)
|
||||
}
|
||||
|
||||
if let pending = slot.pending, pending.fingerprint == fingerprint {
|
||||
guard self.deadlinesAreCompatible(pending.deadline, deadline) else { return timeoutValue }
|
||||
return await self.awaitValue(for: request, target: .pending)
|
||||
}
|
||||
|
||||
if let pending = self.slots[key]?.pending {
|
||||
self.timeoutPending(for: key, generation: pending.generation)
|
||||
}
|
||||
return await self.installPending(request)
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
guard !self.isShutDown else { return }
|
||||
self.isShutDown = true
|
||||
|
||||
let active = self.slots.compactMap { key, slot in
|
||||
slot.active.map { (key, $0.generation) }
|
||||
}
|
||||
let pending = self.slots.compactMap { key, slot in
|
||||
slot.pending.map { (key, $0.generation) }
|
||||
}
|
||||
for (key, generation) in pending {
|
||||
self.timeoutPending(for: key, generation: generation)
|
||||
}
|
||||
for (key, generation) in active {
|
||||
self.shutdownActive(for: key, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot() -> Snapshot {
|
||||
let operations = self.slots.values.flatMap { slot in
|
||||
[slot.active, slot.pending].compactMap(\.self)
|
||||
}
|
||||
return Snapshot(
|
||||
operationCount: operations.count,
|
||||
waiterCount: operations.reduce(0) { $0 + $1.waiters.count },
|
||||
timerCount: operations.reduce(0) { $0 + ($1.deadlineTask == nil ? 0 : 1) },
|
||||
isShutDown: self.isShutDown)
|
||||
}
|
||||
|
||||
private func allocateGeneration() -> UInt64 {
|
||||
self.nextGeneration &+= 1
|
||||
return self.nextGeneration
|
||||
}
|
||||
|
||||
private func installActive(_ request: OperationRequest) async -> Value {
|
||||
let generation = self.allocateGeneration()
|
||||
let waiterID = UUID()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard !Task.isCancelled, !self.isShutDown, !self.isExpired(request.deadline) else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
|
||||
var operation = self.makeOperation(
|
||||
request,
|
||||
generation: generation,
|
||||
waiter: Waiter(
|
||||
id: waiterID,
|
||||
continuation: continuation,
|
||||
timeoutValue: request.timeoutValue))
|
||||
operation.sourceTask = self.makeSourceTask(
|
||||
key: request.key,
|
||||
generation: generation,
|
||||
makeValue: request.makeValue)
|
||||
operation.deadlineTask = self.makeDeadlineTask(
|
||||
key: request.key,
|
||||
generation: generation,
|
||||
deadline: request.deadline)
|
||||
self.slots[request.key] = Slot(active: operation, pending: nil)
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id: waiterID, for: request.key) }
|
||||
}
|
||||
}
|
||||
|
||||
private func installPending(_ request: OperationRequest) async -> Value {
|
||||
guard self.slots[request.key]?.active != nil else {
|
||||
return await self.installActive(request)
|
||||
}
|
||||
|
||||
let generation = self.allocateGeneration()
|
||||
let waiterID = UUID()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard !Task.isCancelled, !self.isShutDown, !self.isExpired(request.deadline) else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
guard var slot = self.slots[request.key], slot.active != nil else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
|
||||
var operation = self.makeOperation(
|
||||
request,
|
||||
generation: generation,
|
||||
waiter: Waiter(
|
||||
id: waiterID,
|
||||
continuation: continuation,
|
||||
timeoutValue: request.timeoutValue))
|
||||
operation.deadlineTask = self.makeDeadlineTask(
|
||||
key: request.key,
|
||||
generation: generation,
|
||||
deadline: request.deadline)
|
||||
slot.pending = operation
|
||||
self.slots[request.key] = slot
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id: waiterID, for: request.key) }
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitValue(for request: OperationRequest, target: WaitTarget) async -> Value {
|
||||
let waiterID = UUID()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard !Task.isCancelled, !self.isShutDown, !self.isExpired(request.deadline),
|
||||
var slot = self.slots[request.key]
|
||||
else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
|
||||
let waiter = Waiter(
|
||||
id: waiterID,
|
||||
continuation: continuation,
|
||||
timeoutValue: request.timeoutValue)
|
||||
switch target {
|
||||
case .active:
|
||||
guard var operation = slot.active,
|
||||
operation.fingerprint == request.fingerprint,
|
||||
operation.phase != .timedOut
|
||||
else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
guard self.canJoinAccepting(operation, deadline: request.deadline) else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
self.tightenDeadline(&operation, to: request.deadline, for: request.key)
|
||||
operation.waiters.append(waiter)
|
||||
slot.active = operation
|
||||
case .pending:
|
||||
guard var operation = slot.pending,
|
||||
operation.fingerprint == request.fingerprint
|
||||
else {
|
||||
continuation.resume(returning: request.timeoutValue)
|
||||
return
|
||||
}
|
||||
self.tightenDeadline(&operation, to: request.deadline, for: request.key)
|
||||
operation.waiters.append(waiter)
|
||||
slot.pending = operation
|
||||
}
|
||||
self.slots[request.key] = slot
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id: waiterID, for: request.key) }
|
||||
}
|
||||
}
|
||||
|
||||
private func makeOperation(
|
||||
_ request: OperationRequest,
|
||||
generation: UInt64,
|
||||
waiter: Waiter) -> Operation
|
||||
{
|
||||
Operation(
|
||||
generation: generation,
|
||||
fingerprint: request.fingerprint,
|
||||
deadline: request.deadline,
|
||||
makeValue: request.makeValue,
|
||||
acceptValue: request.acceptValue,
|
||||
sourceTask: nil,
|
||||
deadlineTask: nil,
|
||||
waiters: [waiter],
|
||||
phase: .running)
|
||||
}
|
||||
|
||||
private func makeSourceTask(
|
||||
key: String,
|
||||
generation: UInt64,
|
||||
makeValue: @Sendable @escaping () async -> Value) -> Task<Void, Never>
|
||||
{
|
||||
Task.detached { [self] in
|
||||
let value = await makeValue()
|
||||
await self.sourceProduced(value, for: key, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeDeadlineTask(
|
||||
key: String,
|
||||
generation: UInt64,
|
||||
deadline: Instant?) -> Task<Void, Never>?
|
||||
{
|
||||
guard let deadline else { return nil }
|
||||
let sleepUntil = self.sleepUntil
|
||||
return Task.detached { [self] in
|
||||
do {
|
||||
try await sleepUntil(deadline)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await self.deadlineReached(for: key, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelWaiter(id: UUID, for key: String) {
|
||||
guard var slot = self.slots[key] else { return }
|
||||
if var active = slot.active,
|
||||
let index = active.waiters.firstIndex(where: { $0.id == id })
|
||||
{
|
||||
let waiter = active.waiters.remove(at: index)
|
||||
slot.active = active
|
||||
self.slots[key] = slot
|
||||
waiter.continuation.resume(returning: waiter.timeoutValue)
|
||||
if active.waiters.isEmpty, active.phase == .running {
|
||||
self.timeoutActive(for: key, generation: active.generation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard var pending = slot.pending,
|
||||
let index = pending.waiters.firstIndex(where: { $0.id == id })
|
||||
else {
|
||||
return
|
||||
}
|
||||
let waiter = pending.waiters.remove(at: index)
|
||||
waiter.continuation.resume(returning: waiter.timeoutValue)
|
||||
if pending.waiters.isEmpty {
|
||||
pending.deadlineTask?.cancel()
|
||||
slot.pending = nil
|
||||
} else {
|
||||
slot.pending = pending
|
||||
}
|
||||
self.store(slot, for: key)
|
||||
}
|
||||
|
||||
private func deadlineReached(for key: String, generation: UInt64) {
|
||||
if self.slots[key]?.active?.generation == generation {
|
||||
self.timeoutActive(for: key, generation: generation)
|
||||
} else if self.slots[key]?.pending?.generation == generation {
|
||||
self.timeoutPending(for: key, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func expireOverdueOperations(for key: String) {
|
||||
if let active = self.slots[key]?.active,
|
||||
active.phase == .running,
|
||||
self.isExpired(active.deadline)
|
||||
{
|
||||
self.timeoutActive(for: key, generation: active.generation)
|
||||
}
|
||||
if let pending = self.slots[key]?.pending,
|
||||
self.isExpired(pending.deadline)
|
||||
{
|
||||
self.timeoutPending(for: key, generation: pending.generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutActive(for key: String, generation: UInt64) {
|
||||
guard var slot = self.slots[key],
|
||||
var operation = slot.active,
|
||||
operation.generation == generation,
|
||||
operation.phase == .running
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
operation.phase = .timedOut
|
||||
operation.sourceTask?.cancel()
|
||||
operation.deadlineTask?.cancel()
|
||||
operation.deadlineTask = nil
|
||||
let waiters = operation.waiters
|
||||
operation.waiters.removeAll()
|
||||
slot.active = operation
|
||||
self.slots[key] = slot
|
||||
|
||||
for waiter in waiters {
|
||||
waiter.continuation.resume(returning: waiter.timeoutValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func shutdownActive(for key: String, generation: UInt64) {
|
||||
guard var slot = self.slots[key],
|
||||
var operation = slot.active,
|
||||
operation.generation == generation
|
||||
else {
|
||||
return
|
||||
}
|
||||
if operation.phase == .running {
|
||||
self.timeoutActive(for: key, generation: generation)
|
||||
return
|
||||
}
|
||||
guard operation.phase == .accepting else { return }
|
||||
|
||||
operation.phase = .timedOut
|
||||
operation.sourceTask?.cancel()
|
||||
let waiters = operation.waiters
|
||||
operation.waiters.removeAll()
|
||||
slot.active = operation
|
||||
self.slots[key] = slot
|
||||
for waiter in waiters {
|
||||
waiter.continuation.resume(returning: waiter.timeoutValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutPending(for key: String, generation: UInt64) {
|
||||
guard var slot = self.slots[key],
|
||||
let operation = slot.pending,
|
||||
operation.generation == generation
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
operation.deadlineTask?.cancel()
|
||||
slot.pending = nil
|
||||
self.store(slot, for: key)
|
||||
for waiter in operation.waiters {
|
||||
waiter.continuation.resume(returning: waiter.timeoutValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceProduced(_ value: Value, for key: String, generation: UInt64) async {
|
||||
guard var slot = self.slots[key],
|
||||
let observed = slot.active,
|
||||
observed.generation == generation
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if observed.phase == .running, self.isExpired(observed.deadline) {
|
||||
self.timeoutActive(for: key, generation: generation)
|
||||
guard let refreshed = self.slots[key] else { return }
|
||||
slot = refreshed
|
||||
}
|
||||
guard var operation = slot.active, operation.generation == generation else { return }
|
||||
|
||||
if operation.phase == .timedOut {
|
||||
slot.active = nil
|
||||
self.store(slot, for: key)
|
||||
self.promotePending(for: key)
|
||||
return
|
||||
}
|
||||
|
||||
// Source completion wins the deadline only on this actor turn. `accept`
|
||||
// is restricted to bounded in-process projection/cache work: keep the
|
||||
// slot owned through that commit so a newer generation cannot start and
|
||||
// then be overwritten by this older result. Shutdown still releases the
|
||||
// waiters even if a commit is briefly queued on another actor.
|
||||
operation.phase = .accepting
|
||||
operation.deadlineTask?.cancel()
|
||||
operation.deadlineTask = nil
|
||||
slot.active = operation
|
||||
self.slots[key] = slot
|
||||
let acceptedValue = await operation.acceptValue(value)
|
||||
|
||||
guard var refreshed = self.slots[key],
|
||||
let completed = refreshed.active,
|
||||
completed.generation == generation
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
refreshed.active = nil
|
||||
self.store(refreshed, for: key)
|
||||
if completed.phase == .accepting {
|
||||
for waiter in completed.waiters {
|
||||
waiter.continuation.resume(returning: acceptedValue)
|
||||
}
|
||||
}
|
||||
self.promotePending(for: key)
|
||||
}
|
||||
|
||||
private func promotePending(for key: String) {
|
||||
guard !self.isShutDown,
|
||||
var slot = self.slots[key],
|
||||
slot.active == nil,
|
||||
var pending = slot.pending
|
||||
else {
|
||||
return
|
||||
}
|
||||
if self.isExpired(pending.deadline) {
|
||||
self.timeoutPending(for: key, generation: pending.generation)
|
||||
return
|
||||
}
|
||||
|
||||
pending.sourceTask = self.makeSourceTask(
|
||||
key: key,
|
||||
generation: pending.generation,
|
||||
makeValue: pending.makeValue)
|
||||
slot.active = pending
|
||||
slot.pending = nil
|
||||
self.slots[key] = slot
|
||||
}
|
||||
|
||||
private func tightenDeadline(_ operation: inout Operation, to requested: Instant?, for key: String) {
|
||||
guard operation.phase == .running,
|
||||
let current = operation.deadline,
|
||||
let requested,
|
||||
requested < current
|
||||
else {
|
||||
return
|
||||
}
|
||||
operation.deadlineTask?.cancel()
|
||||
operation.deadline = requested
|
||||
operation.deadlineTask = self.makeDeadlineTask(
|
||||
key: key,
|
||||
generation: operation.generation,
|
||||
deadline: requested)
|
||||
}
|
||||
|
||||
private func deadlinesAreCompatible(_ active: Instant?, _ requested: Instant?) -> Bool {
|
||||
(active == nil) == (requested == nil)
|
||||
}
|
||||
|
||||
private func canJoinAccepting(_ operation: Operation, deadline: Instant?) -> Bool {
|
||||
guard operation.phase == .accepting,
|
||||
let activeDeadline = operation.deadline,
|
||||
let deadline
|
||||
else {
|
||||
return true
|
||||
}
|
||||
// Source acceptance already canceled the shared timer. An older-budget
|
||||
// follower cannot safely shorten that completed arbitration while the
|
||||
// bounded cache commit is in progress, so fail it closed.
|
||||
return deadline >= activeDeadline
|
||||
}
|
||||
|
||||
private func isExpired(_ deadline: Instant?) -> Bool {
|
||||
guard let deadline else { return false }
|
||||
return deadline <= self.now()
|
||||
}
|
||||
|
||||
private func store(_ slot: Slot, for key: String) {
|
||||
if slot.active == nil, slot.pending == nil {
|
||||
self.slots[key] = nil
|
||||
} else {
|
||||
self.slots[key] = slot
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func runSessions(_ values: ParsedValues) async {
|
||||
let sessions = await LocalAgentSessionScanner().scan()
|
||||
if values.flags.contains("jsonShortcut") {
|
||||
Self.printJSON(sessions, pretty: values.flags.contains("pretty"))
|
||||
} else {
|
||||
print(Self.renderSessionsTable(sessions))
|
||||
}
|
||||
}
|
||||
|
||||
static func runSessionsFocus(_ values: ParsedValues) async {
|
||||
guard let sessionID = values.positional.first, !sessionID.isEmpty else {
|
||||
writeStderr("Missing session id.\n")
|
||||
platformExit(1)
|
||||
}
|
||||
let sessions = await LocalAgentSessionScanner().scan()
|
||||
guard let session = sessions.first(where: { $0.id == sessionID }) else {
|
||||
Self.writeStderr("Unknown session: \(sessionID)\n")
|
||||
Self.platformExit(1)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let result = await MainActor.run {
|
||||
SessionWindowFocuser.focus(session)
|
||||
}
|
||||
switch result {
|
||||
case .focused, .activatedApplicationOnly:
|
||||
Self.platformExit(0)
|
||||
case .failed:
|
||||
Self.writeStderr("Could not focus session: \(sessionID)\n")
|
||||
Self.platformExit(2)
|
||||
}
|
||||
#else
|
||||
Self.writeStderr("Session focus is only available on macOS.\n")
|
||||
Self.platformExit(2)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func renderSessionsTable(_ sessions: [AgentSession], now: Date = Date()) -> String {
|
||||
guard !sessions.isEmpty else { return "No agent sessions found." }
|
||||
let rows = sessions.map { session in
|
||||
[
|
||||
session.state == .active ? "active" : "idle",
|
||||
session.provider.rawValue,
|
||||
session.source.rawValue,
|
||||
session.projectName ?? "—",
|
||||
Self.sessionAge(session, now: now),
|
||||
session.id,
|
||||
]
|
||||
}
|
||||
let headers = ["STATE", "PROVIDER", "SOURCE", "PROJECT", "ACTIVITY", "ID"]
|
||||
let widths = headers.indices.map { index in
|
||||
([headers[index]] + rows.map { $0[index] }).map(\ .count).max() ?? headers[index].count
|
||||
}
|
||||
let render: ([String]) -> String = { columns in
|
||||
columns.indices.map { index in
|
||||
columns[index].padding(toLength: widths[index], withPad: " ", startingAt: 0)
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
return ([render(headers)] + rows.map(render)).joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func sessionAge(_ session: AgentSession, now: Date) -> String {
|
||||
guard let date = session.lastActivityAt ?? session.startedAt else { return "now" }
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
if seconds < 60 {
|
||||
return "\(seconds)s"
|
||||
}
|
||||
if seconds < 3600 {
|
||||
return "\(seconds / 60)m"
|
||||
}
|
||||
if seconds < 86400 {
|
||||
return "\(seconds / 3600)h"
|
||||
}
|
||||
return "\(seconds / 86400)d"
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionsOptions: CommanderParsable {
|
||||
@Flag(name: .long("json"), help: "Emit JSON")
|
||||
var jsonShortcut: Bool = false
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
}
|
||||
|
||||
struct SessionsFocusOptions: CommanderParsable {
|
||||
@Argument(help: "Session identifier")
|
||||
var id: String = ""
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
enum CLITerminalBackend: Equatable, Sendable {
|
||||
case standard
|
||||
case truecolor
|
||||
case kittyGraphics
|
||||
}
|
||||
|
||||
enum CLITerminalCapabilities {
|
||||
static func detect(environment: [String: String] = ProcessInfo.processInfo.environment) -> CLITerminalBackend {
|
||||
if self.supportsKittyGraphics(environment: environment) {
|
||||
return .kittyGraphics
|
||||
}
|
||||
if self.supportsTruecolor(environment: environment) {
|
||||
return .truecolor
|
||||
}
|
||||
return .standard
|
||||
}
|
||||
|
||||
static func supportsEnhancedCards(
|
||||
useColor: Bool,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
|
||||
{
|
||||
guard useColor else { return false }
|
||||
if environment["CODEXBAR_CARDS_ENHANCED"] == "1" { return true }
|
||||
if environment["CODEXBAR_CARDS_ENHANCED"] == "0" { return false }
|
||||
return self.supportsTruecolor(environment: environment)
|
||||
}
|
||||
|
||||
static func supportsKittyGraphics(environment: [String: String]) -> Bool {
|
||||
if environment["KITTY_WINDOW_ID"] != nil { return true }
|
||||
if environment["GHOSTTY_RESOURCES_DIR"] != nil { return true }
|
||||
let term = environment["TERM"]?.lowercased() ?? ""
|
||||
if term.contains("kitty") || term.contains("ghostty") || term.contains("wezterm") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func supportsTruecolor(environment: [String: String]) -> Bool {
|
||||
if self.supportsKittyGraphics(environment: environment) { return true }
|
||||
let colorTerm = environment["COLORTERM"]?.lowercased() ?? ""
|
||||
if colorTerm.contains("truecolor") || colorTerm.contains("24bit") { return true }
|
||||
let term = environment["TERM"]?.lowercased() ?? ""
|
||||
return term.contains("foot") || term.contains("alacritty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import CodexBarCore
|
||||
import Dispatch
|
||||
import Foundation
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
|
||||
private func handleCLITerminationSignal(_: Int32) {}
|
||||
|
||||
final class CLITerminationSignalMonitor: @unchecked Sendable {
|
||||
static let signalNumbers = [SIGINT, SIGTERM, SIGHUP]
|
||||
|
||||
private let lock = NSLock()
|
||||
private let sources: [DispatchSourceSignal]
|
||||
private var isCancelled = false
|
||||
|
||||
init(onSignal: @escaping @Sendable (Int32) -> Void) {
|
||||
self.sources = Self.signalNumbers.map { signalNumber in
|
||||
Self.installCaptureHandler(for: signalNumber)
|
||||
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .global(qos: .utility))
|
||||
source.setEventHandler {
|
||||
onSignal(signalNumber)
|
||||
}
|
||||
source.resume()
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
self.lock.lock()
|
||||
guard !self.isCancelled else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.isCancelled = true
|
||||
self.lock.unlock()
|
||||
|
||||
for source in self.sources {
|
||||
source.cancel()
|
||||
}
|
||||
for signalNumber in Self.signalNumbers {
|
||||
Self.restoreDefaultHandler(for: signalNumber)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.cancel()
|
||||
}
|
||||
|
||||
static func terminateActiveHelpersAndReraise(_ signalNumber: Int32) {
|
||||
TTYCommandRunner.terminateActiveProcessesForAppShutdown()
|
||||
self.restoreDefaultHandler(for: signalNumber)
|
||||
_ = kill(getpid(), signalNumber)
|
||||
}
|
||||
|
||||
private static func installCaptureHandler(for signalNumber: Int32) {
|
||||
#if canImport(Darwin)
|
||||
_ = Darwin.signal(signalNumber, handleCLITerminationSignal)
|
||||
#elseif canImport(Glibc)
|
||||
_ = Glibc.signal(signalNumber, handleCLITerminationSignal)
|
||||
#elseif canImport(Musl)
|
||||
_ = Musl.signal(signalNumber, handleCLITerminationSignal)
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func restoreDefaultHandler(for signalNumber: Int32) {
|
||||
#if canImport(Darwin)
|
||||
_ = Darwin.signal(signalNumber, SIG_DFL)
|
||||
#elseif canImport(Glibc)
|
||||
_ = Glibc.signal(signalNumber, SIG_DFL)
|
||||
#elseif canImport(Musl)
|
||||
_ = Musl.signal(signalNumber, SIG_DFL)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct UsageCommandContext {
|
||||
let format: OutputFormat
|
||||
let includeCredits: Bool
|
||||
let sourceModeOverride: ProviderSourceMode?
|
||||
let antigravityPlanDebug: Bool
|
||||
let augmentDebug: Bool
|
||||
let webDebugDumpHTML: Bool
|
||||
let webTimeout: TimeInterval
|
||||
let verbose: Bool
|
||||
let useColor: Bool
|
||||
let resetStyle: ResetTimeDisplayStyle
|
||||
let weeklyWorkDays: Int?
|
||||
let jsonOnly: Bool
|
||||
let includeAllCodexAccounts: Bool
|
||||
let fetcher: UsageFetcher
|
||||
let claudeFetcher: ClaudeUsageFetcher
|
||||
let browserDetection: BrowserDetection
|
||||
/// True for long-lived hosts (`codexbar serve`) that keep warm provider
|
||||
/// helper sessions (such as the managed Antigravity `agy` process) alive
|
||||
/// between fetches instead of resetting after each one-shot fetch.
|
||||
var persistCLISessions: Bool = false
|
||||
var persistentCLISessionIdleWindow: TimeInterval?
|
||||
var cardsLayout: Bool = false
|
||||
}
|
||||
|
||||
struct UsageCommandOutput {
|
||||
var sections: [String] = []
|
||||
var payload: [ProviderPayload] = []
|
||||
var cards: [CLICardModel] = []
|
||||
var cardFailures: [CLICardFailure] = []
|
||||
var exitCode: ExitCode = .success
|
||||
}
|
||||
|
||||
private struct UsageSuccessRenderInput {
|
||||
let provider: UsageProvider
|
||||
let accountLabel: String?
|
||||
let cacheAccountKey: String?
|
||||
let version: String?
|
||||
let source: String
|
||||
let status: ProviderStatusPayload?
|
||||
let usage: UsageSnapshot
|
||||
let credits: CreditsSnapshot?
|
||||
let antigravityPlanInfo: AntigravityPlanInfoSummary?
|
||||
let dashboard: OpenAIDashboardSnapshot?
|
||||
let effectiveSourceMode: ProviderSourceMode
|
||||
let command: UsageCommandContext
|
||||
let notes: [String]
|
||||
}
|
||||
|
||||
extension UsageCommandOutput {
|
||||
mutating func merge(_ other: UsageCommandOutput) {
|
||||
self.sections.append(contentsOf: other.sections)
|
||||
self.payload.append(contentsOf: other.payload)
|
||||
self.cards.append(contentsOf: other.cards)
|
||||
self.cardFailures.append(contentsOf: other.cardFailures)
|
||||
if other.exitCode != .success {
|
||||
self.exitCode = other.exitCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CodexBarCLI {
|
||||
static func runUsage(_ values: ParsedValues) async {
|
||||
let output = CLIOutputPreferences.from(values: values)
|
||||
let config = Self.loadConfig(output: output)
|
||||
let provider = Self.decodeProvider(from: values, config: config)
|
||||
let format = output.format
|
||||
let includeCredits = format == .json ? true : !values.flags.contains("noCredits")
|
||||
let includeStatus = values.flags.contains("status")
|
||||
let sourceModeRaw = values.options["source"]?.last
|
||||
let parsedSourceMode = Self.decodeSourceMode(from: values)
|
||||
if sourceModeRaw != nil, parsedSourceMode == nil {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: --source must be auto|web|cli|oauth|api.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
let antigravityPlanDebug = values.flags.contains("antigravityPlanDebug")
|
||||
let augmentDebug = values.flags.contains("augmentDebug")
|
||||
let webDebugDumpHTML = values.flags.contains("webDebugDumpHtml")
|
||||
let webTimeout: TimeInterval
|
||||
do {
|
||||
webTimeout = try Self.decodeWebTimeout(from: values) ?? 60
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args)
|
||||
}
|
||||
let verbose = values.flags.contains("verbose")
|
||||
let noColor = values.flags.contains("noColor")
|
||||
let useColor = Self.shouldUseColor(noColor: noColor, format: format)
|
||||
let resetStyle = Self.resetTimeDisplayStyleFromDefaults()
|
||||
let weeklyWorkDays = Self.weeklyProgressWorkDaysFromDefaults()
|
||||
let providerList = provider.asList
|
||||
|
||||
let tokenSelection: TokenAccountCLISelection
|
||||
do {
|
||||
tokenSelection = try Self.decodeTokenAccountSelection(from: values)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .args)
|
||||
}
|
||||
|
||||
if tokenSelection.allAccounts, tokenSelection.label != nil || tokenSelection.index != nil {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: --all-accounts cannot be combined with --account or --account-index.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
|
||||
if tokenSelection.usesOverride {
|
||||
guard providerList.count == 1 else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: account selection requires a single provider.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
let supportsAllCodexAccounts = providerList[0] == .codex
|
||||
&& tokenSelection.allAccounts
|
||||
&& tokenSelection.label == nil
|
||||
&& tokenSelection.index == nil
|
||||
guard supportsAllCodexAccounts || TokenAccountSupportCatalog.support(for: providerList[0]) != nil else {
|
||||
Self.exit(
|
||||
code: .failure,
|
||||
message: "Error: \(providerList[0].rawValue) does not support token accounts.",
|
||||
output: output,
|
||||
kind: .args)
|
||||
}
|
||||
}
|
||||
|
||||
let browserDetection = BrowserDetection()
|
||||
let fetcher = UsageFetcher()
|
||||
let claudeFetcher = ClaudeUsageFetcher(browserDetection: browserDetection)
|
||||
let tokenContext: TokenAccountCLIContext
|
||||
do {
|
||||
tokenContext = try TokenAccountCLIContext(
|
||||
selection: tokenSelection,
|
||||
config: config,
|
||||
verbose: verbose)
|
||||
} catch {
|
||||
Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config)
|
||||
}
|
||||
|
||||
var sections: [String] = []
|
||||
var payload: [ProviderPayload] = []
|
||||
var exitCode: ExitCode = .success
|
||||
let command = UsageCommandContext(
|
||||
format: format,
|
||||
includeCredits: includeCredits,
|
||||
sourceModeOverride: parsedSourceMode,
|
||||
antigravityPlanDebug: antigravityPlanDebug,
|
||||
augmentDebug: augmentDebug,
|
||||
webDebugDumpHTML: webDebugDumpHTML,
|
||||
webTimeout: webTimeout,
|
||||
verbose: verbose,
|
||||
useColor: useColor,
|
||||
resetStyle: resetStyle,
|
||||
weeklyWorkDays: weeklyWorkDays,
|
||||
jsonOnly: output.jsonOnly,
|
||||
includeAllCodexAccounts: tokenSelection.allAccounts && providerList == [.codex],
|
||||
fetcher: fetcher,
|
||||
claudeFetcher: claudeFetcher,
|
||||
browserDetection: browserDetection)
|
||||
|
||||
for p in providerList {
|
||||
let status = includeStatus ? await Self.fetchStatus(for: p) : nil
|
||||
// CLI usage should not clear Keychain cooldowns or attempt interactive Keychain prompts.
|
||||
let output = await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await Self.fetchUsageOutputs(
|
||||
provider: p,
|
||||
status: status,
|
||||
tokenContext: tokenContext,
|
||||
command: command)
|
||||
}
|
||||
if output.exitCode != .success {
|
||||
exitCode = output.exitCode
|
||||
}
|
||||
sections.append(contentsOf: output.sections)
|
||||
payload.append(contentsOf: output.payload)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case .text:
|
||||
if !sections.isEmpty {
|
||||
print(sections.joined(separator: "\n\n"))
|
||||
}
|
||||
case .json:
|
||||
Self.printJSON(payload, pretty: output.pretty)
|
||||
}
|
||||
|
||||
Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider)
|
||||
}
|
||||
|
||||
static func fetchUsageOutputs(
|
||||
provider: UsageProvider,
|
||||
status: ProviderStatusPayload?,
|
||||
tokenContext: TokenAccountCLIContext,
|
||||
command: UsageCommandContext) async -> UsageCommandOutput
|
||||
{
|
||||
if provider == .codex, command.includeAllCodexAccounts {
|
||||
var output = UsageCommandOutput()
|
||||
let accounts = tokenContext.visibleCodexAccounts().visibleAccounts
|
||||
let selections: [CodexVisibleAccount?] = accounts.isEmpty ? [nil] : accounts.map { Optional($0) }
|
||||
for visibleAccount in selections {
|
||||
let result = await Self.fetchUsageOutput(
|
||||
provider: provider,
|
||||
account: nil,
|
||||
codexVisibleAccount: visibleAccount,
|
||||
status: status,
|
||||
tokenContext: tokenContext,
|
||||
command: command)
|
||||
output.merge(result)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
let accounts: [ProviderTokenAccount]
|
||||
do {
|
||||
accounts = try tokenContext.resolvedAccounts(for: provider)
|
||||
} catch {
|
||||
return Self.usageOutputForAccountResolutionError(
|
||||
provider: provider,
|
||||
status: status,
|
||||
command: command,
|
||||
error: error)
|
||||
}
|
||||
|
||||
let selections = Self.accountSelections(from: accounts)
|
||||
var output = UsageCommandOutput()
|
||||
for account in selections {
|
||||
let result = await Self.fetchUsageOutput(
|
||||
provider: provider,
|
||||
account: account,
|
||||
status: status,
|
||||
tokenContext: tokenContext,
|
||||
command: command)
|
||||
output.merge(result)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func accountSelections(from accounts: [ProviderTokenAccount]) -> [ProviderTokenAccount?] {
|
||||
if accounts.isEmpty { return [nil] }
|
||||
return accounts.map { Optional($0) }
|
||||
}
|
||||
|
||||
private static func usageOutputForAccountResolutionError(
|
||||
provider: UsageProvider,
|
||||
status: ProviderStatusPayload?,
|
||||
command: UsageCommandContext,
|
||||
error: Error) -> UsageCommandOutput
|
||||
{
|
||||
var output = UsageCommandOutput()
|
||||
output.exitCode = .failure
|
||||
if command.format == .json {
|
||||
output.payload.append(Self.makeProviderErrorPayload(
|
||||
provider: provider,
|
||||
account: nil,
|
||||
source: command.sourceModeOverride?.rawValue ?? "auto",
|
||||
status: status,
|
||||
error: error,
|
||||
kind: .provider))
|
||||
} else if command.cardsLayout {
|
||||
output.cardFailures.append(CLICardFailure(
|
||||
provider: provider,
|
||||
accountLabel: nil,
|
||||
message: error.localizedDescription))
|
||||
} else if !command.jsonOnly {
|
||||
Self.writeStderr("Error: \(error.localizedDescription)\n")
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
private static func makeUsagePayload(
|
||||
provider: UsageProvider,
|
||||
accountLabel: String?,
|
||||
cacheAccountKey: String?,
|
||||
version: String?,
|
||||
source: String,
|
||||
status: ProviderStatusPayload?,
|
||||
usage: UsageSnapshot,
|
||||
credits: CreditsSnapshot?,
|
||||
antigravityPlanInfo: AntigravityPlanInfoSummary?,
|
||||
dashboard: OpenAIDashboardSnapshot?,
|
||||
weeklyWorkDays: Int?) -> ProviderPayload
|
||||
{
|
||||
ProviderPayload(
|
||||
provider: provider,
|
||||
account: accountLabel,
|
||||
cacheAccountKey: cacheAccountKey,
|
||||
version: version,
|
||||
source: source,
|
||||
status: status,
|
||||
usage: usage,
|
||||
credits: credits,
|
||||
antigravityPlanInfo: antigravityPlanInfo,
|
||||
openaiDashboard: dashboard,
|
||||
error: nil,
|
||||
pace: CLIRenderer.providerPacePayload(provider: provider, snapshot: usage, weeklyWorkDays: weeklyWorkDays))
|
||||
}
|
||||
|
||||
private static func appendSuccessRenderOutput(
|
||||
_ input: UsageSuccessRenderInput,
|
||||
output: inout UsageCommandOutput)
|
||||
{
|
||||
switch input.command.format {
|
||||
case .text:
|
||||
if input.command.cardsLayout {
|
||||
output.cards.append(CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: input.provider,
|
||||
snapshot: input.usage,
|
||||
credits: input.credits,
|
||||
source: input.source,
|
||||
status: input.status,
|
||||
notes: input.notes,
|
||||
useColor: input.command.useColor,
|
||||
resetStyle: input.command.resetStyle,
|
||||
weeklyWorkDays: input.command.weeklyWorkDays,
|
||||
now: Date())))
|
||||
} else {
|
||||
var text = CLIRenderer.renderText(
|
||||
provider: input.provider,
|
||||
snapshot: input.usage,
|
||||
credits: input.credits,
|
||||
context: RenderContext(
|
||||
header: Self.makeHeader(
|
||||
provider: input.provider,
|
||||
version: input.version,
|
||||
source: input.source),
|
||||
status: input.status,
|
||||
useColor: input.command.useColor,
|
||||
resetStyle: input.command.resetStyle,
|
||||
weeklyWorkDays: input.command.weeklyWorkDays,
|
||||
notes: input.notes))
|
||||
if let dashboard = input.dashboard, input.provider == .codex, input.effectiveSourceMode.usesWeb {
|
||||
text += "\n" + Self.renderOpenAIWebDashboardText(dashboard)
|
||||
}
|
||||
output.sections.append(text)
|
||||
}
|
||||
case .json:
|
||||
output.payload.append(self.makeUsagePayload(
|
||||
provider: input.provider,
|
||||
accountLabel: input.accountLabel,
|
||||
cacheAccountKey: input.cacheAccountKey,
|
||||
version: input.version,
|
||||
source: input.source,
|
||||
status: input.status,
|
||||
usage: input.usage,
|
||||
credits: input.credits,
|
||||
antigravityPlanInfo: input.antigravityPlanInfo,
|
||||
dashboard: input.dashboard,
|
||||
weeklyWorkDays: input.command.weeklyWorkDays))
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchUsageOutput(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
codexVisibleAccount: CodexVisibleAccount? = nil,
|
||||
status: ProviderStatusPayload?,
|
||||
tokenContext: TokenAccountCLIContext,
|
||||
command: UsageCommandContext) async -> UsageCommandOutput
|
||||
{
|
||||
var output = UsageCommandOutput()
|
||||
let env = tokenContext.environment(
|
||||
base: ProcessInfo.processInfo.environment,
|
||||
provider: provider,
|
||||
account: account,
|
||||
codexActiveSourceOverride: codexVisibleAccount?.selectionSource)
|
||||
let settings = tokenContext.settingsSnapshot(
|
||||
for: provider,
|
||||
account: account,
|
||||
codexActiveSourceOverride: codexVisibleAccount?.selectionSource)
|
||||
let configSource = tokenContext.preferredSourceMode(for: provider)
|
||||
let baseSource = command.sourceModeOverride ?? configSource
|
||||
let effectiveSourceMode = tokenContext.effectiveSourceMode(
|
||||
base: baseSource,
|
||||
provider: provider,
|
||||
account: account)
|
||||
let cacheAccountKey = Self.usageCacheAccountKey(
|
||||
provider: provider,
|
||||
account: account,
|
||||
codexVisibleAccount: codexVisibleAccount)
|
||||
|
||||
#if !os(macOS)
|
||||
if Self.sourceModeRequiresWebSupport(
|
||||
effectiveSourceMode,
|
||||
provider: provider,
|
||||
environment: env,
|
||||
settings: settings)
|
||||
{
|
||||
return Self.webSourceUnsupportedOutput(
|
||||
provider: provider,
|
||||
account: (
|
||||
label: account?.label ?? codexVisibleAccount?.menuDisplayName,
|
||||
cacheKey: cacheAccountKey),
|
||||
source: effectiveSourceMode.rawValue,
|
||||
status: status,
|
||||
command: command)
|
||||
}
|
||||
#endif
|
||||
|
||||
let fetchContext = ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: effectiveSourceMode,
|
||||
includeCredits: command.includeCredits,
|
||||
webTimeout: command.webTimeout,
|
||||
webDebugDumpHTML: command.webDebugDumpHTML,
|
||||
verbose: command.verbose,
|
||||
env: env,
|
||||
settings: settings,
|
||||
fetcher: tokenContext.fetcher(base: command.fetcher, provider: provider, env: env),
|
||||
claudeFetcher: command.claudeFetcher,
|
||||
browserDetection: command.browserDetection,
|
||||
selectedTokenAccountID: account?.id,
|
||||
tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account),
|
||||
providerManualTokenUpdater: tokenContext.manualTokenUpdater(),
|
||||
persistsCLISessions: Self.persistsCLISessions(provider: provider, command: command),
|
||||
persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow)
|
||||
let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext)
|
||||
if command.verbose, !command.jsonOnly {
|
||||
Self.printFetchAttempts(provider: provider, attempts: outcome.attempts)
|
||||
}
|
||||
|
||||
switch outcome.result {
|
||||
case let .success(result):
|
||||
let antigravityPlanInfo = await Self.fetchAntigravityPlanInfoIfNeeded(
|
||||
provider: provider,
|
||||
command: command)
|
||||
await Self.emitAugmentDebugIfNeeded(provider: provider, command: command)
|
||||
|
||||
var usage = result.usage.scoped(to: provider)
|
||||
if let account {
|
||||
usage = tokenContext.applyAccountLabel(usage, provider: provider, account: account)
|
||||
} else if let codexVisibleAccount {
|
||||
usage = tokenContext.applyCodexVisibleAccountLabel(usage, account: codexVisibleAccount)
|
||||
}
|
||||
|
||||
var dashboard = result.dashboard
|
||||
if dashboard == nil, command.format == .json, provider == .codex {
|
||||
dashboard = Self.loadOpenAIDashboardIfAvailable(
|
||||
usage: usage,
|
||||
sourceLabel: result.sourceLabel,
|
||||
context: fetchContext)
|
||||
}
|
||||
|
||||
let shouldDetectVersion = Self.shouldDetectVersion(provider: provider, result: result)
|
||||
let version = Self.normalizeVersion(
|
||||
raw: shouldDetectVersion
|
||||
? Self.detectVersion(for: provider, browserDetection: command.browserDetection)
|
||||
: nil)
|
||||
let source = result.sourceLabel
|
||||
let notes = Self.usageTextNotes(
|
||||
provider: provider,
|
||||
sourceMode: effectiveSourceMode,
|
||||
resolvedSourceLabel: source)
|
||||
|
||||
Self.appendSuccessRenderOutput(
|
||||
UsageSuccessRenderInput(
|
||||
provider: provider,
|
||||
accountLabel: account?.label ?? codexVisibleAccount?.menuDisplayName,
|
||||
cacheAccountKey: cacheAccountKey,
|
||||
version: version,
|
||||
source: source,
|
||||
status: status,
|
||||
usage: usage,
|
||||
credits: result.credits,
|
||||
antigravityPlanInfo: antigravityPlanInfo,
|
||||
dashboard: dashboard,
|
||||
effectiveSourceMode: effectiveSourceMode,
|
||||
command: command,
|
||||
notes: notes),
|
||||
output: &output)
|
||||
case let .failure(error):
|
||||
output.exitCode = Self.mapError(error)
|
||||
if command.format == .json {
|
||||
output.payload.append(Self.makeProviderErrorPayload(
|
||||
provider: provider,
|
||||
account: account?.label ?? codexVisibleAccount?.menuDisplayName,
|
||||
cacheAccountKey: cacheAccountKey,
|
||||
source: effectiveSourceMode.rawValue,
|
||||
status: status,
|
||||
error: error,
|
||||
kind: .provider))
|
||||
} else if command.cardsLayout {
|
||||
output.cardFailures.append(CLICardFailure(
|
||||
provider: provider,
|
||||
accountLabel: account?.label ?? codexVisibleAccount?.menuDisplayName,
|
||||
message: error.localizedDescription))
|
||||
} else if !command.jsonOnly {
|
||||
if let accountLabel = account?.label ?? codexVisibleAccount?.menuDisplayName {
|
||||
Self.writeStderr(
|
||||
"Error (\(provider.rawValue) - \(accountLabel)): \(error.localizedDescription)\n")
|
||||
} else {
|
||||
Self.writeStderr("Error: \(error.localizedDescription)\n")
|
||||
}
|
||||
if let summary = Self.kiloAutoFallbackSummary(
|
||||
provider: provider,
|
||||
sourceMode: effectiveSourceMode,
|
||||
attempts: outcome.attempts)
|
||||
{
|
||||
Self.writeStderr("\(summary)\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await Self.finishUsageOutput(output, provider: provider, command: command)
|
||||
}
|
||||
|
||||
static func shouldDetectVersion(provider: UsageProvider, result: ProviderFetchResult) -> Bool {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
|
||||
guard descriptor.cli.versionDetector != nil else { return false }
|
||||
guard result.strategyKind != .webDashboard else { return false }
|
||||
return !(provider == .claude && result.strategyKind == .oauth)
|
||||
}
|
||||
|
||||
private static func holdsAntigravitySession(
|
||||
provider: UsageProvider,
|
||||
command: UsageCommandContext) -> Bool
|
||||
{
|
||||
self.holdsAntigravityCLISessionForPlanDebug(
|
||||
provider: provider,
|
||||
planDebugEnabled: command.antigravityPlanDebug,
|
||||
jsonOnly: command.jsonOnly,
|
||||
persistsCLISessions: command.persistCLISessions)
|
||||
}
|
||||
|
||||
private static func persistsCLISessions(
|
||||
provider: UsageProvider,
|
||||
command: UsageCommandContext) -> Bool
|
||||
{
|
||||
command.persistCLISessions || self.holdsAntigravitySession(provider: provider, command: command)
|
||||
}
|
||||
|
||||
static func holdsAntigravityCLISessionForPlanDebug(
|
||||
provider: UsageProvider,
|
||||
planDebugEnabled: Bool,
|
||||
jsonOnly: Bool,
|
||||
persistsCLISessions: Bool) -> Bool
|
||||
{
|
||||
provider == .antigravity
|
||||
&& planDebugEnabled
|
||||
&& !jsonOnly
|
||||
&& !persistsCLISessions
|
||||
}
|
||||
|
||||
private static func finishUsageOutput(
|
||||
_ output: UsageCommandOutput,
|
||||
provider: UsageProvider,
|
||||
command: UsageCommandContext) async -> UsageCommandOutput
|
||||
{
|
||||
if self.holdsAntigravitySession(provider: provider, command: command) {
|
||||
await ProviderCLISessionLifecycle.shutdownPersistentSessions()
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func fetchAntigravityPlanInfoIfNeeded(
|
||||
provider: UsageProvider,
|
||||
command: UsageCommandContext) async -> AntigravityPlanInfoSummary?
|
||||
{
|
||||
guard command.antigravityPlanDebug,
|
||||
provider == .antigravity,
|
||||
!command.jsonOnly
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let info = try? await AntigravityStatusProbe().fetchPlanInfoSummary()
|
||||
if command.format == .text, let info {
|
||||
Self.printAntigravityPlanInfo(info)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
private static func emitAugmentDebugIfNeeded(
|
||||
provider: UsageProvider,
|
||||
command: UsageCommandContext) async
|
||||
{
|
||||
guard command.augmentDebug, provider == .augment else { return }
|
||||
#if os(macOS)
|
||||
let dump = await AugmentStatusProbe.latestDumps()
|
||||
if command.format == .text, !dump.isEmpty, !command.jsonOnly {
|
||||
Self.writeStderr("Augment API responses:\n\(dump)\n")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func webSourceUnsupportedOutput(
|
||||
provider: UsageProvider,
|
||||
account: (label: String?, cacheKey: String?),
|
||||
source: String,
|
||||
status: ProviderStatusPayload?,
|
||||
command: UsageCommandContext) -> UsageCommandOutput
|
||||
{
|
||||
var output = UsageCommandOutput()
|
||||
let error = NSError(
|
||||
domain: "CodexBarCLI",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey:
|
||||
"Error: selected source requires web support and is only supported on macOS."])
|
||||
output.exitCode = .failure
|
||||
if command.format == .json {
|
||||
output.payload.append(Self.makeProviderErrorPayload(
|
||||
provider: provider,
|
||||
account: account.label,
|
||||
cacheAccountKey: account.cacheKey,
|
||||
source: source,
|
||||
status: status,
|
||||
error: error,
|
||||
kind: .runtime))
|
||||
} else if command.cardsLayout {
|
||||
output.cardFailures.append(CLICardFailure(
|
||||
provider: provider,
|
||||
accountLabel: account.label,
|
||||
message: error.localizedDescription))
|
||||
} else if !command.jsonOnly {
|
||||
Self.writeStderr("Error: \(error.localizedDescription)\n")
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
static func usageCacheAccountKey(
|
||||
provider _: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
codexVisibleAccount: CodexVisibleAccount?) -> String?
|
||||
{
|
||||
if let account {
|
||||
return "token:\(account.id.uuidString.lowercased())"
|
||||
}
|
||||
if let codexVisibleAccount {
|
||||
if let workspaceAccountID = codexVisibleAccount.workspaceAccountID?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!workspaceAccountID.isEmpty,
|
||||
!codexVisibleAccount.email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
let email = codexVisibleAccount.email
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return "codex:workspace:\(workspaceAccountID):email:\(email)"
|
||||
}
|
||||
if let storedAccountID = codexVisibleAccount.storedAccountID {
|
||||
return "codex:stored:\(storedAccountID.uuidString.lowercased())"
|
||||
}
|
||||
if let authFingerprint = codexVisibleAccount.authFingerprint {
|
||||
return "codex:auth:\(authFingerprint)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func sourceModeRequiresWebSupport(
|
||||
_ sourceMode: ProviderSourceMode,
|
||||
provider: UsageProvider,
|
||||
environment: [String: String]? = nil,
|
||||
settings: ProviderSettingsSnapshot? = nil) -> Bool
|
||||
{
|
||||
guard provider != .grok, provider != .amp else {
|
||||
return false
|
||||
}
|
||||
if provider == .codex, sourceMode == .auto {
|
||||
return false
|
||||
}
|
||||
if provider == .claude, sourceMode == .auto {
|
||||
// Claude's cross-platform planner skips its unavailable web step and falls back to the CLI.
|
||||
return false
|
||||
}
|
||||
if provider == .opencodego {
|
||||
if sourceMode == .auto || settings?.opencodego?.cookieSource == .manual {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if provider == .commandcode,
|
||||
settings?.commandcode?.cookieSource == .manual
|
||||
{
|
||||
return false
|
||||
}
|
||||
#if os(Linux)
|
||||
if provider == .cursor,
|
||||
settings?.cursor?.cookieSource != .off
|
||||
{
|
||||
// Linux uses Cursor app auth and manual cookies; browser import remains macOS-only.
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
if provider == .sakana,
|
||||
sourceMode == .auto || sourceMode == .web,
|
||||
environment.map({ SakanaSettingsReader.cookieHeader(environment: $0) != nil }) == true
|
||||
{
|
||||
return false
|
||||
}
|
||||
if provider == .qoder,
|
||||
settings?.qoder?.cookieSource == .manual
|
||||
{
|
||||
return false
|
||||
}
|
||||
if provider == .ollama,
|
||||
sourceMode == .auto
|
||||
{
|
||||
let hasEnvironmentToken = environment.map {
|
||||
ProviderTokenResolver.ollamaToken(environment: $0) != nil
|
||||
} == true
|
||||
if settings?.ollama?.cookieSource == .off || hasEnvironmentToken {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if provider == .kimi,
|
||||
sourceMode == .auto,
|
||||
environment.map({ environment in
|
||||
ProviderTokenResolver.kimiAPIToken(environment: environment) != nil ||
|
||||
KimiSettingsReader.hasKimiCodeCredential(environment: environment)
|
||||
}) == true
|
||||
{
|
||||
return false
|
||||
}
|
||||
if provider == .factory,
|
||||
sourceMode == .auto || sourceMode == .cli,
|
||||
environment.map({ FactorySettingsReader.apiKey(environment: $0) != nil }) == true
|
||||
{
|
||||
// Linux Auto/legacy-cli can use FACTORY_API_KEY without browser cookies.
|
||||
return false
|
||||
}
|
||||
if provider == .mimo,
|
||||
sourceMode == .auto,
|
||||
let environment,
|
||||
MiMoLocalUsageFallback.cacheExists(environment: environment)
|
||||
{
|
||||
return false
|
||||
}
|
||||
return switch sourceMode {
|
||||
case .web:
|
||||
true
|
||||
case .auto:
|
||||
ProviderDescriptorRegistry.descriptor(for: provider).fetchPlan.sourceModes.contains(.web)
|
||||
case .cli, .oauth, .api:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct DiagnoseOptions: 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?
|
||||
|
||||
@Option(name: .long("provider"), help: ProviderHelp.optionHelp)
|
||||
var provider: String?
|
||||
|
||||
@Option(name: .long("format"), help: "Output format: json")
|
||||
var format: String?
|
||||
|
||||
@Flag(name: .long("redact"), help: "Explicitly redact sensitive values (always enabled for diagnose)")
|
||||
var redact: Bool = false
|
||||
|
||||
@Option(name: .long("output"), help: "Write redacted JSON diagnostic export to a file")
|
||||
var output: String?
|
||||
|
||||
@Flag(name: .long("pretty"), help: "Pretty-print JSON output")
|
||||
var pretty: Bool = false
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct TokenAccountCLISelection {
|
||||
let label: String?
|
||||
let index: Int?
|
||||
let allAccounts: Bool
|
||||
|
||||
var usesOverride: Bool {
|
||||
self.label != nil || self.index != nil || self.allAccounts
|
||||
}
|
||||
}
|
||||
|
||||
enum TokenAccountCLIError: LocalizedError {
|
||||
case noAccounts(UsageProvider)
|
||||
case accountNotFound(UsageProvider, String)
|
||||
case indexOutOfRange(UsageProvider, Int, Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .noAccounts(provider):
|
||||
"No token accounts configured for \(provider.rawValue)."
|
||||
case let .accountNotFound(provider, label):
|
||||
"No token account labeled '\(label)' for \(provider.rawValue)."
|
||||
case let .indexOutOfRange(provider, index, count):
|
||||
"Token account index \(index) out of range for \(provider.rawValue) (1-\(count))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenAccountCLIContext {
|
||||
let selection: TokenAccountCLISelection
|
||||
let config: CodexBarConfig
|
||||
let accountsByProvider: [UsageProvider: ProviderTokenAccountData]
|
||||
private let baseEnvironment: [String: String]
|
||||
private let managedCodexAccountStoreURL: URL?
|
||||
|
||||
init(
|
||||
selection: TokenAccountCLISelection,
|
||||
config: CodexBarConfig,
|
||||
verbose _: Bool,
|
||||
baseEnvironment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
managedCodexAccountStoreURL: URL? = nil) throws
|
||||
{
|
||||
self.selection = selection
|
||||
self.config = config
|
||||
self.baseEnvironment = baseEnvironment
|
||||
self.managedCodexAccountStoreURL = managedCodexAccountStoreURL
|
||||
self.accountsByProvider = Dictionary(uniqueKeysWithValues: config.providers.compactMap { provider in
|
||||
guard let accounts = provider.tokenAccounts else { return nil }
|
||||
return (provider.id, accounts)
|
||||
})
|
||||
}
|
||||
|
||||
func resolvedAccounts(for provider: UsageProvider) throws -> [ProviderTokenAccount] {
|
||||
guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] }
|
||||
guard let data = self.accountsByProvider[provider], !data.accounts.isEmpty else {
|
||||
if self.selection.usesOverride {
|
||||
throw TokenAccountCLIError.noAccounts(provider)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
if self.selection.allAccounts {
|
||||
return data.accounts
|
||||
}
|
||||
|
||||
if let label = self.selection.label?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty {
|
||||
let normalized = label.lowercased()
|
||||
if let match = data.accounts.first(where: { $0.label.lowercased() == normalized }) {
|
||||
return [match]
|
||||
}
|
||||
throw TokenAccountCLIError.accountNotFound(provider, label)
|
||||
}
|
||||
|
||||
if let index = self.selection.index {
|
||||
guard index >= 0, index < data.accounts.count else {
|
||||
throw TokenAccountCLIError.indexOutOfRange(provider, index + 1, data.accounts.count)
|
||||
}
|
||||
return [data.accounts[index]]
|
||||
}
|
||||
|
||||
let clamped = data.clampedActiveIndex()
|
||||
return [data.accounts[clamped]]
|
||||
}
|
||||
|
||||
func settingsSnapshot(
|
||||
for provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
codexActiveSourceOverride: CodexActiveSource? = nil) -> ProviderSettingsSnapshot?
|
||||
{
|
||||
let config = self.providerConfig(for: provider)
|
||||
if provider == .qoder {
|
||||
let settings = self.cookieSettings(provider: provider, account: account, config: config)
|
||||
return self.makeSnapshot(qoder: self.makeProviderCookieSettings(settings))
|
||||
}
|
||||
if let snapshot = self.makeCookieBackedSnapshot(provider: provider, account: account, config: config) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case .codex:
|
||||
return self.makeSnapshot(codex: self.makeCodexSettingsSnapshot(
|
||||
account: account,
|
||||
codexActiveSourceOverride: codexActiveSourceOverride))
|
||||
case .claude:
|
||||
let routing = self.claudeCredentialRouting(account: account, config: config)
|
||||
let claudeSource: ClaudeUsageDataSource = if routing.adminAPIKey != nil {
|
||||
.api
|
||||
} else if routing.isOAuth {
|
||||
.oauth
|
||||
} else {
|
||||
.auto
|
||||
}
|
||||
let cookieSource = routing.isOAuth || routing.adminAPIKey != nil
|
||||
? ProviderCookieSource.off
|
||||
: self.cookieSource(provider: provider, account: account, config: config)
|
||||
return self.makeSnapshot(
|
||||
claude: ProviderSettingsSnapshot.ClaudeProviderSettings(
|
||||
usageDataSource: claudeSource,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: cookieSource,
|
||||
manualCookieHeader: routing.manualCookieHeader,
|
||||
organizationID: account?.sanitizedOrganizationID))
|
||||
case .zai:
|
||||
return self.makeSnapshot(
|
||||
zai: ProviderSettingsSnapshot.ZaiProviderSettings(
|
||||
apiRegion: self.resolveZaiRegion(config),
|
||||
usageScope: Self.zaiUsageScope(for: account),
|
||||
teamContext: Self.zaiTeamContext(for: account)))
|
||||
case .moonshot:
|
||||
return self.makeSnapshot(
|
||||
moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings(
|
||||
region: self.resolveMoonshotRegion(config)))
|
||||
case .kilo:
|
||||
return self.makeSnapshot(
|
||||
kilo: ProviderSettingsSnapshot.KiloProviderSettings(
|
||||
usageDataSource: Self.kiloUsageDataSource(from: config?.source),
|
||||
extrasEnabled: Self.kiloExtrasEnabled(from: config)))
|
||||
case .jetbrains:
|
||||
return self.makeSnapshot(
|
||||
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
|
||||
ideBasePath: nil))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCookieBackedSnapshot(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?) -> ProviderSettingsSnapshot?
|
||||
{
|
||||
let cookieSettings = self.cookieSettings(provider: provider, account: account, config: config)
|
||||
|
||||
switch provider {
|
||||
case .cursor:
|
||||
return self.makeSnapshot(cursor: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .opencode:
|
||||
return self.makeSnapshot(
|
||||
opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings(
|
||||
cookieSource: cookieSettings.cookieSource,
|
||||
manualCookieHeader: cookieSettings.manualCookieHeader,
|
||||
workspaceID: config?.workspaceID))
|
||||
case .opencodego:
|
||||
return self.makeSnapshot(
|
||||
opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings(
|
||||
cookieSource: cookieSettings.cookieSource,
|
||||
manualCookieHeader: cookieSettings.manualCookieHeader,
|
||||
workspaceID: config?.workspaceID))
|
||||
case .commandcode:
|
||||
return self.makeSnapshot(commandcode: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .alibaba:
|
||||
return self.makeSnapshot(
|
||||
alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings(
|
||||
cookieSource: cookieSettings.cookieSource,
|
||||
manualCookieHeader: cookieSettings.manualCookieHeader,
|
||||
apiRegion: self.resolveAlibabaCodingPlanRegion(config)))
|
||||
case .alibabatokenplan:
|
||||
return self.makeSnapshot(
|
||||
alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings(
|
||||
cookieSource: cookieSettings.cookieSource,
|
||||
manualCookieHeader: cookieSettings.manualCookieHeader,
|
||||
apiRegion: self.resolveAlibabaTokenPlanRegion(config)))
|
||||
case .factory:
|
||||
return self.makeSnapshot(factory: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .minimax:
|
||||
return self.makeSnapshot(
|
||||
minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings(
|
||||
cookieSource: cookieSettings.cookieSource,
|
||||
manualCookieHeader: cookieSettings.manualCookieHeader,
|
||||
apiRegion: self.resolveMiniMaxRegion(config)))
|
||||
case .manus:
|
||||
return self.makeSnapshot(manus: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .augment:
|
||||
return self.makeSnapshot(augment: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .amp:
|
||||
return self.makeSnapshot(amp: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .ollama:
|
||||
return self.makeSnapshot(ollama: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .kimi:
|
||||
return self.makeSnapshot(kimi: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .perplexity:
|
||||
return self.makeSnapshot(perplexity: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .mimo:
|
||||
return self.makeSnapshot(mimo: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .doubao:
|
||||
return nil
|
||||
case .abacus:
|
||||
return self.makeSnapshot(abacus: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .mistral:
|
||||
return self.makeSnapshot(mistral: self.makeProviderCookieSettings(cookieSettings))
|
||||
case .stepfun:
|
||||
let stepfunSettings = self.cookieSettings(
|
||||
provider: provider,
|
||||
account: account,
|
||||
config: config,
|
||||
configuredHeader: config?.sanitizedRegion ?? config?.sanitizedCookieHeader)
|
||||
return self.makeSnapshot(
|
||||
stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(
|
||||
cookieSource: stepfunSettings.cookieSource,
|
||||
manualToken: stepfunSettings.manualCookieHeader ?? "",
|
||||
username: config?.sanitizedAPIKey ?? "",
|
||||
password: ""))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSnapshot(
|
||||
codex: ProviderSettingsSnapshot.CodexProviderSettings? = nil,
|
||||
claude: ProviderSettingsSnapshot.ClaudeProviderSettings? = nil,
|
||||
cursor: ProviderSettingsSnapshot.CursorProviderSettings? = nil,
|
||||
opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings? = nil,
|
||||
opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? = nil,
|
||||
alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil,
|
||||
alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? = nil,
|
||||
factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil,
|
||||
minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil,
|
||||
manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil,
|
||||
zai: ProviderSettingsSnapshot.ZaiProviderSettings? = nil,
|
||||
moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? = nil,
|
||||
kilo: ProviderSettingsSnapshot.KiloProviderSettings? = nil,
|
||||
kimi: ProviderSettingsSnapshot.KimiProviderSettings? = nil,
|
||||
augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil,
|
||||
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
|
||||
commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? = nil,
|
||||
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
|
||||
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
|
||||
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
|
||||
mimo: ProviderSettingsSnapshot.MiMoProviderSettings? = nil,
|
||||
abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil,
|
||||
mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil,
|
||||
qoder: ProviderSettingsSnapshot.QoderProviderSettings? = nil,
|
||||
stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot
|
||||
{
|
||||
ProviderSettingsSnapshot.make(
|
||||
codex: codex,
|
||||
claude: claude,
|
||||
cursor: cursor,
|
||||
opencode: opencode,
|
||||
opencodego: opencodego,
|
||||
alibaba: alibaba,
|
||||
alibabaTokenPlan: alibabaTokenPlan,
|
||||
factory: factory,
|
||||
minimax: minimax,
|
||||
manus: manus,
|
||||
zai: zai,
|
||||
kilo: kilo,
|
||||
kimi: kimi,
|
||||
augment: augment,
|
||||
moonshot: moonshot,
|
||||
amp: amp,
|
||||
commandcode: commandcode,
|
||||
ollama: ollama,
|
||||
jetbrains: jetbrains,
|
||||
perplexity: perplexity,
|
||||
mimo: mimo,
|
||||
abacus: abacus,
|
||||
mistral: mistral,
|
||||
qoder: qoder,
|
||||
stepfun: stepfun)
|
||||
}
|
||||
|
||||
private func makeCodexSettingsSnapshot(
|
||||
account: ProviderTokenAccount?,
|
||||
codexActiveSourceOverride: CodexActiveSource? = nil) ->
|
||||
ProviderSettingsSnapshot.CodexProviderSettings
|
||||
{
|
||||
let config = self.providerConfig(for: .codex)
|
||||
let reconciliationSnapshot = self.codexAccountReconciler(
|
||||
activeSource: codexActiveSourceOverride).loadSnapshot()
|
||||
let resolvedActiveSource = CodexActiveSourceResolver.resolve(from: reconciliationSnapshot)
|
||||
return CodexProviderSettingsBuilder.make(input: CodexProviderSettingsBuilderInput(
|
||||
usageDataSource: .auto,
|
||||
cookieSource: self.cookieSource(provider: .codex, account: account, config: config),
|
||||
manualCookieHeader: self.manualCookieHeader(provider: .codex, account: account, config: config),
|
||||
reconciliationSnapshot: reconciliationSnapshot,
|
||||
resolvedActiveSource: resolvedActiveSource))
|
||||
}
|
||||
|
||||
func environment(
|
||||
base: [String: String],
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
codexActiveSourceOverride: CodexActiveSource? = nil) -> [String: String]
|
||||
{
|
||||
let providerConfig = self.providerConfig(for: provider)
|
||||
var env = ProviderEnvironmentResolver.resolve(
|
||||
base: base,
|
||||
provider: provider,
|
||||
config: providerConfig,
|
||||
selectedAccount: account)
|
||||
if provider == .codex,
|
||||
let codexHomePath = self.codexHomePath(for: codexActiveSourceOverride)
|
||||
{
|
||||
env = CodexHomeScope.scopedEnvironment(base: env, codexHome: codexHomePath)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func tokenUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.TokenAccountTokenUpdater? {
|
||||
guard let account else { return nil }
|
||||
return { provider, accountID, token in
|
||||
guard accountID == account.id else { return }
|
||||
try? Self.updateStoredTokenAccount(provider: provider, accountID: accountID, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
func manualTokenUpdater() -> ProviderFetchContext.ProviderManualTokenUpdater {
|
||||
{ provider, token in
|
||||
try? Self.updateStoredManualToken(provider: provider, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateStoredManualToken(provider: UsageProvider, token: String) throws {
|
||||
guard provider == .stepfun else { return }
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let store = CodexBarConfigStore()
|
||||
var config = try store.load() ?? .makeDefault()
|
||||
var providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider)
|
||||
providerConfig.region = trimmed
|
||||
config.setProviderConfig(providerConfig)
|
||||
try store.save(config)
|
||||
}
|
||||
|
||||
private static func updateStoredTokenAccount(
|
||||
provider: UsageProvider,
|
||||
accountID: UUID,
|
||||
token: String) throws
|
||||
{
|
||||
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let store = CodexBarConfigStore()
|
||||
guard var config = try store.load() else { return }
|
||||
guard var providerConfig = config.providerConfig(for: provider),
|
||||
let data = providerConfig.tokenAccounts,
|
||||
let index = data.accounts.firstIndex(where: { $0.id == accountID })
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let existing = data.accounts[index]
|
||||
var accounts = data.accounts
|
||||
accounts[index] = ProviderTokenAccount(
|
||||
id: existing.id,
|
||||
label: existing.label,
|
||||
token: trimmed,
|
||||
addedAt: existing.addedAt,
|
||||
lastUsed: existing.lastUsed,
|
||||
externalIdentifier: existing.externalIdentifier,
|
||||
usageScope: existing.usageScope,
|
||||
organizationID: existing.organizationID,
|
||||
workspaceID: existing.workspaceID)
|
||||
providerConfig.tokenAccounts = ProviderTokenAccountData(
|
||||
version: data.version,
|
||||
accounts: accounts,
|
||||
activeIndex: data.clampedActiveIndex())
|
||||
config.setProviderConfig(providerConfig)
|
||||
try store.save(config)
|
||||
}
|
||||
|
||||
func fetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) -> UsageFetcher {
|
||||
guard provider == .codex else { return base }
|
||||
return UsageFetcher(environment: env)
|
||||
}
|
||||
|
||||
func visibleCodexAccounts() -> CodexVisibleAccountProjection {
|
||||
self.codexAccountReconciler().loadVisibleAccounts()
|
||||
}
|
||||
|
||||
func applyAccountLabel(
|
||||
_ snapshot: UsageSnapshot,
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount) -> UsageSnapshot
|
||||
{
|
||||
let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !label.isEmpty else { return snapshot }
|
||||
let existing = snapshot.identity(for: provider)
|
||||
let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedEmail = (email?.isEmpty ?? true) ? label : email
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: provider,
|
||||
accountEmail: resolvedEmail,
|
||||
accountOrganization: existing?.accountOrganization,
|
||||
loginMethod: existing?.loginMethod)
|
||||
return snapshot.withIdentity(identity)
|
||||
}
|
||||
|
||||
func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot {
|
||||
let existing = snapshot.identity(for: .codex)
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: .codex,
|
||||
accountEmail: account.email,
|
||||
accountOrganization: account.workspaceLabel ?? existing?.accountOrganization,
|
||||
loginMethod: existing?.loginMethod)
|
||||
return snapshot.withIdentity(identity)
|
||||
}
|
||||
|
||||
func effectiveSourceMode(
|
||||
base: ProviderSourceMode,
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?) -> ProviderSourceMode
|
||||
{
|
||||
guard provider == .claude else {
|
||||
return base
|
||||
}
|
||||
let config = self.providerConfig(for: provider)
|
||||
let routing = self.claudeCredentialRouting(account: account, config: config)
|
||||
|
||||
if base == .auto {
|
||||
if routing.adminAPIKey != nil { return .api }
|
||||
return routing.isOAuth ? .oauth : base
|
||||
}
|
||||
|
||||
guard base == .cli, account != nil else {
|
||||
return base
|
||||
}
|
||||
|
||||
// Claude CLI usage is ambient to the active local CLI profile, so per-token-account
|
||||
// CLI reads can be mislabeled as separate accounts. Use the selected account's
|
||||
// routable credential instead.
|
||||
switch routing {
|
||||
case .adminAPIKey:
|
||||
return .api
|
||||
case .oauth:
|
||||
return .oauth
|
||||
case .webCookie:
|
||||
return .web
|
||||
case .none:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
func preferredSourceMode(for provider: UsageProvider) -> ProviderSourceMode {
|
||||
let config = self.providerConfig(for: provider)
|
||||
return config?.source ?? .auto
|
||||
}
|
||||
|
||||
private func providerConfig(for provider: UsageProvider) -> ProviderConfig? {
|
||||
self.config.providerConfig(for: provider)
|
||||
}
|
||||
|
||||
private func codexAccountReconciler(activeSource: CodexActiveSource? = nil) -> DefaultCodexAccountReconciler {
|
||||
let storeLoader: @Sendable () throws -> ManagedCodexAccountSet = if let managedCodexAccountStoreURL {
|
||||
{
|
||||
try FileManagedCodexAccountStore(fileURL: managedCodexAccountStoreURL).loadAccounts()
|
||||
}
|
||||
} else {
|
||||
{
|
||||
try FileManagedCodexAccountStore().loadAccounts()
|
||||
}
|
||||
}
|
||||
return DefaultCodexAccountReconciler(
|
||||
storeLoader: storeLoader,
|
||||
activeSource: activeSource ?? self.providerConfig(for: .codex)?.codexActiveSource ?? .liveSystem,
|
||||
baseEnvironment: self.baseEnvironment,
|
||||
profileHomePaths: self.providerConfig(for: .codex)?.codexProfileHomePaths ?? [],
|
||||
managedEnvironmentBuilder: { environment, account in
|
||||
CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath)
|
||||
})
|
||||
}
|
||||
|
||||
private func codexHomePath(for activeSourceOverride: CodexActiveSource?) -> String? {
|
||||
let activeSource: CodexActiveSource = if let activeSourceOverride {
|
||||
activeSourceOverride
|
||||
} else {
|
||||
CodexActiveSourceResolver.resolve(from: self.codexAccountReconciler().loadSnapshot())
|
||||
.resolvedSource
|
||||
}
|
||||
|
||||
switch activeSource {
|
||||
case .liveSystem:
|
||||
return nil
|
||||
case let .managedAccount(id):
|
||||
let accounts: ManagedCodexAccountSet? = if let managedCodexAccountStoreURL {
|
||||
try? FileManagedCodexAccountStore(fileURL: managedCodexAccountStoreURL).loadAccounts()
|
||||
} else {
|
||||
try? FileManagedCodexAccountStore().loadAccounts()
|
||||
}
|
||||
return accounts?.account(id: id)?.managedHomePath
|
||||
case let .profileHome(path):
|
||||
guard let normalizedPath = CodexHomeScope.normalizedHomePath(path) else { return nil }
|
||||
let configuredPaths = self.providerConfig(for: .codex)?.codexProfileHomePaths ?? []
|
||||
return configuredPaths.contains {
|
||||
CodexHomeScope.normalizedHomePath($0) == normalizedPath
|
||||
} ? normalizedPath : nil
|
||||
}
|
||||
}
|
||||
|
||||
private func manualCookieHeader(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?) -> String?
|
||||
{
|
||||
self.cookieSettings(provider: provider, account: account, config: config).manualCookieHeader
|
||||
}
|
||||
|
||||
private func cookieSource(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?) -> ProviderCookieSource
|
||||
{
|
||||
self.cookieSettings(provider: provider, account: account, config: config).cookieSource
|
||||
}
|
||||
|
||||
private func cookieSettings(
|
||||
provider: UsageProvider,
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?,
|
||||
configuredHeader: String? = nil) -> ProviderSettingsSnapshot.CookieProviderSettings
|
||||
{
|
||||
let configuredSource: ProviderCookieSource = if let override = config?.cookieSource {
|
||||
override
|
||||
} else if provider == .stepfun, config?.sanitizedRegion != nil {
|
||||
.manual
|
||||
} else if config?.sanitizedCookieHeader != nil {
|
||||
.manual
|
||||
} else {
|
||||
.auto
|
||||
}
|
||||
return ProviderCookieSettingsResolver.resolve(
|
||||
provider: provider,
|
||||
configuredSource: configuredSource,
|
||||
configuredHeader: configuredHeader ?? config?.sanitizedCookieHeader,
|
||||
selectedAccount: account)
|
||||
}
|
||||
|
||||
private func makeProviderCookieSettings<Settings: ProviderCookieSettings>(
|
||||
_ resolved: ProviderSettingsSnapshot.CookieProviderSettings) -> Settings
|
||||
{
|
||||
Settings(
|
||||
cookieSource: resolved.cookieSource,
|
||||
manualCookieHeader: resolved.manualCookieHeader)
|
||||
}
|
||||
|
||||
private func resolveZaiRegion(_ config: ProviderConfig?) -> ZaiAPIRegion {
|
||||
guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
return .global
|
||||
}
|
||||
return ZaiAPIRegion(rawValue: raw) ?? .global
|
||||
}
|
||||
|
||||
private static func zaiUsageScope(for account: ProviderTokenAccount?) -> ZaiUsageScope {
|
||||
guard let raw = account?.sanitizedUsageScope?.lowercased(),
|
||||
let scope = ZaiUsageScope(rawValue: raw)
|
||||
else {
|
||||
return .personal
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
private static func zaiTeamContext(for account: ProviderTokenAccount?) -> ZaiBigModelTeamContext? {
|
||||
guard self.zaiUsageScope(for: account) == .team else { return nil }
|
||||
return ZaiBigModelTeamContext(
|
||||
organizationID: account?.sanitizedOrganizationID,
|
||||
projectID: account?.sanitizedWorkspaceID)
|
||||
}
|
||||
|
||||
private func resolveMiniMaxRegion(_ config: ProviderConfig?) -> MiniMaxAPIRegion {
|
||||
guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
return .global
|
||||
}
|
||||
return MiniMaxAPIRegion(rawValue: raw) ?? .global
|
||||
}
|
||||
|
||||
private func resolveMoonshotRegion(_ config: ProviderConfig?) -> MoonshotRegion? {
|
||||
guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return MoonshotRegion(rawValue: raw) ?? .international
|
||||
}
|
||||
|
||||
private func resolveAlibabaCodingPlanRegion(_ config: ProviderConfig?) -> AlibabaCodingPlanAPIRegion {
|
||||
guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
return .international
|
||||
}
|
||||
return AlibabaCodingPlanAPIRegion(rawValue: raw) ?? .international
|
||||
}
|
||||
|
||||
private func resolveAlibabaTokenPlanRegion(_ config: ProviderConfig?) -> AlibabaTokenPlanAPIRegion {
|
||||
guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
return .chinaMainland
|
||||
}
|
||||
return AlibabaTokenPlanAPIRegion(rawValue: raw) ?? .chinaMainland
|
||||
}
|
||||
|
||||
private static func kiloUsageDataSource(from source: ProviderSourceMode?) -> KiloUsageDataSource {
|
||||
guard let source else { return .auto }
|
||||
switch source {
|
||||
case .auto, .web, .oauth:
|
||||
return .auto
|
||||
case .api:
|
||||
return .api
|
||||
case .cli:
|
||||
return .cli
|
||||
}
|
||||
}
|
||||
|
||||
private static func kiloExtrasEnabled(from config: ProviderConfig?) -> Bool {
|
||||
guard self.kiloUsageDataSource(from: config?.source) == .auto else { return false }
|
||||
return config?.extrasEnabled ?? false
|
||||
}
|
||||
|
||||
private func claudeCredentialRouting(
|
||||
account: ProviderTokenAccount?,
|
||||
config: ProviderConfig?) -> ClaudeCredentialRouting
|
||||
{
|
||||
let manualCookieHeader = account == nil ? config?.sanitizedCookieHeader : nil
|
||||
return ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: account?.token,
|
||||
manualCookieHeader: manualCookieHeader)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user