diff --git a/README.md b/README.md index 40dd56d2a..68fc010c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Jules, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -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 ` (default: 60) @@ -102,6 +102,7 @@ codexbar --provider claude --account steipete@gmail.com codexbar --provider claude --all-accounts --format json --pretty codexbar --json-only --format json --pretty codexbar --provider gemini --source api --format json --pretty +codexbar --provider jules --source cli --format json --pretty codexbar config validate --format json --pretty codexbar config dump --pretty ``` diff --git a/docs/configuration.md b/docs/configuration.md index 467207988..245046b6a 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`, `jules`, `antigravity`, `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/gemini.md b/docs/gemini.md index c3c3f915e..2aadc8289 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -1,23 +1,29 @@ --- -summary: "Gemini provider data sources: OAuth-backed quota APIs, token refresh, and tier detection." +summary: "Gemini and Jules provider data sources: OAuth-backed quota APIs, token refresh, and tier detection." read_when: - - Debugging Gemini quota fetch or auth issues - - Updating Gemini CLI OAuth integration + - Debugging Gemini or Jules quota fetch or auth issues + - Updating Gemini/Jules CLI OAuth integration - Adjusting tier detection or model mapping --- -# Gemini provider +# Gemini and Jules providers -Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser cookies. +Gemini and Jules use the Gemini CLI OAuth credentials and private quota APIs. No browser cookies. -## Data sources + fallback order +## Data sources + fallback order (Gemini) 1) **OAuth-backed quota API** (only path used in `fetch()`) - Reads auth type from `~/.gemini/settings.json`. - Supported: `oauth-personal` (or unknown → try OAuth creds). - Unsupported: `api-key`, `vertex-ai` (hard error). -2) **Legacy CLI parsing** (parser exists but not used in current fetch path) +## Data sources (Jules) + +1) **CLI-based parsing** + - Runs `jules remote list --session` to count active sessions. + - Leverages shared Gemini credentials (`~/.gemini/oauth_creds.json`) for email and tier detection via `loadCodeAssist`. + +## Legacy CLI parsing (Gemini) - `GeminiStatusProbe.parse(text:)` can parse `/stats` output. ## OAuth credentials @@ -72,3 +78,5 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser ## Key files - `Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift` +- `Sources/CodexBarCore/Providers/Jules/JulesStatusProbe.swift` +- `Sources/CodexBar/Providers/Jules/JulesProviderImplementation.swift` diff --git a/docs/index.html b/docs/index.html index 044afa1fd..6f9a1045a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar @@ -14,7 +14,7 @@ @@ -31,7 +31,7 @@

CodexBar

-

Codex, Claude Code, Cursor, Gemini, Antigravity, Droid, Copilot, z.ai limits.

+

Codex, Claude Code, Cursor, Gemini, Jules, Antigravity, Droid, Copilot, z.ai limits.