Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bdcc76e
Claude config-dir discovery: extra account cards from custom homes
robinebers Jul 19, 2026
72789dd
tests: config-dir discovery, assembly card plan, scoped auth, layout …
robinebers Jul 19, 2026
cc0c019
fix(claude): bind swapped default accounts to their permanent cards
robinebers Aug 24, 2026
893d55f
Make account foundations safe for ownership, downgrade, and privacy
robinebers Aug 24, 2026
a3fef8d
Rename cards: context menu + Customize name field, live titles from t…
robinebers Jul 19, 2026
49e2ec6
Account-first Phase 2b: one name resolver for card titles (#1031)
robinebers Jul 19, 2026
220d4f9
feat(ui): give account cards stable provider-colored chart slices
robinebers Aug 24, 2026
321b6fc
Keep derived account names live when rename is unchanged
robinebers Aug 24, 2026
0202c36
fix: keep account card names bound to their verified owners
robinebers Aug 24, 2026
5de0d62
fix: preserve verified credential homes and reject incomplete discovery
robinebers Aug 24, 2026
45032f6
fix: canonicalize Claude home paths without increasing launch budget
robinebers Aug 24, 2026
bd55890
fix: reject stale account cards and discard unverified cached usage
robinebers Aug 24, 2026
90f63f8
fix: retain verified Claude ownership while identity is unreadable
robinebers Aug 24, 2026
8979481
fix: protect account discovery and persisted identity boundaries
robinebers Aug 24, 2026
ac77007
fix: retain account identity aliases across downgrades
robinebers Aug 24, 2026
59ac619
fix: fail closed on corrupt Claude account files
robinebers Aug 24, 2026
795d481
fix: harden account discovery and downgrade identity ownership
robinebers Aug 24, 2026
d0ebed2
fix: quarantine skipped Claude account discovery
robinebers Aug 24, 2026
f12747a
fix: fold verified account aliases into their existing card
robinebers Aug 24, 2026
bc6a3b8
fix: deduplicate config homes belonging to one account
robinebers Aug 24, 2026
04a0de7
fix: retain bound Claude accounts when discovery is skipped
robinebers Aug 24, 2026
2941f52
fix: canonicalize account aliases and skip irrelevant discovery entries
robinebers Aug 24, 2026
bcbd0c9
fix: preserve first-launch Desktop login and isolate Pi spend
robinebers Aug 24, 2026
3932758
fix: quarantine Pi spending when Claude discovery is incomplete
robinebers Aug 24, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, 'codex/account-*']

