@@ -26,7 +26,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
### First run
- Open Settings → Providers and enable what you use.
-- Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running).
+- Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, `jules`, browser cookies, or OAuth; Antigravity requires the Antigravity app running).
- Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras.
## Providers
@@ -34,7 +34,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras.
- [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage.
- [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).
+- [Gemini / Jules](docs/gemini.md) — OAuth-backed quota API using CLI credentials (no browser cookies).
- [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth.
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
@@ -75,6 +75,7 @@ Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it re
- **Keychain access (prompted by macOS)**:
- Chrome cookie import needs the “Chrome Safe Storage” key to decrypt cookies.
- Claude OAuth credentials (written by the Claude CLI) are read from Keychain when present.
+ - Gemini/Jules CLI credentials are read from Keychain or local config.
- z.ai API token is stored in Keychain from Preferences → Providers; Copilot stores its API token in Keychain during device flow.
- **How do I prevent those keychain alerts?**
- Open **Keychain Access.app** → login keychain → search the item (e.g., “Claude Code-credentials”).
@@ -86,7 +87,7 @@ Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it re
- Find the browser’s “Safe Storage” key (e.g., “Chrome Safe Storage”, “Brave Safe Storage”, “Firefox”, “Microsoft Edge Safe Storage”).
- Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”.
- This removes the prompt when CodexBar decrypts cookies for that browser.
-- **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs (codex/claude/gemini/antigravity). If those CLIs read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the CLI’s working directory, not background disk scanning.
+- **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs (codex/claude/gemini/jules/antigravity). If those CLIs read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the CLI’s working directory, not background disk scanning.
- **What we do not request**: no Screen Recording, Accessibility, or Automation permissions; no passwords are stored (browser cookies are reused when you opt in).
## Docs
diff --git a/Sources/CodexBar/Providers/Jules/JulesProviderImplementation.swift b/Sources/CodexBar/Providers/Jules/JulesProviderImplementation.swift
new file mode 100644
index 000000000..d1c5302d6
--- /dev/null
+++ b/Sources/CodexBar/Providers/Jules/JulesProviderImplementation.swift
@@ -0,0 +1,41 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct JulesProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .jules
+ let supportsLoginFlow: Bool = true
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { context in
+ let versionText = context.store.version(for: context.provider) ?? "not detected"
+ let email = context.store.snapshot(for: .jules)?.identity?.accountEmail ?? ""
+ let plan = context.store.snapshot(for: .jules)?.identity?.loginMethod ?? ""
+
+ var detail = "\(context.metadata.cliName) \(versionText)"
+ if !email.isEmpty {
+ detail += " • \(email)"
+ }
+ if !plan.isEmpty {
+ detail += " (\(plan))"
+ }
+ return detail
+ }
+ }
+
+ @MainActor
+ func runLoginFlow(context: ProviderLoginContext) async -> Bool {
+ // Run `jules login` in Terminal.
+ // We can't easily automate this since it's an interactive login,
+ // so we just show an alert or instructions.
+ let alert = NSAlert()
+ alert.messageText = "Jules Login"
+ alert.informativeText = "To use Jules, you must be logged in via the CLI. Run 'jules login' in your terminal."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ return true
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 1cb530ce8..42a60d5bd 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -34,6 +34,7 @@ enum ProviderImplementationRegistry {
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
+ case .jules: JulesProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-jules.svg b/Sources/CodexBar/Resources/ProviderIcon-jules.svg
new file mode 100644
index 000000000..6b5821594
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-jules.svg
@@ -0,0 +1,3 @@
+
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 8e9d0acab..067603ec4 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -238,6 +238,7 @@ final class UsageStore {
codexBinary: nil,
claudeBinary: nil,
geminiBinary: nil,
+ julesBinary: nil,
effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]),
loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":"))
Task { @MainActor [weak self] in
@@ -1181,6 +1182,7 @@ extension UsageStore {
.kimi: "Kimi debug log not yet implemented",
.kimik2: "Kimi K2 debug log not yet implemented",
.jetbrains: "JetBrains AI debug log not yet implemented",
+ .jules: "Jules debug log not yet implemented",
]
let text: String
switch provider {
@@ -1243,7 +1245,7 @@ extension UsageStore {
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
- case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kiro, .kimi, .kimik2, .jetbrains:
+ case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kiro, .kimi, .kimik2, .jetbrains, .jules:
text = unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index c1617905e..2c1b92393 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -152,7 +152,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
+ case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .jules:
return nil
}
}
diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift
index ece93eda2..0b2bc0b72 100644
--- a/Sources/CodexBarCore/PathEnvironment.swift
+++ b/Sources/CodexBarCore/PathEnvironment.swift
@@ -10,6 +10,7 @@ public struct PathDebugSnapshot: Equatable, Sendable {
public let codexBinary: String?
public let claudeBinary: String?
public let geminiBinary: String?
+ public let julesBinary: String?
public let effectivePATH: String
public let loginShellPATH: String?
@@ -17,6 +18,7 @@ public struct PathDebugSnapshot: Equatable, Sendable {
codexBinary: nil,
claudeBinary: nil,
geminiBinary: nil,
+ julesBinary: nil,
effectivePATH: "",
loginShellPATH: nil)
@@ -24,18 +26,39 @@ public struct PathDebugSnapshot: Equatable, Sendable {
codexBinary: String?,
claudeBinary: String?,
geminiBinary: String? = nil,
+ julesBinary: String? = nil,
effectivePATH: String,
loginShellPATH: String?)
{
self.codexBinary = codexBinary
self.claudeBinary = claudeBinary
self.geminiBinary = geminiBinary
+ self.julesBinary = julesBinary
self.effectivePATH = effectivePATH
self.loginShellPATH = loginShellPATH
}
}
public enum BinaryLocator {
+ public static func resolveJulesBinary(
+ env: [String: String] = ProcessInfo.processInfo.environment,
+ loginPATH: [String]? = LoginShellPathCache.shared.current,
+ commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
+ aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
+ .resolveAlias,
+ fileManager: FileManager = .default,
+ home: String = NSHomeDirectory()) -> String?
+ {
+ self.resolveBinary(
+ name: "jules",
+ overrideKey: "JULES_CLI_PATH",
+ env: env,
+ loginPATH: loginPATH,
+ commandV: commandV,
+ aliasResolver: aliasResolver,
+ fileManager: fileManager,
+ home: home)
+ }
public static func resolveClaudeBinary(
env: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current,
@@ -367,11 +390,13 @@ public enum PathBuilder {
let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home)
let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home)
let gemini = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: login, home: home)
+ let jules = BinaryLocator.resolveJulesBinary(env: env, loginPATH: login, home: home)
let loginString = login?.joined(separator: ":")
return PathDebugSnapshot(
codexBinary: codex,
claudeBinary: claude,
geminiBinary: gemini,
+ julesBinary: jules,
effectivePATH: effective,
loginShellPATH: loginString)
}
diff --git a/Sources/CodexBarCore/Providers/Jules/JulesProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Jules/JulesProviderDescriptor.swift
new file mode 100644
index 000000000..2797f575a
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Jules/JulesProviderDescriptor.swift
@@ -0,0 +1,41 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum JulesProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .jules,
+ metadata: ProviderMetadata(
+ id: .jules,
+ displayName: "Jules",
+ sessionLabel: "Sessions",
+ weeklyLabel: "Daily",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Jules usage",
+ cliName: "jules",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ dashboardURL: "https://jules.google.com",
+ statusPageURL: nil,
+ statusLinkURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .gemini,
+ iconResourceName: "ProviderIcon-jules",
+ color: ProviderColor(red: 66 / 255, green: 133 / 255, blue: 244 / 255)), // Google Blue
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Jules cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .cli],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [JulesFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "jules",
+ versionDetector: { _ in ProviderVersionDetector.genericVersion(command: "jules", argument: "--version") }))
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Jules/JulesStatusProbe.swift b/Sources/CodexBarCore/Providers/Jules/JulesStatusProbe.swift
new file mode 100644
index 000000000..f4449de4a
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Jules/JulesStatusProbe.swift
@@ -0,0 +1,246 @@
+import Foundation
+
+public struct JulesStatusSnapshot: Sendable {
+ public let activeSessions: Int
+ public let totalLimit: Int
+ public let isAuthenticated: Bool
+ public let rawText: String
+ public let accountEmail: String?
+ public let accountPlan: String?
+
+ public init(
+ activeSessions: Int,
+ totalLimit: Int = 100,
+ isAuthenticated: Bool,
+ rawText: String,
+ accountEmail: String? = nil,
+ accountPlan: String? = nil)
+ {
+ self.activeSessions = activeSessions
+ self.totalLimit = totalLimit
+ self.isAuthenticated = isAuthenticated
+ self.rawText = rawText
+ self.accountEmail = accountEmail
+ self.accountPlan = accountPlan
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let limit = max(1, self.totalLimit)
+ let usedPercent = (Double(self.activeSessions) / Double(limit)) * 100.0
+
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 24 * 60, // 24h rolling window
+ resetsAt: nil,
+ resetDescription: "\(self.activeSessions)/\(limit) (24h rolling)")
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .jules,
+ accountEmail: self.accountEmail,
+ accountOrganization: nil,
+ loginMethod: self.accountPlan)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ zaiUsage: nil,
+ minimaxUsage: nil,
+ openRouterUsage: nil,
+ cursorRequests: nil,
+ updatedAt: Date(),
+ identity: identity)
+ }
+}
+
+public enum JulesStatusProbeError: LocalizedError, Sendable, Equatable {
+ case julesNotInstalled
+ case notLoggedIn
+ case commandFailed(String)
+ case timedOut
+ case apiError(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .julesNotInstalled:
+ "Jules CLI is not installed or not on PATH."
+ case .notLoggedIn:
+ "Not logged in to Jules. Run 'jules login' in Terminal to authenticate."
+ case let .commandFailed(msg):
+ "Jules CLI error: \(msg)"
+ case .timedOut:
+ "Jules CLI request timed out."
+ case let .apiError(msg):
+ "Jules API error: \(msg)"
+ }
+ }
+}
+
+public struct JulesStatusProbe: Sendable {
+ public var timeout: TimeInterval = 10.0
+ private static let log = CodexBarLog.logger(LogCategories.providers)
+
+ private static let loadCodeAssistEndpoint = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
+ private static let geminiCredentialsPath = "/.gemini/oauth_creds.json"
+
+ public init(timeout: TimeInterval = 10.0) {
+ self.timeout = timeout
+ }
+
+ public static func parse(text: String, email: String? = nil, plan: String? = nil) throws -> JulesStatusSnapshot {
+ if text.contains("did you forget to login") || text.contains("jules login") {
+ throw JulesStatusProbeError.notLoggedIn
+ }
+
+ let lines = text.components(separatedBy: .newlines)
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+
+ if text.contains("No sessions found") {
+ return JulesStatusSnapshot(
+ activeSessions: 0,
+ totalLimit: 100,
+ isAuthenticated: true,
+ rawText: text,
+ accountEmail: email,
+ accountPlan: plan)
+ }
+
+ var activeSessions = 0
+ if let first = lines.first, first.contains("ID") && first.contains("Description") {
+ activeSessions = max(0, lines.count - 1)
+ } else {
+ activeSessions = lines.count
+ }
+
+ return JulesStatusSnapshot(
+ activeSessions: activeSessions,
+ totalLimit: 100,
+ isAuthenticated: true,
+ rawText: text,
+ accountEmail: email,
+ accountPlan: plan)
+ }
+
+ public func fetch() async throws -> JulesStatusSnapshot {
+ guard TTYCommandRunner.which("jules") != nil else {
+ throw JulesStatusProbeError.julesNotInstalled
+ }
+
+ // Try identity fetch leveraging Gemini credentials
+ let (email, plan) = await self.fetchIdentityFromCLIState()
+
+ let binary = TTYCommandRunner.which("jules") ?? "jules"
+
+ let result: SubprocessResult
+ do {
+ result = try await SubprocessRunner.run(
+ binary: binary,
+ arguments: ["remote", "list", "--session"],
+ environment: TTYCommandRunner.enrichedEnvironment(),
+ timeout: self.timeout,
+ label: "jules-status")
+ } catch let SubprocessRunnerError.nonZeroExit(_, stderr) {
+ // Even if the command failed, it might contain the login error message.
+ return try Self.parse(text: stderr, email: email, plan: plan)
+ } catch {
+ throw error
+ }
+
+ return try Self.parse(text: result.stdout + result.stderr, email: email, plan: plan)
+ }
+
+ // MARK: - Identity Resolution
+
+ private struct OAuthCredentials {
+ let accessToken: String?
+ let idToken: String?
+ }
+
+ /// Fetches identity info from shared CLI state (Gemini credentials).
+ private func fetchIdentityFromCLIState() async -> (email: String?, plan: String?) {
+ guard let creds = try? loadSharedCredentials() else {
+ Self.log.info("No shared credentials found for Jules identity")
+ return (nil, nil)
+ }
+
+ let email = extractEmailFromToken(creds.idToken)
+
+ var plan: String? = nil
+ if let accessToken = creds.accessToken {
+ plan = await fetchTier(accessToken: accessToken)
+ }
+
+ return (email, plan)
+ }
+
+ private func loadSharedCredentials() throws -> OAuthCredentials {
+ let home = NSHomeDirectory()
+ let credsURL = URL(fileURLWithPath: home + Self.geminiCredentialsPath)
+
+ guard FileManager.default.fileExists(atPath: credsURL.path) else {
+ throw JulesStatusProbeError.notLoggedIn
+ }
+
+ let data = try Data(contentsOf: credsURL)
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw JulesStatusProbeError.apiError("Invalid credentials file")
+ }
+
+ return OAuthCredentials(
+ accessToken: json["access_token"] as? String,
+ idToken: json["id_token"] as? String)
+ }
+
+ private func extractEmailFromToken(_ idToken: String?) -> String? {
+ guard let token = idToken else { return nil }
+
+ let parts = token.components(separatedBy: ".")
+ guard parts.count >= 2 else { return nil }
+
+ var payload = parts[1]
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+
+ let remainder = payload.count % 4
+ if remainder > 0 {
+ payload += String(repeating: "=", count: 4 - remainder)
+ }
+
+ guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ return nil
+ }
+
+ return json["email"] as? String
+ }
+
+ private func fetchTier(accessToken: String) async -> String? {
+ guard let url = URL(string: Self.loadCodeAssistEndpoint) else { return nil }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = Data("{\"metadata\":{\"ideType\":\"GEMINI_CLI\",\"pluginType\":\"GEMINI\"}}".utf8)
+ request.timeoutInterval = self.timeout
+
+ do {
+ let (data, _) = try await URLSession.shared.data(for: request)
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let currentTier = json["currentTier"] as? [String: Any],
+ let tierId = currentTier["id"] as? String {
+ switch tierId {
+ case "standard-tier": return "Paid"
+ case "free-tier": return "Free"
+ case "legacy-tier": return "Legacy"
+ default: return nil
+ }
+ }
+ } catch {
+ Self.log.warning("Jules identity fetch failed", metadata: ["error": "\(error)"])
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Jules/JulesStrategies.swift b/Sources/CodexBarCore/Providers/Jules/JulesStrategies.swift
new file mode 100644
index 000000000..dbc5997f7
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Jules/JulesStrategies.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+struct JulesFetchStrategy: ProviderFetchStrategy {
+ let id: String = "jules.cli"
+ let kind: ProviderFetchKind = .cli
+
+ func isAvailable(_: ProviderFetchContext) async -> Bool {
+ // Use TTYCommandRunner.which which is a synchronous static method, no await needed.
+ return TTYCommandRunner.which("jules") != nil
+ }
+
+ func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let probe = JulesStatusProbe()
+ let snap = try await probe.fetch()
+ return self.makeResult(
+ usage: snap.toUsageSnapshot(),
+ sourceLabel: "cli")
+ }
+
+ func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool {
+ // If authentication fails, we don't fallback, we just report it.
+ if let julesError = error as? JulesStatusProbeError {
+ switch julesError {
+ case .notLoggedIn, .julesNotInstalled:
+ return false
+ default:
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 0e18e2c3f..6247b44fb 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -74,6 +74,7 @@ public enum ProviderDescriptorRegistry {
.synthetic: SyntheticProviderDescriptor.descriptor,
.openrouter: OpenRouterProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.descriptor,
+ .jules: JulesProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
index e79d0487d..2aab10f49 100644
--- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
+++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
@@ -28,6 +28,11 @@ public enum ProviderVersionDetector {
return nil
}
+ public static func genericVersion(command: String, argument: String = "--version") -> String? {
+ guard let path = TTYCommandRunner.which(command) else { return nil }
+ return Self.run(path: path, args: [argument])
+ }
+
private static func run(path: String, args: [String]) -> String? {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: path)
@@ -54,10 +59,12 @@ public enum ProviderVersionDetector {
}
if proc.isRunning {
kill(proc.processIdentifier, SIGKILL)
+ proc.waitUntilExit()
}
}
let data = out.fileHandleForReading.readDataToEndOfFile()
+ proc.waitUntilExit()
guard proc.terminationStatus == 0,
let text = String(data: data, encoding: .utf8)?
.split(whereSeparator: \.isNewline).first
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index b6d75ebb1..932aa1e5b 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 synthetic
case warp
case openrouter
+ case jules
}
// swiftformat:enable sortDeclarations
@@ -50,6 +51,7 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case jules
case combined
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index f5cb75f5e..2f1765fb5 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -71,7 +71,7 @@ enum CostUsageScanner {
}
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kiro, .kimi, .kimik2,
- .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp:
+ .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .jules:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 5b88abbdd..cd69e9151 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -12,6 +12,7 @@ enum ProviderChoice: String, AppEnum {
case copilot
case minimax
case opencode
+ case jules
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
@@ -24,6 +25,7 @@ enum ProviderChoice: String, AppEnum {
.copilot: DisplayRepresentation(title: "Copilot"),
.minimax: DisplayRepresentation(title: "MiniMax"),
.opencode: DisplayRepresentation(title: "OpenCode"),
+ .jules: DisplayRepresentation(title: "Jules"),
]
var provider: UsageProvider {
@@ -36,6 +38,7 @@ enum ProviderChoice: String, AppEnum {
case .copilot: .copilot
case .minimax: .minimax
case .opencode: .opencode
+ case .jules: .jules
}
}
@@ -63,6 +66,7 @@ enum ProviderChoice: String, AppEnum {
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
+ case .jules: self = .jules
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 7ad1064e5..74b5227cc 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -278,6 +278,7 @@ private struct ProviderSwitchChip: View {
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
case .warp: "Warp"
+ case .jules: "Jules"
}
}
}
@@ -615,6 +616,8 @@ enum WidgetColors {
Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
case .warp:
Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
+ case .jules:
+ Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue
}
}
}
diff --git a/Tests/CodexBarTests/JulesStatusProbeTests.swift b/Tests/CodexBarTests/JulesStatusProbeTests.swift
new file mode 100644
index 000000000..cc777589e
--- /dev/null
+++ b/Tests/CodexBarTests/JulesStatusProbeTests.swift
@@ -0,0 +1,69 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+@Suite
+struct JulesStatusProbeTests {
+ @Test
+ func parsesMultipleSessionsWithHeader() throws {
+ let output = """
+ ID Description Repo Last active Status
+ 49456769777706412… another test session kirtangajjar/Code… 2s ago In Progress
+ 59979648718544086… test session for codexbar kirtangajjar/Code… 32s ago Completed
+ 45493069977919456… Implement Jules integration kirtangajjar/Code… 52m43s ago Completed
+ """
+ let snap = try JulesStatusProbe.parse(text: output, email: "test@example.com", plan: "Paid")
+ #expect(snap.activeSessions == 3)
+ #expect(snap.isAuthenticated == true)
+ #expect(snap.accountEmail == "test@example.com")
+ #expect(snap.accountPlan == "Paid")
+
+ let usage = snap.toUsageSnapshot()
+ let primary = try #require(usage.primary)
+ #expect(primary.usedPercent == 3.0)
+ #expect(primary.remainingPercent == 97.0)
+ #expect(primary.resetDescription == "3/100 (24h rolling)")
+
+ let identity = try #require(usage.identity)
+ #expect(identity.accountEmail == "test@example.com")
+ #expect(identity.loginMethod == "Paid")
+ }
+
+ @Test
+ func handlesNoSessionsFound() throws {
+ let output = "No sessions found"
+ let snap = try JulesStatusProbe.parse(text: output)
+ #expect(snap.activeSessions == 0)
+ #expect(snap.isAuthenticated == true)
+
+ let usage = snap.toUsageSnapshot()
+ let primary = try #require(usage.primary)
+ #expect(primary.usedPercent == 0.0)
+ #expect(primary.remainingPercent == 100.0)
+ }
+
+ @Test
+ func throwsNotLoggedInOnAuthError() {
+ let output = "Error: failed to list tasks: Trying to make a GET request without a valid client (did you forget to login?)"
+ #expect(throws: JulesStatusProbeError.notLoggedIn) {
+ try JulesStatusProbe.parse(text: output)
+ }
+ }
+
+ @Test
+ func handlesEmptyOutputAsZeroSessions() throws {
+ let snap = try JulesStatusProbe.parse(text: "")
+ #expect(snap.activeSessions == 0)
+ #expect(snap.isAuthenticated == true)
+ }
+
+ @Test
+ func preservesRawText() throws {
+ let output = """
+ session-1
+ session-2
+ """
+ let snap = try JulesStatusProbe.parse(text: output)
+ #expect(snap.rawText == output)
+ }
+}
diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
index 7ecbe7b26..b68a3381c 100644
--- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift
+++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift
@@ -21,6 +21,7 @@ struct ProviderIconResourcesTests {
"antigravity",
"factory",
"copilot",
+ "jules",
]
for slug in slugs {
let url = resources.appending(path: "ProviderIcon-\(slug).svg")
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 96d5145c4..20c84efc2 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -683,6 +683,7 @@ struct SettingsStoreTests {
.synthetic,
.warp,
.openrouter,
+ .jules,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/docs/antigravity.md b/docs/antigravity.md
index ab99af30f..b9fe45e67 100644
--- a/docs/antigravity.md
+++ b/docs/antigravity.md
@@ -63,7 +63,7 @@ Antigravity is a local-only provider. We talk directly to the Antigravity langua
- Provider metadata:
- Display: `Antigravity`
- Labels: `Claude` (primary), `Gemini Pro` (secondary), `Gemini Flash` (tertiary)
-- Status badge: Google Workspace incidents for the Gemini product.
+- Status badge: Google Workspace incidents for the Gemini/Jules product.
## Constraints
- Internal protocol; fields may change.
diff --git a/docs/cli.md b/docs/cli.md
index d4b6d1386..bc0a18c87 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -54,7 +54,7 @@ See `docs/configuration.md` for the schema.
- `web` (macOS only): web-only; no CLI fallback.
- `cli`: CLI-only (Codex RPC → PTY fallback; Claude PTY).
- `oauth`: Claude OAuth only (debug); no fallback. Not supported for Codex.
- - `api`: API key flow when the provider supports it (z.ai, Gemini, Copilot, Kimi K2, MiniMax, Synthetic).
+ - `api`: API key flow when the provider supports it (z.ai, Gemini, Jules, Copilot, Kimi K2, MiniMax, Synthetic).
- Output `source` reflects the strategy actually used (`openai-web`, `web`, `oauth`, `api`, `local`, or provider CLI label).
- Codex web: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown).
- `--web-timeout Codex, Claude Code, Cursor, Gemini, Antigravity, Droid, Copilot, z.ai limits.
+Codex, Claude Code, Cursor, Gemini, Jules, Antigravity, Droid, Copilot, z.ai limits.