Skip to content

Commit 4a83b87

Browse files
authored
Fix Claude CLI fallback availability
1 parent 98c7196 commit 4a83b87

7 files changed

Lines changed: 244 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixed
66
- Settings: show CodexBar in the Dock while Settings or an update dialog is open, so Check for Updates and new-version prompts reliably appear in front.
7+
- Claude CLI: let explicit CLI usage and Auto fallback delegate authentication to the installed Claude executable, so an unavailable browser session no longer masks usable reduced-fidelity CLI usage.
78

89
## 0.49.0 — 2026-08-09
910

Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import Foundation
22

33
enum ClaudeCLIAuthStatusProbe {
4+
enum AuthenticationStatus: Equatable {
5+
case loggedIn
6+
case loggedOut
7+
case unavailable
8+
}
9+
410
private struct Response: Decodable {
511
let loggedIn: Bool
612
}
@@ -31,9 +37,22 @@ enum ClaudeCLIAuthStatusProbe {
3137
environment: [String: String],
3238
workingDirectory: URL? = nil,
3339
timeout: TimeInterval = 5) async -> Bool
40+
{
41+
await self.authenticationStatus(
42+
binary: binary,
43+
environment: environment,
44+
workingDirectory: workingDirectory,
45+
timeout: timeout) == .loggedIn
46+
}
47+
48+
static func authenticationStatus(
49+
binary: String,
50+
environment: [String: String],
51+
workingDirectory: URL? = nil,
52+
timeout: TimeInterval = 5) async -> AuthenticationStatus
3453
{
3554
if let resultOverrideForTesting = self.resultOverrideForTesting {
36-
return resultOverrideForTesting
55+
return resultOverrideForTesting ? .loggedIn : .loggedOut
3756
}
3857
do {
3958
let workingDirectory = workingDirectory ?? ClaudeStatusProbe.preparedProbeWorkingDirectoryURL()
@@ -46,19 +65,24 @@ enum ClaudeCLIAuthStatusProbe {
4665
timeout: self.timeoutOverrideForTesting ?? timeout,
4766
standardInput: FileHandle.nullDevice,
4867
currentDirectoryURL: workingDirectory,
68+
acceptsNonZeroExit: true,
4969
label: "claude-auth-status")
50-
return self.parseLoggedIn(result.stdout)
70+
return self.parseStatus(result.stdout) ?? .unavailable
5171
} catch {
52-
return false
72+
return .unavailable
5373
}
5474
}
5575

5676
static func parseLoggedIn(_ output: String) -> Bool {
77+
self.parseStatus(output) == .loggedIn
78+
}
79+
80+
static func parseStatus(_ output: String) -> AuthenticationStatus? {
5781
guard let data = output.data(using: .utf8),
5882
let response = try? JSONDecoder().decode(Response.self, from: data)
5983
else {
60-
return false
84+
return nil
6185
}
62-
return response.loggedIn
86+
return response.loggedIn ? .loggedIn : .loggedOut
6387
}
6488
}

Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -966,11 +966,18 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
966966
let hasWebFallback: Bool
967967

968968
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
969-
// Claude's "auth status" command is an opaque child process that may invoke /usr/bin/security itself.
970-
// CodexBar cannot impose its no-UI policy on that child, so background Auto refresh must not launch it
971-
// unless the user explicitly opted into Keychain access for background work.
972-
let isBackgroundAppRefresh = context.runtime == .app
973-
&& ProviderInteractionContext.current == .background
969+
guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false }
970+
971+
if context.runtime == .cli {
972+
// A CodexBarCLI invocation is already an explicit user action. Preserve the definitive logged-out guard,
973+
// but do not let an unavailable credential-reading `auth status` child override the owner CLI's ability
974+
// to provide usage. The app keeps the stricter marker policy below for prompt-free scheduled refreshes.
975+
return await ClaudeCLIAuthStatusProbe.authenticationStatus(
976+
binary: binary,
977+
environment: context.env) != .loggedOut
978+
}
979+
980+
let isBackgroundAppRefresh = ProviderInteractionContext.current == .background
974981
// Explicit OAuth may recover through the interactive owner CLI only from a user action. A scheduled
975982
// refresh with missing credentials must remain on the selected OAuth authority and fail without UI.
976983
if isBackgroundAppRefresh, context.sourceMode == .oauth {
@@ -983,18 +990,13 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy {
983990
// `claude auth status`. Background Auto therefore reuses only availability established by a
984991
// successful user-initiated CLI fetch in this process. The narrow exception is the owner usage
985992
// fetch when Keychain access is explicitly disabled; version/auth children retain the global gate.
986-
guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false }
987993
return ClaudeCLIBackgroundAvailability.allowsBackgroundAutoUsageFetch(
988994
binary: binary,
989995
environment: context.env)
990996
}
991997

