codeburn/mac/Sources/CodeBurnMenubar/Data/CapacityDockProviderQuotaService.swift
Resham Joshi 39b9b3adcd
feat(quota): Command Code quota and ClinePass from the Cline sign-in (#1682)
* feat(quota): Command Code quota and ClinePass from the Cline sign-in

Command Code: reads the CLI's ~/.commandcode/auth.json key and the billing
credits and subscription endpoints. 5-hour and weekly windows as used of
cap, remaining credits as a note, plan from planId. Live in the Mac dock
from the local CLI, in codeburn quota and the Windows dock.

ClinePass: without a saved or env key, falls back to the Cline CLI's
providers.json session (accessToken, then apiKey, then auth.apiKey, with
Cline's path overrides). Read-only; an expired session stops before the
network and says to run cline. The Mac catalog adds the local CLI source
so Automatic connects without a key.

* fix(quota): keep retrying after an expired Cline sign-in

Running cline refreshes the session file CodeBurn only reads, so an
expired or rejected Cline session is now a retryable failure that keeps
the last quota and shows the guidance, on the Mac and in the CLI. A
rejected API key and a missing credential stay terminal.

* test(quota): build expected Cline paths with path.join for Windows
2026-10-07 23:34:31 +02:00

246 lines
8.8 KiB
Swift

import Foundation
/// Marks the refresh as an explicit user action without granting hidden access
/// to browser cookies, another app's Keychain items, or copied provider code.
@MainActor
enum CapacityDockProviderRefreshInteraction {
static func userInitiated<T>(
operation: () async throws -> T
) async rethrows -> T {
try await operation()
}
}
/// CodeBurn-owned registry for Capacity Dock providers that are not served by
/// AppStore's six native quota paths. A provider is connectable only when an
/// adapter is explicitly registered here.
@MainActor
final class CapacityDockProviderQuotaService {
struct Dependencies: Sendable {
// No defaults here: only `.live` may reach the real adapters, so a
// test can never silently read the local Cursor session or hit the
// network by omitting a field.
var refreshClinePass: @Sendable (String?) async throws -> QuotaSummary
var refreshCommandCode: @Sendable () async throws -> QuotaSummary
var refreshCursor: @Sendable () async throws -> QuotaSummary
var refreshDevin: @Sendable () async throws -> QuotaSummary
var refreshGrok: @Sendable () async throws -> QuotaSummary
var refreshGrokBot: @Sendable () async throws -> QuotaSummary
var refreshZai: @Sendable (String?) async throws -> QuotaSummary
var refreshZcode: @Sendable () async throws -> QuotaSummary
static let live = Dependencies(
refreshClinePass: { apiKey in
try await ClinePassSubscriptionService.refresh(apiKey: apiKey)
},
refreshCommandCode: {
try await CommandCodeSubscriptionService.refresh()
},
refreshCursor: {
try await CursorSubscriptionService.refresh()
},
refreshDevin: {
try DevinSubscriptionService.refresh()
},
refreshGrok: {
try await GrokBuildSubscriptionService.refresh()
},
refreshGrokBot: {
try await GrokBotSubscriptionService.refresh()
},
refreshZai: { apiKey in
try await ZaiSubscriptionService.refresh(apiKey: apiKey)
},
refreshZcode: {
try await ZcodeSubscriptionService.refresh()
}
)
}
static let shared = CapacityDockProviderQuotaService(dependencies: .live)
private let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
func fetch(
provider: CapacityDockProvider,
credential: CapacityDockProviderCredential
) async throws -> QuotaSummary {
switch provider.id {
case "cursor":
do {
return try await dependencies.refreshCursor()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "devin":
do {
return try await dependencies.refreshDevin()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "grok":
do {
return try await dependencies.refreshGrok()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "grokbot":
do {
return try await dependencies.refreshGrokBot()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "clinepass":
do {
return try await dependencies.refreshClinePass(credential.sanitizedOverride.apiKey)
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "commandcode":
do {
return try await dependencies.refreshCommandCode()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "zai":
do {
return try await dependencies.refreshZai(credential.sanitizedOverride.apiKey)
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "zcode":
do {
return try await dependencies.refreshZcode()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
default:
throw CapacityDockProviderFetchFailure(
message: "\(provider.displayName) does not have a CodeBurn live quota adapter yet. "
+ "Remove it from the dock or choose a supported provider.",
disposition: .terminal
)
}
}
}
enum CapacityDockProviderFetchFailureDisposition: Equatable, Sendable {
case terminal
case transient
}
/// The only failure distinction the dock needs: user-actionable connection
/// failures versus failures that should preserve last-known quota and retry.
struct CapacityDockProviderFetchFailure: LocalizedError, Equatable, Sendable {
let message: String
let disposition: CapacityDockProviderFetchFailureDisposition
init(message: String, disposition: CapacityDockProviderFetchFailureDisposition) {
self.message = message
self.disposition = disposition
}
init(error: Error) {
message = error.localizedDescription
disposition = Self.disposition(for: error)
}
var errorDescription: String? { message }
static func disposition(for error: Error) -> CapacityDockProviderFetchFailureDisposition {
if let failure = error as? CapacityDockProviderFetchFailure {
return failure.disposition
}
if let error = error as? ClinePassSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? CommandCodeSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? CursorSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? DevinSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? GrokBuildSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? GrokBotSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? ZaiSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if let error = error as? ZcodeSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if error is URLError {
return .transient
}
// Unknown adapter failures are treated as temporary. They must never
// erase a valid last-known connection merely because classification is
// incomplete.
return .transient
}
}