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
13 changes: 13 additions & 0 deletions Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ struct CodexUIErrorMapper {
return cachedMessage
}

if self.looksCodexCLIMissing(lower: lower) {
return CodexStatusProbeError.codexNotInstalled.localizedDescription
}

if self.looksExpired(lower: lower) {
return "Codex session expired. Sign in again."
}
Expand Down Expand Up @@ -76,6 +80,15 @@ struct CodexUIErrorMapper {
|| lower.contains("codex usage is temporarily unavailable. try refreshing.")
}

private static func looksCodexCLIMissing(lower: String) -> Bool {
lower.contains("codex cli missing")
|| lower.contains("codex cli not found")
|| lower.contains("missing cli codex")
|| lower.contains("missing cli 'codex'")
|| lower.contains("missing cli \"codex\"")
|| (lower.contains("binary not found") && lower.contains("codex"))
}

private static func looksExpired(lower: String) -> Bool {
lower.contains("token_expired")
|| lower.contains("authentication token is expired")
Expand Down
28 changes: 20 additions & 8 deletions Sources/CodexBarCore/UsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,13 @@ enum RPCWireError: Error, LocalizedError {
}
}

typealias CodexExecutableResolver = @Sendable (_ environment: [String: String], _ executable: String) -> String?

let defaultCodexExecutableResolver: CodexExecutableResolver = { environment, executable in
BinaryLocator.resolveCodexBinary(env: environment)
?? TTYCommandRunner.which(executable)
}

