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
15 changes: 15 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,28 @@ extension UsageMenuCardView.Model {
}
}

static func isClaudeStatusLineSource(_ sourceLabel: String?) -> Bool {
sourceLabel?
.trimmingCharacters(in: .whitespacesAndNewlines)
.caseInsensitiveCompare(ClaudeUsageDataSource.statusline.sourceLabel) == .orderedSame
}

static func usageNotes(input: Input) -> [String] {
let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider)

if input.provider == .kiro {
return self.kiroUsageNotes(input: input) + subscriptionNotes
}

// The statusLine feed reports numbers the user's own Claude configuration published, so the card has to
// say where they came from rather than presenting them as a CodexBar reading (owner ruling, #2733).
//
// Must precede the dataConfidence check below: a composed feed snapshot inherits its confidence from the
// previous poll, so a prior CLI scrape would otherwise label live statusLine windows as CLI-sourced.
if input.provider == .claude, Self.isClaudeStatusLineSource(input.sourceLabel) {
return [L("From your Claude statusLine config")] + subscriptionNotes
}

if input.provider == .kilo {
var notes = Self.kiloLoginDetails(snapshot: input.snapshot)
let resolvedSource = input.sourceLabel?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
_ = settings.claudeOAuthDirectKeychainReadAllowed
_ = settings.claudeOAuthKeychainReadStrategy
_ = settings.claudeWebExtrasEnabled
_ = settings.claudeStatusLineFeedEnabled
_ = settings.claudeSwapEnabled
_ = settings.claudeSwapShowSingleAccount
_ = settings.claudeSwapExecutablePath
Expand Down Expand Up @@ -67,6 +68,8 @@ struct ClaudeProviderImplementation: ProviderImplementation {
case .oauth: .oauth
case .web: .web
case .cli: .cli
// Never user-selectable: the feed participates only inside Auto.
case .statusline: .auto
}
}

Expand Down Expand Up @@ -125,6 +128,20 @@ struct ClaudeProviderImplementation: ProviderImplementation {
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "claude-statusline-feed",
title: "Read usage from your Claude statusLine",
subtitle: "Uses the rate limits Claude Code publishes to your own statusLine command, so the "
+ "card stays current between polls. Composes with OAuth/CLI and never replaces them. "
+ "Requires a statusLine helper you configure — see docs/claude-statusline-feed.md.",
binding: context.boolBinding(\.claudeStatusLineFeedEnabled),
statusText: nil,
actions: [],
isVisible: nil,
isEnabled: nil,
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "claude-oauth-prompt-free-credentials",
title: "Avoid Keychain prompts",
Expand Down Expand Up @@ -204,7 +221,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
?? .onlyOnUserAction
})

let usageOptions = ClaudeUsageDataSource.allCases.map {
let usageOptions = ClaudeUsageDataSource.userSelectableCases.map {
ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName)
}
let cookieOptions = ProviderCookieSourceUI.options(
Expand Down
18 changes: 18 additions & 0 deletions Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ extension SettingsStore {
case .oauth: .oauth
case .web: .web
case .cli: .cli
// Never user-selectable: the feed participates only inside Auto, so persist Auto.
case .statusline: .auto
}
self.updateProviderConfig(provider: .claude) { entry in
entry.source = source
Expand Down Expand Up @@ -104,13 +106,29 @@ extension SettingsStore {
routing: routing,
hasSelectedAccount: account != nil),
webExtrasEnabled: self.claudeWebExtrasEnabled,
statusLineFeedEnabled: self.claudeStatusLineFeedEnabled,
statusLineFeedRowsAreOwned: self.claudeStatusLineFeedRowsAreOwned(),
cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing),
manualCookieHeader: self.claudeSnapshotCookieHeader(
routing: routing,
hasSelectedAccount: account != nil),
organizationID: account?.sanitizedOrganizationID)
}

