Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 7 additions & 4 deletions Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
10 changes: 10 additions & 0 deletions Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions Tests/CodexBarTests/ClaudeSourcePlannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
79 changes: 79 additions & 0 deletions TestsLinux/ClaudeCLIAutoOAuthLinuxTests.swift
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions TestsLinux/ClaudeWebManualCookieLinuxTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 5 additions & 5 deletions TestsLinux/PlatformGatingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion docs/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/refactor/claude-current-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down