From b8c5bbddbe81ff93edf3aaecf308949fe96bace9 Mon Sep 17 00:00:00 2001 From: Parsa Date: Sat, 7 Feb 2026 19:05:42 -0800 Subject: [PATCH 1/5] Plan windsurf usage tracking --- README.md | 3 +- Sources/CodexBar/IconRenderer.swift | 20 +-- .../ProviderImplementationRegistry.swift | 1 + .../WindsurfProviderImplementation.swift | 8 + .../Resources/ProviderIcon-windsurf.svg | 16 ++ Sources/CodexBar/UsageStore.swift | 4 + Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Providers/ProviderDescriptor.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Windsurf/WindsurfLocalStorageReader.swift | 164 ++++++++++++++++++ .../Windsurf/WindsurfProviderDescriptor.swift | 62 +++++++ .../Windsurf/WindsurfUsageModels.swift | 60 +++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../CLIProviderSelectionTests.swift | 1 + .../ProviderIconResourcesTests.swift | 1 + Tests/CodexBarTests/SettingsStoreTests.swift | 1 + Tests/CodexBarTests/WindsurfUsageTests.swift | 73 ++++++++ docs/configuration.md | 2 +- docs/providers.md | 9 + docs/windsurf.md | 53 ++++++ 22 files changed, 476 insertions(+), 13 deletions(-) create mode 100644 Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-windsurf.svg create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift create mode 100644 Tests/CodexBarTests/WindsurfUsageTests.swift create mode 100644 docs/windsurf.md diff --git a/README.md b/README.md index 757fbf030..449cab65b 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, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); 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, Windsurf, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -36,6 +36,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. - [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. +- [Windsurf](docs/windsurf.md) — Local Windsurf plan cache (state.vscdb); messages/actions/credits. - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. - [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. diff --git a/Sources/CodexBar/IconRenderer.swift b/Sources/CodexBar/IconRenderer.swift index 0f9a4b794..a970b62ee 100644 --- a/Sources/CodexBar/IconRenderer.swift +++ b/Sources/CodexBar/IconRenderer.swift @@ -595,8 +595,8 @@ enum IconRenderer { remaining: topValue, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, blink: blink) drawBar(rectPx: bottomRectPx, remaining: bottomValue) @@ -611,8 +611,8 @@ enum IconRenderer { alpha: creditsAlpha, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, blink: blink) drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45) @@ -622,8 +622,8 @@ enum IconRenderer { remaining: topValue, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, blink: blink) drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45) @@ -637,8 +637,8 @@ enum IconRenderer { alpha: creditsAlpha, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, blink: blink) } else { @@ -648,8 +648,8 @@ enum IconRenderer { remaining: topValue, addNotches: style == .claude, addFace: style == .codex, - addGeminiTwist: style == .gemini || style == .antigravity, - addAntigravityTwist: style == .antigravity, + addGeminiTwist: style == .gemini || style == .antigravity || style == .windsurf, + addAntigravityTwist: style == .antigravity || style == .windsurf, addFactoryTwist: style == .factory, blink: blink) } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3b..94d6fa85d 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -19,6 +19,7 @@ enum ProviderImplementationRegistry { case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() + case .windsurf: WindsurfProviderImplementation() case .copilot: CopilotProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift new file mode 100644 index 000000000..763e72a6e --- /dev/null +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift @@ -0,0 +1,8 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct WindsurfProviderImplementation: ProviderImplementation { + let id: UsageProvider = .windsurf +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg new file mode 100644 index 000000000..a977e31a5 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 155355906..2d045e954 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1171,6 +1171,10 @@ extension UsageStore { let text = "Antigravity debug log not yet implemented" await MainActor.run { self.probeLogs[.antigravity] = text } return text + case .windsurf: + let text = "Windsurf debug log not yet implemented" + await MainActor.run { self.probeLogs[.windsurf] = text } + return text case .cursor: let text = await self.debugCursorLog( cursorCookieSource: cursorCookieSource, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4809cfb06..1d1c35883 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -147,7 +147,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .gemini, .antigravity, .windsurf, .copilot, .kiro, .vertexai, .kimik2, .synthetic: return nil } } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff83695..f8548aa37 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -60,6 +60,7 @@ public enum ProviderDescriptorRegistry { .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, + .windsurf: WindsurfProviderDescriptor.descriptor, .copilot: CopilotProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb953..cf9be6083 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -10,6 +10,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case factory case gemini case antigravity + case windsurf case copilot case zai case minimax @@ -32,6 +33,7 @@ public enum IconStyle: Sendable, CaseIterable { case minimax case gemini case antigravity + case windsurf case cursor case opencode case factory diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift new file mode 100644 index 000000000..c2dc7ff53 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift @@ -0,0 +1,164 @@ +import Foundation + +#if os(macOS) +import SQLite3 +#endif + +public enum WindsurfUsageError: LocalizedError, Sendable, Equatable { + case unsupportedPlatform + case dbMissing(URL) + case sqliteFailed(String) + case cachedPlanMissing + case decodeFailed + + public var errorDescription: String? { + switch self { + case .unsupportedPlatform: + "Windsurf usage tracking is only supported on macOS." + case let .dbMissing(url): + "Windsurf data not found at \(url.path). Launch Windsurf once and sign in, then refresh." + case let .sqliteFailed(message): + "Failed to read Windsurf usage: \(message)" + case .cachedPlanMissing: + "Windsurf cached plan usage is missing. Open Windsurf, then refresh." + case .decodeFailed: + "Could not decode Windsurf usage. Update Windsurf and retry." + } + } +} + +public enum WindsurfLocalStorageReader { + public static let envStateDBKey = "CODEXBAR_WINDSURF_STATE_DB" + public static let envStateDBFallbackKey = "WINDSURF_STATE_DB" + + /// Default: ~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb + public static func stateDBURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = cleaned(environment[envStateDBKey]) ?? cleaned(environment[envStateDBFallbackKey]) { + return URL(fileURLWithPath: override) + } + let home = FileManager.default.homeDirectoryForCurrentUser + return home + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Windsurf", isDirectory: true) + .appendingPathComponent("User", isDirectory: true) + .appendingPathComponent("globalStorage", isDirectory: true) + .appendingPathComponent("state.vscdb", isDirectory: false) + } + + public static func loadCachedPlanInfo(environment: [String: String]) throws -> WindsurfCachedPlanInfo { + #if os(macOS) + let dbURL = self.stateDBURL(environment: environment) + guard FileManager.default.fileExists(atPath: dbURL.path) else { + throw WindsurfUsageError.dbMissing(dbURL) + } + + var db: OpaquePointer? + let open = sqlite3_open_v2(dbURL.path, &db, SQLITE_OPEN_READONLY, nil) + guard open == SQLITE_OK, let db else { + let msg = db.flatMap { sqlite3_errmsg($0) }.map { String(cString: $0) } ?? "sqlite open failed" + throw WindsurfUsageError.sqliteFailed(msg) + } + defer { sqlite3_close(db) } + + sqlite3_busy_timeout(db, 250) + + let sql = "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + let msg = sqlite3_errmsg(db).map { String(cString: $0) } ?? "sqlite prepare failed" + throw WindsurfUsageError.sqliteFailed(msg) + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, "windsurf.settings.cachedPlanInfo", -1, transient) + + let step = sqlite3_step(stmt) + guard step == SQLITE_ROW else { + if step != SQLITE_DONE { + let msg = sqlite3_errmsg(db).map { String(cString: $0) } ?? "sqlite step failed" + throw WindsurfUsageError.sqliteFailed(msg) + } + throw WindsurfUsageError.cachedPlanMissing + } + + guard let bytes = sqlite3_column_blob(stmt, 0) else { + throw WindsurfUsageError.cachedPlanMissing + } + let byteCount = Int(sqlite3_column_bytes(stmt, 0)) + guard byteCount > 0 else { throw WindsurfUsageError.cachedPlanMissing } + + let data = Data(bytes: bytes, count: byteCount) + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { + throw WindsurfUsageError.decodeFailed + } + let decoded = try? JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(text.utf8)) + guard let decoded else { throw WindsurfUsageError.decodeFailed } + return decoded + #else + _ = environment + throw WindsurfUsageError.unsupportedPlatform + #endif + } + + public static func parseEpoch(_ value: Int?) -> Date? { + guard let value else { return nil } + if value >= 1_000_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(value) / 1000.0) + } + if value > 0 { + return Date(timeIntervalSince1970: TimeInterval(value)) + } + return nil + } + + public static func makeUsageSnapshot(info: WindsurfCachedPlanInfo, now: Date = Date()) throws -> UsageSnapshot { + guard let usage = info.usage else { + throw WindsurfUsageError.cachedPlanMissing + } + + let resetsAt = Self.parseEpoch(info.endTimestamp) + + func window(total: Int?, used: Int?) -> RateWindow? { + guard let total, total > 0, let used else { return nil } + let ratio = max(0, min(1.0, Double(used) / Double(total))) + return RateWindow( + usedPercent: ratio * 100.0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: nil) + } + + let primary = window(total: usage.messages, used: usage.usedMessages) + let secondary = window(total: usage.flowActions, used: usage.usedFlowActions) + let tertiary = window(total: usage.flexCredits, used: usage.usedFlexCredits) + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: nil, + loginMethod: info.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + updatedAt: now, + identity: identity) + } + + private 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 + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift new file mode 100644 index 000000000..e725fea19 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -0,0 +1,62 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WindsurfProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .windsurf, + metadata: ProviderMetadata( + id: .windsurf, + displayName: "Windsurf", + sessionLabel: "Messages", + weeklyLabel: "Actions", + opusLabel: "Credits", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Windsurf usage", + cliName: "windsurf", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: nil, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .windsurf, + iconResourceName: "ProviderIcon-windsurf", + color: ProviderColor(red: 14 / 255, green: 165 / 255, blue: 166 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Windsurf cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WindsurfLocalStorageFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "windsurf", + aliases: ["ws"], + versionDetector: nil)) + } +} + +struct WindsurfLocalStorageFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let info = try WindsurfLocalStorageReader.loadCachedPlanInfo(environment: context.env) + let usage = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info) + return self.makeResult( + usage: usage, + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift new file mode 100644 index 000000000..e84cae73a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageModels.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct WindsurfCachedPlanInfo: Codable, Sendable, Equatable { + public let planName: String? + public let startTimestamp: Int? + public let endTimestamp: Int? + public let usage: WindsurfPlanUsage? + public let hasBillingWritePermissions: Bool? + + public init( + planName: String?, + startTimestamp: Int?, + endTimestamp: Int?, + usage: WindsurfPlanUsage?, + hasBillingWritePermissions: Bool?) + { + self.planName = planName + self.startTimestamp = startTimestamp + self.endTimestamp = endTimestamp + self.usage = usage + self.hasBillingWritePermissions = hasBillingWritePermissions + } +} + +public struct WindsurfPlanUsage: Codable, Sendable, Equatable { + public let duration: Int? + public let messages: Int? + public let flowActions: Int? + public let flexCredits: Int? + public let usedMessages: Int? + public let usedFlowActions: Int? + public let usedFlexCredits: Int? + public let remainingMessages: Int? + public let remainingFlowActions: Int? + public let remainingFlexCredits: Int? + + public init( + duration: Int?, + messages: Int?, + flowActions: Int?, + flexCredits: Int?, + usedMessages: Int?, + usedFlowActions: Int?, + usedFlexCredits: Int?, + remainingMessages: Int?, + remainingFlowActions: Int?, + remainingFlexCredits: Int?) + { + self.duration = duration + self.messages = messages + self.flowActions = flowActions + self.flexCredits = flexCredits + self.usedMessages = usedMessages + self.usedFlowActions = usedFlowActions + self.usedFlexCredits = usedFlexCredits + self.remainingMessages = remainingMessages + self.remainingFlowActions = remainingFlowActions + self.remainingFlexCredits = remainingFlexCredits + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index d47d7d557..0453a4a39 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -69,6 +69,8 @@ enum CostUsageScanner { return CostUsageDailyReport(data: [], summary: nil) case .antigravity: return CostUsageDailyReport(data: [], summary: nil) + case .windsurf: + return CostUsageDailyReport(data: [], summary: nil) case .cursor: return CostUsageDailyReport(data: [], summary: nil) case .opencode: diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611ee..49967cbe9 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -45,6 +45,7 @@ enum ProviderChoice: String, AppEnum { case .claude: self = .claude case .gemini: self = .gemini case .antigravity: self = .antigravity + case .windsurf: return nil // Windsurf not yet supported in widgets case .cursor: return nil // Cursor not yet supported in widgets case .opencode: self = .opencode case .zai: self = .zai diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b4506..c6478a8ca 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -261,6 +261,7 @@ private struct ProviderSwitchChip: View { case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" + case .windsurf: "Windsurf" case .cursor: "Cursor" case .opencode: "OpenCode" case .zai: "z.ai" @@ -577,6 +578,8 @@ enum WidgetColors { Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) case .antigravity: Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255) + case .windsurf: + Color(red: 14 / 255, green: 165 / 255, blue: 166 / 255) case .cursor: Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .opencode: diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index ec8551802..bd37462d0 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -17,6 +17,7 @@ struct CLIProviderSelectionTests { "|cursor|", "|gemini|", "|antigravity|", + "|windsurf|", "|copilot|", "|synthetic|", "|kiro|", diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7ecbe7b26..97abcb92c 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -19,6 +19,7 @@ struct ProviderIconResourcesTests { "opencode", "gemini", "antigravity", + "windsurf", "factory", "copilot", ] diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index fad99a763..2d799d0ab 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -353,6 +353,7 @@ struct SettingsStoreTests { .opencode, .factory, .antigravity, + .windsurf, .copilot, .zai, .minimax, diff --git a/Tests/CodexBarTests/WindsurfUsageTests.swift b/Tests/CodexBarTests/WindsurfUsageTests.swift new file mode 100644 index 000000000..bf2e803f6 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfUsageTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct WindsurfUsageTests { + @Test + func decodesCachedPlanInfoAndBuildsUsageSnapshot() throws { + // Build representative JSON without embedding large numeric literals in a multiline string, + // which SwiftFormat can lint for grouping. + let obj: [String: Any] = [ + "planName": "Pro", + "startTimestamp": 1_735_689_600, + "endTimestamp": 1_738_368_000, + "usage": [ + "messages": 500, + "flowActions": 200, + "flexCredits": 1000, + "usedMessages": 125, + "usedFlowActions": 50, + "usedFlexCredits": 250, + "remainingMessages": 375, + "remainingFlowActions": 150, + "remainingFlexCredits": 750, + ], + "hasBillingWritePermissions": true, + ] + + let jsonData = try JSONSerialization.data(withJSONObject: obj) + let info = try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData) + let snap = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info, now: Date(timeIntervalSince1970: 0)) + + #expect(snap.primary?.usedPercent.rounded() == 25) + #expect(snap.secondary?.usedPercent.rounded() == 25) + #expect(snap.tertiary?.usedPercent.rounded() == 25) + #expect(snap.loginMethod(for: .windsurf) == "Pro") + #expect(snap.accountEmail(for: .windsurf) == nil) + } + + @Test + func parseEpochSupportsSecondsAndMilliseconds() { + let seconds = WindsurfLocalStorageReader.parseEpoch(1_738_368_000) + let millis = WindsurfLocalStorageReader.parseEpoch(1_738_368_000_000) + #expect(seconds != nil) + #expect(millis != nil) + #expect(abs((seconds?.timeIntervalSince1970 ?? 0) - (millis?.timeIntervalSince1970 ?? 0)) < 1.0) + } + + @Test + func omitsWindowsWhenTotalsAreZero() throws { + let info = WindsurfCachedPlanInfo( + planName: "Free", + startTimestamp: 1, + endTimestamp: 2, + usage: WindsurfPlanUsage( + duration: nil, + messages: 0, + flowActions: 0, + flexCredits: 0, + usedMessages: 0, + usedFlowActions: 0, + usedFlexCredits: 0, + remainingMessages: 0, + remainingFlowActions: 0, + remainingFlexCredits: 0), + hasBillingWritePermissions: nil) + + let snap = try WindsurfLocalStorageReader.makeUsageSnapshot(info: info, now: Date()) + #expect(snap.primary == nil) + #expect(snap.secondary == nil) + #expect(snap.tertiary == nil) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 020293718..476f2352a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,7 +72,7 @@ All provider fields are optional unless noted. ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`. +`codex`, `claude`, `cursor`, `opencode`, `factory`, `gemini`, `antigravity`, `windsurf`, `copilot`, `zai`, `minimax`, `kimi`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `amp`, `synthetic`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/providers.md b/docs/providers.md index d25dfb887..92f6e4662 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -22,6 +22,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Claude | OAuth API (`oauth`) → Web API (`web`) → CLI PTY (`claude`). | | Gemini | OAuth API via Gemini CLI credentials (`api`). | | Antigravity | Local LSP/HTTP probe (`local`). | +| Windsurf | Local storage plan cache from `state.vscdb` (`local`). | | Cursor | Web API via cookies → stored WebKit session (`web`). | | OpenCode | Web dashboard via cookies (`web`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | @@ -91,6 +92,14 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: Google Workspace incidents (Gemini product). - Details: `docs/antigravity.md`. +## Windsurf +- Reads Windsurf’s local VS Code-style global storage DB: + - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` + - Key: `windsurf.settings.cachedPlanInfo` +- Shows up to three meters (Messages, Flow Actions, Flex Credits) and uses `endTimestamp` as reset time. +- No network requests and no cookies required. +- Details: `docs/windsurf.md`. + ## Cursor - Web API via browser cookies (`cursor.com` + `cursor.sh`). - Fallback: stored WebKit session. diff --git a/docs/windsurf.md b/docs/windsurf.md new file mode 100644 index 000000000..32e2bde1f --- /dev/null +++ b/docs/windsurf.md @@ -0,0 +1,53 @@ +--- +summary: "Windsurf provider notes: local state.vscdb parsing, fields, and troubleshooting." +read_when: + - Adding or modifying the Windsurf provider + - Debugging missing Windsurf usage + - Explaining Windsurf usage data sources +--- + +# Windsurf provider + +Windsurf usage tracking is **local-only**. CodexBar reads Windsurf's cached plan usage from Windsurf's VS Code-style +global storage SQLite database. + +## Data source + +- Default path (macOS): + - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` +- Table: `ItemTable` +- Key: `windsurf.settings.cachedPlanInfo` + +The value is JSON that includes: +- `planName` +- `startTimestamp` / `endTimestamp` (epoch seconds or milliseconds) +- `usage` totals and used counts for: + - messages + - flowActions + - flexCredits + +CodexBar maps these into: +- Primary: Messages +- Secondary: Flow Actions +- Tertiary: Flex Credits +- Reset time: `endTimestamp` + +## Overrides + +For debugging/tests you can override the DB path: +- `CODEXBAR_WINDSURF_STATE_DB=/absolute/path/to/state.vscdb` +- `WINDSURF_STATE_DB=/absolute/path/to/state.vscdb` (fallback) + +## Troubleshooting + +If CodexBar shows "Windsurf data not found" or "cached plan usage is missing": +1. Launch Windsurf. +2. Sign in (if needed). +3. Open the Windsurf settings/plan page once so the plan cache is populated. +4. Refresh CodexBar. + +## Privacy + +CodexBar reads a single SQLite value from a local DB. It does not send this data anywhere and does not write back to +Windsurf's files. + From 4e37505937360a10be4e2fafd5d34cd1a52fcaed Mon Sep 17 00:00:00 2001 From: Parsa Date: Sat, 7 Feb 2026 19:18:34 -0800 Subject: [PATCH 2/5] Windsurf: drop Flow Actions, show Flex Credits Update Windsurf provider to stop treating Flow Actions as a separate meter and to use Flex Credits as the secondary meter. Adjusted WindsurfLocalStorageReader to map flexCredits to the secondary slot and remove the tertiary meter, updated ProviderDescriptor to remove opus support and change labels, and updated tests and docs to reflect the new two-meter model (Messages + Flex Credits). Tests now allow nil flowActions fields and expect no tertiary usage snapshot. --- .../Windsurf/WindsurfLocalStorageReader.swift | 5 ++--- .../Windsurf/WindsurfProviderDescriptor.swift | 6 +++--- Tests/CodexBarTests/WindsurfUsageTests.swift | 11 ++++------- docs/providers.md | 2 +- docs/windsurf.md | 5 +---- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift index c2dc7ff53..eaba6b36a 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift @@ -131,8 +131,7 @@ public enum WindsurfLocalStorageReader { } let primary = window(total: usage.messages, used: usage.usedMessages) - let secondary = window(total: usage.flowActions, used: usage.usedFlowActions) - let tertiary = window(total: usage.flexCredits, used: usage.usedFlexCredits) + let secondary = window(total: usage.flexCredits, used: usage.usedFlexCredits) let identity = ProviderIdentitySnapshot( providerID: .windsurf, @@ -143,7 +142,7 @@ public enum WindsurfLocalStorageReader { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: tertiary, + tertiary: nil, updatedAt: now, identity: identity) } diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift index e725fea19..cf71417fe 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -11,9 +11,9 @@ public enum WindsurfProviderDescriptor { id: .windsurf, displayName: "Windsurf", sessionLabel: "Messages", - weeklyLabel: "Actions", - opusLabel: "Credits", - supportsOpus: true, + weeklyLabel: "Credits", + opusLabel: nil, + supportsOpus: false, supportsCredits: false, creditsHint: "", toggleTitle: "Show Windsurf usage", diff --git a/Tests/CodexBarTests/WindsurfUsageTests.swift b/Tests/CodexBarTests/WindsurfUsageTests.swift index bf2e803f6..f17db464b 100644 --- a/Tests/CodexBarTests/WindsurfUsageTests.swift +++ b/Tests/CodexBarTests/WindsurfUsageTests.swift @@ -14,13 +14,10 @@ struct WindsurfUsageTests { "endTimestamp": 1_738_368_000, "usage": [ "messages": 500, - "flowActions": 200, "flexCredits": 1000, "usedMessages": 125, - "usedFlowActions": 50, "usedFlexCredits": 250, "remainingMessages": 375, - "remainingFlowActions": 150, "remainingFlexCredits": 750, ], "hasBillingWritePermissions": true, @@ -32,7 +29,7 @@ struct WindsurfUsageTests { #expect(snap.primary?.usedPercent.rounded() == 25) #expect(snap.secondary?.usedPercent.rounded() == 25) - #expect(snap.tertiary?.usedPercent.rounded() == 25) + #expect(snap.tertiary == nil) #expect(snap.loginMethod(for: .windsurf) == "Pro") #expect(snap.accountEmail(for: .windsurf) == nil) } @@ -55,13 +52,13 @@ struct WindsurfUsageTests { usage: WindsurfPlanUsage( duration: nil, messages: 0, - flowActions: 0, + flowActions: nil, flexCredits: 0, usedMessages: 0, - usedFlowActions: 0, + usedFlowActions: nil, usedFlexCredits: 0, remainingMessages: 0, - remainingFlowActions: 0, + remainingFlowActions: nil, remainingFlexCredits: 0), hasBillingWritePermissions: nil) diff --git a/docs/providers.md b/docs/providers.md index 92f6e4662..a02b48be9 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -96,7 +96,7 @@ until the session is invalid, to avoid repeated Keychain prompts. - Reads Windsurf’s local VS Code-style global storage DB: - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` - Key: `windsurf.settings.cachedPlanInfo` -- Shows up to three meters (Messages, Flow Actions, Flex Credits) and uses `endTimestamp` as reset time. +- Shows up to two meters (Messages, Flex Credits) and uses `endTimestamp` as reset time. - No network requests and no cookies required. - Details: `docs/windsurf.md`. diff --git a/docs/windsurf.md b/docs/windsurf.md index 32e2bde1f..60e4648af 100644 --- a/docs/windsurf.md +++ b/docs/windsurf.md @@ -23,13 +23,11 @@ The value is JSON that includes: - `startTimestamp` / `endTimestamp` (epoch seconds or milliseconds) - `usage` totals and used counts for: - messages - - flowActions - flexCredits CodexBar maps these into: - Primary: Messages -- Secondary: Flow Actions -- Tertiary: Flex Credits +- Secondary: Flex Credits - Reset time: `endTimestamp` ## Overrides @@ -50,4 +48,3 @@ If CodexBar shows "Windsurf data not found" or "cached plan usage is missing": CodexBar reads a single SQLite value from a local DB. It does not send this data anywhere and does not write back to Windsurf's files. - From ef7294a0cfc2c014687bb165fef44418165c67dc Mon Sep 17 00:00:00 2001 From: Parsa Date: Sat, 7 Feb 2026 19:27:09 -0800 Subject: [PATCH 3/5] Revert "Windsurf: drop Flow Actions, show Flex Credits" This reverts commit 4e37505937360a10be4e2fafd5d34cd1a52fcaed. --- .../Windsurf/WindsurfLocalStorageReader.swift | 5 +++-- .../Windsurf/WindsurfProviderDescriptor.swift | 6 +++--- Tests/CodexBarTests/WindsurfUsageTests.swift | 11 +++++++---- docs/providers.md | 2 +- docs/windsurf.md | 5 ++++- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift index eaba6b36a..c2dc7ff53 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfLocalStorageReader.swift @@ -131,7 +131,8 @@ public enum WindsurfLocalStorageReader { } let primary = window(total: usage.messages, used: usage.usedMessages) - let secondary = window(total: usage.flexCredits, used: usage.usedFlexCredits) + let secondary = window(total: usage.flowActions, used: usage.usedFlowActions) + let tertiary = window(total: usage.flexCredits, used: usage.usedFlexCredits) let identity = ProviderIdentitySnapshot( providerID: .windsurf, @@ -142,7 +143,7 @@ public enum WindsurfLocalStorageReader { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, + tertiary: tertiary, updatedAt: now, identity: identity) } diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift index cf71417fe..e725fea19 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -11,9 +11,9 @@ public enum WindsurfProviderDescriptor { id: .windsurf, displayName: "Windsurf", sessionLabel: "Messages", - weeklyLabel: "Credits", - opusLabel: nil, - supportsOpus: false, + weeklyLabel: "Actions", + opusLabel: "Credits", + supportsOpus: true, supportsCredits: false, creditsHint: "", toggleTitle: "Show Windsurf usage", diff --git a/Tests/CodexBarTests/WindsurfUsageTests.swift b/Tests/CodexBarTests/WindsurfUsageTests.swift index f17db464b..bf2e803f6 100644 --- a/Tests/CodexBarTests/WindsurfUsageTests.swift +++ b/Tests/CodexBarTests/WindsurfUsageTests.swift @@ -14,10 +14,13 @@ struct WindsurfUsageTests { "endTimestamp": 1_738_368_000, "usage": [ "messages": 500, + "flowActions": 200, "flexCredits": 1000, "usedMessages": 125, + "usedFlowActions": 50, "usedFlexCredits": 250, "remainingMessages": 375, + "remainingFlowActions": 150, "remainingFlexCredits": 750, ], "hasBillingWritePermissions": true, @@ -29,7 +32,7 @@ struct WindsurfUsageTests { #expect(snap.primary?.usedPercent.rounded() == 25) #expect(snap.secondary?.usedPercent.rounded() == 25) - #expect(snap.tertiary == nil) + #expect(snap.tertiary?.usedPercent.rounded() == 25) #expect(snap.loginMethod(for: .windsurf) == "Pro") #expect(snap.accountEmail(for: .windsurf) == nil) } @@ -52,13 +55,13 @@ struct WindsurfUsageTests { usage: WindsurfPlanUsage( duration: nil, messages: 0, - flowActions: nil, + flowActions: 0, flexCredits: 0, usedMessages: 0, - usedFlowActions: nil, + usedFlowActions: 0, usedFlexCredits: 0, remainingMessages: 0, - remainingFlowActions: nil, + remainingFlowActions: 0, remainingFlexCredits: 0), hasBillingWritePermissions: nil) diff --git a/docs/providers.md b/docs/providers.md index a02b48be9..92f6e4662 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -96,7 +96,7 @@ until the session is invalid, to avoid repeated Keychain prompts. - Reads Windsurf’s local VS Code-style global storage DB: - `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb` - Key: `windsurf.settings.cachedPlanInfo` -- Shows up to two meters (Messages, Flex Credits) and uses `endTimestamp` as reset time. +- Shows up to three meters (Messages, Flow Actions, Flex Credits) and uses `endTimestamp` as reset time. - No network requests and no cookies required. - Details: `docs/windsurf.md`. diff --git a/docs/windsurf.md b/docs/windsurf.md index 60e4648af..32e2bde1f 100644 --- a/docs/windsurf.md +++ b/docs/windsurf.md @@ -23,11 +23,13 @@ The value is JSON that includes: - `startTimestamp` / `endTimestamp` (epoch seconds or milliseconds) - `usage` totals and used counts for: - messages + - flowActions - flexCredits CodexBar maps these into: - Primary: Messages -- Secondary: Flex Credits +- Secondary: Flow Actions +- Tertiary: Flex Credits - Reset time: `endTimestamp` ## Overrides @@ -48,3 +50,4 @@ If CodexBar shows "Windsurf data not found" or "cached plan usage is missing": CodexBar reads a single SQLite value from a local DB. It does not send this data anywhere and does not write back to Windsurf's files. + From fa0acffbfb13c568c315f8efc55d0f7bb2bd684c Mon Sep 17 00:00:00 2001 From: Parsa Date: Sat, 7 Feb 2026 19:38:32 -0800 Subject: [PATCH 4/5] Update ProviderIcon-windsurf.svg --- .../CodexBar/Resources/ProviderIcon-windsurf.svg | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg index a977e31a5..3bc424679 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg @@ -1,16 +1,3 @@ - - + - From 6e684293cf37cd306ca5ee36804c1bbbd41b01f3 Mon Sep 17 00:00:00 2001 From: Parsa Date: Fri, 13 Feb 2026 22:24:14 -0800 Subject: [PATCH 5/5] Run install script and resolve merge --- Sources/CodexBar/UsageStore.swift | 84 +++++++++---------- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 35 +------- .../ClaudeOAuthCredentialsStoreTests.swift | 3 + ...eOAuthFetchStrategyAvailabilityTests.swift | 3 + 5 files changed, 47 insertions(+), 80 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index ee4b56f1f..c3d916696 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1150,6 +1150,8 @@ extension UsageStore { let keepCLISessionsAlive = self.settings.debugKeepCLISessionsAlive let cursorCookieSource = self.settings.cursorCookieSource let cursorCookieHeader = self.settings.cursorCookieHeader + let ampCookieSource = self.settings.ampCookieSource + let ampCookieHeader = self.settings.ampCookieHeader return await Task.detached(priority: .utility) { () -> String in switch provider { case .codex: @@ -1179,36 +1181,12 @@ extension UsageStore { let text = "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" await MainActor.run { self.probeLogs[.synthetic] = text } return text - case .gemini: - let text = "Gemini debug log not yet implemented" - await MainActor.run { self.probeLogs[.gemini] = text } - return text - case .antigravity: - let text = "Antigravity debug log not yet implemented" - await MainActor.run { self.probeLogs[.antigravity] = text } - return text - case .windsurf: - let text = "Windsurf debug log not yet implemented" - await MainActor.run { self.probeLogs[.windsurf] = text } - return text case .cursor: let text = await self.debugCursorLog( cursorCookieSource: cursorCookieSource, cursorCookieHeader: cursorCookieHeader) await MainActor.run { self.probeLogs[.cursor] = text } return text - case .opencode: - let text = "OpenCode debug log not yet implemented" - await MainActor.run { self.probeLogs[.opencode] = text } - return text - case .factory: - let text = "Droid debug log not yet implemented" - await MainActor.run { self.probeLogs[.factory] = text } - return text - case .copilot: - let text = "Copilot debug log not yet implemented" - await MainActor.run { self.probeLogs[.copilot] = text } - return text case .minimax: let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() @@ -1219,36 +1197,16 @@ extension UsageStore { "source=\(cookieSource)" await MainActor.run { self.probeLogs[.minimax] = text } return text - case .vertexai: - let text = "Vertex AI debug log not yet implemented" - await MainActor.run { self.probeLogs[.vertexai] = text } - return text - case .kiro: - let text = "Kiro debug log not yet implemented" - await MainActor.run { self.probeLogs[.kiro] = text } - return text case .augment: let text = await self.debugAugmentLog() await MainActor.run { self.probeLogs[.augment] = text } return text - case .kimi: - let text = "Kimi debug log not yet implemented" - await MainActor.run { self.probeLogs[.kimi] = text } - return text - case .kimik2: - let text = "Kimi K2 debug log not yet implemented" - await MainActor.run { self.probeLogs[.kimik2] = text } - return text case .amp: let text = await self.debugAmpLog( - ampCookieSource: self.settings.ampCookieSource, - ampCookieHeader: self.settings.ampCookieHeader) + ampCookieSource: ampCookieSource, + ampCookieHeader: ampCookieHeader) await MainActor.run { self.probeLogs[.amp] = text } return text - case .jetbrains: - let text = "JetBrains AI debug log not yet implemented" - await MainActor.run { self.probeLogs[.jetbrains] = text } - return text case .warp: let resolution = ProviderTokenResolver.warpResolution() let hasAny = resolution != nil @@ -1256,10 +1214,44 @@ extension UsageStore { let text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" await MainActor.run { self.probeLogs[.warp] = text } return text + case .gemini, .antigravity, .windsurf, .opencode, .factory, .copilot, .vertexai, .kiro, .kimi, .kimik2, + .jetbrains: + let text = Self.notImplementedDebugLogText(for: provider) + await MainActor.run { self.probeLogs[provider] = text } + return text } }.value } + private nonisolated static func notImplementedDebugLogText(for provider: UsageProvider) -> String { + switch provider { + case .gemini: + "Gemini debug log not yet implemented" + case .antigravity: + "Antigravity debug log not yet implemented" + case .windsurf: + "Windsurf debug log not yet implemented" + case .opencode: + "OpenCode debug log not yet implemented" + case .factory: + "Droid debug log not yet implemented" + case .copilot: + "Copilot debug log not yet implemented" + case .vertexai: + "Vertex AI debug log not yet implemented" + case .kiro: + "Kiro debug log not yet implemented" + case .kimi: + "Kimi debug log not yet implemented" + case .kimik2: + "Kimi K2 debug log not yet implemented" + case .jetbrains: + "JetBrains AI debug log not yet implemented" + case .codex, .claude, .cursor, .zai, .minimax, .augment, .amp, .synthetic, .warp: + "Debug log not yet implemented" + } + } + private func debugClaudeLog( claudeWebExtrasEnabled: Bool, claudeUsageDataSource: ClaudeUsageDataSource, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 4809cfb06..864f6f78a 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -147,7 +147,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .gemini, .antigravity, .windsurf, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .warp: return nil } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 4d7dd9619..b463b010b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -63,45 +63,14 @@ enum CostUsageScanner { return self.loadCodexDaily(range: range, now: now, options: options) case .claude: return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) - case .zai: - return CostUsageDailyReport(data: [], summary: nil) - case .gemini: - return CostUsageDailyReport(data: [], summary: nil) - case .antigravity: - return CostUsageDailyReport(data: [], summary: nil) - case .windsurf: - return CostUsageDailyReport(data: [], summary: nil) - case .cursor: - return CostUsageDailyReport(data: [], summary: nil) - case .opencode: - return CostUsageDailyReport(data: [], summary: nil) - case .factory: - return CostUsageDailyReport(data: [], summary: nil) - case .copilot: - return CostUsageDailyReport(data: [], summary: nil) - case .minimax: - return CostUsageDailyReport(data: [], summary: nil) case .vertexai: var filtered = options if filtered.claudeLogProviderFilter == .all { filtered.claudeLogProviderFilter = .vertexAIOnly } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .kiro: - return CostUsageDailyReport(data: [], summary: nil) - case .kimi: - return CostUsageDailyReport(data: [], summary: nil) - case .kimik2: - return CostUsageDailyReport(data: [], summary: nil) - case .augment: - return CostUsageDailyReport(data: [], summary: nil) - case .jetbrains: - return CostUsageDailyReport(data: [], summary: nil) - case .amp: - return CostUsageDailyReport(data: [], summary: nil) - case .synthetic: - return CostUsageDailyReport(data: [], summary: nil) - case .warp: + case .zai, .gemini, .antigravity, .windsurf, .cursor, .opencode, .factory, .copilot, .minimax, .kiro, + .kimi, .kimik2, .augment, .jetbrains, .amp, .synthetic, .warp: return CostUsageDailyReport(data: [], summary: nil) } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index e5c35d3b4..848bb3b6b 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -74,6 +74,8 @@ struct ClaudeOAuthCredentialsStoreTests { func loadRecord_nonInteractiveRepairCanBeDisabled() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try KeychainCacheStore.withServiceOverrideForTesting(service) { + // Make the test independent of any persisted debugDisableKeychainAccess UserDefaults value. + try KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -127,6 +129,7 @@ struct ClaudeOAuthCredentialsStoreTests { } } } + } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 1953257b2..13845623d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -157,6 +157,8 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" try await KeychainCacheStore.withServiceOverrideForTesting(service) { + // Make the test independent of any persisted debugDisableKeychainAccess UserDefaults value. + try await KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -187,6 +189,7 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { #expect(available == true) } + } } } }