Skip to content
Open
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
20 changes: 19 additions & 1 deletion AgentBar/Services/ClaudeUsageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,21 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable {

static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!

private static let credentialsFileRelativePath = ".claude/.credentials.json"
private static let keychainService = "Claude Code-credentials"
private static let tokenCacheLock = NSLock()
private static let defaultCacheTTL: TimeInterval = 60
private static let securityCLITimeout: TimeInterval = 2
nonisolated(unsafe) private static var cachedToken: String?
nonisolated(unsafe) private static var tokenLastLookupAt: Date?
typealias SecurityCLIProcessRuntime = CLIProcessRuntime
nonisolated(unsafe) static var credentialsFileURLProvider: @Sendable () -> URL = {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(credentialsFileRelativePath)
}
nonisolated(unsafe) static var credentialsFileReader: @Sendable (_ url: URL) -> String? = { url in
try? String(contentsOf: url, encoding: .utf8)
}
nonisolated(unsafe) static var securityCLIRunner: @Sendable (_ timeout: TimeInterval) -> String? = { timeout in
runSecurityCLICommand(timeout: timeout)
}
Expand All @@ -90,7 +98,7 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable {
defaults: UserDefaults = .standard
) {
self.session = session
self.credentialProvider = credentialProvider ?? { Self.readKeychainTokenViaCLI() }
self.credentialProvider = credentialProvider ?? { Self.readAccessToken() }
self.defaults = defaults
}

Expand Down Expand Up @@ -260,6 +268,16 @@ final class ClaudeUsageProvider: UsageProviderProtocol, @unchecked Sendable {

// MARK: - Keychain Access via security CLI

static func readAccessToken() -> String? {
readTokenFromCredentialsFile() ?? readKeychainTokenViaCLI()
}

static func readTokenFromCredentialsFile() -> String? {
let url = credentialsFileURLProvider()
guard let rawJSON = credentialsFileReader(url) else { return nil }
return parseAccessToken(from: rawJSON)
}

/// Reads the Claude Code OAuth token using the `security` CLI to avoid
/// per-app Keychain ACL prompts. The result is cached with a TTL matching
/// the app's refresh interval.
Expand Down
60 changes: 60 additions & 0 deletions AgentBarTests/ClaudeUsageProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import XCTest
@testable import AgentBar

final class ClaudeUsageProviderTests: XCTestCase {
private var originalCredentialsFileURLProvider: (@Sendable () -> URL)!
private var originalCredentialsFileReader: (@Sendable (URL) -> String?)!
private var originalSecurityCLIRunner: (@Sendable (TimeInterval) -> String?)!

override func setUp() {
super.setUp()
MockURLProtocol.reset()
ClaudeUsageProvider.resetTokenCache()
originalCredentialsFileURLProvider = ClaudeUsageProvider.credentialsFileURLProvider
originalCredentialsFileReader = ClaudeUsageProvider.credentialsFileReader
originalSecurityCLIRunner = ClaudeUsageProvider.securityCLIRunner
}

override func tearDown() {
MockURLProtocol.reset()
ClaudeUsageProvider.resetTokenCache()
ClaudeUsageProvider.credentialsFileURLProvider = originalCredentialsFileURLProvider
ClaudeUsageProvider.credentialsFileReader = originalCredentialsFileReader
ClaudeUsageProvider.securityCLIRunner = originalSecurityCLIRunner
super.tearDown()
}
Expand Down Expand Up @@ -421,6 +427,60 @@ final class ClaudeUsageProviderTests: XCTestCase {
XCTAssertEqual(counter.value, 1)
}

func testReadAccessTokenPrefersCredentialsFile() {
ClaudeUsageProvider.credentialsFileURLProvider = {
URL(fileURLWithPath: "/tmp/test-claude-credentials.json")
}
ClaudeUsageProvider.credentialsFileReader = { _ in
"""
{"claudeAiOauth":{"accessToken":"file-token"}}
"""
}
ClaudeUsageProvider.securityCLIRunner = { _ in
XCTFail("Should not read keychain when file credentials are present")
return nil
}

XCTAssertEqual(ClaudeUsageProvider.readAccessToken(), "file-token")
}

func testReadAccessTokenFallsBackToKeychainWhenFileMissing() {
ClaudeUsageProvider.credentialsFileReader = { _ in nil }
ClaudeUsageProvider.securityCLIRunner = { _ in
"""
{"claudeAiOauth":{"accessToken":"keychain-token"}}
"""
}

XCTAssertEqual(ClaudeUsageProvider.readAccessToken(), "keychain-token")
}

func testReadAccessTokenFallsBackToKeychainWhenFileIsMalformed() {
ClaudeUsageProvider.credentialsFileReader = { _ in "not json" }
ClaudeUsageProvider.securityCLIRunner = { _ in
"""
{"claudeAiOauth":{"accessToken":"keychain-token"}}
"""
}

XCTAssertEqual(ClaudeUsageProvider.readAccessToken(), "keychain-token")
}

func testDefaultCredentialProviderUsesFileCredentials() async {
let defaults = makeDefaultsSuite()
ClaudeUsageProvider.credentialsFileReader = { _ in
"""
{"claudeAiOauth":{"accessToken":"file-token"}}
"""
}
ClaudeUsageProvider.securityCLIRunner = { _ in nil }

let provider = ClaudeUsageProvider(defaults: defaults)

let isConfigured = await provider.isConfigured()
XCTAssertTrue(isConfigured)
}

func testExecuteSecurityCLICommandTimeoutForceKillsIfStillRunning() {
var terminateCallCount = 0
var forceTerminateCallCount = 0
Expand Down