416 lines
16 KiB
Swift
416 lines
16 KiB
Swift
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
|