992-
// The interactive Claude REPL can open browser OAuth when it starts logged out. CLI-runtime paths
993-
// establish authentication through the noninteractive status command first. App user
994-
// actions intentionally launch the interactive path directly so the user can complete authentication.
995-
guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false }
996-
guard context.runtime == .cli else { return true }
997-
return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env)
998+
// App user actions intentionally launch the interactive path directly so the user can complete authentication.
999+
return true
9981000
}
9991001

10001002
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {

Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,137 @@ struct ClaudeBaselineCharacterizationTests {
209209
#expect(strategyIDs == ["claude.web", "claude.cli"])
210210
}
211211

212+
@Test
213+
func `CLI explicit source delegates authentication to the configured Claude executable`() async throws {
214+
let invocationLog = FileManager.default.temporaryDirectory
215+
.appendingPathComponent("claude-invocations-\(UUID().uuidString).log")
216+
let stubCLIPath = try self.makeStubClaudeCLI(
217+
authStatusScript: "printf '%s\\n' 'not-json'",
218+
invocationLog: invocationLog)
219+
defer {
220+
try? FileManager.default.removeItem(atPath: stubCLIPath)
221+
try? FileManager.default.removeItem(at: invocationLog)
222+
}
223+
let settings = ProviderSettingsSnapshot.make(claude: .init(
224+
usageDataSource: .cli,
225+
webExtrasEnabled: false,
226+
cookieSource: .off,
227+
manualCookieHeader: nil))
228+
let env = ["CLAUDE_CLI_PATH": stubCLIPath]
229+
let fetchOverride: ClaudeStatusProbe.FetchOverride = { binary, _, _ in
230+
#expect(binary == stubCLIPath)
231+
return Self.makeUsageStatusSnapshot()
232+
}
233+
234+
let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) {
235+
await self.fetchOutcome(runtime: .cli, sourceMode: .cli, env: env, settings: settings)
236+
}
237+
let result = try outcome.result.get()
238+
239+
#expect(outcome.attempts.map(\.strategyID) == ["claude.cli"])
240+
#expect(outcome.attempts.map(\.wasAvailable) == [true])
241+
#expect(result.strategyID == "claude.cli")
242+
#expect(result.usage.dataConfidence == .percentOnly)
243+
#expect(try String(contentsOf: invocationLog, encoding: .utf8) == "auth status --json\n")
244+
}
245+
246+
@Test
247+
func `CLI auto reaches Claude executable after web credentials are unavailable`() async throws {
248+
let invocationLog = FileManager.default.temporaryDirectory
249+
.appendingPathComponent("claude-invocations-\(UUID().uuidString).log")
250+
let stubCLIPath = try self.makeStubClaudeCLI(
251+
authStatusScript: "printf '%s\\n' 'not-json'",
252+
invocationLog: invocationLog)
253+
defer {
254+
try? FileManager.default.removeItem(atPath: stubCLIPath)
255+
try? FileManager.default.removeItem(at: invocationLog)
256+
}
257+
let settings = ProviderSettingsSnapshot.make(claude: .init(
258+
usageDataSource: .auto,
259+
webExtrasEnabled: false,
260+
cookieSource: .auto,
261+
manualCookieHeader: nil))
262+
let env = ["CLAUDE_CLI_PATH": stubCLIPath]
263+
let webLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in
264+
throw ClaudeWebAPIFetcher.FetchError.noSessionKeyFound
265+
}
266+
let fetchOverride: ClaudeStatusProbe.FetchOverride = { binary, _, _ in
267+
#expect(binary == stubCLIPath)
268+
return Self.makeUsageStatusSnapshot()
269+
}
270+
271+
let outcome = await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(webLoader) {
272+
await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) {
273+
await self.fetchOutcome(runtime: .cli, sourceMode: .auto, env: env, settings: settings)
274+
}
275+
}
276+
let result = try outcome.result.get()
277+
278+
#expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"])
279+
#expect(outcome.attempts.map(\.wasAvailable) == [true, true])
280+
#expect(result.strategyID == "claude.cli")
281+
#expect(result.usage.dataConfidence == .percentOnly)
282+
#expect(try String(contentsOf: invocationLog, encoding: .utf8) == "auth status --json\n")
283+
}
284+
285+
@Test(arguments: [
286+
"/definitely/missing/claude",
287+
"/etc/hosts",
288+
])
289+
func `CLI source rejects missing and non executable Claude paths`(path: String) async {
290+
let settings = ProviderSettingsSnapshot.make(claude: .init(
291+
usageDataSource: .cli,
292+
webExtrasEnabled: false,
293+
cookieSource: .off,
294+
manualCookieHeader: nil))
295+
let outcome = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(path) {
296+
await self.fetchOutcome(
297+
runtime: .cli,
298+
sourceMode: .cli,
299+
env: ["CLAUDE_CLI_PATH": path],
300+
settings: settings)
301+
}
302+
303+
#expect(outcome.attempts.map(\.strategyID) == ["claude.cli"])
304+
#expect(outcome.attempts.map(\.wasAvailable) == [false])
305+
switch outcome.result {
306+
case let .failure(error as ProviderFetchError):
307+
guard case .noAvailableStrategy(.claude) = error else {
308+
Issue.record("Unexpected provider fetch error: \(error)")
309+
return
310+
}
311+
case let .failure(error):
312+
Issue.record("Unexpected error: \(error)")
313+
case let .success(result):
314+
Issue.record("Unavailable Claude executable unexpectedly produced \(result.strategyID)")
315+
}
316+
}
317+
318+
@Test
319+
func `CLI source keeps definitive logged out guard`() async throws {
320+
let invocationLog = FileManager.default.temporaryDirectory
321+
.appendingPathComponent("claude-invocations-\(UUID().uuidString).log")
322+
let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog)
323+
defer {
324+
try? FileManager.default.removeItem(atPath: stubCLIPath)
325+
try? FileManager.default.removeItem(at: invocationLog)
326+
}
327+
let settings = ProviderSettingsSnapshot.make(claude: .init(
328+
usageDataSource: .cli,
329+
webExtrasEnabled: false,
330+
cookieSource: .off,
331+
manualCookieHeader: nil))
332+
let outcome = await self.fetchOutcome(
333+
runtime: .cli,
334+
sourceMode: .cli,
335+
env: ["CLAUDE_CLI_PATH": stubCLIPath],
336+
settings: settings)
337+
338+
#expect(outcome.attempts.map(\.strategyID) == ["claude.cli"])
339+
#expect(outcome.attempts.map(\.wasAvailable) == [false])
340+
#expect(try String(contentsOf: invocationLog, encoding: .utf8) == "auth status --json\n")
341+
}
342+
212343
@Test
213344
func `app explicit CLI remains available for interactive authentication without preflight`() async {
214345
let settings = ProviderSettingsSnapshot.make(claude: .init(
@@ -699,4 +830,18 @@ struct ClaudeBaselineCharacterizationTests {
699830
#expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("sessionKey=sk-ant-session"))
700831
#expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("Cookie: sessionKey=sk-ant-session; foo=bar"))
701832
}
833+
834+
private static func makeUsageStatusSnapshot() -> ClaudeStatusSnapshot {
835+
ClaudeStatusSnapshot(
836+
sessionPercentLeft: 88,
837+
weeklyPercentLeft: 60,
838+
opusPercentLeft: 95,
839+
accountEmail: "user@example.com",
840+
accountOrganization: "Example Org",
841+
loginMethod: nil,
842+
primaryResetDescription: "Resets 11am",
843+
secondaryResetDescription: "Resets Nov 21",
844+
opusResetDescription: "Resets Nov 21",
845+
rawText: "stub")
846+
}
702847
}

Tests/CodexBarTests/ClaudeCLIAuthStatusProbeTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ struct ClaudeCLIAuthStatusProbeTests {
1313
#expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"loggedIn":false,"authMethod":"none"}"#))
1414
#expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn("not-json"))
1515
#expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"authMethod":"none"}"#))
16+
#expect(ClaudeCLIAuthStatusProbe.parseStatus(#"{"loggedIn":false}"#) == .loggedOut)
17+
#expect(ClaudeCLIAuthStatusProbe.parseStatus("not-json") == nil)
1618
}
1719

1820
@Test
@@ -48,4 +50,22 @@ struct ClaudeCLIAuthStatusProbeTests {
4850
#expect(loggedIn)
4951
#expect(try String(contentsOf: invocationLog, encoding: .utf8) == "\(workingDirectory.path)|yes\n")
5052
}
53+
54+
@Test
55+
func `nonzero logged out status remains definitive`() async throws {
56+
let root = FileManager.default.temporaryDirectory
57+
.appendingPathComponent("ClaudeCLIAuthStatusProbe-\(UUID().uuidString)", isDirectory: true)
58+
let binary = root.appendingPathComponent("claude")
59+
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
60+
defer { try? FileManager.default.removeItem(at: root) }
61+
try Data("#!/bin/sh\nprintf '%s\\n' '{\"loggedIn\":false}'\nexit 1\n".utf8).write(to: binary)
62+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
63+
64+
let status = await ClaudeCLIAuthStatusProbe.authenticationStatus(
65+
binary: binary.path,
66+
environment: [:],
67+
workingDirectory: root)
68+
69+
#expect(status == .loggedOut)
70+
}
5171
}

TestsLinux/PlatformGatingTests.swift

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,35 @@ struct PlatformGatingTests {
134134
#endif
135135
}
136136

137+
@Test
138+
func `Claude CLI runtime delegates unavailable auth status to owner executable`() async throws {
139+
let invocationLog = FileManager.default.temporaryDirectory
140+
.appendingPathComponent("claude-cli-runtime-invocations-\(UUID().uuidString).log")
141+
let binaryURL = try Self.makeClaudeCLI(loggedIn: nil, invocationLog: invocationLog)
142+
defer {
143+
try? FileManager.default.removeItem(at: binaryURL)
144+
try? FileManager.default.removeItem(at: invocationLog)
145+
}
146+
let context = self.makeClaudeContext(
147+
sourceMode: .cli,
148+
env: ["CLAUDE_CLI_PATH": binaryURL.path])
149+
let cliFetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in
150+
Self.makeClaudeStatus()
151+
}
152+
153+
let outcome = await ClaudeStatusProbe.withFetchOverrideForTesting(cliFetchOverride) {
154+
await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome(
155+
context: context,
156+
provider: .claude)
157+
}
158+
let result = try outcome.result.get()
159+
160+
#expect(result.strategyID == "claude.cli")
161+
#expect(outcome.attempts.map(\.strategyID) == ["claude.cli"])
162+
#expect(outcome.attempts.map(\.wasAvailable) == [true])
163+
#expect(try String(contentsOf: invocationLog, encoding: .utf8) == "auth status --json\n")
164+
}
165+
137166
@Test
138167
func claudeOAuthUsageDoesNotDetectCLIVersion() {
139168
#expect(!CodexBarCLI.shouldDetectVersion(
@@ -215,19 +244,19 @@ struct PlatformGatingTests {
215244
browserDetection: browserDetection)
216245
}
217246