permissions:
contents: read
Expand Down
46 changes: 40 additions & 6 deletions Sources/OpenUsage/App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ final class AppContainer {
/// provider were ever removed from the registry. Injected into the view tree via
/// `\.codexResetClaim`.
let codexResetClaim: CodexResetClaimService?
/// The account registry the launch pass reconciled. The UI observes it live: a rename
/// (`customLabel`) re-titles the card everywhere without a relaunch.
let accounts: ProviderAccountsStore
/// The provider runtimes, kept so on-demand credential detection (the Customize "Reset All" reseed)
/// can re-probe `hasLocalCredentials()` the same way first-run seeding does.
private let providers: [ProviderRuntime]
Expand All @@ -67,11 +70,23 @@ final class AppContainer {
// Once the capture lands, persist its identity-relevant facts so the NEXT launch has them
// even if that launch's own capture is slow (see `ShellEnvironmentSnapshot`).
self.shellEnvironmentSnapshotTask = ShellEnvironmentSnapshotStore(defaults: .standard).startRefreshTask()
// The launch account pass: which account is signed in at each family's default home. Feeds
// the snapshot cache's account stamp and reconciles the account registry.
let accountAssembly = ProviderAccountAssembly.make(waitsForLoginShell: true)
// The launch account pass: which account is signed in at each family's default home, plus
// the config-dir scan for extra Claude logins. Feeds the snapshot cache's account stamp,
// reconciles the account registry, and hands the catalog its extra-card build plan.
let accounts = ProviderAccountsStore()
let accountAssembly = ProviderAccountAssembly.make(accountsStore: accounts, waitsForLoginShell: true)
self.accounts = accounts

let providers = ProviderCatalog.make()
let providers = ProviderCatalog.make(
claudeCards: accountAssembly.claudeCards,
defaultClaudeExtraLogRoots: accountAssembly.defaultClaudeExtraLogRoots,
defaultClaudeDisplayName: accountAssembly.defaultClaudeDisplayName,
defaultClaudeVerifiedIdentityAliases: accountAssembly.defaultClaudeVerifiedIdentityAliases,
defaultClaudeCardID: accountAssembly.defaultClaudeCardID,
claudeIdentityKeys: accountAssembly.identityKeysByCard,
isClaudeDiscoveryComplete: accountAssembly.isClaudeDiscoveryComplete,
allowsUnownedClaudeDesktopFallback: accountAssembly.allowsUnownedClaudeDesktopFallback
)
let registry = WidgetRegistry.from(providers)
let apiKeyProviders = providers.compactMap { $0 as? any APIKeyManaging }
let enablement = ProviderEnablementStore()
Expand All @@ -86,7 +101,8 @@ final class AppContainer {
isProviderEnabled: { [enablement] in enablement.isEnabled($0) },
orderedDescriptors: { [layout] in layout.visiblePlaced.compactMap { layout.descriptor(for: $0) } },
notificationSettings: { notificationSettings },
providerIdentityKeys: accountAssembly.identityKeysByCard
providerIdentityKeys: accountAssembly.identityKeysByCard,
resolveDisplayName: { [accounts] in accounts.resolvedDisplayName(cardID: $0) }
)
let iCloudSync = ICloudUsageSyncStore(dataStore: dataStore)
// Re-enabling a provider should fetch it promptly, so clear any leftover failure backoff before
Expand Down Expand Up @@ -196,14 +212,17 @@ final class AppContainer {
self.telemetry = telemetry
self.transparency = PopoverTransparencyStore()
self.privacy = MenuBarPrivacyStore()
self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore] in
self.localAPI = LocalUsageServer(state: { [layout, enablement, dataStore, accounts] in
LocalUsageAPI.State(
enabledOrderedIDs: layout.orderedProviderIDs().filter { enablement.isEnabled($0) },
knownIDs: Set(registry.providers.map(\.id)),
snapshots: dataStore.snapshots,
limitDescriptors: registry.limitDescriptorsByProvider,
errors: dataStore.providerErrors
)
// API output is human-read too: resolve card titles at respond time so renames show,
// exactly like every UI surface.
.resolvingDisplayNames(accounts.resolvedDisplayNamesByCardID)
})
self.refreshTask = Self.startPeriodicRefresh(dataStore: dataStore, telemetry: telemetry)
localAPI.start()
Expand All @@ -220,6 +239,21 @@ final class AppContainer {
shellEnvironmentSnapshotTask.cancel()
}

/// The name a card renders under right now — the app-side face of the one resolver
/// (`ProviderAccountRecord.resolvedDisplayName`). Live: a rename in the account registry
/// re-titles the card everywhere without a relaunch. Non-account providers (no record) keep
/// their static display name; `Provider.displayName` itself only ever carries the derived
/// default, so the fallback can never be a stale rename.
func displayName(for provider: Provider) -> String {
accounts.resolvedDisplayName(cardID: provider.id) ?? provider.displayName
}

/// Whether the card has an account record a rename can attach to (accounts-model families only,
/// and only once the account's identity has been observed at least once).
func canRename(_ providerID: String) -> Bool {
accounts.runtimeRecord(for: providerID) != nil
}

/// Re-runs first-launch credential detection on demand — the enablement half of the Customize
/// "Reset All" action (`LayoutStore.resetToDefault` handles metrics, order, pins, and expansion).
/// Delegates to `FirstRunSeeder.reseed`; returns its detection task so callers can await it.
Expand Down
3 changes: 2 additions & 1 deletion Sources/OpenUsage/App/StatusItemImageUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ final class StatusItemImageUpdater {
}
let content = MenuBarContentBuilder.build(
groups: container.layout.pinnedGroups,
data: { container.dataStore.data(for: $0) }
data: { container.dataStore.data(for: $0) },
title: { container.displayName(for: $0) }
)
return MenuBarStripRenderer.image(for: content, style: container.layout.menuBarStyle)
?? MenuBarIcon.image
Expand Down
10 changes: 8 additions & 2 deletions Sources/OpenUsage/Models/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,19 @@ enum MenuBarContentBuilder {
/// The strip is dynamic: a pinned metric without data is dropped (one of two pins renders alone at
/// full size), and a provider with no data-carrying pins contributes no icon at all. Pins are
/// membership; the strip shows whatever subset is real right now.
static func build(groups: [ProviderMetrics], data: (WidgetDescriptor) -> WidgetData) -> MenuBarContent {
/// `title` resolves each provider's card title (the VoiceOver summary is a human-facing name, so
/// the caller passes the account-registry resolver); defaults to the baked derived name.
static func build(
groups: [ProviderMetrics],
data: (WidgetDescriptor) -> WidgetData,
title: (Provider) -> String = { $0.displayName }
) -> MenuBarContent {
let resolvedGroups = groups.compactMap { group -> MenuBarContent.Group? in
let metrics = group.metrics.map { resolve($0, data($0)) }.filter(\.hasData)
guard !metrics.isEmpty else { return nil }
return MenuBarContent.Group(
providerID: group.provider.id,
displayName: group.provider.displayName,
displayName: title(group.provider),
icon: group.provider.icon,
metrics: metrics
)
Expand Down
6 changes: 5 additions & 1 deletion Sources/OpenUsage/Models/ProviderSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import Foundation
/// Latest normalized output for one provider refresh.
struct ProviderSnapshot: Hashable, Sendable, Codable {
let providerID: String
let displayName: String
/// The card title at refresh time — always the baked DERIVED name (renames never reach the
/// cache or iCloud). The CLI/API boundary re-resolves it against the account registry at
/// respond time (`LocalUsageAPI.State.resolvingDisplayNames`), so human-facing output carries
/// renames without persisting them.
var displayName: String
var plan: String?
var lines: [MetricLine]
var refreshedAt: Date
Expand Down
Loading