diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e282c4b93..4d5853ce843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Claude: try readable OAuth credentials before starting the CLI in Linux Auto mode, avoiding unnecessary interactive startup while preserving explicit source selection (#4613, #4615). Thanks @yeseoLee! - Claude: include Desktop Cowork audit transcripts in local token and cost history, using the existing response deduplication and ignoring result totals (#3209). Thanks @vinschger and @Akagilnc! ## 0.77.0 — 2026-10-16 diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 17e8fb75111..75de2ca9e9d 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -96,7 +96,6 @@ extension CodexBarCLI { - Codex: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown). Auto falls back to Codex CLI only when cookies are missing. - Claude: claude.ai API. - Auto falls back to Claude CLI only when cookies are missing. - Kilo: app.kilo.ai API. Auto falls back to Kilo CLI when API credentials are missing or unauthorized. Token accounts are loaded from the resolved CodexBar config file. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index fe99d819e46..a7af9f2d04b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -309,7 +309,9 @@ public enum ClaudeProviderDescriptor { browserDetection: context.browserDetection, hasWebFallback: false)] } - if context.sourceMode == .api || self.hasAutoAdminAPIKey(context: context) { + if context.sourceMode == .api || + (context.sourceMode == .auto && ClaudeAdminAPISettingsReader.apiKey(environment: context.env) != nil) + { return [ClaudeAdminAPIFetchStrategy()] } @@ -343,10 +345,6 @@ public enum ClaudeProviderDescriptor { } } - private static func hasAutoAdminAPIKey(context: ProviderFetchContext) -> Bool { - context.sourceMode == .auto && ClaudeAdminAPISettingsReader.apiKey(environment: context.env) != nil - } - private static func isSelectedOAuthTokenAccount(context: ProviderFetchContext) -> Bool { guard context.selectedTokenAccountID != nil, context.settings?.claude?.usageDataSource == .oauth @@ -369,18 +367,21 @@ public enum ClaudeProviderDescriptor { } private static func makePlanningInput(context: ProviderFetchContext) -> ClaudeSourcePlanningInput { - let webExtrasEnabled = context.settings?.claude?.webExtrasEnabled ?? false - let hasWebSession = Self.hasPlausibleWebSession(context: context) + #if os(Linux) + let shouldAttemptOAuth = context.sourceMode == .auto || + (context.runtime == .app && context.sourceMode == .oauth) + #else let shouldAttemptOAuth = context.runtime == .app && (context.sourceMode == .auto || context.sourceMode == .oauth) + #endif return ClaudeSourcePlanningInput( runtime: context.runtime, selectedDataSource: Self.sourceDataSource(from: context.sourceMode), - webExtrasEnabled: webExtrasEnabled, - hasWebSession: hasWebSession, + webExtrasEnabled: context.settings?.claude?.webExtrasEnabled ?? false, + hasWebSession: Self.hasPlausibleWebSession(context: context), hasCLI: ClaudeCLIResolver.isAvailable(environment: context.env), - // App Auto and explicit OAuth perform one real, noninteractive OAuth attempt. Do not preflight here: + // App Auto, Linux CLI Auto, and explicit OAuth plan a noninteractive attempt. Do not preflight here: // a preflight can mutate cache state and make the real fetch misclassify a typed error. hasOAuthCredentials: shouldAttemptOAuth) } @@ -525,8 +526,11 @@ private struct ClaudePlannedFetchStrategy: ProviderFetchStrategy { return await self.base.isAvailable(context) } guard self.plannedStep.isPlausiblyAvailable else { return false } + // CLI Auto OAuth uses the same noninteractive credential check as explicit sources, so a + // logged-out user still gets `noAvailableStrategy` rather than an OAuth credential error. if self.plannedStep.dataSource == .cli || - (context.runtime == .app && self.plannedStep.dataSource == .web) + (context.runtime == .app && self.plannedStep.dataSource == .web) || + (context.runtime == .cli && self.plannedStep.dataSource == .oauth) { return await self.base.isAvailable(context) } @@ -788,7 +792,11 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { } // In Auto mode, fall back to the next strategy (cli/web) when safe credentials are absent or OAuth fails. // Cancellation itself is always terminal. + #if os(Linux) + return context.sourceMode == .auto + #else return context.runtime == .app && context.sourceMode == .auto + #endif } fileprivate static func snapshot( diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift index ec65a64fba5..cf8204478e7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift @@ -32,6 +32,7 @@ public enum ClaudeSourcePlanReason: String, Equatable, Sendable { case appAutoFallbackCLI = "app-auto-fallback-cli" case appAutoFallbackWeb = "app-auto-fallback-web" case cliAutoPreferredWeb = "cli-auto-preferred-web" + case cliAutoFallbackOAuth = "cli-auto-fallback-oauth" case cliAutoFallbackCLI = "cli-auto-fallback-cli" } @@ -182,10 +183,12 @@ public enum ClaudeSourcePlanner { self.step(.web, reason: .appAutoFallbackWeb, input: input), ] case .cli: - [ - self.step(.web, reason: .cliAutoPreferredWeb, input: input), - self.step(.cli, reason: .cliAutoFallbackCLI, input: input), - ] + // OAuth is only planned where CLI Auto attempts it (Linux); macOS keeps web → cli. + [self.step(.web, reason: .cliAutoPreferredWeb, input: input)] + + (input.hasOAuthCredentials + ? [self.step(.oauth, reason: .cliAutoFallbackOAuth, input: input)] + : []) + + [self.step(.cli, reason: .cliAutoFallbackCLI, input: input)] } case .api: [self.step(.api, reason: .explicitSourceSelection, input: input)] diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift index 063de7ffa5f..579259b346a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift @@ -22,17 +22,6 @@ public enum ClaudeUsageDataSource: String, CaseIterable, Identifiable, Sendable } public var sourceLabel: String { - switch self { - case .auto: - "auto" - case .api: - "api" - case .oauth: - "oauth" - case .web: - "web" - case .cli: - "cli" - } + self.rawValue } } diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 397abc9259a..92f0f2062c5 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -220,6 +220,16 @@ struct ClaudeBaselineCharacterizationTests { #expect(strategyIDs == ["claude.web", "claude.cli"]) } + @Test + func `CLI auto keeps web then CLI on macOS when OAuth credentials are readable`() async { + let env = [ + "CLAUDE_CLI_PATH": "/usr/bin/true", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat01-characterization", + ] + let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto, env: env) + #expect(strategyIDs == ["claude.web", "claude.cli"]) + } + @Test func `CLI explicit source delegates authentication to the configured Claude executable`() async throws { let invocationLog = FileManager.default.temporaryDirectory diff --git a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift index 05b58dd946f..1f6cb200795 100644 --- a/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift +++ b/Tests/CodexBarTests/ClaudeSourcePlannerTests.swift @@ -41,6 +41,25 @@ struct ClaudeSourcePlannerTests { #expect(plan.preferredStep?.dataSource == .web) } + @Test + func `CLI auto plan prefers OAuth over CLI when web session is unavailable`() { + let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( + runtime: .cli, + selectedDataSource: .auto, + webExtrasEnabled: false, + hasWebSession: false, + hasCLI: true, + hasOAuthCredentials: true)) + + #expect(plan.orderedSteps.map(\.inclusionReason) == [ + .cliAutoPreferredWeb, + .cliAutoFallbackOAuth, + .cliAutoFallbackCLI, + ]) + #expect(plan.availableSteps.map(\.dataSource) == [.oauth, .cli]) + #expect(plan.preferredStep?.dataSource == .oauth) + } + @Test func `explicit mode plan is single step`() { let plan = ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput( diff --git a/TestsLinux/ClaudeCLIAutoOAuthLinuxTests.swift b/TestsLinux/ClaudeCLIAutoOAuthLinuxTests.swift new file mode 100644 index 00000000000..0f61995a474 --- /dev/null +++ b/TestsLinux/ClaudeCLIAutoOAuthLinuxTests.swift @@ -0,0 +1,79 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeCLIAutoOAuthLinuxTests { + private func makeContext(sourceMode: ProviderSourceMode) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: ProviderSettingsSnapshot.make(), + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `CLI auto tries OAuth before the Claude CLI PTY fallback off macOS`() async { + // Without browser cookies, CLI Auto used to jump straight to the PTY fallback, which boots a full + // interactive `claude` session (~9s on WSL). The noninteractive OAuth file read must come first. + let context = self.makeContext(sourceMode: .auto) + let ids = await ProviderDescriptorRegistry + .descriptor(for: .claude) + .fetchPlan + .pipeline + .resolveStrategies(context) + .map(\.id) + + let oauthIndex = ids.firstIndex(of: "claude.oauth") + let cliIndex = ids.firstIndex(of: "claude.cli") + #expect(oauthIndex != nil) + #expect(cliIndex != nil) + if let oauthIndex, let cliIndex { + #expect(oauthIndex < cliIndex) + } + } + + @Test + func `CLI auto OAuth failure falls back to the next strategy`() { + let context = self.makeContext(sourceMode: .auto) + #expect(ClaudeOAuthFetchStrategy().shouldFallback( + on: ClaudeOAuthCredentialsError.notFound, + context: context)) + } + + @Test(arguments: [ + ClaudeOAuthFetchError.unauthorized, + ClaudeOAuthFetchError.rateLimited(retryAfter: nil), + ClaudeOAuthFetchError.serverError(503, nil), + ]) + func `CLI auto OAuth request failures fall back to the Claude CLI`(error: ClaudeOAuthFetchError) { + let context = self.makeContext(sourceMode: .auto) + #expect(ClaudeOAuthFetchStrategy().shouldFallback(on: error, context: context)) + } + + @Test + func `CLI auto OAuth cancellation does not fall back to the Claude CLI`() { + let context = self.makeContext(sourceMode: .auto) + #expect(!ClaudeOAuthFetchStrategy().shouldFallback(on: CancellationError(), context: context)) + #expect(!ClaudeOAuthFetchStrategy().shouldFallback( + on: ClaudeOAuthFetchError.networkError(URLError(.cancelled)), + context: context)) + } + + @Test + func `explicit CLI OAuth stays terminal`() { + let context = self.makeContext(sourceMode: .oauth) + #expect(!ClaudeOAuthFetchStrategy().shouldFallback( + on: ClaudeOAuthCredentialsError.notFound, + context: context)) + } +} +#endif diff --git a/TestsLinux/ClaudeWebManualCookieLinuxTests.swift b/TestsLinux/ClaudeWebManualCookieLinuxTests.swift index 045476d2977..7a2ba9776d3 100644 --- a/TestsLinux/ClaudeWebManualCookieLinuxTests.swift +++ b/TestsLinux/ClaudeWebManualCookieLinuxTests.swift @@ -153,8 +153,8 @@ struct ClaudeWebManualCookieLinuxTests { await ProviderDescriptorRegistry.descriptor(for: .claude).fetchOutcome(context: context) } } - #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true, false, true]) #expect(outcome.attempts.first?.errorDescription != nil) let result = try outcome.result.get() #expect(result.strategyID == "claude.cli") diff --git a/TestsLinux/PlatformGatingTests.swift b/TestsLinux/PlatformGatingTests.swift index a0aa7e59c43..68b0e76220d 100644 --- a/TestsLinux/PlatformGatingTests.swift +++ b/TestsLinux/PlatformGatingTests.swift @@ -66,8 +66,8 @@ struct PlatformGatingTests { let result = try outcome.result.get() #expect(result.strategyID == "claude.cli") - #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) #else #expect(Bool(true)) #endif @@ -98,8 +98,8 @@ struct PlatformGatingTests { #expect(provider == .claude) } } - #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false]) + #expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) #else #expect(Bool(true)) #endif @@ -144,7 +144,7 @@ struct PlatformGatingTests { #expect(provider == .claude) } } - let expectedStrategyIDs = sourceMode == .auto ? ["claude.web", "claude.cli"] : ["claude.cli"] + let expectedStrategyIDs = sourceMode == .auto ? ["claude.web", "claude.oauth", "claude.cli"] : ["claude.cli"] #expect(outcome.attempts.map(\.strategyID) == expectedStrategyIDs) #expect(outcome.attempts.allSatisfy { !$0.wasAvailable }) let invocations = try String(contentsOf: invocationLog, encoding: .utf8) diff --git a/docs/claude.md b/docs/claude.md index fdbe9afde31..2b6ccf1fefe 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -37,7 +37,7 @@ Incomplete proxy records with an explicit null `stop_reason`, positive input, ze ### Default selection (debug menu disabled) - If an Admin API key is configured, the Admin API strategy is used for Claude API spend/usage. - App runtime main pipeline: OAuth API → CLI PTY → Web API. -- CLI runtime main pipeline: Web API → CLI PTY. +- CLI runtime main pipeline: Web API → CLI PTY (Linux: Web API → OAuth API → CLI PTY). - Explicit picker modes (OAuth/Web/CLI) bypass automatic fallback. - Explicit OAuth retains the last successful quota measurement through recognized temporary network failures, including localized DNS/offline errors. Its original timestamp remains visible; authentication rejection still @@ -312,6 +312,10 @@ is stored on the token account in the config file and does not change quota fetc Web success returns without launching Claude Code; authentication rejection or a Cloudflare challenge falls back to an available CLI using the existing Auto policy. Cancellation stops without launching the CLI. Explicit Web mode does not fall back. Use explicit OAuth mode for passive polling that must not launch the CLI. +- Linux CLI Auto mode: the plan is Web → OAuth → CLI PTY. OAuth runs when Claude Code OAuth credentials + (`~/.claude/.credentials.json`) are usable without a prompt, so a logged-in user does not launch an interactive + `claude` session. Without usable OAuth credentials, the OAuth step is skipped and CLI remains the fallback. + OAuth request failures such as 401 or 429 also fall back to the CLI; cancellation stops without launching it. - Cookie name required: - `sessionKey` (value prefix `sk-ant-...`). - Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.claude`, source + timestamp). diff --git a/docs/cli.md b/docs/cli.md index e454963a9ad..3c5823d2980 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -205,7 +205,8 @@ sources and setup guide. The [provider ID list](provider-ids.md) is generated fr - `--web-debug-dump-html` (writes HTML snapshots to `/tmp` when data is missing) - Claude web: claude.ai API (session + weekly usage, account metadata, and prepaid Usage credits balance when available). - CLI Auto falls back to the installed Claude executable when web credentials are unavailable. This foreground + CLI Auto falls back to the installed Claude executable when web credentials are unavailable (on Linux, readable + Claude Code OAuth credentials are tried first). This foreground command delegates authentication to Claude Code; the app keeps its stricter prompt-free background availability gate for scheduled refreshes. - Command Code auto: `COMMAND_CODE_API_KEY`/config `apiKey` when set, otherwise commandcode.ai browser session cookies on macOS or a configured manual cookie on Linux. Explicit `api`/`web` pins that source. diff --git a/docs/providers.md b/docs/providers.md index b00261134ee..573ed8d485f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -76,7 +76,7 @@ complete when the available scan window covers fewer days. | Codex | App Auto: OAuth API (`oauth`) → CLI RPC/PTy (`codex-cli`). CLI Auto: Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`). | | OpenAI | Admin API key (`api`) for organization spend/usage; legacy API-key balance fallback. | | Azure OpenAI | API key + endpoint + deployment probe (`api`) for deployment status validation. | -| Claude | Admin API key (`api`) when configured; otherwise App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). | +| Claude | Admin API key (`api`) when configured; otherwise App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`); on Linux, Web API (`web`) → OAuth API (`oauth`) → CLI PTY (`claude`). | | Gemini | Gemini CLI OAuth quotas (`auto`/`api`, default), or opt-in Gemini app 5-hour/weekly limits via cookies (`web`). | | Antigravity | Local LSP/HTTP probe (`local`). | | Cursor | Web API via cookies → legacy stored session → Cursor.app local auth (`web`). | @@ -217,6 +217,8 @@ complete when the available scan window covers fewer days. - Admin API shows organization spend/messages summaries with the same inline dashboard pattern as OpenAI API. - App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). - CLI Auto: Web API (`web`) → CLI PTY (`claude`). +- Linux CLI Auto: Web API (`web`) → OAuth API (`oauth`) → CLI PTY (`claude`). The OAuth step is skipped without + usable noninteractive credentials; OAuth request failures fall back to the CLI. - Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects`, `~/.claude/projects` (including current Claude Desktop Code/Cowork CLI sessions), and nested Claude Desktop local-agent JSONL files for the configured history window. diff --git a/docs/refactor/claude-current-baseline.md b/docs/refactor/claude-current-baseline.md index 80b7d1e0272..d1ac6c4c9d1 100644 --- a/docs/refactor/claude-current-baseline.md +++ b/docs/refactor/claude-current-baseline.md @@ -53,7 +53,7 @@ The generic provider pipeline currently resolves Claude strategies in this order | app | oauth | `oauth` | No fallback. | | app | cli | `cli` | No fallback. | | app | web | `web` | No fallback. | -| cli | auto | `web -> cli` | Web can fall through to CLI. CLI is terminal. | +| cli | auto | `web -> cli` (Linux: `web -> oauth -> cli`) | Web can fall through to OAuth/CLI. Linux always plans OAuth; it is skipped without usable noninteractive credentials, and its request failures fall through to CLI. Cancellation is terminal. CLI is terminal. | | cli | oauth | `oauth` | No fallback. | | cli | cli | `cli` | No fallback. | | cli | web | `web` | No fallback. |