218-
private static func makeClaudeCLI(loggedIn: Bool, invocationLog: URL? = nil) throws -> URL {
247+
private static func makeClaudeCLI(loggedIn: Bool?, invocationLog: URL? = nil) throws -> URL {
219248
if let invocationLog {
220249
try Data().write(to: invocationLog)
221250
}
222251
let binaryURL = FileManager.default.temporaryDirectory
223252
.appendingPathComponent("claude-cli-runtime-\(UUID().uuidString)")
224253
let recordInvocation = invocationLog.map { "printf '%s\\n' \"$*\" >> '\($0.path)'" } ?? ""
225-
let loggedInJSON = loggedIn ? "true" : "false"
254+
let authStatusJSON = loggedIn.map { #"{"loggedIn":\#($0)}"# } ?? "not-json"
226255
let script = """
227256
#!/bin/sh
228257
\(recordInvocation)
229258
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
230-
printf '%s\\n' '{"loggedIn":\(loggedInJSON)}'
259+
printf '%s\\n' '\(authStatusJSON)'
231260
fi
232261
"""
233262
try FakeExecutable.install(script, at: binaryURL)

docs/cli.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ See `docs/configuration.md` for the schema.
139139
- `--web-debug-dump-html` (writes HTML snapshots to `/tmp` when data is missing)
140140
- Claude web: claude.ai API (session + weekly usage, account metadata, and prepaid Usage credits balance when
141141
available).
142+
CLI Auto falls back to the installed Claude executable when web credentials are unavailable. This foreground
143+
command delegates authentication to Claude Code; the app keeps its stricter prompt-free background availability
144+
gate for scheduled refreshes.
142145
- Command Code web: commandcode.ai browser session cookies on macOS, or a configured manual cookie on Linux, for monthly credit usage.
143146
- OpenCode Go auto: local SQLite usage on macOS and Linux, with optional manual-cookie web enrichment.
144147
- Kilo auto: app.kilo.ai API first, then CLI auth fallback (`~/.local/share/kilo/auth.json`) on missing/unauthorized API credentials.

0 commit comments

Comments
 (0)