/// RPC helper used on background tasks; safe because we confine it to the owning task.
private final class CodexRPCClient: @unchecked Sendable {
private static let log = CodexBarLog.logger(LogCategories.codexRPC)
Expand Down Expand Up @@ -576,7 +583,8 @@ private final class CodexRPCClient: @unchecked Sendable {
arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"],
environment: [String: String] = ProcessInfo.processInfo.environment,
initializeTimeoutSeconds: TimeInterval = 8.0,
requestTimeoutSeconds: TimeInterval = 3.0) throws
requestTimeoutSeconds: TimeInterval = 3.0,
resolveExecutable: CodexExecutableResolver = defaultCodexExecutableResolver) throws
{
self.initializeTimeoutSeconds = initializeTimeoutSeconds
self.requestTimeoutSeconds = requestTimeoutSeconds
Expand All @@ -586,13 +594,11 @@ private final class CodexRPCClient: @unchecked Sendable {
}
self.stdoutLineContinuation = stdoutContinuation

let resolvedExec = BinaryLocator.resolveCodexBinary(env: environment)
?? TTYCommandRunner.which(executable)
let resolvedExec = resolveExecutable(environment, executable)

guard let resolvedExec else {
Self.log.warning("Codex RPC binary not found", metadata: ["binary": executable])
throw RPCWireError.startFailed(
"Codex CLI not found. Install with `npm i -g @openai/codex` (or bun) then relaunch CodexBar.")
throw CodexStatusProbeError.codexNotInstalled
}
var env = environment
env["PATH"] = PathBuilder.effectivePATH(
Expand Down Expand Up @@ -805,22 +811,26 @@ public struct UsageFetcher: Sendable {
private let environment: [String: String]
private let initializeTimeoutSeconds: TimeInterval
private let requestTimeoutSeconds: TimeInterval
private let codexExecutableResolver: CodexExecutableResolver

public init(environment: [String: String] = ProcessInfo.processInfo.environment) {
self.environment = environment
self.initializeTimeoutSeconds = 8.0
self.requestTimeoutSeconds = 3.0
self.codexExecutableResolver = defaultCodexExecutableResolver
LoginShellPathCache.shared.captureOnce()
}

init(
environment: [String: String],
initializeTimeoutSeconds: TimeInterval,
requestTimeoutSeconds: TimeInterval)
requestTimeoutSeconds: TimeInterval,
codexExecutableResolver: @escaping CodexExecutableResolver = defaultCodexExecutableResolver)
{
self.environment = environment
self.initializeTimeoutSeconds = initializeTimeoutSeconds
self.requestTimeoutSeconds = requestTimeoutSeconds
self.codexExecutableResolver = codexExecutableResolver
LoginShellPathCache.shared.captureOnce()
}

Expand All @@ -836,7 +846,8 @@ public struct UsageFetcher: Sendable {
let rpc = try CodexRPCClient(
environment: self.environment,
initializeTimeoutSeconds: self.initializeTimeoutSeconds,
requestTimeoutSeconds: self.requestTimeoutSeconds)
requestTimeoutSeconds: self.requestTimeoutSeconds,
resolveExecutable: self.codexExecutableResolver)
defer { rpc.shutdown() }
do {
try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
Expand Down Expand Up @@ -894,7 +905,8 @@ public struct UsageFetcher: Sendable {
let rpc = try CodexRPCClient(
environment: self.environment,
initializeTimeoutSeconds: self.initializeTimeoutSeconds,
requestTimeoutSeconds: self.requestTimeoutSeconds)
requestTimeoutSeconds: self.requestTimeoutSeconds,
resolveExecutable: self.codexExecutableResolver)
defer { rpc.shutdown() }
try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4")
let limits = try await rpc.fetchRateLimits()
Expand Down
8 changes: 8 additions & 0 deletions Tests/CodexBarTests/CLIEntryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ final class CLIEntryTests: XCTestCase {
XCTAssertEqual(CodexBarCLI.mapError(UsageError.noRateLimitsFound), ExitCode(3))
}

func test_missingCodexBinaryErrorPayloadUsesInstallGuidance() {
let payload = CodexBarCLI.makeErrorPayload(CodexStatusProbeError.codexNotInstalled, kind: .provider)

XCTAssertEqual(payload.code, ExitCode.binaryNotFound.rawValue)
XCTAssertTrue(payload.message.contains("Codex CLI missing"))
XCTAssertFalse(payload.message.contains("Codex not running"))
}

func test_providerSelectionFallsBackToBothForPrimaryPair() {
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [.codex, .claude])
switch selection {
Expand Down
20 changes: 20 additions & 0 deletions Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import Testing

@Suite(.serialized)
struct CodexUsageFetcherFallbackTests {
@Test
func `missing CLI binary reports install guidance instead of not running`() async throws {
let fetcher = UsageFetcher(
environment: [:],
initializeTimeoutSeconds: 0.1,
requestTimeoutSeconds: 0.1,
codexExecutableResolver: { _, _ in nil })

do {
_ = try await fetcher.loadLatestCLIAccountSnapshot()
Issue.record("Expected missing Codex CLI to throw")
} catch CodexStatusProbeError.codexNotInstalled {
let message = CodexStatusProbeError.codexNotInstalled.localizedDescription
#expect(message.contains("Codex CLI missing"))
#expect(!message.contains("Codex not running"))
} catch {
Issue.record("Expected CodexStatusProbeError.codexNotInstalled, got \(type(of: error)): \(error)")
}
}

@Test
func `CLI usage recovers from RPC decode mismatch body payload`() {
let snapshot = UsageFetcher._recoverCodexRPCUsageFromErrorForTesting(
Expand Down
21 changes: 21 additions & 0 deletions Tests/CodexBarTests/CodexUserFacingErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import Testing

@MainActor
struct CodexUserFacingErrorTests {
@Test
func `missing codex CLI guidance is not collapsed to not running`() {
let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-missing-cli")
store.errors[.codex] = "Codex not running. Try running a Codex command first. "
+ "(Codex CLI not found. Install with `npm i -g @openai/codex`.)"

#expect(store.userFacingError(for: .codex) == CodexStatusProbeError.codexNotInstalled.localizedDescription)
}

@Test
func `expired codex auth is sanitized`() {
let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-expired-auth")
Expand Down Expand Up @@ -51,6 +60,18 @@ struct CodexUserFacingErrorTests {
"Codex usage is temporarily unavailable. Try refreshing. Cached values from 2m ago.")
}

@Test
func `cached missing codex CLI failure preserves cached suffix`() {
let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-cached-missing-cli")
store.lastCreditsError =
"Last Codex credits refresh failed: Codex CLI not found. "
+ "Install with `npm i -g @openai/codex`. Cached values from 2m ago."

#expect(
store.userFacingLastCreditsError ==
CodexStatusProbeError.codexNotInstalled.localizedDescription + " Cached values from 2m ago.")
}

@Test
func `browser mismatch remains unchanged`() {
let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-browser-mismatch")
Expand Down