diff --git a/README.md b/README.md
index d0e0474de..01fa93e41 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# CodexBar ποΈ - May your tokens never run out.
-Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
+Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, Abacus AI, and Codebuff limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
@@ -48,6 +48,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [JetBrains AI](docs/jetbrains.md) β Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
- [OpenRouter](docs/openrouter.md) β API token for credit-based usage tracking across multiple AI providers.
- [Abacus AI](docs/abacus.md) β Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
+- [Codebuff](docs/codebuff.md) β API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit.
- Open to new providers: [provider authoring guide](docs/provider.md).
## Icon & Screenshot
diff --git a/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift b/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift
new file mode 100644
index 000000000..6ca91443c
--- /dev/null
+++ b/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift
@@ -0,0 +1,42 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct CodebuffProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .codebuff
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.codebuffAPIToken
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "codebuff-api-key",
+ title: "API key",
+ subtitle: "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let " +
+ "CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`).",
+ kind: .secure,
+ placeholder: "cb_...",
+ binding: context.stringBinding(\.codebuffAPIToken),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "codebuff-open-dashboard",
+ title: "Open Codebuff Dashboard",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://www.codebuff.com/usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Codebuff/CodebuffSettingsStore.swift b/Sources/CodexBar/Providers/Codebuff/CodebuffSettingsStore.swift
new file mode 100644
index 000000000..d07c8b3fa
--- /dev/null
+++ b/Sources/CodexBar/Providers/Codebuff/CodebuffSettingsStore.swift
@@ -0,0 +1,14 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var codebuffAPIToken: String {
+ get { self.configSnapshot.providerConfig(for: .codebuff)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .codebuff) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .codebuff, field: "apiKey", value: newValue)
+ }
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 6dd8c45c4..08cc9a12d 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -39,6 +39,7 @@ enum ProviderImplementationRegistry {
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
case .abacus: AbacusProviderImplementation()
+ case .codebuff: CodebuffProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-codebuff.svg b/Sources/CodexBar/Resources/ProviderIcon-codebuff.svg
new file mode 100644
index 000000000..6d5f9e455
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-codebuff.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 36cc0bf2a..03f51d305 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -880,7 +880,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains, .perplexity, .abacus:
+ .kimik2, .jetbrains, .perplexity, .abacus, .codebuff:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index e43073e82..63a778fb2 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -193,7 +193,8 @@ struct TokenAccountCLIContext {
abacus: ProviderSettingsSnapshot.AbacusProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
+ case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp,
+ .codebuff:
return nil
}
}
diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
index 6620ae879..27d207aac 100644
--- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
+++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
@@ -31,6 +31,8 @@ public enum ProviderConfigEnvironment {
}
case .openrouter:
env[OpenRouterSettingsReader.envKey] = apiKey
+ case .codebuff:
+ env[CodebuffSettingsReader.apiTokenKey] = apiKey
default:
break
}
diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift
new file mode 100644
index 000000000..e04ba2dad
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift
@@ -0,0 +1,86 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum CodebuffProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .codebuff,
+ metadata: ProviderMetadata(
+ id: .codebuff,
+ displayName: "Codebuff",
+ sessionLabel: "Credits",
+ weeklyLabel: "Weekly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: true,
+ creditsHint: "Credit balance from the Codebuff API",
+ toggleTitle: "Show Codebuff usage",
+ cliName: "codebuff",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://www.codebuff.com/usage",
+ statusPageURL: nil,
+ statusLinkURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .codebuff,
+ iconResourceName: "ProviderIcon-codebuff",
+ color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Codebuff cost summary is not yet supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodebuffAPIFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "codebuff",
+ aliases: ["manicode"],
+ versionDetector: nil))
+ }
+}
+
+struct CodebuffAPIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "codebuff.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ _ = context
+ // Keep the strategy available so missing-token surfaces as a user-friendly error
+ // instead of a generic "no strategy" outcome.
+ return true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = Self.resolveToken(environment: context.env) else {
+ throw CodebuffUsageError.missingCredentials
+ }
+ let usage = try await CodebuffUsageFetcher.fetchUsage(apiKey: apiKey, environment: context.env)
+ return self.makeResult(
+ usage: usage.toUsageSnapshot(),
+ sourceLabel: "api")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveToken(environment: [String: String]) -> String? {
+ ProviderTokenResolver.codebuffToken(environment: environment)
+ }
+}
+
+/// Errors related to Codebuff settings.
+public enum CodebuffSettingsError: LocalizedError, Sendable {
+ case missingToken
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingToken:
+ "Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " +
+ "populate ~/.config/manicode/credentials.json."
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffSettingsReader.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffSettingsReader.swift
new file mode 100644
index 000000000..397adb8e8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffSettingsReader.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// Reads Codebuff settings from the environment or the local credentials file
+/// that the `codebuff` CLI (formerly `manicode`) writes when the user logs in.
+public enum CodebuffSettingsReader {
+ /// Environment variable key for the Codebuff API token.
+ public static let apiTokenKey = "CODEBUFF_API_KEY"
+
+ /// Returns the API token from environment if present and non-empty.
+ public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
+ self.cleaned(environment[self.apiTokenKey])
+ }
+
+ /// Returns the API base URL, defaulting to the production endpoint.
+ public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
+ if let override = environment["CODEBUFF_API_URL"],
+ let url = URL(string: cleaned(override) ?? "")
+ {
+ return url
+ }
+ return URL(string: "https://www.codebuff.com")!
+ }
+
+ /// Returns the auth token from the local credentials file if present.
+ public static func authToken(
+ authFileURL: URL? = nil,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String?
+ {
+ let fileURL = authFileURL ?? self.defaultAuthFileURL(homeDirectory: homeDirectory)
+ guard let data = try? Data(contentsOf: fileURL) else { return nil }
+ return self.parseAuthToken(data: data)
+ }
+
+ /// Default on-disk credentials path: `~/.config/manicode/credentials.json`.
+ static func defaultAuthFileURL(homeDirectory: URL) -> URL {
+ homeDirectory
+ .appendingPathComponent(".config", isDirectory: true)
+ .appendingPathComponent("manicode", isDirectory: true)
+ .appendingPathComponent("credentials.json", isDirectory: false)
+ }
+
+ static func parseAuthToken(data: Data) -> String? {
+ guard let payload = try? JSONDecoder().decode(CredentialsFile.self, from: data) else {
+ return nil
+ }
+ return self.cleaned(payload.authToken)
+ }
+
+ static func cleaned(_ 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.removeFirst()
+ value.removeLast()
+ }
+
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
+
+private struct CredentialsFile: Decodable {
+ let authToken: String?
+ let fingerprintId: String?
+ let email: String?
+ let name: String?
+}
diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageError.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageError.swift
new file mode 100644
index 000000000..729d17f89
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageError.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+public enum CodebuffUsageError: LocalizedError, Sendable, Equatable {
+ case missingCredentials
+ case unauthorized
+ case endpointNotFound
+ case serviceUnavailable(Int)
+ case apiError(Int)
+ case networkError(String)
+ case parseFailed(String)
+
+ public static let missingToken: CodebuffUsageError = .missingCredentials
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " +
+ "populate ~/.config/manicode/credentials.json."
+ case .unauthorized:
+ "Unauthorized. Please sign in to Codebuff again."
+ case .endpointNotFound:
+ "Codebuff usage endpoint not found."
+ case let .serviceUnavailable(status):
+ "Codebuff API is temporarily unavailable (status \(status))."
+ case let .apiError(status):
+ "Codebuff API returned an unexpected status (\(status))."
+ case let .networkError(message):
+ "Codebuff API error: \(message)"
+ case let .parseFailed(message):
+ "Could not parse Codebuff usage: \(message)"
+ }
+ }
+
+ public var isAuthRelated: Bool {
+ switch self {
+ case .unauthorized, .missingCredentials: true
+ default: false
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageFetcher.swift
new file mode 100644
index 000000000..cb8a5f6ae
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageFetcher.swift
@@ -0,0 +1,257 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// Fetches live credit balance and subscription details from the Codebuff API.
+/// Uses Bearer token auth against the public www.codebuff.com endpoints used by
+/// the dashboard + the `codebuff` CLI.
+public enum CodebuffUsageFetcher {
+ private static let requestTimeoutSeconds: TimeInterval = 15
+
+ public static func fetchUsage(
+ apiKey: String,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ session: URLSession = .shared) async throws -> CodebuffUsageSnapshot
+ {
+ let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw CodebuffUsageError.missingCredentials
+ }
+
+ let baseURL = CodebuffSettingsReader.apiURL(environment: environment)
+ async let usage = self.fetchUsagePayload(apiKey: trimmed, baseURL: baseURL, session: session)
+ async let subscription = self.fetchSubscriptionPayload(
+ apiKey: trimmed,
+ baseURL: baseURL,
+ session: session)
+
+ let usageValues = try await usage
+ let subscriptionValues = try? await subscription
+
+ return CodebuffUsageSnapshot(
+ creditsUsed: usageValues.used,
+ creditsTotal: usageValues.total,
+ creditsRemaining: usageValues.remaining,
+ weeklyUsed: subscriptionValues?.weeklyUsed,
+ weeklyLimit: subscriptionValues?.weeklyLimit,
+ billingPeriodEnd: subscriptionValues?.billingPeriodEnd,
+ nextQuotaReset: usageValues.nextQuotaReset,
+ tier: subscriptionValues?.tier,
+ subscriptionStatus: subscriptionValues?.status,
+ autoTopUpEnabled: usageValues.autoTopupEnabled,
+ accountEmail: subscriptionValues?.email,
+ updatedAt: Date())
+ }
+
+ // MARK: - Endpoint helpers
+
+ struct UsagePayload {
+ let used: Double?
+ let total: Double?
+ let remaining: Double?
+ let nextQuotaReset: Date?
+ let autoTopupEnabled: Bool?
+ }
+
+ struct SubscriptionPayload {
+ let status: String?
+ let tier: String?
+ let billingPeriodEnd: Date?
+ let weeklyUsed: Double?
+ let weeklyLimit: Double?
+ let email: String?
+ }
+
+ static func usageURL(baseURL: URL) -> URL {
+ baseURL.appendingPathComponent("/api/v1/usage")
+ }
+
+ static func subscriptionURL(baseURL: URL) -> URL {
+ baseURL.appendingPathComponent("/api/user/subscription")
+ }
+
+ static func statusError(for statusCode: Int) -> CodebuffUsageError? {
+ switch statusCode {
+ case 401, 403: .unauthorized
+ case 404: .endpointNotFound
+ case 500...599: .serviceUnavailable(statusCode)
+ default: nil
+ }
+ }
+
+ static func parseUsagePayload(_ data: Data) throws -> UsagePayload {
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw CodebuffUsageError.parseFailed("Invalid JSON")
+ }
+
+ let used = self.double(from: root["usage"]) ?? self.double(from: root["used"])
+ let total = self.double(from: root["quota"]) ?? self.double(from: root["limit"])
+ let remaining = self.double(from: root["remainingBalance"]) ?? self.double(from: root["remaining"])
+ let reset = self.date(from: root["next_quota_reset"])
+ let autoTopUp = root["autoTopupEnabled"] as? Bool ?? root["auto_topup_enabled"] as? Bool
+
+ return UsagePayload(
+ used: used,
+ total: total,
+ remaining: remaining,
+ nextQuotaReset: reset,
+ autoTopupEnabled: autoTopUp)
+ }
+
+ static func parseSubscriptionPayload(_ data: Data) throws -> SubscriptionPayload {
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw CodebuffUsageError.parseFailed("Invalid JSON")
+ }
+
+ let subscription = root["subscription"] as? [String: Any]
+ let rateLimit = root["rateLimit"] as? [String: Any]
+
+ let tier = (subscription?["tier"] as? String)
+ ?? (root["tier"] as? String)
+ ?? (subscription?["scheduledTier"] as? String)
+ let status = subscription?["status"] as? String
+ let email = root["email"] as? String ?? (root["user"] as? [String: Any])?["email"] as? String
+ let billingPeriodEnd = self.date(from: subscription?["billingPeriodEnd"])
+ ?? self.date(from: subscription?["currentPeriodEnd"])
+ let weeklyUsed = self.double(from: rateLimit?["weeklyUsed"])
+ ?? self.double(from: rateLimit?["used"])
+ let weeklyLimit = self.double(from: rateLimit?["weeklyLimit"])
+ ?? self.double(from: rateLimit?["limit"])
+
+ return SubscriptionPayload(
+ status: status,
+ tier: tier,
+ billingPeriodEnd: billingPeriodEnd,
+ weeklyUsed: weeklyUsed,
+ weeklyLimit: weeklyLimit,
+ email: email)
+ }
+
+ // MARK: - Test hooks
+
+ static func _parseUsagePayloadForTesting(_ data: Data) throws -> UsagePayload {
+ try self.parseUsagePayload(data)
+ }
+
+ static func _parseSubscriptionPayloadForTesting(_ data: Data) throws -> SubscriptionPayload {
+ try self.parseSubscriptionPayload(data)
+ }
+
+ static func _statusErrorForTesting(_ statusCode: Int) -> CodebuffUsageError? {
+ self.statusError(for: statusCode)
+ }
+
+ // MARK: - Networking
+
+ private static func fetchUsagePayload(
+ apiKey: String,
+ baseURL: URL,
+ session: URLSession) async throws -> UsagePayload
+ {
+ var request = URLRequest(url: self.usageURL(baseURL: baseURL))
+ request.httpMethod = "POST"
+ request.timeoutInterval = self.requestTimeoutSeconds
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.httpBody = try? JSONSerialization.data(withJSONObject: [:] as [String: Any])
+
+ let (data, response) = try await self.send(request: request, session: session)
+ if let err = self.statusError(for: response.statusCode) {
+ throw err
+ }
+ guard response.statusCode == 200 else {
+ throw CodebuffUsageError.apiError(response.statusCode)
+ }
+ return try self.parseUsagePayload(data)
+ }
+
+ private static func fetchSubscriptionPayload(
+ apiKey: String,
+ baseURL: URL,
+ session: URLSession) async throws -> SubscriptionPayload
+ {
+ var request = URLRequest(url: self.subscriptionURL(baseURL: baseURL))
+ request.httpMethod = "GET"
+ request.timeoutInterval = self.requestTimeoutSeconds
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await self.send(request: request, session: session)
+ if let err = self.statusError(for: response.statusCode) {
+ throw err
+ }
+ guard response.statusCode == 200 else {
+ throw CodebuffUsageError.apiError(response.statusCode)
+ }
+ return try self.parseSubscriptionPayload(data)
+ }
+
+ private static func send(
+ request: URLRequest,
+ session: URLSession) async throws -> (Data, HTTPURLResponse)
+ {
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw CodebuffUsageError.networkError("Invalid response")
+ }
+ return (data, httpResponse)
+ } catch let error as CodebuffUsageError {
+ throw error
+ } catch {
+ throw CodebuffUsageError.networkError(error.localizedDescription)
+ }
+ }
+
+ // MARK: - Value parsing
+
+ private static func double(from value: Any?) -> Double? {
+ switch value {
+ case let number as NSNumber:
+ let raw = number.doubleValue
+ return raw.isFinite ? raw : nil
+ case let string as String:
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, let raw = Double(trimmed), raw.isFinite else { return nil }
+ return raw
+ default:
+ return nil
+ }
+ }
+
+ private static func date(from value: Any?) -> Date? {
+ switch value {
+ case let string as String:
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = fractional.date(from: trimmed) {
+ return date
+ }
+ let plain = ISO8601DateFormatter()
+ plain.formatOptions = [.withInternetDateTime]
+ if let date = plain.date(from: trimmed) {
+ return date
+ }
+ if let interval = Double(trimmed), interval.isFinite {
+ return Self.dateFromNumeric(interval)
+ }
+ return nil
+ case let number as NSNumber:
+ let raw = number.doubleValue
+ return raw.isFinite ? Self.dateFromNumeric(raw) : nil
+ default:
+ return nil
+ }
+ }
+
+ private static func dateFromNumeric(_ value: Double) -> Date? {
+ if value > 10_000_000_000 {
+ return Date(timeIntervalSince1970: value / 1000)
+ }
+ return Date(timeIntervalSince1970: value)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageSnapshot.swift
new file mode 100644
index 000000000..f27ee4f5b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageSnapshot.swift
@@ -0,0 +1,141 @@
+import Foundation
+
+/// Parsed view of a Codebuff usage + subscription response pair.
+public struct CodebuffUsageSnapshot: Sendable {
+ public let creditsUsed: Double?
+ public let creditsTotal: Double?
+ public let creditsRemaining: Double?
+ public let weeklyUsed: Double?
+ public let weeklyLimit: Double?
+ public let billingPeriodEnd: Date?
+ public let nextQuotaReset: Date?
+ public let tier: String?
+ public let subscriptionStatus: String?
+ public let autoTopUpEnabled: Bool?
+ public let accountEmail: String?
+ public let updatedAt: Date
+
+ public init(
+ creditsUsed: Double? = nil,
+ creditsTotal: Double? = nil,
+ creditsRemaining: Double? = nil,
+ weeklyUsed: Double? = nil,
+ weeklyLimit: Double? = nil,
+ billingPeriodEnd: Date? = nil,
+ nextQuotaReset: Date? = nil,
+ tier: String? = nil,
+ subscriptionStatus: String? = nil,
+ autoTopUpEnabled: Bool? = nil,
+ accountEmail: String? = nil,
+ updatedAt: Date = Date())
+ {
+ self.creditsUsed = creditsUsed
+ self.creditsTotal = creditsTotal
+ self.creditsRemaining = creditsRemaining
+ self.weeklyUsed = weeklyUsed
+ self.weeklyLimit = weeklyLimit
+ self.billingPeriodEnd = billingPeriodEnd
+ self.nextQuotaReset = nextQuotaReset
+ self.tier = tier
+ self.subscriptionStatus = subscriptionStatus
+ self.autoTopUpEnabled = autoTopUpEnabled
+ self.accountEmail = accountEmail
+ self.updatedAt = updatedAt
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let primary = self.makeCreditsWindow()
+ let secondary = self.makeWeeklyWindow()
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .codebuff,
+ accountEmail: self.accountEmail,
+ accountOrganization: nil,
+ loginMethod: self.makeLoginMethod())
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+
+ private func makeCreditsWindow() -> RateWindow? {
+ let total = self.resolvedTotal
+ guard let total, total > 0 else {
+ if self.creditsRemaining != nil || self.creditsUsed != nil {
+ // Degenerate case: no usable quota in the payload. Surface the row as fully
+ // exhausted so missing quota data is visibly surfaced (matches Kilo's behaviour
+ // for zero/unknown totals) rather than rendering a misleading healthy bar.
+ return RateWindow(
+ usedPercent: 100,
+ windowMinutes: nil,
+ resetsAt: self.nextQuotaReset,
+ resetDescription: nil)
+ }
+ return nil
+ }
+ let used = self.resolvedUsed
+ let percent = min(100, max(0, (used / total) * 100))
+ let usedText = Self.compactNumber(used)
+ let totalText = Self.compactNumber(total)
+ return RateWindow(
+ usedPercent: percent,
+ windowMinutes: nil,
+ resetsAt: self.nextQuotaReset,
+ resetDescription: "\(usedText)/\(totalText) credits")
+ }
+
+ private func makeWeeklyWindow() -> RateWindow? {
+ guard let limit = self.weeklyLimit, limit > 0 else { return nil }
+ let used = max(0, self.weeklyUsed ?? 0)
+ let percent = min(100, max(0, (used / limit) * 100))
+ return RateWindow(
+ usedPercent: percent,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: nil,
+ resetDescription: "\(Self.compactNumber(used))/\(Self.compactNumber(limit)) weekly")
+ }
+
+ private var resolvedTotal: Double? {
+ if let creditsTotal { return max(0, creditsTotal) }
+ if let creditsUsed, let creditsRemaining {
+ return max(0, creditsUsed + creditsRemaining)
+ }
+ return nil
+ }
+
+ private var resolvedUsed: Double {
+ if let creditsUsed {
+ return max(0, creditsUsed)
+ }
+ if let total = self.resolvedTotal, let creditsRemaining {
+ return max(0, total - creditsRemaining)
+ }
+ return 0
+ }
+
+ private func makeLoginMethod() -> String? {
+ var parts: [String] = []
+ if let tier = self.tier?.trimmingCharacters(in: .whitespacesAndNewlines), !tier.isEmpty {
+ parts.append(tier.capitalized)
+ }
+ if let remaining = self.creditsRemaining {
+ parts.append("\(Self.compactNumber(remaining)) remaining")
+ }
+ if self.autoTopUpEnabled == true {
+ parts.append("auto top-up")
+ }
+ return parts.isEmpty ? nil : parts.joined(separator: " Β· ")
+ }
+
+ static func compactNumber(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.locale = Locale(identifier: "en_US")
+ formatter.maximumFractionDigits = value >= 1000 ? 0 : 1
+ return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6fb994efc..6b54749e0 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -79,6 +79,7 @@ public enum ProviderDescriptorRegistry {
.warp: WarpProviderDescriptor.descriptor,
.perplexity: PerplexityProviderDescriptor.descriptor,
.abacus: AbacusProviderDescriptor.descriptor,
+ .codebuff: CodebuffProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index 85113cc26..24d1175f4 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -71,6 +71,13 @@ public enum ProviderTokenResolver {
self.perplexityResolution(environment: environment)?.token
}
+ public static func codebuffToken(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ authFileURL: URL? = nil) -> String?
+ {
+ self.codebuffResolution(environment: environment, authFileURL: authFileURL)?.token
+ }
+
public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
@@ -157,6 +164,19 @@ public enum ProviderTokenResolver {
self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
}
+ public static func codebuffResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ authFileURL: URL? = nil) -> ProviderTokenResolution?
+ {
+ if let resolution = self.resolveEnv(CodebuffSettingsReader.apiKey(environment: environment)) {
+ return resolution
+ }
+ if let token = CodebuffSettingsReader.authToken(authFileURL: authFileURL) {
+ return ProviderTokenResolution(token: token, source: .authFile)
+ }
+ return nil
+ }
+
public static func perplexityResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 83dade054..95c140d02 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -29,6 +29,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case openrouter
case perplexity
case abacus
+ case codebuff
}
// swiftformat:enable sortDeclarations
@@ -60,6 +61,7 @@ public enum IconStyle: Sendable, CaseIterable {
case openrouter
case perplexity
case abacus
+ case codebuff
case combined
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 17ddf1dba..c6770693b 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -235,7 +235,8 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot,
.minimax, .kilo, .kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus,
+ .codebuff:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index f5f5187e3..be78f2e8b 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -77,6 +77,7 @@ enum ProviderChoice: String, AppEnum {
case .warp: return nil // Warp not yet supported in widgets
case .perplexity: return nil // Perplexity not yet supported in widgets
case .abacus: return nil // Abacus AI not yet supported in widgets
+ case .codebuff: return nil // Codebuff not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 4e03801b3..62b8e1c24 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -283,6 +283,7 @@ private struct ProviderSwitchChip: View {
case .warp: "Warp"
case .perplexity: "Pplx"
case .abacus: "Abacus"
+ case .codebuff: "Codebuff"
}
}
}
@@ -644,6 +645,8 @@ enum WidgetColors {
Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal
case .abacus:
Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255)
+ case .codebuff:
+ Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime
}
}
}
diff --git a/Tests/CodexBarTests/CodebuffSettingsReaderTests.swift b/Tests/CodexBarTests/CodebuffSettingsReaderTests.swift
new file mode 100644
index 000000000..5c1909a9a
--- /dev/null
+++ b/Tests/CodexBarTests/CodebuffSettingsReaderTests.swift
@@ -0,0 +1,100 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct CodebuffSettingsReaderTests {
+ @Test
+ func `api URL defaults to www codebuff com`() {
+ let url = CodebuffSettingsReader.apiURL(environment: [:])
+ #expect(url.scheme == "https")
+ #expect(url.host() == "www.codebuff.com")
+ }
+
+ @Test
+ func `api URL honors environment override`() {
+ let url = CodebuffSettingsReader.apiURL(environment: [
+ "CODEBUFF_API_URL": "https://staging.codebuff.com",
+ ])
+ #expect(url.host() == "staging.codebuff.com")
+ }
+
+ @Test
+ func `api key reads from CODEBUFF_API_KEY and trims wrapping whitespace`() {
+ let token = CodebuffSettingsReader.apiKey(environment: [
+ CodebuffSettingsReader.apiTokenKey: " cb-test-token ",
+ ])
+ #expect(token == "cb-test-token")
+ }
+
+ @Test
+ func `api key strips surrounding quotes`() {
+ let token = CodebuffSettingsReader.apiKey(environment: [
+ CodebuffSettingsReader.apiTokenKey: "\"cb-test-token\"",
+ ])
+ #expect(token == "cb-test-token")
+ }
+
+ @Test
+ func `api key returns nil for empty environment`() {
+ #expect(CodebuffSettingsReader.apiKey(environment: [:]) == nil)
+ }
+
+ @Test
+ func `auth token parses credentials json`() throws {
+ let contents = #"{"authToken":"file-token","fingerprintId":"fp-1","email":"a@b.com"}"#
+ let url = try self.writeTempFile(named: "credentials.json", contents: contents)
+ defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
+
+ let token = CodebuffSettingsReader.authToken(authFileURL: url)
+ #expect(token == "file-token")
+ }
+
+ @Test
+ func `auth token returns nil for malformed credentials json`() throws {
+ let url = try self.writeTempFile(named: "credentials.json", contents: "{not-json}")
+ defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
+
+ let token = CodebuffSettingsReader.authToken(authFileURL: url)
+ #expect(token == nil)
+ }
+
+ @Test
+ func `auth token returns nil when file missing`() {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ .appendingPathComponent("credentials.json", isDirectory: false)
+ #expect(CodebuffSettingsReader.authToken(authFileURL: url) == nil)
+ }
+
+ @Test
+ func `descriptor uses codebuff dashboard URL`() {
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: .codebuff)
+ #expect(descriptor.metadata.dashboardURL == "https://www.codebuff.com/usage")
+ #expect(descriptor.metadata.displayName == "Codebuff")
+ #expect(descriptor.metadata.cliName == "codebuff")
+ }
+
+ @Test
+ func `descriptor uses dedicated codebuff icon resource`() {
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: .codebuff)
+ #expect(descriptor.branding.iconResourceName == "ProviderIcon-codebuff")
+ }
+
+ @Test
+ func `descriptor supports auto and API source modes`() {
+ let descriptor = ProviderDescriptorRegistry.descriptor(for: .codebuff)
+ let expected: Set = [.auto, .api]
+ #expect(descriptor.fetchPlan.sourceModes == expected)
+ }
+
+ // MARK: - Helpers
+
+ private func writeTempFile(named name: String, contents: String) throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let fileURL = directory.appendingPathComponent(name, isDirectory: false)
+ try contents.write(to: fileURL, atomically: true, encoding: .utf8)
+ return fileURL
+ }
+}
diff --git a/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift b/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift
new file mode 100644
index 000000000..7b50fb072
--- /dev/null
+++ b/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift
@@ -0,0 +1,211 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct CodebuffUsageFetcherTests {
+ @Test
+ func `usage URL composes the correct endpoint`() {
+ let base = URL(string: "https://www.codebuff.com")!
+ let url = CodebuffUsageFetcher.usageURL(baseURL: base)
+ #expect(url.absoluteString == "https://www.codebuff.com/api/v1/usage")
+ }
+
+ @Test
+ func `subscription URL composes the correct endpoint`() {
+ let base = URL(string: "https://www.codebuff.com")!
+ let url = CodebuffUsageFetcher.subscriptionURL(baseURL: base)
+ #expect(url.absoluteString == "https://www.codebuff.com/api/user/subscription")
+ }
+
+ @Test
+ func `status 401 maps to unauthorized`() {
+ #expect(CodebuffUsageFetcher._statusErrorForTesting(401) == .unauthorized)
+ #expect(CodebuffUsageFetcher._statusErrorForTesting(403) == .unauthorized)
+ }
+
+ @Test
+ func `status 404 maps to endpoint not found`() {
+ #expect(CodebuffUsageFetcher._statusErrorForTesting(404) == .endpointNotFound)
+ }
+
+ @Test
+ func `status 500 maps to service unavailable`() {
+ guard case .serviceUnavailable(503) = CodebuffUsageFetcher._statusErrorForTesting(503)
+ else {
+ Issue.record("Expected .serviceUnavailable(503)")
+ return
+ }
+ }
+
+ @Test
+ func `status 200 returns nil`() {
+ #expect(CodebuffUsageFetcher._statusErrorForTesting(200) == nil)
+ }
+
+ @Test
+ func `usage payload parses numeric credit fields`() throws {
+ let json = """
+ {
+ "usage": 1250,
+ "quota": 5000,
+ "remainingBalance": 3750,
+ "autoTopupEnabled": true,
+ "next_quota_reset": "2026-05-01T00:00:00Z"
+ }
+ """
+
+ let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data(json.utf8))
+ #expect(payload.used == 1250)
+ #expect(payload.total == 5000)
+ #expect(payload.remaining == 3750)
+ #expect(payload.autoTopupEnabled == true)
+ #expect(payload.nextQuotaReset != nil)
+ }
+
+ @Test
+ func `usage payload accepts string-encoded numbers`() throws {
+ let json = """
+ { "usage": "12", "quota": "100", "remainingBalance": "88" }
+ """
+ let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data(json.utf8))
+ #expect(payload.used == 12)
+ #expect(payload.total == 100)
+ #expect(payload.remaining == 88)
+ }
+
+ @Test
+ func `usage payload returns nil fields when absent`() throws {
+ let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data("{}".utf8))
+ #expect(payload.used == nil)
+ #expect(payload.total == nil)
+ #expect(payload.remaining == nil)
+ #expect(payload.autoTopupEnabled == nil)
+ }
+
+ @Test
+ func `usage payload throws on malformed JSON`() {
+ #expect {
+ _ = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data("not-json".utf8))
+ } throws: { error in
+ guard case CodebuffUsageError.parseFailed = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `subscription payload parses tier and weekly window`() throws {
+ let json = """
+ {
+ "hasSubscription": true,
+ "subscription": {
+ "status": "active",
+ "tier": "pro",
+ "billingPeriodEnd": "2026-05-15T00:00:00Z"
+ },
+ "rateLimit": {
+ "weeklyUsed": 2100,
+ "weeklyLimit": 7000
+ },
+ "email": "user@example.com"
+ }
+ """
+
+ let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
+ #expect(payload.tier == "pro")
+ #expect(payload.status == "active")
+ #expect(payload.weeklyUsed == 2100)
+ #expect(payload.weeklyLimit == 7000)
+ #expect(payload.email == "user@example.com")
+ #expect(payload.billingPeriodEnd != nil)
+ }
+
+ @Test
+ func `subscription payload falls back to scheduled tier`() throws {
+ let json = """
+ { "subscription": { "scheduledTier": "team" } }
+ """
+ let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
+ #expect(payload.tier == "team")
+ }
+
+ @Test
+ func `subscription payload tolerates missing rate limit`() throws {
+ let json = """
+ { "subscription": { "status": "trialing", "tier": "free" } }
+ """
+ let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8))
+ #expect(payload.weeklyUsed == nil)
+ #expect(payload.weeklyLimit == nil)
+ #expect(payload.status == "trialing")
+ }
+
+ @Test
+ func `snapshot maps to rate window with credits window`() {
+ let snapshot = CodebuffUsageSnapshot(
+ creditsUsed: 250,
+ creditsTotal: 1000,
+ creditsRemaining: 750,
+ weeklyUsed: 100,
+ weeklyLimit: 500,
+ tier: "pro",
+ autoTopUpEnabled: true,
+ updatedAt: Date())
+
+ let unified = snapshot.toUsageSnapshot()
+ #expect(unified.primary?.usedPercent == 25)
+ #expect(unified.primary?.resetDescription == "250/1,000 credits")
+ #expect(unified.secondary?.usedPercent == 20)
+ #expect(unified.secondary?.windowMinutes == 7 * 24 * 60)
+ #expect(unified.identity?.providerID == .codebuff)
+ #expect(unified.identity?.loginMethod?.contains("Pro") == true)
+ #expect(unified.identity?.loginMethod?.contains("auto top-up") == true)
+ }
+
+ @Test
+ func `snapshot infers total from used plus remaining`() {
+ let snapshot = CodebuffUsageSnapshot(
+ creditsUsed: 40,
+ creditsTotal: nil,
+ creditsRemaining: 60)
+
+ let unified = snapshot.toUsageSnapshot()
+ #expect(unified.primary?.usedPercent == 40)
+ }
+
+ @Test
+ func `snapshot surfaces exhausted state when quota is missing from payload`() {
+ // Only `creditsUsed` is populated (no total, no remaining) β the API response is
+ // degenerate but we still want the row to be visible so the user notices the
+ // missing configuration instead of seeing an empty/healthy-looking bar.
+ let usedOnly = CodebuffUsageSnapshot(
+ creditsUsed: 42,
+ creditsTotal: nil,
+ creditsRemaining: nil)
+ #expect(usedOnly.toUsageSnapshot().primary?.usedPercent == 100)
+
+ // Only `creditsRemaining` is populated β same fallback should apply.
+ let remainingOnly = CodebuffUsageSnapshot(
+ creditsUsed: nil,
+ creditsTotal: nil,
+ creditsRemaining: 17)
+ #expect(remainingOnly.toUsageSnapshot().primary?.usedPercent == 100)
+ }
+
+ @Test
+ func `snapshot hides credit window when no credit fields are present`() {
+ let empty = CodebuffUsageSnapshot()
+ #expect(empty.toUsageSnapshot().primary == nil)
+ }
+
+ @Test
+ func `missing credentials fetch call throws missing credentials`() async {
+ do {
+ _ = try await CodebuffUsageFetcher.fetchUsage(apiKey: " ")
+ Issue.record("Expected missingCredentials error")
+ } catch let error as CodebuffUsageError {
+ #expect(error == .missingCredentials)
+ } catch {
+ Issue.record("Unexpected error: \(error)")
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift
index 6567cd240..52e9402b0 100644
--- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift
+++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift
@@ -80,4 +80,53 @@ struct ProviderTokenResolverTests {
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
return fileURL
}
+
+ @Test
+ func `codebuff resolution prefers environment over credentials file`() throws {
+ let fileURL = try self.makeCodebuffCredentialsFile(
+ contents: #"{"authToken":"file-token"}"#)
+ defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
+
+ let env = [CodebuffSettingsReader.apiTokenKey: "env-token"]
+ let resolution = ProviderTokenResolver.codebuffResolution(
+ environment: env,
+ authFileURL: fileURL)
+
+ #expect(resolution?.token == "env-token")
+ #expect(resolution?.source == .environment)
+ }
+
+ @Test
+ func `codebuff resolution falls back to credentials file`() throws {
+ let fileURL = try self.makeCodebuffCredentialsFile(
+ contents: #"{"authToken":"file-token","fingerprintId":"fp"}"#)
+ defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
+
+ let resolution = ProviderTokenResolver.codebuffResolution(
+ environment: [:],
+ authFileURL: fileURL)
+
+ #expect(resolution?.token == "file-token")
+ #expect(resolution?.source == .authFile)
+ }
+
+ @Test
+ func `codebuff resolution returns nil for malformed credentials file`() throws {
+ let fileURL = try self.makeCodebuffCredentialsFile(contents: #"{not-json}"#)
+ defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
+
+ let resolution = ProviderTokenResolver.codebuffResolution(
+ environment: [:],
+ authFileURL: fileURL)
+ #expect(resolution == nil)
+ }
+
+ private func makeCodebuffCredentialsFile(contents: String) throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let fileURL = directory.appendingPathComponent("credentials.json", isDirectory: false)
+ try contents.write(to: fileURL, atomically: true, encoding: .utf8)
+ return fileURL
+ }
}
diff --git a/docs/codebuff.md b/docs/codebuff.md
new file mode 100644
index 000000000..fbada54b9
--- /dev/null
+++ b/docs/codebuff.md
@@ -0,0 +1,62 @@
+# Codebuff
+
+CodexBar surfaces [Codebuff](https://www.codebuff.com) credit balance and
+weekly rate limits next to your other AI providers.
+
+## Data sources
+
+- `POST https://www.codebuff.com/api/v1/usage` β current credit usage,
+ remaining balance, auto top-up state, and the next quota reset date.
+- `GET https://www.codebuff.com/api/user/subscription` β subscription tier,
+ billing period end, and the weekly rate-limit window (`weeklyUsed` /
+ `weeklyLimit`).
+
+Both endpoints use a Bearer token. CodexBar never stores Codebuff credentials
+outside the existing macOS Keychain / `~/.codexbar/config.json` that the other
+providers use.
+
+## Authentication
+
+CodexBar resolves the Codebuff API token in this order:
+
+1. `CODEBUFF_API_KEY` environment variable (takes precedence so CI overrides
+ work).
+2. The per-provider API key stored in Settings β Providers β Codebuff (saved
+ in `~/.codexbar/config.json` via the normal CodexBar config flow).
+3. `~/.config/manicode/credentials.json` β the file the official `codebuff`
+ CLI (formerly `manicode`) writes after `codebuff login`. CodexBar reads
+ the `authToken` field and treats it as a Bearer token.
+
+If none of those is available, Codebuff shows the βmissing tokenβ error.
+
+## Credit window mapping
+
+- **Primary row** β credit balance (`usage / quota`), with the "next quota
+ reset" date if provided.
+- **Secondary row** β weekly rate-limit window (`weeklyUsed / weeklyLimit`)
+ shown with a 7-day window.
+
+The account panel shows the Codebuff tier (e.g. "Pro"), remaining balance,
+and whether auto top-up is enabled.
+
+## Troubleshooting
+
+- Run `codebuff login` to refresh `~/.config/manicode/credentials.json`.
+- Override the API base with `CODEBUFF_API_URL` for staging environments.
+- Verify your token works manually:
+
+ ```sh
+ curl -s -X POST -H "Authorization: Bearer $CODEBUFF_API_KEY" \
+ -H 'Content-Type: application/json' -d '{}' \
+ https://www.codebuff.com/api/v1/usage
+ ```
+
+## Related files
+
+- `Sources/CodexBarCore/Providers/Codebuff/` β descriptor, fetcher, snapshot,
+ settings reader, error types.
+- `Sources/CodexBar/Providers/Codebuff/` β settings store bridge + macOS
+ settings pane implementation.
+- `Tests/CodexBarTests/CodebuffSettingsReaderTests.swift`,
+ `CodebuffUsageFetcherTests.swift`, and the Codebuff extensions in
+ `ProviderTokenResolverTests.swift`.