/// Whether the stored Claude rows are provably owned by the account that is active now.
///
/// Read from the same persisted key `UsageStore` writes when it stores a snapshot from a source that carries
/// its own identity. An unknown value on either side answers false: the feed may only ever supplement rows
/// whose owner is established, never assert one.
func claudeStatusLineFeedRowsAreOwned(
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
{
guard let recorded = self.userDefaults.string(forKey: UsageStore.claudeSnapshotAccountUuidKey),
let active = ClaudeAccountProfile.accountUuid(environment: environment)
else { return false }
return recorded == active
}

private static func claudeUsageDataSource(from source: ProviderSourceMode?) -> ClaudeUsageDataSource {
guard let source else { return .auto }
switch source {
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,18 @@ extension SettingsStore {
set { self.claudeWebExtrasEnabledRaw = newValue }
}

var claudeStatusLineFeedEnabled: Bool {
get { self.defaultsState.claudeStatusLineFeedEnabledRaw }
set {
self.defaultsState.claudeStatusLineFeedEnabledRaw = newValue
self.userDefaults.set(newValue, forKey: "claudeStatusLineFeedEnabled")
CodexBarLog.logger(LogCategories.settings).info(
"Claude statusLine feed updated",
metadata: ["enabled": newValue ? "1" : "0"])
self.noteBackgroundWorkSettingsChanged()
}
}

var copilotBudgetExtrasEnabled: Bool {
get { self.defaultsState.copilotBudgetExtrasEnabled }
set {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ extension SettingsStore {
_ = self.claudeOAuthDirectKeychainReadAllowed
_ = self.claudeOAuthKeychainReadStrategy
_ = self.claudeWebExtrasEnabled
_ = self.claudeStatusLineFeedEnabled
_ = self.copilotBudgetExtrasEnabled
_ = self.showOptionalCreditsAndExtraUsage
_ = self.claudeDailyRoutinesUsageVisible
Expand Down
16 changes: 12 additions & 4 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ final class SettingsStore {
copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(),
tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore(),
antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore(),
performInitialProviderDetection: Bool = !SettingsStore.isRunningTests)
performInitialProviderDetection: Bool = !SettingsStore.isRunningTests,
writesLaunchResetsToRawState: Bool = !SettingsStore.isRunningTests)
{
if !Self.isRunningTests {
_ = UserProviderPluginRegistry.refresh()
Expand Down Expand Up @@ -359,10 +360,13 @@ final class SettingsStore {
self.ensureAlibabaProviderAutoEnabledIfNeeded()
self.applyTokenCostDefaultIfNeeded()
if self.claudeUsageDataSource != .cli {
if Self.isRunningTests {
self.claudeWebExtrasEnabled = false
} else {
// Why: this reset is CLI-scoped on purpose. The statusLine feed is deliberately not cleared
// here — the planner emits its step only under `.auto`, which is exactly the branch this
// condition covers, so resetting it would clear the opt-in in the one mode that consumes it.
if writesLaunchResetsToRawState {
self.defaultsState.claudeWebExtrasEnabledRaw = false
} else {
self.claudeWebExtrasEnabled = false
}
}
let resolvedOpenAIWebAccessEnabled = if hasStoredOpenAIWebAccessPreference {
Expand Down Expand Up @@ -496,6 +500,9 @@ extension SettingsStore {
let claudeOAuthDirectKeychainReadAllowed = userDefaults.object(
forKey: ClaudeOAuthDirectKeychainReadConsent.userDefaultsKey) as? Bool ?? false
let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false
// Off unless the user opts in (owner ruling, #2733).
let claudeStatusLineFeedEnabledRaw = userDefaults
.object(forKey: "claudeStatusLineFeedEnabled") as? Bool ?? false
let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool
let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true
if Self.isRunningTests, creditsExtrasDefault == nil {
Expand Down Expand Up @@ -610,6 +617,7 @@ extension SettingsStore {
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
claudeOAuthDirectKeychainReadAllowed: claudeOAuthDirectKeychainReadAllowed,
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
claudeStatusLineFeedEnabledRaw: claudeStatusLineFeedEnabledRaw,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible,
codexSparkUsageVisible: codexSparkUsageVisible,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ struct SettingsDefaultsState {
var claudeOAuthKeychainReadStrategyRaw: String?
var claudeOAuthDirectKeychainReadAllowed: Bool
var claudeWebExtrasEnabledRaw: Bool
var claudeStatusLineFeedEnabledRaw: Bool
var showOptionalCreditsAndExtraUsage: Bool
var claudeDailyRoutinesUsageVisible: Bool
var codexSparkUsageVisible: Bool
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/UsageStore+ClaudeDebug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ extension UsageStore {
case .auto:
lines.append("Auto source selected.")
return lines.joined(separator: "\n")
case .statusline:
lines.append("Claude statusLine feed selected (opt-in, user-configured).")
return lines.joined(separator: "\n")
case .api:
let hasAdminKey = ProviderTokenResolver.token(
for: .claude,
Expand Down
156 changes: 156 additions & 0 deletions Sources/CodexBar/UsageStore+ClaudeStatusLineComposition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import CodexBarCore
import Foundation

/// Which account owns the Claude rows a statusLine observation would be composed over.
///
/// Three states rather than two: "cannot tell" has to act differently from "belongs to someone else", because
/// only one of them justifies discarding rows the user can currently see.
enum ClaudeStatusLineRowOwnership {
case owned
case foreign
case unknown
}

/// What a refresh should publish, and whether the fetched result is what it came from.
///
/// The source label has to travel with the snapshot rather than be assumed from the result: a discarded
/// observation republishes the previous rows, and labelling those as statusLine-sourced would have the card
/// claim an old OAuth or CLI reading came from the user's status line.
struct ClaudeComposedSnapshot {
let snapshot: UsageSnapshot
let usedFetchedResult: Bool
}

/// Split out of `UsageStore+Refresh.swift` to keep that file within the file-length limit.
extension UsageStore {
/// Applies the Claude statusLine composition to a scoped result before it is stored.
func composedSnapshot(
_ scoped: UsageSnapshot,
_ provider: UsageProvider,
_ result: ProviderFetchResult,
_ context: ProviderRefreshOutcomeContext) -> ClaudeComposedSnapshot
{
// Provider-specific by design: the statusLine feed is a Claude-only source, so every other provider
// must reach the composition helper with no source label and pass straight through it.
let claudeSourceLabel = provider == .claude ? result.sourceLabel : nil
return Self.claudeSnapshotComposingStatusLineFeed(
current: scoped,
previous: self.snapshots[provider.instanceID],
sourceLabel: claudeSourceLabel,
accountIsStable: context.claudeOAuthActiveAccountObservation != .changed,
ownership: self.claudeStatusLineRowOwnership())
}

/// Composes a statusLine observation with the last polled Claude snapshot instead of publishing it whole.
///
/// The feed carries only the 5h/7d windows — no identity, plan, model-scoped weekly, Daily Routines or extra
/// usage — so publishing it as-is would blank every one of those rows. The owner ruling for this source is
/// that it composes with the polled sources and never replaces them, which is what this restores.
///
/// Always reports a snapshot to publish, and says whether it came from the fetched observation. Returning nil
/// stopped the refresh dead in an earlier revision: the pipeline had already accepted the statusLine result,
/// so nothing fell through to the CLI probe. The unusable cases now republish what is already on the card and
/// mark the result unused, so the caller keeps the label the visible rows were actually fetched under.
///
/// In production these unusable cases are unreachable — an unowned feed is not planned as a source at all, so
/// the step never runs. They are kept because the planner's inputs are sampled a moment before the fetch, and
/// a discarded observation must not be able to change what the card claims about itself.
static func claudeSnapshotComposingStatusLineFeed(
current: UsageSnapshot,
previous: UsageSnapshot?,
sourceLabel: String?,
accountIsStable: Bool,
ownership: ClaudeStatusLineRowOwnership) -> ClaudeComposedSnapshot
{
let used = { ClaudeComposedSnapshot(snapshot: $0, usedFetchedResult: true) }
guard self.isClaudeStatusLineSourceLabel(sourceLabel) else { return used(current) }
// Nothing to compose with: no prior rows exist, so none can be lost. Publishing the windows alone is
// additive rather than destructive.
guard let previous else { return used(current) }
let discarded = ClaudeComposedSnapshot(snapshot: previous, usedFetchedResult: false)
// The account moved during this very fetch, so nothing here describes it: the stored rows belong to
// whoever was signed in before, and the observation cannot say which account it counted.
guard accountIsStable else { return discarded }

switch ownership {
case .owned:
return used(current.composingOverPreviousClaudeSnapshot(previous))
case .foreign, .unknown:
// The observation carries no account of its own and its drop file is shared by every account on the
// profile, so it cannot be attributed to the active one. Publishing it anyway — even stripped of
// identity — would put another account's numbers on the card; blanking the stored rows would throw
// away verified data on a guess. Keep what is there and let a source that knows its own account
// re-establish ownership.
return discarded
}
}

/// Records where the published rows came from.
///
/// A discarded observation republishes the previous rows, so the label has to stay with them: the card reads
/// this to say where its numbers came from, and those numbers were not fetched by this result.
func recordSourceLabel(
_ sourceLabel: String?,
provider: UsageProvider,
composition: ClaudeComposedSnapshot)
{
guard composition.usedFetchedResult else { return }
self.lastSourceLabels[provider.instanceID] = sourceLabel
}

/// Whether the account that produced the stored Claude rows is still the active one.
///
/// `claudeOAuthActiveAccountObservation` only proves the account held still during *this* fetch. If the user
/// switched accounts and the first refresh afterwards is served by the statusLine file, nothing in that fetch
/// re-read the account, so stability alone would let the new account's windows sit under the old account's
/// identity.
func claudeStatusLineRowOwnership(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ClaudeStatusLineRowOwnership
{
guard let recorded = self.claudeSnapshotAccountUuid,
let active = ClaudeAccountProfile.accountUuid(environment: environment)
else { return .unknown }
return recorded == active ? .owned : .foreign
}

/// Stores a provider snapshot, recording which Claude account owns it.
func storeSnapshot(
_ snapshot: UsageSnapshot,
provider: UsageProvider,
sourceLabel: String?,
environment: [String: String] = ProcessInfo.processInfo.environment)
{
self.snapshots[provider.instanceID] = snapshot
self.recordClaudeSnapshotAccount(
provider: provider,
sourceLabel: sourceLabel,
environment: environment)
}

/// Records the account that owns a stored Claude snapshot.
///
/// Only sources that carry their own identity may establish this. Recording the *active* account against a
/// feed observation would manufacture evidence the observation does not contain: the drop file is scoped to
/// the profile rather than the account, and its freshness window is minutes wide, so a file written by the
/// previous account's session would be stamped as belonging to the new one and composed beneath its identity
/// on the next refresh.
func recordClaudeSnapshotAccount(
provider: UsageProvider,
sourceLabel: String?,
environment: [String: String] = ProcessInfo.processInfo.environment)
{
// Provider-specific by design: Claude is the only provider whose stored rows can be composed over by a
// later identity-free source, so it is the only one that needs its owner recorded.
guard provider == .claude, !Self.isClaudeStatusLineSourceLabel(sourceLabel) else { return }
// An unreadable account must not erase a known one: that would drop ownership to unknown and take the
// feed out of the plan until the next successful poll of a real source.
guard let uuid = ClaudeAccountProfile.accountUuid(environment: environment) else { return }
self.claudeSnapshotAccountUuid = uuid
}

static func isClaudeStatusLineSourceLabel(_ sourceLabel: String?) -> Bool {
sourceLabel?
.trimmingCharacters(in: .whitespacesAndNewlines)
.caseInsensitiveCompare(ClaudeUsageDataSource.statusline.sourceLabel) == .orderedSame
}
}
Loading