From 44e27245b41d8520887f099adcfaff15a60a4cb1 Mon Sep 17 00:00:00 2001 From: Rahul Sharma Date: Thu, 9 Apr 2026 18:57:15 +0530 Subject: [PATCH] Add Atlassian Rovo Dev provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `.rovodev` provider that tracks Atlassian Rovo Dev monthly credit usage via a silent Web-API based background fetcher — no CLI dependency, same pattern as the Ollama extension. ## What's included **New provider files** - `RovoDevUsageSnapshot` — data model: `currentUsage`, `creditCap`, `nextRefresh`, `effectiveEntitlement`; converts to `UsageSnapshot` with `usedPercent = currentUsage / creditCap * 100` - `RovoDevACLIConfig` — reads `~/.config/acli/global_auth_config.yaml` at runtime to resolve the active Atlassian site and cloud ID - `RovoDevUsageFetcher` — POSTs to `https://{site}/gateway/api/rovodev/v3/credits/entitlements/entitlement-allowance` authenticated via browser cookies imported with `SweetCookieKit` - `RovoDevProviderDescriptor` — metadata, fetch strategy, status link points to the Rovo Dev usage page (not the generic status page) - `RovoDevSettingsStore` — cookie source (auto/manual) and manual cookie header settings - `RovoDevProviderImplementation` — settings UI, cookie picker, "Open Rovo Dev Usage" link action - `ProviderIcon-rovodev.svg` — Atlassian-branded diamond icon **Shared wiring** - `Providers.swift` — `.rovodev` added to `UsageProvider` and `IconStyle` - `ProviderSettingsSnapshot` — `RovoDevProviderSettings` struct + full builder chain - `ProviderDescriptor` — descriptor registered - `ProviderImplementationRegistry` — implementation registered - `LogCategories` — `rovodev` log category - `CostUsageScanner` — exhaustive switch updated - `TokenAccountCLI` — `.rovodev` case + snapshot wiring - `UsageStore` — `.rovodev` debug probe - `CodexBarWidgetProvider` / `CodexBarWidgetViews` — widget stubs **Build fix** - `MenuHighlightStyle.swift` — manually expanded `@Entry` macro so the project builds with `swift build` CLI (SwiftUIMacros plugin is only available inside the full Xcode toolchain) ## Validation - `./Scripts/compile_and_run.sh` - Verified live: app shows "0% used · ROVO_DEV_STANDARD_TRIAL · Resets May 7, 2026" matching https://outreach-io.atlassian.net/rovodev/your-usage --- Scripts/test_rovodev.sh | 132 +++++++ Sources/CodexBar/MenuHighlightStyle.swift | 9 +- .../RovoDevProviderImplementation.swift | 102 +++++ .../RovoDev/RovoDevSettingsStore.swift | 63 ++++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-rovodev.svg | 5 + Sources/CodexBar/UsageStore.swift | 9 + Sources/CodexBarCLI/TokenAccountCLI.swift | 9 + .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 19 + .../CodexBarCore/Providers/Providers.swift | 2 + .../RovoDev/RovoDevProviderDescriptor.swift | 72 ++++ .../RovoDev/RovoDevUsageFetcher.swift | 352 ++++++++++++++++++ .../RovoDev/RovoDevUsageSnapshot.swift | 70 ++++ .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + 18 files changed, 851 insertions(+), 2 deletions(-) create mode 100755 Scripts/test_rovodev.sh create mode 100644 Sources/CodexBar/Providers/RovoDev/RovoDevProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/RovoDev/RovoDevSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-rovodev.svg create mode 100644 Sources/CodexBarCore/Providers/RovoDev/RovoDevProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageSnapshot.swift diff --git a/Scripts/test_rovodev.sh b/Scripts/test_rovodev.sh new file mode 100755 index 000000000..8e3fc8fd3 --- /dev/null +++ b/Scripts/test_rovodev.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Test the Rovo Dev usage fetcher end-to-end. +# +# Usage: +# ./Scripts/test_rovodev.sh # build + run (full app, adhoc signed) +# ./Scripts/test_rovodev.sh --fetch-only # print raw API response without launching the app +# +# The --fetch-only mode reads ~/.config/acli/global_auth_config.yaml, imports +# browser cookies for your Atlassian site, and prints the usage JSON. + +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +FETCH_ONLY=0 +for arg in "$@"; do + case "${arg}" in + --fetch-only|-f) FETCH_ONLY=1 ;; + --help|-h) + echo "Usage: $(basename "$0") [--fetch-only]" + exit 0 + ;; + esac +done + +# ── helpers ────────────────────────────────────────────────────────────────── +log() { printf '==> %s\n' "$*"; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +CONFIG_FILE="${HOME}/.config/acli/global_auth_config.yaml" + +check_config() { + if [[ ! -f "${CONFIG_FILE}" ]]; then + fail "Atlassian CLI config not found: ${CONFIG_FILE} +Run 'acli' to set up your profile first." + fi + local site cloud_id + site="$(grep 'site:' "${CONFIG_FILE}" | head -1 | awk -F': ' '{print $2}' | tr -d '[:space:]')" + cloud_id="$(grep 'cloud_id:' "${CONFIG_FILE}" | head -1 | awk -F': ' '{print $2}' | tr -d '[:space:]')" + if [[ -z "${site}" || -z "${cloud_id}" ]]; then + fail "Could not parse site/cloud_id from ${CONFIG_FILE}" + fi + echo "${site}:${cloud_id}" +} + +fetch_usage() { + local site="$1" + local cloud_id="$2" + local api_url="https://${site}/gateway/api/rovodev/v3/credits/entitlements/entitlement-allowance" + + log "Fetching Rovo Dev usage from ${api_url}" + + # Pull browser cookies for the Atlassian domain. + # The easiest cross-browser method on macOS is to read Chrome's cookie DB directly, + # but that requires unlocking Keychain. We use a simpler curl with --cookie-jar approach + # by leveraging the fact that 'cookies' in Safari are accessible without decryption. + # + # Preferred: use the app's built-in debug probe. We trigger that below. + # Fallback shown here calls the API directly with cookies from the system. + + log "NOTE: For full cookie import use the app's Debug pane → Rovo Dev → 'Run Probe'." + log "Calling API directly (may 401 without a valid browser session cookie)..." + + local body + body="$(printf '{"cloudId":"%s","entitlementId":"unknown","productKey":"unknown"}' "${cloud_id}")" + + local response + if ! response="$(curl -sf \ + --max-time 15 \ + -X POST "${api_url}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Origin: https://${site}" \ + -H "Referer: https://${site}/rovodev/your-usage" \ + -b "${HOME}/Library/Cookies/Cookies.binarycookies" \ + --data "${body}" 2>&1)"; then + log "WARN: curl failed (likely no cookies). Trying without cookie file..." + response="$(curl -sf \ + --max-time 15 \ + -X POST "${api_url}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Origin: https://${site}" \ + -H "Referer: https://${site}/rovodev/your-usage" \ + --data "${body}" 2>&1 || echo '{"error":"request failed"}')" + fi + + echo "" + log "Raw API response:" + echo "${response}" | python3 -m json.tool 2>/dev/null || echo "${response}" + + # Parse key fields + local current_usage credit_cap + current_usage="$(echo "${response}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('currentUsage','?'))" 2>/dev/null || echo "?")" + credit_cap="$(echo "${response}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('creditCap','?'))" 2>/dev/null || echo "?")" + + echo "" + log "Summary:" + echo " Current usage : ${current_usage}" + echo " Credit cap : ${credit_cap}" + if [[ "${current_usage}" != "?" && "${credit_cap}" != "?" && "${credit_cap}" != "0" ]]; then + local pct + pct="$(python3 -c "print(f'{${current_usage}/${credit_cap}*100:.1f}%')" 2>/dev/null || echo "?")" + echo " Used : ${pct}" + fi +} + +# ── main ───────────────────────────────────────────────────────────────────── +log "Rovo Dev Provider Test" +echo "" + +log "Checking ACLI config at ${CONFIG_FILE}..." +IFS=':' read -r site cloud_id <<< "$(check_config)" +log "Site : ${site}" +log "Cloud ID : ${cloud_id}" +echo "" + +if [[ "${FETCH_ONLY}" == "1" ]]; then + fetch_usage "${site}" "${cloud_id}" + echo "" + log "Done. To test with full browser cookie import, run the app and open:" + echo " Preferences → Providers → Rovo Dev → Enable" + echo " Debug pane → Probe Logs → Rovo Dev" + exit 0 +fi + +# Full build + launch +log "Building and launching CodexBar with Rovo Dev provider..." +echo " The app will appear in the menu bar." +echo " Go to Preferences → Providers → Rovo Dev to enable it." +echo "" + +exec "${ROOT_DIR}/Scripts/compile_and_run.sh" "$@" diff --git a/Sources/CodexBar/MenuHighlightStyle.swift b/Sources/CodexBar/MenuHighlightStyle.swift index be76fe04a..48cca81d5 100644 --- a/Sources/CodexBar/MenuHighlightStyle.swift +++ b/Sources/CodexBar/MenuHighlightStyle.swift @@ -1,7 +1,14 @@ import SwiftUI extension EnvironmentValues { - @Entry var menuItemHighlighted: Bool = false + // Manual expansion of @Entry (SwiftUIMacros plugin not available in CLI builds) + private struct __Key_menuItemHighlighted: EnvironmentKey { + static let defaultValue: Bool = false + } + var menuItemHighlighted: Bool { + get { self[__Key_menuItemHighlighted.self] } + set { self[__Key_menuItemHighlighted.self] = newValue } + } } enum MenuHighlightStyle { diff --git a/Sources/CodexBar/Providers/RovoDev/RovoDevProviderImplementation.swift b/Sources/CodexBar/Providers/RovoDev/RovoDevProviderImplementation.swift new file mode 100644 index 000000000..43b2e6781 --- /dev/null +++ b/Sources/CodexBar/Providers/RovoDev/RovoDevProviderImplementation.swift @@ -0,0 +1,102 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct RovoDevProviderImplementation: ProviderImplementation { + let id: UsageProvider = .rovodev + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.rovodevCookieSource + _ = settings.rovodevCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .rovodev(context.settings.rovodevSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.rovodevCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.rovodevCookieSource != .manual { + settings.rovodevCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.rovodevCookieSource.rawValue }, + set: { raw in + context.settings.rovodevCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.rovodevCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports cookies from your browser.", + manual: "Paste a Cookie header captured from your Atlassian browser session.", + off: "Rovo Dev cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "rovodev-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports cookies from your browser.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + let siteURL: String = { + if let config = try? RovoDevACLIConfig.load() { + return "https://\(config.site)/rovodev/your-usage" + } + return "https://atlassian.net/rovodev/your-usage" + }() + + return [ + ProviderSettingsFieldDescriptor( + id: "rovodev-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: …", + binding: context.stringBinding(\.rovodevCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "rovodev-open-usage", + title: "Open Rovo Dev Usage", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: siteURL) { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.rovodevCookieSource == .manual }, + onActivate: { context.settings.ensureRovoDevCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/RovoDev/RovoDevSettingsStore.swift b/Sources/CodexBar/Providers/RovoDev/RovoDevSettingsStore.swift new file mode 100644 index 000000000..f32e19f05 --- /dev/null +++ b/Sources/CodexBar/Providers/RovoDev/RovoDevSettingsStore.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var rovodevCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .rovodev)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .rovodev) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .rovodev, field: "cookieHeader", value: newValue) + } + } + + var rovodevCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .rovodev, fallback: .auto) } + set { + self.updateProviderConfig(provider: .rovodev) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .rovodev, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureRovoDevCookieLoaded() {} +} + +extension SettingsStore { + func rovodevSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .RovoDevProviderSettings { + ProviderSettingsSnapshot.RovoDevProviderSettings( + cookieSource: self.rovodevSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.rovodevSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func rovodevSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String? { + let fallback = self.rovodevCookieHeader.isEmpty ? nil : self.rovodevCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .rovodev), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .rovodev, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func rovodevSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.rovodevCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .rovodev), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .rovodev).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index c2b008592..cd981c19a 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -34,6 +34,7 @@ enum ProviderImplementationRegistry { case .kimik2: KimiK2ProviderImplementation() case .amp: AmpProviderImplementation() case .ollama: OllamaProviderImplementation() + case .rovodev: RovoDevProviderImplementation() case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() diff --git a/Sources/CodexBar/Resources/ProviderIcon-rovodev.svg b/Sources/CodexBar/Resources/ProviderIcon-rovodev.svg new file mode 100644 index 000000000..2d08f2ed8 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-rovodev.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 42acabb03..b90010623 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -824,6 +824,8 @@ extension UsageStore { let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .rovodev: + return await Self.debugRovoDevLog(browserDetection: browserDetection) case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains, .perplexity: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" @@ -1034,6 +1036,13 @@ extension UsageStore { } } + private static func debugRovoDevLog(browserDetection: BrowserDetection) async -> String { + await runWithTimeout(seconds: 15) { + let fetcher = RovoDevUsageFetcher(browserDetection: browserDetection) + return await fetcher.debugRawProbe() + } + } + private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index d639019cf..250597c6a 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -160,6 +160,13 @@ struct TokenAccountCLIContext { ollama: ProviderSettingsSnapshot.OllamaProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .rovodev: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + rovodev: ProviderSettingsSnapshot.RovoDevProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .kimi: let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) let cookieSource = self.cookieSource(provider: provider, account: account, config: config) @@ -206,6 +213,7 @@ struct TokenAccountCLIContext { augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil, amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil, ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, + rovodev: ProviderSettingsSnapshot.RovoDevProviderSettings? = nil, jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot { @@ -224,6 +232,7 @@ struct TokenAccountCLIContext { augment: augment, amp: amp, ollama: ollama, + rovodev: rovodev, jetbrains: jetbrains, perplexity: perplexity) } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 5f6cf9217..8667f3bd0 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -41,6 +41,7 @@ public enum LogCategories { public static let openAIWeb = "openai-web" public static let openAIWebview = "openai-webview" public static let ollama = "ollama" + public static let rovodev = "rovodev" public static let opencodeUsage = "opencode-usage" public static let opencodeGoUsage = "opencode-go-usage" public static let openRouterUsage = "openrouter-usage" diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index c55d0a194..6b35e526a 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -74,6 +74,7 @@ public enum ProviderDescriptorRegistry { .kimik2: KimiK2ProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, .ollama: OllamaProviderDescriptor.descriptor, + .rovodev: RovoDevProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index d0e6be940..4485583d2 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -19,6 +19,7 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, + rovodev: RovoDevProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot { @@ -40,6 +41,7 @@ public struct ProviderSettingsSnapshot: Sendable { augment: augment, amp: amp, ollama: ollama, + rovodev: rovodev, jetbrains: jetbrains, perplexity: perplexity) } @@ -222,6 +224,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct RovoDevProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct PerplexityProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -249,6 +261,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let augment: AugmentProviderSettings? public let amp: AmpProviderSettings? public let ollama: OllamaProviderSettings? + public let rovodev: RovoDevProviderSettings? public let jetbrains: JetBrainsProviderSettings? public let perplexity: PerplexityProviderSettings? @@ -274,6 +287,7 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings?, amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, + rovodev: RovoDevProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil) { @@ -294,6 +308,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.augment = augment self.amp = amp self.ollama = ollama + self.rovodev = rovodev self.jetbrains = jetbrains self.perplexity = perplexity } @@ -315,6 +330,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case augment(ProviderSettingsSnapshot.AugmentProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) + case rovodev(ProviderSettingsSnapshot.RovoDevProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) } @@ -337,6 +353,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? + public var rovodev: ProviderSettingsSnapshot.RovoDevProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? @@ -362,6 +379,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .augment(value): self.augment = value case let .amp(value): self.amp = value case let .ollama(value): self.ollama = value + case let .rovodev(value): self.rovodev = value case let .jetbrains(value): self.jetbrains = value case let .perplexity(value): self.perplexity = value } @@ -386,6 +404,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { augment: self.augment, amp: self.amp, ollama: self.ollama, + rovodev: self.rovodev, jetbrains: self.jetbrains, perplexity: self.perplexity) } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f573978f3..bec8c2913 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -24,6 +24,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case kimik2 case amp case ollama + case rovodev case synthetic case warp case openrouter @@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable { case jetbrains case amp case ollama + case rovodev case synthetic case warp case openrouter diff --git a/Sources/CodexBarCore/Providers/RovoDev/RovoDevProviderDescriptor.swift b/Sources/CodexBarCore/Providers/RovoDev/RovoDevProviderDescriptor.swift new file mode 100644 index 000000000..5d4b2d6f1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/RovoDev/RovoDevProviderDescriptor.swift @@ -0,0 +1,72 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum RovoDevProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .rovodev, + metadata: ProviderMetadata( + id: .rovodev, + displayName: "Rovo Dev", + sessionLabel: "Monthly credits", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Rovo Dev usage", + cliName: "rovodev", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: nil, + statusPageURL: nil, + statusLinkURL: (try? RovoDevACLIConfig.load()).map { "https://\($0.site)/rovodev/your-usage" } ?? "https://atlassian.net/rovodev/your-usage"), + branding: ProviderBranding( + iconStyle: .rovodev, + iconResourceName: "ProviderIcon-rovodev", + color: ProviderColor(red: 0 / 255, green: 101 / 255, blue: 255 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Rovo Dev cost tracking is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [RovoDevWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "rovodev", + versionDetector: nil)) + } +} + +struct RovoDevWebFetchStrategy: ProviderFetchStrategy { + let id: String = "rovodev.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.rovodev?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = RovoDevUsageFetcher(browserDetection: context.browserDetection) + let manual = context.settings?.rovodev?.cookieSource == .manual + ? CookieHeaderNormalizer.normalize(context.settings?.rovodev?.manualCookieHeader) + : nil + let isManualMode = context.settings?.rovodev?.cookieSource == .manual + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.rovodev).verbose(msg) } + : nil + let snap = try await fetcher.fetch( + cookieHeaderOverride: manual, + manualCookieMode: isManualMode, + logger: logger) + return self.makeResult(usage: snap.toUsageSnapshot(), sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageFetcher.swift b/Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageFetcher.swift new file mode 100644 index 000000000..ca4b5b663 --- /dev/null +++ b/Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageFetcher.swift @@ -0,0 +1,352 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) +import SweetCookieKit +#endif + +// MARK: - Errors + +public enum RovoDevUsageError: LocalizedError, Sendable { + case notLoggedIn + case invalidCredentials + case configNotFound + case parseFailed(String) + case networkError(String) + case noSessionCookie + + public var errorDescription: String? { + switch self { + case .notLoggedIn: + "Not logged in to Atlassian. Please visit your Atlassian site in a browser." + case .invalidCredentials: + "Atlassian session expired. Please log in again in your browser." + case .configNotFound: + "Atlassian CLI config not found at ~/.config/acli/global_auth_config.yaml. " + + "Please run `acli` to set up your profile." + case let .parseFailed(message): + "Could not parse Rovo Dev usage: \(message)" + case let .networkError(message): + "Rovo Dev request failed: \(message)" + case .noSessionCookie: + "No Atlassian session cookie found. Please log in to your Atlassian site in your browser." + } + } +} + +// MARK: - Config reader + +/// Reads the ACLI global auth config to discover the active Atlassian site and cloud ID. +public struct RovoDevACLIConfig: Sendable { + public let site: String // e.g. "outreach-io.atlassian.net" + public let cloudID: String // e.g. "74570b23-8e0a-4453-a336-43a98125368f" + + /// URL for the allowance API. + public var allowanceURL: URL { + URL(string: "https://\(self.site)/gateway/api/rovodev/v3/credits/entitlements/entitlement-allowance")! + } + + /// The Atlassian host used for cookie import. + public var host: String { self.site } + + static let configPath: String = "~/.config/acli/global_auth_config.yaml" + + public static func load() throws -> RovoDevACLIConfig { + let expanded = (Self.configPath as NSString).expandingTildeInPath + guard let raw = try? String(contentsOfFile: expanded, encoding: .utf8) else { + throw RovoDevUsageError.configNotFound + } + return try Self.parse(raw) + } + + static func parse(_ yaml: String) throws -> RovoDevACLIConfig { + // Simple line-based YAML parsing – sufficient for the known config structure. + var site: String? + var cloudID: String? + + for line in yaml.components(separatedBy: "\n") { + // Strip leading whitespace and YAML list indicator "- " so both + // "site: foo" and " - site: foo" forms are handled. + var trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("- ") { trimmed = String(trimmed.dropFirst(2)) } + if trimmed.hasPrefix("site:") { + site = Self.value(after: "site:", in: trimmed) + } else if trimmed.hasPrefix("cloud_id:") { + cloudID = Self.value(after: "cloud_id:", in: trimmed) + } + if site != nil, cloudID != nil { break } + } + + guard let site, let cloudID, !site.isEmpty, !cloudID.isEmpty else { + throw RovoDevUsageError.parseFailed("Could not read site/cloud_id from ACLI config.") + } + return RovoDevACLIConfig(site: site, cloudID: cloudID) + } + + private static func value(after prefix: String, in line: String) -> String? { + let tail = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) + // Strip surrounding quotes if present. + if (tail.hasPrefix("\"") && tail.hasSuffix("\"")) || + (tail.hasPrefix("'") && tail.hasSuffix("'")) + { + let inner = tail.dropFirst().dropLast() + return inner.isEmpty ? nil : String(inner) + } + return tail.isEmpty ? nil : tail + } +} + +// MARK: - Cookie importer + +#if os(macOS) +public enum RovoDevCookieImporter { + private static let cookieClient = BrowserCookieClient() + + public static func importSession( + site: String, + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> String + { + let log: (String) -> Void = { msg in logger?("[rovodev-cookie] \(msg)") } + let domains = [site, "atlassian.net", ".atlassian.net", "id.atlassian.com"] + let candidates = Browser.defaultImportOrder.cookieImportCandidates(using: browserDetection) + + var allCookies: [HTTPCookie] = [] + for browser in candidates { + do { + let query = BrowserCookieQuery(domains: domains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, in: browser, logger: log) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + allCookies.append(contentsOf: cookies) + if !cookies.isEmpty { + log("Found \(cookies.count) cookie(s) from \(browser.displayName)") + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browser.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + guard !allCookies.isEmpty else { + throw RovoDevUsageError.noSessionCookie + } + + let header = allCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + return header + } +} +#endif + +// MARK: - Fetcher + +public struct RovoDevUsageFetcher: Sendable { + @MainActor private static var recentDumps: [String] = [] + + public let browserDetection: BrowserDetection + private let makeURLSession: @Sendable (URLSessionTaskDelegate?) -> URLSession + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + self.makeURLSession = { delegate in + URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + } + } + + // MARK: Public API + + public func fetch( + cookieHeaderOverride: String?, + manualCookieMode: Bool, + logger: ((String) -> Void)? = nil, + now: Date = Date()) async throws -> RovoDevUsageSnapshot + { + let config = try RovoDevACLIConfig.load() + let cookieHeader = try await self.resolveCookieHeader( + override: cookieHeaderOverride, + manualCookieMode: manualCookieMode, + site: config.site, + logger: logger) + return try await self.fetchAllowance( + config: config, + cookieHeader: cookieHeader, + logger: logger, + now: now) + } + + // MARK: Debug + + public func debugRawProbe( + cookieHeaderOverride: String? = nil, + manualCookieMode: Bool = false) async -> String + { + let stamp = ISO8601DateFormatter().string(from: Date()) + var lines: [String] = ["=== Rovo Dev Debug Probe @ \(stamp) ===", ""] + + do { + let config = try RovoDevACLIConfig.load() + lines.append("Site: \(config.site)") + lines.append("Cloud ID: \(config.cloudID)") + + let cookieHeader = try await self.resolveCookieHeader( + override: cookieHeaderOverride, + manualCookieMode: manualCookieMode, + site: config.site, + logger: { msg in lines.append("[cookie] \(msg)") }) + let cookieNames = CookieHeaderNormalizer.pairs(from: cookieHeader).map(\.name) + lines.append("Cookie names: \(cookieNames.joined(separator: ", "))") + + let snap = try await self.fetchAllowance( + config: config, cookieHeader: cookieHeader, logger: { msg in lines.append(msg) }) + lines.append("") + lines.append("Fetch Success") + lines.append("Current usage: \(snap.currentUsage)") + lines.append("Credit cap: \(snap.creditCap)") + lines.append("Used %: \(snap.creditCap > 0 ? Double(snap.currentUsage) / Double(snap.creditCap) * 100 : 0)") + lines.append("Next refresh: \(snap.nextRefresh?.description ?? "nil")") + lines.append("Entitlement: \(snap.effectiveEntitlement ?? "nil")") + } catch { + lines.append("") + lines.append("Probe Failed: \(error.localizedDescription)") + } + + let output = lines.joined(separator: "\n") + Task { @MainActor in Self.recordDump(output) } + return output + } + + public static func latestDumps() async -> String { + await MainActor.run { + let result = Self.recentDumps.joined(separator: "\n\n---\n\n") + return result.isEmpty ? "No Rovo Dev probe dumps captured yet." : result + } + } + + // MARK: Private + + private func resolveCookieHeader( + override: String?, + manualCookieMode: Bool, + site: String, + logger: ((String) -> Void)?) async throws -> String + { + if let normalized = CookieHeaderNormalizer.normalize(override), !normalized.isEmpty { + logger?("[rovodev] Using manual cookie header") + return normalized + } + if manualCookieMode { + throw RovoDevUsageError.noSessionCookie + } + #if os(macOS) + logger?("[rovodev] Importing cookies for \(site) from browser") + return try RovoDevCookieImporter.importSession( + site: site, + browserDetection: self.browserDetection, + logger: logger) + #else + throw RovoDevUsageError.noSessionCookie + #endif + } + + private func fetchAllowance( + config: RovoDevACLIConfig, + cookieHeader: String, + logger: ((String) -> Void)? = nil, + now: Date = Date()) async throws -> RovoDevUsageSnapshot + { + let url = config.allowanceURL + logger?("[rovodev] POST \(url)") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue( + "https://\(config.site)/rovodev/your-usage", + forHTTPHeaderField: "Referer") + request.setValue("https://\(config.site)", forHTTPHeaderField: "Origin") + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + forHTTPHeaderField: "User-Agent") + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + request.setValue("no-cache", forHTTPHeaderField: "Pragma") + + let body: [String: String] = [ + "cloudId": config.cloudID, + "entitlementId": "unknown", + "productKey": "unknown", + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + let session = self.makeURLSession(nil) + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw RovoDevUsageError.networkError(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw RovoDevUsageError.networkError("Invalid response") + } + logger?("[rovodev] HTTP \(httpResponse.statusCode)") + + switch httpResponse.statusCode { + case 200: + break + case 401, 403: + throw RovoDevUsageError.invalidCredentials + default: + throw RovoDevUsageError.networkError("HTTP \(httpResponse.statusCode)") + } + + return try Self.parseAllowance(data: data, now: now) + } + + private static func parseAllowance(data: Data, now: Date) throws -> RovoDevUsageSnapshot { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw RovoDevUsageError.parseFailed("Invalid JSON response.") + } + + // Accept either Int or Double for numeric fields (Atlassian API returns integers). + func intValue(_ key: String) -> Int? { + if let v = json[key] as? Int { return v } + if let v = json[key] as? Double { return Int(v) } + return nil + } + + guard let currentUsage = intValue("currentUsage"), + let creditCap = intValue("creditCap"), + creditCap > 0 + else { + throw RovoDevUsageError.parseFailed("Missing currentUsage or creditCap in response.") + } + + var nextRefresh: Date? + if let ms = intValue("nextRefresh") { + nextRefresh = Date(timeIntervalSince1970: Double(ms) / 1000) + } + + let entitlement = json["effectiveEntitlement"] as? String + // Email is not included in the allowance response; the caller may supply it separately. + + return RovoDevUsageSnapshot( + currentUsage: currentUsage, + creditCap: creditCap, + nextRefresh: nextRefresh, + effectiveEntitlement: entitlement, + accountEmail: nil, + updatedAt: now) + } + + @MainActor private static func recordDump(_ text: String) { + if Self.recentDumps.count >= 5 { Self.recentDumps.removeFirst() } + Self.recentDumps.append(text) + } +} diff --git a/Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageSnapshot.swift b/Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageSnapshot.swift new file mode 100644 index 000000000..4eb0b422c --- /dev/null +++ b/Sources/CodexBarCore/Providers/RovoDev/RovoDevUsageSnapshot.swift @@ -0,0 +1,70 @@ +import Foundation + +public struct RovoDevUsageSnapshot: Sendable { + /// Credits consumed in the current billing cycle. + public let currentUsage: Int + /// Credit cap for the billing cycle (e.g. 6000). + public let creditCap: Int + /// Timestamp when the allowance resets. + public let nextRefresh: Date? + /// Active entitlement name (e.g. "ROVO_DEV_STANDARD_TRIAL"). + public let effectiveEntitlement: String? + /// Atlassian account email, if resolved. + public let accountEmail: String? + public let updatedAt: Date + + public init( + currentUsage: Int, + creditCap: Int, + nextRefresh: Date?, + effectiveEntitlement: String?, + accountEmail: String?, + updatedAt: Date) + { + self.currentUsage = currentUsage + self.creditCap = creditCap + self.nextRefresh = nextRefresh + self.effectiveEntitlement = effectiveEntitlement + self.accountEmail = accountEmail + self.updatedAt = updatedAt + } +} + +extension RovoDevUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double + if self.creditCap > 0 { + usedPercent = min(100, max(0, Double(self.currentUsage) / Double(self.creditCap) * 100)) + } else { + usedPercent = 0 + } + + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.nextRefresh, + resetDescription: nil) + + let plan = self.effectiveEntitlement.flatMap { raw -> String? in + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let email = self.accountEmail.flatMap { raw -> String? in + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let identity = ProviderIdentitySnapshot( + providerID: .rovodev, + accountEmail: email, + accountOrganization: nil, + loginMethod: plan) + + return UsageSnapshot( + primary: window, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a982934ae..505539b42 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -93,7 +93,7 @@ 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: + .kimik2, .augment, .jetbrains, .amp, .ollama, .rovodev, .synthetic, .openrouter, .warp, .perplexity: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 7e4a7ddb0..11b775fea 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -72,6 +72,7 @@ enum ProviderChoice: String, AppEnum { case .kimik2: return nil // Kimi K2 not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets + case .rovodev: return nil // Rovo Dev not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 4ccc2b57e..5a7345f66 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -278,6 +278,7 @@ private struct ProviderSwitchChip: View { case .kimik2: "Kimi K2" case .amp: "Amp" case .ollama: "Ollama" + case .rovodev: "RovoDev" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" @@ -633,6 +634,8 @@ enum WidgetColors { Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal + case .rovodev: + Color(red: 0 / 255, green: 101 / 255, blue: 255 / 255) // Atlassian blue case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: