feat: own Claude OAuth sign-in — no Keychain prompts, multi-account#20
Conversation
Replace reading Claude Code's Keychain token (whose ACL is wiped on its every refresh → recurring macOS prompt) with our OWN OAuth: PKCE loopback sign-in mints a token we store in our own Keychain item and refresh in place, so a signed build reads it back with zero prompts and it persists across restarts. Add multiple accounts; each shows live 5H/7D limits. Removes the opt-in Connect toggle. New Core (ClaudeOAuth, ClaudeOAuthLoopback, ClaudeTokenStore, ClaudeTokenProvider); ClaudeReader now maps our tokens to profiles by identity and emits live-only cards for accounts with no local profile. Uses Claude Code's OAuth client; token endpoint needs the CLI's axios UA (it 429s claude-code/*), the usage endpoint needs claude-code/<ver>. Hardening from adversarial review: bounded loopback recv timeout, require+match OAuth state (CSRF), coalesce concurrent refreshes per account, org-UUID last-resort in identity matching, single-profile fallback requires exactly one token, delete stale-keyed item on identity firm-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughClaude live-limit authentication now uses app-managed OAuth tokens stored per account in Keychain. The reader supports multiple token-backed accounts and local-profile matching, while the UI replaces the single Connect toggle with account sign-in and removal flows. ChangesClaude OAuth account redesign
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant AppModel
participant ClaudeTokenProvider
participant Browser
participant ClaudeOAuthLoopback
participant ClaudeOAuth
participant ClaudeTokenStore
participant ClaudeReader
User->>AppModel: Add account
AppModel->>ClaudeTokenProvider: signIn(openURL:)
ClaudeTokenProvider->>ClaudeOAuth: Create PKCE authorization URL
ClaudeTokenProvider->>Browser: Open authorization URL
Browser->>ClaudeOAuthLoopback: Send OAuth callback
ClaudeOAuthLoopback->>ClaudeTokenProvider: Return code and state
ClaudeTokenProvider->>ClaudeOAuth: Exchange code for token
ClaudeOAuth-->>ClaudeTokenProvider: Return ClaudeOAuthToken
ClaudeTokenProvider->>ClaudeTokenStore: Save account token
AppModel->>ClaudeReader: Refresh usage
ClaudeReader->>ClaudeTokenProvider: Request valid tokens
ClaudeTokenProvider->>ClaudeTokenStore: Load or refresh stored tokens
ClaudeReader-->>AppModel: Return account usage cards
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (1)
Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift (1)
140-171: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftMove the success-page presentation out of Core.
Inject the response body or renderer from the UI layer so this listener only handles callback transport and parsing.
As per coding guidelines,
AIUsageBarCoremust remain pure logic with no UI code.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift` around lines 140 - 171, Remove the hardcoded successHTML presentation from ClaudeOAuthLoopback and make respond(_ client:ok:) obtain the success response body or renderer through dependency injection from the UI layer. Keep ClaudeOAuthLoopback responsible only for callback transport, status, headers, and parsing, while preserving the existing success and failure response behavior.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Line 14: Update the changelog reference definitions to add links for releases
0.2.0 and 0.1.6, and change the Unreleased comparison baseline from v0.1.5 to
v0.2.0 while preserving the existing link format.
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift`:
- Around line 178-181: Update ClaudeOAuth.randomBytes to check the return status
from SecRandomCopyBytes and stop PKCE/sign-in processing unless it equals
errSecSuccess; propagate the failure through the caller rather than returning
the zero-filled buffer, adjusting the method’s error handling as needed.
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift`:
- Around line 116-120: Update readRequest(_:) to accumulate data across multiple
recv calls until the HTTP request line terminator \r\n is received or the
8192-byte buffer limit is reached, while preserving the empty-string result for
failed or closed reads. Return the accumulated request so the caller’s parse()
receives complete request-line data.
- Around line 59-67: Update waitForCallback to use withTaskCancellationHandler
so task cancellation resumes the pending continuation with the cancellation
error immediately. In the timeout closure, resume the continuation with the
timeout error before calling stop(), while preserving single-resumption behavior
when acceptLoop races with cancellation or timeout.
In `@Sources/AIUsageBarCore/Claude/ClaudeReader.swift`:
- Around line 47-87: Restrict Claude profile binding in ClaudeReader’s
identityMatches to precise account UUID or email matches, rejecting tokens when
both precise identifiers exist but conflict and removing organization-only
matching. Also update Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift lines
110-136 to verify organization-only identities are rejected and conflicting
singleton identities do not bind.
In `@Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift`:
- Around line 62-66: Update the refresh error handling around refreshShared in
ClaudeTokenProvider so ClaudeOAuthError.invalidGrant is caught separately from
transient failures. Persist the required re-authentication state for
invalidGrant and avoid returning the unchanged token, while preserving the
existing stored-token fallback for other errors.
- Around line 89-96: Handle a false result from ClaudeTokenStore.save in the
refresh flow around the task.value result: check the persistence outcome before
deleting the old account entry or updating cache, and propagate an appropriate
failure when saving is unsuccessful. Apply the same handling to the initial
sign-in path around lines 103-105 so authentication is not reported as
successful unless the token is durably stored.
- Around line 108-111: The signOut(account:) flow must invalidate any in-flight
refresh before deleting credentials so a resumed refresh cannot restore the
account. Update refreshShared and signOut(account:) to track or cancel
per-account refresh generations, and require the refresh generation to remain
current before ClaudeTokenStore.save(fresh); preserve cache invalidation on
sign-out.
In `@Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift`:
- Around line 23-25: Require stable identity before persisting Claude tokens:
update ClaudeTokenStore.accountKey(for:) to remove the shared "default" fallback
and return an error when both account UUID and normalized email are absent. In
Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift lines 38-44, ensure the
provider does not call store until identity resolution succeeds; preserve
association by email or UUID only.
In `@Sources/AIUsageBarUI/AppModel.swift`:
- Around line 421-423: Update the account-change flows around
reloadClaudeAccounts() and refresh() to invalidate UsageService’s cached Claude
usage data, or force the next Claude usage read, before calling refresh(). Apply
the same change to both the sign-in completion path and the corresponding
account-removal path.
- Line 142: Update the account initialization around AppModel’s claudeAccounts
assignment and the related account-loading path around lines 504-509 to honor
the AIUSAGEBAR_NO_KEYCHAIN environment flag. When the flag is enabled, skip
Self.loadClaudeAccounts() entirely and use the existing empty-account/default
value; otherwise preserve normal account loading behavior and avoid any
ClaudeTokenStore access.
In `@Sources/AIUsageBarUI/MenuContentView.swift`:
- Around line 23-24: Update the Claude empty-account menu flow around
ClaudeConnectBanner to pass model.claudeSignInError into the banner, then render
that error near the banner’s CTA. Ensure failed OAuth attempts visibly display
the stored sign-in error while preserving the existing busy and
account-selection behavior.
In `@Sources/AIUsageBarUI/SettingsView.swift`:
- Around line 43-51: Update the Claude account rows in SettingsView’s ForEach
over model.claudeAccounts to apply the existing maskAccounts-aware account-label
masking used by provider cards before passing the value to Label. Keep the
unmasked account.label behavior when masking is disabled.
---
Nitpick comments:
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift`:
- Around line 140-171: Remove the hardcoded successHTML presentation from
ClaudeOAuthLoopback and make respond(_ client:ok:) obtain the success response
body or renderer through dependency injection from the UI layer. Keep
ClaudeOAuthLoopback responsible only for callback transport, status, headers,
and parsing, while preserving the existing success and failure response
behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bd7b9391-5235-45f4-ae05-1c62f844cf0a
📒 Files selected for processing (15)
CHANGELOG.mdCLAUDE.mdREADME.mdSources/AIUsageBarCore/Claude/ClaudeKeychain.swiftSources/AIUsageBarCore/Claude/ClaudeOAuth.swiftSources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swiftSources/AIUsageBarCore/Claude/ClaudeProfile.swiftSources/AIUsageBarCore/Claude/ClaudeReader.swiftSources/AIUsageBarCore/Claude/ClaudeTokenProvider.swiftSources/AIUsageBarCore/Claude/ClaudeTokenStore.swiftSources/AIUsageBarUI/AppModel.swiftSources/AIUsageBarUI/MenuContentView.swiftSources/AIUsageBarUI/SettingsView.swiftTests/AIUsageBarCoreTests/ClaudeOAuthTests.swiftTests/AIUsageBarUITests/ProviderSettingsTests.swift
💤 Files with no reviewable changes (2)
- Tests/AIUsageBarUITests/ProviderSettingsTests.swift
- Sources/AIUsageBarCore/Claude/ClaudeKeychain.swift
|
|
||
| ## [Unreleased] | ||
|
|
||
| ## [0.2.0] - 2026-07-16 |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add the 0.2.0 release link definitions.
[0.2.0] has no matching reference, and [Unreleased] still starts at v0.1.5. Add links for 0.2.0 and 0.1.6, then move the Unreleased baseline to v0.2.0.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CHANGELOG.md` at line 14, Update the changelog reference definitions to add
links for releases 0.2.0 and 0.1.6, and change the Unreleased comparison
baseline from v0.1.5 to v0.2.0 while preserving the existing link format.
| static func randomBytes(_ n: Int) -> Data { | ||
| var bytes = [UInt8](repeating: 0, count: n) | ||
| _ = SecRandomCopyBytes(kSecRandomDefault, n, &bytes) | ||
| return Data(bytes) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file='Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift'
echo '--- outline ---'
ast-grep outline "$file" --view expanded || true
echo
echo '--- relevant lines ---'
sed -n '1,260p' "$file" | cat -n
echo
echo '--- call sites for randomBytes ---'
rg -n "randomBytes\(" Sources/AIUsageBarCore -SRepository: Balanced02/ai-usage-bar
Length of output: 14418
Abort PKCE generation if SecRandomCopyBytes fails
randomBytes ignores the entropy status and still returns a zero-filled buffer, which would make the PKCE verifier/state predictable if the call fails. Make this throw or otherwise stop the sign-in flow unless the status is errSecSuccess.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuth.swift` around lines 178 - 181,
Update ClaudeOAuth.randomBytes to check the return status from
SecRandomCopyBytes and stop PKCE/sign-in processing unless it equals
errSecSuccess; propagate the failure through the caller rather than returning
the zero-filled buffer, adjusting the method’s error handling as needed.
| public func waitForCallback(timeout: TimeInterval = 300) async throws -> (code: String, state: String?) { | ||
| try await withCheckedThrowingContinuation { c in | ||
| lock.lock(); cont = c; lock.unlock() | ||
| DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.acceptLoop() } | ||
| DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in | ||
| guard let self else { return } | ||
| self.stop() | ||
| self.resume(.failure(ClaudeOAuthError.transport("timed out waiting for sign-in"))) | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the file structure first, then inspect the relevant slice.
ast-grep outline Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift --view expanded || true
echo '--- FILE (1-220) ---'
cat -n Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift | sed -n '1,220p'Repository: Balanced02/ai-usage-bar
Length of output: 9905
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Probe the claim about cancellation/timeout races with a small read-only simulator.
python3 - <<'PY'
import asyncio, threading, time
class Cont:
def __init__(self):
self.done = False
self.result = None
self.lock = threading.Lock()
def resume(self, value):
with self.lock:
if self.done:
return False
self.done = True
self.result = value
return True
class Sim:
def __init__(self):
self.cont = None
self.lock = threading.Lock()
self.fd = 1
self.stop_called = False
def stop(self):
self.stop_called = True
def resume(self, value):
c = self.cont
if c:
return c.resume(value)
return False
def timeout_race(order='stop-first'):
s = Sim()
c = Cont()
s.cont = c
def accept_loop():
time.sleep(0.01)
s.resume(('accept', 'cancelled'))
t = threading.Thread(target=accept_loop)
t.start()
if order == 'stop-first':
s.stop()
s.resume(('timeout', 'timed out'))
else:
s.resume(('timeout', 'timed out'))
s.stop()
t.join()
return c.result, s.stop_called
for order in ['stop-first', 'resume-first']:
print(order, timeout_race(order))
PYRepository: Balanced02/ai-usage-bar
Length of output: 251
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for related code paths and any helper methods that affect cancellation/timeout behavior.
rg -n "waitForCallback|acceptLoop|resume\(|stop\(|ClaudeOAuthError.transport|cancel" Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift Sources/AIUsageBarCore/Claude -SRepository: Balanced02/ai-usage-bar
Length of output: 5501
Make callback waiting cancellation-aware and deterministic
Task cancellation still leaves this pending until the 5-minute timeout, and the timeout path can race acceptLoop() into reporting .cancelled instead of the timeout error. Wrap this in withTaskCancellationHandler, and resume the continuation before calling stop() so timeout wins deterministically.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift` around lines 59 -
67, Update waitForCallback to use withTaskCancellationHandler so task
cancellation resumes the pending continuation with the cancellation error
immediately. In the timeout closure, resume the continuation with the timeout
error before calling stop(), while preserving single-resumption behavior when
acceptLoop races with cancellation or timeout.
| private static func readRequest(_ client: Int32) -> String { | ||
| var buf = [UInt8](repeating: 0, count: 8192) | ||
| let n = recv(client, &buf, buf.count, 0) | ||
| guard n > 0 else { return "" } | ||
| return String(decoding: buf[0..<n], as: UTF8.self) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
sed -n '1,220p' Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift | cat -nRepository: Balanced02/ai-usage-bar
Length of output: 9281
Read until the HTTP request line is complete. recv() can return a fragmented request, so a valid OAuth callback may be misparsed as noise and answered with 404. Accumulate until \r\n (or the buffer limit) before calling parse().
🧰 Tools
🪛 SwiftLint (0.65.0)
[Warning] 120-120: Prefer failable String(bytes:encoding:) initializer when converting Data to String
(optional_data_string_conversion)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarCore/Claude/ClaudeOAuthLoopback.swift` around lines 116 -
120, Update readRequest(_:) to accumulate data across multiple recv calls until
the HTTP request line terminator \r\n is received or the 8192-byte buffer limit
is reached, while preserving the empty-string result for failed or closed reads.
Return the accumulated request so the caller’s parse() receives complete
request-line data.
| // Bind each token to the best-matching profile by the identity it carries. | ||
| var tokenForProfile: [String: ClaudeOAuthToken] = [:] | ||
| var usedTokens = Set<Int>() | ||
| for profile in profiles { | ||
| let account = ClaudeAccountLoader.load(profile) | ||
| if let hit = live.enumerated().first(where: { entry in | ||
| !usedTokens.contains(entry.offset) && Self.identityMatches(entry.element, account) | ||
| }) { | ||
| tokenForProfile[profile.id] = hit.element | ||
| usedTokens.insert(hit.offset) | ||
| } | ||
| } | ||
| // Exactly one profile and one token, unmatched: no ambiguity, pair them. | ||
| // (Requires live.count == 1 — with 2+ tokens we'd bind an arbitrary one and | ||
| // drop the others' cards.) | ||
| if profiles.count == 1, live.count == 1, tokenForProfile.isEmpty, let only = live.first { | ||
| tokenForProfile[profiles[0].id] = only | ||
| usedTokens.insert(0) | ||
| } | ||
|
|
||
| var results: [ProviderUsage] = [] | ||
| for profile in profiles { | ||
| results.append(await readProfile(profile, credential: mapping[profile.id])) | ||
| results.append(await readProfile(profile, token: tokenForProfile[profile.id])) | ||
| } | ||
| // Signed-in accounts with no local profile → live-only cards. | ||
| for entry in live.enumerated() where !usedTokens.contains(entry.offset) { | ||
| results.append(await readAccountOnly(entry.element)) | ||
| } | ||
| return results | ||
| } | ||
|
|
||
| private func readProfile(_ profile: ClaudeProfile, credential: ClaudeCredential?) async -> ProviderUsage { | ||
| static func identityMatches(_ token: ClaudeOAuthToken, _ account: ClaudeAccount?) -> Bool { | ||
| guard let account else { return false } | ||
| // Precise per-account identifiers are definitive: when both sides carry one, | ||
| // it settles the match (== true / != false). A shared organization UUID is | ||
| // only a last resort — two accounts in one org share it, so matching on org | ||
| // alone would bind a token to the wrong same-org profile. | ||
| if let a = token.accountUUID, let b = account.accountUuid { return a == b } | ||
| if let a = token.accountEmail?.lowercased(), let b = account.emailAddress?.lowercased() { return a == b } | ||
| if let a = token.orgUUID, let b = account.organizationUuid { return a == b } | ||
| return false |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Restrict profile binding to precise account identity. Organization-only and contradictory singleton fallback matches can display one account’s limits under another profile.
Sources/AIUsageBarCore/Claude/ClaudeReader.swift#L47-L87: require matching email/account UUID and reject conflicting precise identifiers.Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift#L110-L136: reject organization-only matching and cover conflicting singleton identities.
As per coding guidelines, live tokens must match profiles by account email or UUID.
📍 Affects 2 files
Sources/AIUsageBarCore/Claude/ClaudeReader.swift#L47-L87(this comment)Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift#L110-L136
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarCore/Claude/ClaudeReader.swift` around lines 47 - 87,
Restrict Claude profile binding in ClaudeReader’s identityMatches to precise
account UUID or email matches, rejecting tokens when both precise identifiers
exist but conflict and removing organization-only matching. Also update
Tests/AIUsageBarCoreTests/ClaudeOAuthTests.swift lines 110-136 to verify
organization-only identities are rejected and conflicting singleton identities
do not bind.
Source: Coding guidelines
| public static func accountKey(for token: ClaudeOAuthToken) -> String { | ||
| token.accountUUID ?? token.accountEmail ?? "default" | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Require stable identity before persisting an account. Suppressed profile lookup failures allow multiple identity-less tokens to share and overwrite the "default" Keychain item.
Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift#L23-L25: remove the shared fallback and return an error when neither account UUID nor normalized email exists.Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift#L38-L44: do not callstoreuntil identity resolution succeeds.
As per coding guidelines, live tokens must be associated by account email or UUID.
📍 Affects 2 files
Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift#L23-L25(this comment)Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift#L38-L44
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarCore/Claude/ClaudeTokenStore.swift` around lines 23 - 25,
Require stable identity before persisting Claude tokens: update
ClaudeTokenStore.accountKey(for:) to remove the shared "default" fallback and
return an error when both account UUID and normalized email are absent. In
Sources/AIUsageBarCore/Claude/ClaudeTokenProvider.swift lines 38-44, ensure the
provider does not call store until identity resolution succeeds; preserve
association by email or UUID only.
Source: Coding guidelines
| self.monthlyBudgetUSD = monthlyBudgetUSD | ||
| self.maskAccounts = maskAccounts | ||
| self.claudeConnected = claudeConnected | ||
| self.claudeAccounts = Self.loadClaudeAccounts() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make AIUSAGEBAR_NO_KEYCHAIN skip account loading too.
The escape hatch disables token reads in ClaudeReader, but Line 142 still queries ClaudeTokenStore. Gate loadClaudeAccounts() with the same environment flag so tests/dev runs truly avoid Keychain access.
Also applies to: 504-509
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarUI/AppModel.swift` at line 142, Update the account
initialization around AppModel’s claudeAccounts assignment and the related
account-loading path around lines 504-509 to honor the AIUSAGEBAR_NO_KEYCHAIN
environment flag. When the flag is enabled, skip Self.loadClaudeAccounts()
entirely and use the existing empty-account/default value; otherwise preserve
normal account loading behavior and avoid any ClaudeTokenStore access.
| signingInClaude = false | ||
| reloadClaudeAccounts() | ||
| await refresh() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Invalidate Claude usage caching after account changes.
UsageService can return its cached Claude cards for at least 180 seconds, so adding an account may not show it and removing one may leave its live card visible. Invalidate the Claude cache or force the next read before calling refresh().
Also applies to: 436-438
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarUI/AppModel.swift` around lines 421 - 423, Update the
account-change flows around reloadClaudeAccounts() and refresh() to invalidate
UsageService’s cached Claude usage data, or force the next Claude usage read,
before calling refresh(). Apply the same change to both the sign-in completion
path and the corresponding account-removal path.
| if model.selectedKind == .claude && model.claudeAccounts.isEmpty { | ||
| ClaudeConnectBanner(busy: model.signingInClaude) { model.addClaudeAccount() } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Show OAuth failures in the Claude banner.
Sign-in errors are stored in model.claudeSignInError, but this menu entry point never renders them. A failed browser flow therefore appears to do nothing. Pass the error into ClaudeConnectBanner and display it near the CTA.
Also applies to: 103-122
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarUI/MenuContentView.swift` around lines 23 - 24, Update the
Claude empty-account menu flow around ClaudeConnectBanner to pass
model.claudeSignInError into the banner, then render that error near the
banner’s CTA. Ensure failed OAuth attempts visibly display the stored sign-in
error while preserving the existing busy and account-selection behavior.
| ForEach(model.claudeAccounts) { account in | ||
| LabeledContent { | ||
| Button("Remove") { model.removeClaudeAccount(account.key) } | ||
| .foregroundStyle(.red) | ||
| } label: { | ||
| Label(account.label, systemImage: "checkmark.circle.fill") | ||
| .foregroundStyle(.primary) | ||
| .labelStyle(.titleAndIcon) | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Honor account masking for signed-in account labels.
These rows expose the full email or UUID even when maskAccounts is enabled. Apply the same masking used by provider cards before rendering account.label.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/AIUsageBarUI/SettingsView.swift` around lines 43 - 51, Update the
Claude account rows in SettingsView’s ForEach over model.claudeAccounts to apply
the existing maskAccounts-aware account-label masking used by provider cards
before passing the value to Label. Keep the unmasked account.label behavior when
masking is disabled.
Why
The app read the token Claude Code stored in the Keychain. Claude Code delete+re-adds that item on every token refresh, which wipes our app off the item's access list — so macOS re-prompted at random. Unfixable from the reader side.
What
The app now runs its own OAuth (PKCE loopback, Claude Code's OAuth client), mints its own token, and stores it in its own Keychain item that it refreshes in place. Because the app created the item, a signed build reads it back with zero prompts, and it persists across restarts (sign in once). Add account in Settings → Claude (or the Claude-tab banner); add more than one (Personal + Work), each with live 5H/7D limits. The opt-in Connect toggle is removed.
Implementation
ClaudeOAuth(PKCE S256, authorize/exchange/refresh),ClaudeOAuthLoopback(one-shot localhost server capturing?code&state),ClaudeTokenStore(our Keychain items,SecItemUpdatein place),ClaudeTokenProvider(actor: serializes refresh, runs sign-in).ClaudeReadermaps our tokens to config-dir profiles by identity; a signed-in account with no local profile gets a live-only card.AppModel:addClaudeAccount()/removeClaudeAccount(); deletedClaudeKeychain(Claude Code reader).Non-obvious gotchas (documented in CLAUDE.md)
User-Agent: claude-code/<ver>; the token endpoint's edge hard-429sclaude-code/*andcurl/*and only accepts the CLI's real transport UA (axios/*).kSecReturnData+kSecMatchLimitAllin one query iserrSecParam— list attributes, then read each item.invalid_granton refresh is terminal.Review + tests
state(CSRF), per-account refresh coalescing, org-UUID last-resort in identity matching, single-profile fallback count guard, delete stale-keyed item on identity firm-up.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation