diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d7a8997b..648ad19973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.51.1 — Unreleased ### Added +- Claude: add an off-by-default Claude Code statusLine integration that stores only sanitized 5-hour/7-day observations and can show an anonymous reduced-detail card while Keychain access is disabled (#2769). Thanks @luisgonzaleznf! - Usage & Spend: aggregate per-project spend into ranked, window-scoped rows and carry project/session breakdowns through the cached Codex prefill so the dashboard and the menu chart agree on project data (#2984). Thanks @Yuxin-Qiao! - Usage & Spend: add a Projects panel to the settings pane and let the Models and Projects lists expand beyond the top eight rows (#2985). Thanks @Yuxin-Qiao! diff --git a/README.md b/README.md index 2d3508c4eb..f91f55a050 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Codex](docs/codex.md) — OAuth API or local Codex CLI, plus optional OpenAI web dashboard extras. - [OpenAI](docs/openai.md) — Admin API key usage/cost graphs with legacy credit-balance fallback. - [Azure OpenAI](docs/azure-openai.md) — API key, endpoint, and deployment validation probe. -- [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available. +- [Claude](docs/claude.md) — OAuth API, browser cookies, CLI PTY fallback, or an optional sanitized Claude Code statusLine observation while Keychain access is disabled. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. - [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage. - [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows. diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 94d7c0743c..e3fcb50046 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -245,6 +245,12 @@ extension UsageMenuCardView.Model { } } + static func isClaudeStatusLineSource(_ sourceLabel: String?) -> Bool { + sourceLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare(ClaudeStatusLineFeed.standaloneSourceLabel) == .orderedSame + } + static func usageNotes(input: Input) -> [String] { let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider) @@ -252,6 +258,13 @@ extension UsageMenuCardView.Model { return self.kiroUsageNotes(input: input) + subscriptionNotes } + if input.provider == .claude, Self.isClaudeStatusLineSource(input.sourceLabel) { + return [ + L("Available 5h/7d usage from your Claude Code statusLine configuration."), + L("Detailed Claude limits are unavailable while Keychain access is disabled."), + ] + } + if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) let resolvedSource = input.sourceLabel? diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 672037eb30..303c68dd20 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import SwiftUI @@ -26,6 +27,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeOAuthDirectKeychainReadAllowed _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudeStatusLineFeedEnabled _ = settings.claudeSwapEnabled _ = settings.claudeSwapShowSingleAccount _ = settings.claudeSwapExecutablePath @@ -39,7 +41,9 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } - if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { + return true + } return context.settings.claudeCookieSource == .manual } @@ -137,6 +141,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + Self.statusLineToggle(context: context), ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", title: "Avoid Keychain prompts", @@ -177,6 +182,119 @@ struct ClaudeProviderImplementation: ProviderImplementation { ] } + @MainActor + private static func statusLineToggle(context: ProviderSettingsContext) -> ProviderSettingsToggleDescriptor { + let state = { Self.statusLineInstallState() } + return ProviderSettingsToggleDescriptor( + id: "claude-statusline-feed", + title: "Use your Claude Code statusLine feed", + subtitle: "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code " + + "statusLine configuration provides them. With Keychain access disabled, it can show an " + + "anonymous reduced-detail card.", + binding: context.boolBinding(\.claudeStatusLineFeedEnabled), + statusText: { + context.statusText("claude-statusline-feed") ?? Self.statusLineStatusText(state: state()) + }, + actions: [ + ProviderSettingsActionDescriptor( + id: "claude-statusline-install", + title: "Install", + style: .bordered, + isVisible: { state() == .absent }, + perform: { await Self.updateStatusLineInstallation(context: context, uninstall: false) }), + ProviderSettingsActionDescriptor( + id: "claude-statusline-repair", + title: "Repair", + style: .bordered, + isVisible: { state() == .needsRepair }, + perform: { await Self.updateStatusLineInstallation(context: context, uninstall: false) }), + ProviderSettingsActionDescriptor( + id: "claude-statusline-uninstall", + title: "Uninstall", + style: .bordered, + isVisible: { + let value = state() + return value == .installed || value == .needsRepair + }, + perform: { await Self.updateStatusLineInstallation(context: context, uninstall: true) }), + ProviderSettingsActionDescriptor( + id: "claude-statusline-guide", + title: "Manual composition guide", + style: .link, + isVisible: nil, + perform: { + guard let url = URL( + string: "https://github.com/steipete/CodexBar/blob/main/docs/claude-statusline-feed.md") + else { return } + NSWorkspace.shared.open(url) + }), + ], + isVisible: nil, + isEnabled: nil, + onChange: { enabled in + guard !enabled else { return } + let value = state() + guard value == .installed || value == .needsRepair else { return } + await Self.updateStatusLineInstallation(context: context, uninstall: true) + }, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil) + } + + private static func statusLineInstallState() -> ClaudeStatusLineInstallState? { + guard let executable = ClaudeStatusLineInstaller.bundledCLIURL() else { return nil } + return ClaudeStatusLineInstaller.inspect( + settingsURL: ClaudeStatusLineInstaller.settingsURL(), + executableURL: executable) + } + + private static func statusLineStatusText(state: ClaudeStatusLineInstallState?) -> String { + switch state { + case nil: L("CodexBarCLI was not found in the installed app bundle.") + case .absent?: L("Managed statusLine command is not installed.") + case .installed?: L("Managed statusLine command is installed.") + case .needsRepair?: L("Managed statusLine command points to another CodexBar app. Repair it.") + case .userOwned?: L("Claude Code already has a custom statusLine. Use the manual composition guide.") + case .malformed?: L("Claude Code settings are malformed; CodexBar will not overwrite them.") + case .unsafeSymlink?: L("Claude Code settings use a symbolic link; CodexBar will not write them.") + } + } + + @MainActor + private static func updateStatusLineInstallation(context: ProviderSettingsContext, uninstall: Bool) async { + let statusID = "claude-statusline-feed" + guard let executable = ClaudeStatusLineInstaller.bundledCLIURL() else { + context.setStatusText(statusID, L("CodexBarCLI was not found in the installed app bundle.")) + return + } + do { + if uninstall { + try ClaudeStatusLineInstaller.uninstall( + settingsURL: ClaudeStatusLineInstaller.settingsURL(), + executableURL: executable) + context.setStatusText(statusID, L("CodexBar statusLine command removed.")) + } else { + try ClaudeStatusLineInstaller.install( + settingsURL: ClaudeStatusLineInstaller.settingsURL(), + executableURL: executable) + context.setStatusText(statusID, L("CodexBar statusLine command installed.")) + } + } catch { + context.setStatusText(statusID, L(Self.statusLineErrorMessage(error))) + } + } + + private static func statusLineErrorMessage(_ error: Error) -> String { + switch error as? ClaudeStatusLineInstallerError { + case .helperUnavailable?: "CodexBarCLI was not found in the installed app bundle." + case .userOwned?: "Claude Code already has a custom statusLine. Use the manual composition guide." + case .malformedSettings?: "Claude Code settings are malformed; CodexBar will not overwrite them." + case .unsafeSymlink?: "Claude Code settings use a symbolic link; CodexBar will not write them." + case .notInstalled?: "Managed statusLine command is not installed." + case nil: "The statusLine integration could not be updated." + } + } + @MainActor private static func claudeSwapStatusText(store: UsageStore, settings: SettingsStore) -> String? { guard settings.claudeSwapEnabled else { return nil } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift index 9ad7f176d3..3833cb719c 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift @@ -104,6 +104,9 @@ extension SettingsStore { routing: routing, hasSelectedAccount: account != nil), webExtrasEnabled: self.claudeWebExtrasEnabled, + statusLineFeedEnabled: self.claudeStatusLineFeedEnabled, + keychainAccessDisabled: self.debugDisableKeychainAccess, + statusLineStandaloneAllowed: self.claudeStatusLineStandaloneAllowed(account: account), cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing), manualCookieHeader: self.claudeSnapshotCookieHeader( routing: routing, @@ -111,6 +114,15 @@ extension SettingsStore { organizationID: account?.sanitizedOrganizationID) } + private func claudeStatusLineStandaloneAllowed(account: ProviderTokenAccount?) -> Bool { + self.debugDisableKeychainAccess && + self.claudeStatusLineFeedEnabled && + self.claudeUsageDataSource == .auto && + account == nil && + self.tokenAccounts(for: .claude).isEmpty && + !self.claudeSwapEnabled + } + private static func claudeUsageDataSource(from source: ProviderSourceMode?) -> ClaudeUsageDataSource { guard let source else { return .auto } switch source { @@ -174,7 +186,9 @@ extension SettingsStore { if routing.adminAPIKey != nil { return .off } - if self.tokenAccounts(for: .claude).isEmpty { return fallback } + if self.tokenAccounts(for: .claude).isEmpty { + return fallback + } return .manual } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 339fc419b6..4b5fba2a27 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1464,3 +1464,20 @@ "Total usage" = "إجمالي الاستخدام"; "claude_oauth_keychain_access_revoked" = "تم إلغاء وصول CodexBar إلى سلسلة مفاتيح Claude بسبب تدوير الرمز المميز في Claude Code. انقر على «تحديث» لمنح الوصول مجددًا، أو بدّل مصدر استخدام Claude إلى CLI/Web."; "claude_showing_last_known_usage" = "يتم عرض آخر بيانات استخدام معروفة، تم التقاطها %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index dddf9304de..147cdbe8dd 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1463,3 +1463,20 @@ "Total usage" = "Ús total"; "claude_oauth_keychain_access_revoked" = "L'accés al clauer de Claude s'ha revocat per la rotació del testimoni de Claude Code. Feu clic a Actualitza per tornar a concedir l'accés, o canvieu l'origen d'ús de Claude a CLI/Web."; "claude_showing_last_known_usage" = "Es mostra l'últim ús conegut capturat %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 0a35ca519e..a3685acd80 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1461,3 +1461,20 @@ "Total usage" = "Gesamtnutzung"; "claude_oauth_keychain_access_revoked" = "Der Zugriff auf den Claude-Schlüsselbund wurde durch die Token-Rotation von Claude Code widerrufen. Klicken Sie auf „Aktualisieren“, um den Zugriff erneut zu gewähren, oder stellen Sie die Claude-Nutzungsquelle auf CLI/Web um."; "claude_showing_last_known_usage" = "Letzte bekannte Nutzung wird angezeigt (erfasst: %@)."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 35461d956d..7fe4e4cb83 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1465,3 +1465,20 @@ "5-hour usage" = "5-hour usage"; "7-day usage" = "7-day usage"; "Total usage" = "Total usage"; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 427852bc03..66a481c8f4 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1459,3 +1459,20 @@ "Total usage" = "Uso total"; "claude_oauth_keychain_access_revoked" = "El acceso al llavero de Claude fue revocado por la rotación del token de Claude Code. Haz clic en Actualizar para volver a conceder acceso o cambia el origen del uso de Claude a CLI/Web."; "claude_showing_last_known_usage" = "Mostrando el último uso conocido, capturado %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index b4aefbc3a5..15210d1ff8 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1464,3 +1464,20 @@ "Total usage" = "مصرف کل"; "claude_oauth_keychain_access_revoked" = "دسترسی به Keychain کلود با چرخش توکن Claude Code لغو شد. برای اعطای مجدد دسترسی روی «تازه‌سازی» کلیک کنید، یا منبع استفاده Claude را به CLI/Web تغییر دهید."; "claude_showing_last_known_usage" = "آخرین میزان استفاده شناخته‌شده که در %@ ثبت شده نمایش داده می‌شود."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 7b4e74dac8..ba91881328 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1460,3 +1460,20 @@ "Total usage" = "Utilisation totale"; "claude_oauth_keychain_access_revoked" = "L’accès au trousseau Claude a été révoqué par la rotation du jeton de Claude Code. Cliquez sur Actualiser pour accorder à nouveau l’accès, ou définissez la source d’utilisation de Claude sur CLI/Web."; "claude_showing_last_known_usage" = "Affichage de la dernière utilisation connue, capturée %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 2e18a5bc75..c9f8baf880 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1460,3 +1460,20 @@ "Total usage" = "Uso total"; "claude_oauth_keychain_access_revoked" = "O acceso ao chaveiro de Claude foi revogado pola rotación do token de Claude Code. Preme Actualizar para volver conceder o acceso ou cambia a orixe de uso de Claude a CLI/Web."; "claude_showing_last_known_usage" = "Mostrando o último uso coñecido, capturado %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 9524c9f47e..f48be77dcf 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1464,3 +1464,20 @@ "Total usage" = "Total penggunaan"; "claude_oauth_keychain_access_revoked" = "Akses Rantai Kunci Claude dicabut akibat rotasi token Claude Code. Klik Segarkan untuk memberikan akses lagi, atau ubah sumber penggunaan Claude ke CLI/Web."; "claude_showing_last_known_usage" = "Menampilkan penggunaan terakhir yang diketahui, diambil %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 3e64c31e2f..a48c7d2845 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1464,3 +1464,20 @@ "Total usage" = "Utilizzo totale"; "claude_oauth_keychain_access_revoked" = "L'accesso al portachiavi di Claude è stato revocato dalla rotazione del token di Claude Code. Fai clic su Aggiorna per concedere nuovamente l'accesso oppure imposta la fonte di utilizzo di Claude su CLI/Web."; "claude_showing_last_known_usage" = "Visualizzazione dell'ultimo utilizzo noto, acquisito %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7955995850..68afc17516 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1461,3 +1461,20 @@ "Total usage" = "合計使用量"; "claude_oauth_keychain_access_revoked" = "Claude Code のトークン更新により、Claude キーチェーンへのアクセスが取り消されました。「更新」をクリックしてアクセスを再許可するか、Claude の使用量の取得元を CLI/Web に切り替えてください。"; "claude_showing_last_known_usage" = "最後に取得した既知の使用量を表示しています(取得: %@)。"; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 65d736291b..e049e6385a 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1428,3 +1428,20 @@ "Total usage" = "총 사용량"; "claude_oauth_keychain_access_revoked" = "Claude Code의 토큰 교체로 Claude 키체인 접근 권한이 취소되었습니다. 새로 고침을 클릭해 접근 권한을 다시 부여하거나 Claude 사용량 소스를 CLI/Web으로 전환하세요."; "claude_showing_last_known_usage" = "마지막으로 확인된 사용량을 표시 중입니다(캡처: %@)."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index dc861df956..d19549b75d 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1460,3 +1460,20 @@ "Total usage" = "Totaal gebruik"; "claude_oauth_keychain_access_revoked" = "De toegang tot de Claude-sleutelhanger is ingetrokken door de tokenrotatie van Claude Code. Klik op Vernieuwen om opnieuw toegang te verlenen of zet de Claude-gebruiksbron op CLI/Web."; "claude_showing_last_known_usage" = "De laatst bekende gebruiksgegevens worden weergegeven (vastgelegd: %@)."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 4841f7ee01..7eadad371e 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1464,3 +1464,20 @@ "Total usage" = "Łączne użycie"; "claude_oauth_keychain_access_revoked" = "Dostęp do pęku kluczy Claude został cofnięty wskutek rotacji tokenu przez Claude Code. Kliknij Odśwież, aby ponownie przyznać dostęp, albo przełącz źródło użycia Claude na CLI/Web."; "claude_showing_last_known_usage" = "Wyświetlane jest ostatnie znane użycie zarejestrowane %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 55101fd069..3896c06495 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1461,3 +1461,20 @@ "Total usage" = "Uso total"; "claude_oauth_keychain_access_revoked" = "O acesso às Chaves do Claude foi revogado pela rotação do token do Claude Code. Clique em Atualizar para conceder o acesso novamente ou altere a fonte de uso do Claude para CLI/Web."; "claude_showing_last_known_usage" = "Exibindo o último uso conhecido, capturado %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index b26a39b7b5..e33fd63f8c 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1462,3 +1462,20 @@ "Total usage" = "Общее использование"; "claude_oauth_keychain_access_revoked" = "Доступ к Связке ключей Claude был отозван из-за ротации токена в Claude Code. Нажмите «Обновить», чтобы повторно предоставить доступ, или переключите источник использования Claude на CLI/Web."; "claude_showing_last_known_usage" = "Показаны последние известные данные об использовании (получены %@)."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index bbb288a4ec..fa7b5cc510 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1459,3 +1459,20 @@ "Total usage" = "Total användning"; "claude_oauth_keychain_access_revoked" = "Åtkomsten till Claudes nyckelring återkallades när Claude Code roterade token. Klicka på Uppdatera för att ge åtkomst igen, eller byt Claudes användningskälla till CLI/Web."; "claude_showing_last_known_usage" = "Visar senast kända användning, registrerad %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 1ee9086b11..c2c4388996 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1464,3 +1464,20 @@ "Total usage" = "การใช้งานทั้งหมด"; "claude_oauth_keychain_access_revoked" = "สิทธิ์เข้าถึงพวงกุญแจ Claude ถูกเพิกถอนจากการหมุนเวียนโทเค็นของ Claude Code คลิกรีเฟรชเพื่อให้สิทธิ์อีกครั้ง หรือเปลี่ยนแหล่งที่มาการใช้งาน Claude เป็น CLI/Web"; "claude_showing_last_known_usage" = "กำลังแสดงการใช้งานล่าสุดที่ทราบ ซึ่งบันทึกเมื่อ %@"; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index c26a72bf96..f891a8a650 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1462,3 +1462,20 @@ "Total usage" = "Toplam kullanım"; "claude_oauth_keychain_access_revoked" = "Claude Anahtar Zinciri erişimi, Claude Code'un belirteç yenilemesi nedeniyle iptal edildi. Erişimi yeniden vermek için Yenile'ye tıklayın veya Claude Kullanım kaynağını CLI/Web olarak değiştirin."; "claude_showing_last_known_usage" = "Bilinen son kullanım gösteriliyor (yakalanma zamanı: %@)."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index c41779dcd7..fbc28185b1 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1460,3 +1460,20 @@ "Total usage" = "Загальне використання"; "claude_oauth_keychain_access_revoked" = "Доступ до В’язки ключів Claude було відкликано через ротацію токена в Claude Code. Натисніть «Оновити», щоб повторно надати доступ, або перемкніть джерело використання Claude на CLI/Web."; "claude_showing_last_known_usage" = "Показано останні відомі дані про використання (отримано %@)."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index c0c19bd2af..482c23d7cc 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1461,3 +1461,20 @@ "Total usage" = "Tổng mức sử dụng"; "claude_oauth_keychain_access_revoked" = "Quyền truy cập Chuỗi khóa Claude đã bị thu hồi do Claude Code xoay vòng mã thông báo. Nhấp vào Làm mới để cấp lại quyền truy cập hoặc chuyển nguồn sử dụng Claude sang CLI/Web."; "claude_showing_last_known_usage" = "Đang hiển thị mức sử dụng đã biết gần nhất, được ghi nhận %@."; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 7bd52093d5..0792845c74 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1439,3 +1439,20 @@ "Total usage" = "总用量"; "claude_oauth_keychain_access_revoked" = "Claude Code 轮换令牌后撤销了对 Claude 钥匙串的访问权限。点击“刷新”以重新授权,或将 Claude 用量来源切换为 CLI/Web。"; "claude_showing_last_known_usage" = "正在显示 %@ 采集的最后已知用量。"; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 678d88aca6..83daf9c7b6 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1491,3 +1491,20 @@ "Total usage" = "總用量"; "claude_oauth_keychain_access_revoked" = "Claude Code 輪替權杖後撤銷了 Claude 鑰匙圈的存取權。按一下「重新整理」以重新授權,或將 Claude 使用量來源切換為 CLI/Web。"; "claude_showing_last_known_usage" = "正在顯示於 %@ 擷取的最後已知使用量。"; +"CodexBarCLI was not found in the installed app bundle." = "CodexBarCLI was not found in the installed app bundle."; +"CodexBar statusLine command removed." = "CodexBar statusLine command removed."; +"CodexBar statusLine command installed." = "CodexBar statusLine command installed."; +"The statusLine integration could not be updated." = "The statusLine integration could not be updated."; +"Use your Claude Code statusLine feed" = "Use your Claude Code statusLine feed"; +"Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card." = "Off by default. Reads sanitized 5-hour and/or 7-day limits when your Claude Code statusLine configuration provides them. With Keychain access disabled, it can show an anonymous reduced-detail card."; +"Managed statusLine command is not installed." = "Managed statusLine command is not installed."; +"Managed statusLine command is installed." = "Managed statusLine command is installed."; +"Managed statusLine command points to another CodexBar app. Repair it." = "Managed statusLine command points to another CodexBar app. Repair it."; +"Claude Code already has a custom statusLine. Use the manual composition guide." = "Claude Code already has a custom statusLine. Use the manual composition guide."; +"Claude Code settings are malformed; CodexBar will not overwrite them." = "Claude Code settings are malformed; CodexBar will not overwrite them."; +"Claude Code settings use a symbolic link; CodexBar will not write them." = "Claude Code settings use a symbolic link; CodexBar will not write them."; +"Repair" = "Repair"; +"Uninstall" = "Uninstall"; +"Manual composition guide" = "Manual composition guide"; +"Available 5h/7d usage from your Claude Code statusLine configuration." = "Available 5h/7d usage from your Claude Code statusLine configuration."; +"Detailed Claude limits are unavailable while Keychain access is disabled." = "Detailed Claude limits are unavailable while Keychain access is disabled."; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ce25e10cfd..ce6372a37b 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -673,6 +673,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 { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 6e34e8f6e1..77dd5112e0 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -57,6 +57,7 @@ extension SettingsStore { _ = self.claudeOAuthDirectKeychainReadAllowed _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled + _ = self.claudeStatusLineFeedEnabled _ = self.copilotBudgetExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.claudeDailyRoutinesUsageVisible diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f8d80dd8db..e71480817c 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -303,7 +303,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() @@ -380,10 +381,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 { @@ -521,6 +525,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 { @@ -645,6 +652,7 @@ extension SettingsStore { claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeOAuthDirectKeychainReadAllowed: claudeOAuthDirectKeychainReadAllowed, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, + claudeStatusLineFeedEnabledRaw: claudeStatusLineFeedEnabledRaw, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible, claudeModelScopedWeeklyUsageVisible: claudeModelScopedWeeklyUsageVisible, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 694ce3844b..cdf106d9ac 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -58,6 +58,7 @@ struct SettingsDefaultsState { var claudeOAuthKeychainReadStrategyRaw: String? var claudeOAuthDirectKeychainReadAllowed: Bool var claudeWebExtrasEnabledRaw: Bool + var claudeStatusLineFeedEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool var claudeDailyRoutinesUsageVisible: Bool var claudeModelScopedWeeklyUsageVisible: Bool diff --git a/Sources/CodexBarCLI/CLIClaudeStatusLineCommand.swift b/Sources/CodexBarCLI/CLIClaudeStatusLineCommand.swift new file mode 100644 index 0000000000..0179904df3 --- /dev/null +++ b/Sources/CodexBarCLI/CLIClaudeStatusLineCommand.swift @@ -0,0 +1,34 @@ +import CodexBarCore +import Foundation + +extension CodexBarCLI { + static func runClaudeStatusLineCapture( + input: FileHandle = .standardInput, + environment: [String: String] = ProcessInfo.processInfo.environment, + applicationSupport: URL? = nil, + now: Date = Date()) + { + guard let data = try? self.readBoundedInput(input, limit: ClaudeStatusLineFeed.maximumInputBytes), + let observation = ClaudeStatusLinePayloadParser.parseOfficialPayload( + data, + capturedAt: now, + environment: environment), + let support = applicationSupport ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first + else { return } + try? ClaudeStatusLineDropStore.write(observation, applicationSupport: support) + // Intentionally no stdout: Claude Code renders stdout as the status line. + } + + static func readBoundedInput(_ input: FileHandle, limit: Int) throws -> Data? { + var result = Data() + while let chunk = try input.read(upToCount: min(64 * 1024, limit + 1 - result.count)), !chunk.isEmpty { + result.append(chunk) + if result.count > limit { + return nil + } + } + return result + } +} diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 80e65ecfd5..d05c2a8858 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -79,6 +79,8 @@ enum CodexBarCLI { await self.runGuard(invocation.parsedValues) case let path where path.first == "plugins": await self.runPlugins(path: path, values: invocation.parsedValues) + case ["claude", "statusline", "capture"]: + self.runClaudeStatusLineCapture() default: Self.exit( code: .failure, @@ -279,9 +281,34 @@ enum CodexBarCLI { signature: diagnoseSignature), ] descriptors.append(Self.pluginsCommandDescriptor()) + descriptors.append(Self.claudeCommandDescriptor()) return descriptors } + private static func claudeCommandDescriptor() -> CommandDescriptor { + CommandDescriptor( + name: "claude", + abstract: "Claude Code integrations", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "statusline", + abstract: "Claude Code statusLine integration", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "capture", + abstract: "Capture sanitized Claude Code rate-limit windows from stdin", + discussion: nil, + signature: CommandSignature()), + ], + defaultSubcommandName: "capture"), + ], + defaultSubcommandName: "statusline") + } + private static func pluginsCommandDescriptor() -> CommandDescriptor { CommandDescriptor( name: "plugins", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 6ff64710c8..095a1c3b99 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -485,6 +485,7 @@ extension CodexBarCLI { codexbar cookie refresh <--provider |--all> [--allow-keychain-prompt] codexbar diagnose --provider --format json [--redact] [--output ] [--pretty] codexbar guard --provider [--min-remaining ] [--window session|weekly] [--json] + codexbar claude statusline capture Global flags: -h, --help Show help @@ -518,4 +519,18 @@ extension CodexBarCLI { codexbar guard --provider claude --min-remaining 20 """ } + + static func claudeStatusLineHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar claude statusline capture + + Description: + Reads one bounded Claude Code statusLine JSON object from stdin and stores only the official + rate_limits.five_hour and rate_limits.seven_day percentages and reset times. Stdout is empty so + this helper does not add text to Claude Code's status line. Invalid input is ignored. + """ + } } diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index 81eaf49b15..6adf193bdb 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -51,6 +51,8 @@ extension CodexBarCLI { print(Self.guardHelp(version: version)) case "plugins": print(Self.pluginsHelp(version: version)) + case "claude", "statusline", "capture": + print(Self.claudeStatusLineHelp(version: version)) default: print(Self.rootHelp(version: version)) } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 3142e7cffe..79e68a2088 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -261,7 +261,7 @@ public enum ClaudeProviderDescriptor { let plan = ClaudeSourcePlanner.resolve(input: planningInput) let webEnrichmentAccess = Self.webEnrichmentAccess(context: context) - return plan.orderedSteps.map { step in + var strategies: [any ProviderFetchStrategy] = plan.orderedSteps.map { step in let strategy: any ProviderFetchStrategy = switch step.dataSource { case .api: ClaudeAdminAPIFetchStrategy() @@ -285,6 +285,10 @@ public enum ClaudeProviderDescriptor { } return ClaudePlannedFetchStrategy(base: strategy, plannedStep: step) } + if Self.shouldUseStatusLineStandalone(context: context) { + strategies.insert(ClaudeStatusLineFetchStrategy(), at: 0) + } + return strategies } private static func hasAutoAdminAPIKey(context: ProviderFetchContext) -> Bool { @@ -329,6 +333,17 @@ public enum ClaudeProviderDescriptor { hasOAuthCredentials: shouldAttemptOAuth) } + private static func shouldUseStatusLineStandalone(context: ProviderFetchContext) -> Bool { + guard context.runtime == .app, + context.sourceMode == .auto, + context.selectedTokenAccountID == nil, + let settings = context.settings?.claude + else { return false } + return settings.statusLineFeedEnabled && + settings.keychainAccessDisabled && + settings.statusLineStandaloneAllowed + } + private static func hasPlausibleWebSession(context: ProviderFetchContext) -> Bool { switch context.sourceMode { case .api, .oauth, .cli: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderSettings.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderSettings.swift index 1af4c1f154..7a367346a7 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderSettings.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderSettings.swift @@ -3,6 +3,13 @@ import Foundation public struct ClaudeProviderSettings: Sendable { public let usageDataSource: ClaudeUsageDataSource public let webExtrasEnabled: Bool + /// Opt-in Claude statusLine observation feed. Off unless the user turns it on. + public let statusLineFeedEnabled: Bool + /// Mirrors the user's global Disable Keychain access preference, not the test-process safety gate. + public let keychainAccessDisabled: Bool + /// True only for the anonymous ambient Auto card. Explicit credentials and multi-account presentations + /// cannot consume an identity-free observation. + public let statusLineStandaloneAllowed: Bool public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? public let organizationID: String? @@ -10,12 +17,18 @@ public struct ClaudeProviderSettings: Sendable { public init( usageDataSource: ClaudeUsageDataSource, webExtrasEnabled: Bool, + statusLineFeedEnabled: Bool = false, + keychainAccessDisabled: Bool = false, + statusLineStandaloneAllowed: Bool = false, cookieSource: ProviderCookieSource, manualCookieHeader: String?, organizationID: String? = nil) { self.usageDataSource = usageDataSource self.webExtrasEnabled = webExtrasEnabled + self.statusLineFeedEnabled = statusLineFeedEnabled + self.keychainAccessDisabled = keychainAccessDisabled + self.statusLineStandaloneAllowed = statusLineStandaloneAllowed self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader self.organizationID = organizationID diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineDropStore.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineDropStore.swift new file mode 100644 index 0000000000..f0628c5498 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineDropStore.swift @@ -0,0 +1,149 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Foundation + +public enum ClaudeStatusLineDropStore { + public static let freshnessWindow: TimeInterval = 15 * 60 + public static let maximumClockSkew: TimeInterval = 5 * 60 + public static let directoryName = "claude-statusline" + + public static func directoryURL(applicationSupport: URL) -> URL { + applicationSupport + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent(self.directoryName, isDirectory: true) + } + + public static func observationURL(applicationSupport: URL, profileID: String) -> URL { + self.directoryURL(applicationSupport: applicationSupport) + .appendingPathComponent("\(profileID).json", isDirectory: false) + } + + public static func write( + _ observation: ClaudeStatusLineRateLimits, + applicationSupport: URL, + fileManager: FileManager = .default) throws + { + guard self.isValid(observation) else { throw ClaudeStatusLineFileError.invalidObservation } + let codexBarDirectory = applicationSupport.appendingPathComponent("CodexBar", isDirectory: true) + let directory = self.directoryURL(applicationSupport: applicationSupport) + try self.preparePrivateDirectory(codexBarDirectory, fileManager: fileManager) + try self.preparePrivateDirectory(directory, fileManager: fileManager) + let destination = self.observationURL(applicationSupport: applicationSupport, profileID: observation.profileID) + guard !self.isSymbolicLink(destination, fileManager: fileManager) else { + throw ClaudeStatusLineFileError.symbolicLink(destination.path) + } + + let envelope = ClaudeStatusLineObservationEnvelope( + schema: ClaudeStatusLineFeed.schemaVersion, + observation: observation) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + try CredentialFileWriter.writePrivate(encoder.encode(envelope), to: destination) + } + + public static func load( + applicationSupport: URL, + expectedProfileID: String, + now: Date = Date()) -> ClaudeStatusLineRateLimits? + { + let url = self.observationURL(applicationSupport: applicationSupport, profileID: expectedProfileID) + guard !self.isSymbolicLink(url), + let data = try? Data(contentsOf: url), + let envelope = self.decode(data), + envelope.observation.profileID == expectedProfileID, + self.isValid(envelope.observation), + self.isFresh(envelope.observation, now: now) + else { return nil } + return envelope.observation + } + + public static func isFresh(_ observation: ClaudeStatusLineRateLimits, now: Date) -> Bool { + let age = now.timeIntervalSince(observation.capturedAt) + return age <= self.freshnessWindow && age >= -self.maximumClockSkew + } + + public static func makeSnapshot(from limits: ClaudeStatusLineRateLimits) -> UsageSnapshot? { + guard self.isValid(limits) else { return nil } + return UsageSnapshot( + primary: limits.fiveHour.map { self.window($0, minutes: 300) }, + secondary: limits.sevenDay.map { self.window($0, minutes: 10080) }, + updatedAt: limits.capturedAt, + identity: nil, + dataConfidence: .unknown) + } + + private static func decode(_ data: Data) -> ClaudeStatusLineObservationEnvelope? { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + guard let envelope = try? decoder.decode(ClaudeStatusLineObservationEnvelope.self, from: data), + envelope.schema == ClaudeStatusLineFeed.schemaVersion + else { return nil } + return envelope + } + + private static func isValid(_ observation: ClaudeStatusLineRateLimits) -> Bool { + guard !observation.profileID.isEmpty, + observation.capturedAt.timeIntervalSince1970.isFinite, + observation.fiveHour != nil || observation.sevenDay != nil + else { return false } + return [observation.fiveHour, observation.sevenDay].compactMap(\.self).allSatisfy { window in + window.usedPercent.isFinite && + (0...100).contains(window.usedPercent) && + self.isValidReset(window.resetsAt) + } + } + + private static func isValidReset(_ reset: Date?) -> Bool { + guard let reset else { return true } + let seconds = reset.timeIntervalSince1970 + return seconds.isFinite && ClaudeStatusLineFeed.validResetEpochSecondsRange.contains(seconds) + } + + private static func window(_ source: ClaudeStatusLineWindow, minutes: Int) -> RateWindow { + RateWindow( + usedPercent: source.usedPercent, + windowMinutes: minutes, + resetsAt: source.resetsAt, + resetDescription: nil) + } + + private static func preparePrivateDirectory(_ url: URL, fileManager: FileManager) throws { + guard !self.isSymbolicLink(url, fileManager: fileManager) else { + throw ClaudeStatusLineFileError.symbolicLink(url.path) + } + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { throw ClaudeStatusLineFileError.notDirectory(url.path) } + } else { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + } + + private static func isSymbolicLink(_ url: URL, fileManager: FileManager = .default) -> Bool { + guard let attributes = try? fileManager.attributesOfItem(atPath: url.path), + let type = attributes[.type] as? FileAttributeType + else { return false } + return type == .typeSymbolicLink + } +} + +public enum ClaudeStatusLineFileError: LocalizedError, Equatable { + case symbolicLink(String) + case notDirectory(String) + case invalidObservation + + public var errorDescription: String? { + switch self { + case let .symbolicLink(path): "Refusing to write through symbolic link: \(path)" + case let .notDirectory(path): "Expected a directory at: \(path)" + case .invalidObservation: "Claude statusLine observation has no valid usage window." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineFetchStrategy.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineFetchStrategy.swift new file mode 100644 index 0000000000..54ac9b9720 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineFetchStrategy.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Supplies an anonymous ambient Claude card only while the user's global Keychain-disable preference is on. +/// The provider descriptor excludes explicit credentials, non-Auto sources, and multi-account presentations +/// before this strategy can enter the chain. +struct ClaudeStatusLineFetchStrategy: ProviderFetchStrategy { + typealias ObservationLoader = @Sendable (ProviderFetchContext) -> ClaudeStatusLineRateLimits? + + #if DEBUG + @TaskLocal static var observationLoaderOverrideForTesting: ObservationLoader? + #endif + + let id = "claude.statusline.standalone" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + self.loadObservation(context) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let limits = self.loadObservation(context), + let snapshot = ClaudeStatusLineDropStore.makeSnapshot(from: limits) + else { throw ClaudeStatusLineFetchError.noFreshObservation } + return self.makeResult( + usage: snapshot, + sourceLabel: ClaudeStatusLineFeed.standaloneSourceLabel) + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + !ClaudeOAuthFetchError.isCancellation(error) + } + + private func loadObservation(_ context: ProviderFetchContext) -> ClaudeStatusLineRateLimits? { + #if DEBUG + if let override = Self.observationLoaderOverrideForTesting { + return override(context) + } + #endif + guard let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + else { return nil } + return ClaudeStatusLineDropStore.load( + applicationSupport: support, + expectedProfileID: ClaudeStatusLineProfile.identifier(environment: context.env)) + } +} + +enum ClaudeStatusLineFetchError: Error { + case noFreshObservation +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineInstaller.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineInstaller.swift new file mode 100644 index 0000000000..a6cdfc735f --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineInstaller.swift @@ -0,0 +1,230 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Foundation + +public enum ClaudeStatusLineInstallState: Equatable, Sendable { + case absent + case installed + case needsRepair + case userOwned + case malformed + case unsafeSymlink +} + +public enum ClaudeStatusLineInstallerError: LocalizedError, Equatable { + case helperUnavailable + case userOwned + case malformedSettings + case unsafeSymlink + case notInstalled + + public var errorDescription: String? { + switch self { + case .helperUnavailable: + "CodexBarCLI was not found in the installed CodexBar app bundle." + case .userOwned: + "Claude Code already has a custom statusLine. CodexBar did not change it; use the manual composition guide." + case .malformedSettings: + "Claude Code settings are malformed. CodexBar did not overwrite them." + case .unsafeSymlink: + "Claude Code settings use a symbolic link. CodexBar refuses to write through it." + case .notInstalled: + "The Claude Code statusLine is not owned by CodexBar. Nothing was removed." + } + } +} + +public enum ClaudeStatusLineInstaller { + private static let statusLineKey = "statusLine" + private static let commandType = "command" + private static let commandSuffix = " claude statusline capture" + + public static func settingsURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + ClaudeConfigPaths.configRoot(environment: environment).appendingPathComponent("settings.json") + } + + public static func bundledCLIURL(bundle: Bundle = .main) -> URL? { + let url = bundle.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Helpers", isDirectory: true) + .appendingPathComponent("CodexBarCLI", isDirectory: false) + guard bundle.bundleURL.pathExtension == "app", + FileManager.default.isExecutableFile(atPath: url.path) + else { return nil } + return url + } + + public static func inspect(settingsURL: URL, executableURL: URL) -> ClaudeStatusLineInstallState { + guard !self.isSymbolicLink(settingsURL.deletingLastPathComponent()), + !self.isSymbolicLink(settingsURL) + else { return .unsafeSymlink } + guard FileManager.default.fileExists(atPath: settingsURL.path) else { return .absent } + guard let root = self.readRoot(settingsURL) else { return .malformed } + guard let raw = root[self.statusLineKey] else { return .absent } + guard let object = raw as? [String: Any] else { return .userOwned } + if self.isExactManagedObject(object, executableURL: executableURL) { + return .installed + } + return self.isRecognizedManagedObject(object) ? .needsRepair : .userOwned + } + + public static func install(settingsURL: URL, executableURL: URL) throws { + try self.install(settingsURL: settingsURL, executableURL: executableURL, beforePublish: nil) + } + + public static func uninstall(settingsURL: URL, executableURL: URL) throws { + let state = self.inspect(settingsURL: settingsURL, executableURL: executableURL) + guard state != .unsafeSymlink else { throw ClaudeStatusLineInstallerError.unsafeSymlink } + guard state != .malformed else { throw ClaudeStatusLineInstallerError.malformedSettings } + guard state == .installed || state == .needsRepair else { + throw state == .userOwned ? ClaudeStatusLineInstallerError.userOwned : .notInstalled + } + guard var root = self.readRoot(settingsURL), + let object = root[self.statusLineKey] as? [String: Any], + self.isRecognizedManagedObject(object) + else { throw ClaudeStatusLineInstallerError.notInstalled } + root.removeValue(forKey: self.statusLineKey) + try self.writeRoot(root, to: settingsURL, beforePublish: nil) + } + + static func install( + settingsURL: URL, + executableURL: URL, + beforePublish: ((URL) throws -> Void)?) throws + { + let state = self.inspect(settingsURL: settingsURL, executableURL: executableURL) + switch state { + case .installed: + return + case .userOwned: + throw ClaudeStatusLineInstallerError.userOwned + case .malformed: + throw ClaudeStatusLineInstallerError.malformedSettings + case .unsafeSymlink: + throw ClaudeStatusLineInstallerError.unsafeSymlink + case .absent, .needsRepair: + break + } + var root = self.readRoot(settingsURL) ?? [:] + root[self.statusLineKey] = self.managedObject(executableURL: executableURL) + try self.writeRoot(root, to: settingsURL, beforePublish: beforePublish) + } + + static func managedObject(executableURL: URL) -> [String: Any] { + [ + "type": self.commandType, + "command": self.shellQuote(executableURL.path) + self.commandSuffix, + ] + } + + private static func isExactManagedObject(_ object: [String: Any], executableURL: URL) -> Bool { + guard object.count == 2, + object["type"] as? String == self.commandType, + object["command"] as? String == self.managedObject(executableURL: executableURL)["command"] as? String + else { return false } + return true + } + + private static func isRecognizedManagedObject(_ object: [String: Any]) -> Bool { + guard object.count == 2, + object["type"] as? String == self.commandType, + let command = object["command"] as? String, + self.recognizedManagedExecutablePath(command: command) != nil + else { return false } + return true + } + + /// Recognizes only the exact command grammar emitted by `managedObject`, while allowing the app path to move. + private static func recognizedManagedExecutablePath(command: String) -> String? { + guard command.hasSuffix(self.commandSuffix) else { return nil } + let quotedPath = String(command.dropLast(self.commandSuffix.count)) + guard quotedPath.first == "'", quotedPath.last == "'", quotedPath.count >= 2 else { return nil } + + let encodedPath = String(quotedPath.dropFirst().dropLast()) + let path = encodedPath.replacingOccurrences(of: "'\"'\"'", with: "'") + guard self.shellQuote(path) == quotedPath, + path.hasPrefix("/"), + !path.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { return nil } + + let helperURL = URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + guard helperURL.path == path, + helperURL.lastPathComponent == "CodexBarCLI", + helperURL.deletingLastPathComponent().lastPathComponent == "Helpers", + helperURL.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "Contents" + else { return nil } + let appURL = helperURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + guard appURL.pathExtension.caseInsensitiveCompare("app") == .orderedSame, + !appURL.deletingPathExtension().lastPathComponent.isEmpty + else { return nil } + return path + } + + private static func readRoot(_ url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data), + let root = object as? [String: Any] + else { return nil } + return root + } + + private static func writeRoot( + _ root: [String: Any], + to url: URL, + beforePublish: ((URL) throws -> Void)?) throws + { + let fileManager = FileManager.default + let directory = url.deletingLastPathComponent() + guard !self.isSymbolicLink(directory), !self.isSymbolicLink(url) else { + throw ClaudeStatusLineInstallerError.unsafeSymlink + } + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let mode = ((try? fileManager.attributesOfItem(atPath: url.path)[.posixPermissions]) as? NSNumber)? + .uint16Value ?? 0o600 + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + let staged = directory.appendingPathComponent(".settings.json.codexbar-staged-\(UUID().uuidString)") + let descriptor = staged.path.withCString { + open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(mode)) + } + guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + var isOpen = true + do { + guard fchmod(descriptor, mode_t(mode)) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + isOpen = false + try beforePublish?(staged) + let result = staged.path.withCString { source in + url.path.withCString { destination in rename(source, destination) } + } + guard result == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + } catch { + if isOpen { + try? handle.close() + } + try? fileManager.removeItem(at: staged) + throw error + } + } + + private static func isSymbolicLink(_ url: URL) -> Bool { + guard let type = try? FileManager.default.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType + else { return false } + return type == .typeSymbolicLink + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLinePayload.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLinePayload.swift new file mode 100644 index 0000000000..bbc59e1be3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusLinePayload.swift @@ -0,0 +1,98 @@ +import Crypto +import Foundation + +public enum ClaudeStatusLineFeed { + public static let standaloneSourceLabel = "statusline-standalone" + public static let schemaVersion = 1 + public static let maximumInputBytes = 1_048_576 + /// StatusLine reset epochs outside 2000-01-01 through 2100-01-01 are treated as drifted metadata. + public static let validResetEpochSecondsRange: ClosedRange = 946_684_800...4_102_444_800 +} + +public struct ClaudeStatusLineWindow: Codable, Equatable, Sendable { + public let usedPercent: Double + public let resetsAt: Date? + + public init(usedPercent: Double, resetsAt: Date?) { + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } +} + +public struct ClaudeStatusLineRateLimits: Codable, Equatable, Sendable { + public let profileID: String + public let capturedAt: Date + public let fiveHour: ClaudeStatusLineWindow? + public let sevenDay: ClaudeStatusLineWindow? + + public init( + profileID: String, + capturedAt: Date, + fiveHour: ClaudeStatusLineWindow?, + sevenDay: ClaudeStatusLineWindow?) + { + self.profileID = profileID + self.capturedAt = capturedAt + self.fiveHour = fiveHour + self.sevenDay = sevenDay + } +} + +public enum ClaudeStatusLineProfile { + public static func identifier(environment: [String: String]) -> String { + let raw = environment[ClaudeConfigPaths.configDirectoryEnvironmentKey] ?? "" + let material = raw.isEmpty ? "codexbar.claude-statusline.default" : "codexbar.claude-statusline.\(raw)" + return SHA256.hash(data: Data(material.utf8)).map { String(format: "%02x", $0) }.joined() + } +} + +/// Parses Claude Code's statusLine stdin. Only the two documented rate-limit windows are accepted. +public enum ClaudeStatusLinePayloadParser { + public static func parseOfficialPayload( + _ data: Data, + capturedAt: Date = Date(), + environment: [String: String] = ProcessInfo.processInfo.environment) -> ClaudeStatusLineRateLimits? + { + guard data.count <= ClaudeStatusLineFeed.maximumInputBytes, + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rateLimits = root["rate_limits"] as? [String: Any] + else { return nil } + + let fiveHour = self.window(rateLimits["five_hour"]) + let sevenDay = self.window(rateLimits["seven_day"]) + guard fiveHour != nil || sevenDay != nil else { return nil } + return ClaudeStatusLineRateLimits( + profileID: ClaudeStatusLineProfile.identifier(environment: environment), + capturedAt: capturedAt, + fiveHour: fiveHour, + sevenDay: sevenDay) + } + + private static func window(_ raw: Any?) -> ClaudeStatusLineWindow? { + guard let object = raw as? [String: Any], + let used = self.finiteNumber(object["used_percentage"]), + (0...100).contains(used) + else { return nil } + return ClaudeStatusLineWindow(usedPercent: used, resetsAt: self.resetDate(object["resets_at"])) + } + + private static func resetDate(_ raw: Any?) -> Date? { + guard let seconds = self.finiteNumber(raw), + ClaudeStatusLineFeed.validResetEpochSecondsRange.contains(seconds) + else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + private static func finiteNumber(_ raw: Any?) -> Double? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } + let value = number.doubleValue + return value.isFinite ? value : nil + } +} + +struct ClaudeStatusLineObservationEnvelope: Codable, Equatable { + let schema: Int + let observation: ClaudeStatusLineRateLimits +} diff --git a/Tests/CodexBarTests/BoundedChildProcessProofTests.swift b/Tests/CodexBarTests/BoundedChildProcessProofTests.swift index a2019fd2dc..e644968645 100644 --- a/Tests/CodexBarTests/BoundedChildProcessProofTests.swift +++ b/Tests/CodexBarTests/BoundedChildProcessProofTests.swift @@ -46,7 +46,7 @@ struct BoundedChildProcessProofTests { Issue.record("Unexpected overflow error: \(error)") } // A small test-only limit isolates abort latency from the host's PTY throughput. - #expect(start.duration(to: .now) < .seconds(5)) + #expect(start.duration(to: .now) < .seconds(15)) let pidText = try String(contentsOf: pidURL, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -80,8 +80,8 @@ struct BoundedChildProcessProofTests { "PATH": "/usr/bin:/bin", "GROK_CLI_PATH": scriptURL.path, ], - initializeTimeoutSeconds: 2, - requestTimeoutSeconds: 2) + initializeTimeoutSeconds: 10, + requestTimeoutSeconds: 10) defer { client.shutdown() } try await client.initialize() @@ -134,7 +134,7 @@ struct BoundedChildProcessProofTests { } catch { Issue.record("Unexpected Grok overflow error: \(error)") } - #expect(start.duration(to: .now) < .seconds(5)) + #expect(start.duration(to: .now) < .seconds(15)) let pidText = try String(contentsOf: pidURL, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 57c72d1c38..397abc9259 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -97,7 +97,9 @@ struct ClaudeBaselineCharacterizationTests { { let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, env: env, settings: settings) - return await descriptor.fetchPlan.fetchOutcome(context: context, provider: .claude) + return await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(30) { + await descriptor.fetchPlan.fetchOutcome(context: context, provider: .claude) + } } private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { diff --git a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift index d946f7eb06..1aeef15531 100644 --- a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift +++ b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift @@ -347,7 +347,9 @@ struct ClaudeCLITimeoutRetryTests { data: nil, fingerprint: nil) { - try await operation() + try await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(30) { + try await operation() + } } } } diff --git a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift index eeba8ccb68..4f63cd1811 100644 --- a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift @@ -10,6 +10,8 @@ struct ClaudeLoginFlowTests { let registry = ProviderRegistry.shared let claudeMetadata = try #require(registry.metadata[.claude]) + // Only user-selectable sources round-trip through settings; the statusLine feed is never a selection, + // so it persists as Auto by design and has nothing to preserve here. for source in ClaudeUsageDataSource.allCases { let settings = testSettingsStore( suiteName: "ClaudeLoginFlowTests-controller-\(source.rawValue)") diff --git a/Tests/CodexBarTests/ClaudeStatusLineFeedTests.swift b/Tests/CodexBarTests/ClaudeStatusLineFeedTests.swift new file mode 100644 index 0000000000..ef76089a02 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeStatusLineFeedTests.swift @@ -0,0 +1,287 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeStatusLineFeedTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func limits( + profileID: String = "profile-a", + capturedAt: Date? = nil, + fiveHour: Double? = 40, + sevenDay: Double? = 60) -> ClaudeStatusLineRateLimits + { + ClaudeStatusLineRateLimits( + profileID: profileID, + capturedAt: capturedAt ?? self.now, + fiveHour: fiveHour.map { + ClaudeStatusLineWindow(usedPercent: $0, resetsAt: self.now.addingTimeInterval(3600)) + }, + sevenDay: sevenDay.map { + ClaudeStatusLineWindow(usedPercent: $0, resetsAt: self.now.addingTimeInterval(86400)) + }) + } + + private func settings( + enabled: Bool = true, + keychainDisabled: Bool = true, + standaloneAllowed: Bool = true, + source: ClaudeUsageDataSource = .auto) -> ProviderSettingsSnapshot + { + .make(claude: ClaudeProviderSettings( + usageDataSource: source, + webExtrasEnabled: false, + statusLineFeedEnabled: enabled, + keychainAccessDisabled: keychainDisabled, + statusLineStandaloneAllowed: standaloneAllowed, + cookieSource: .off, + manualCookieHeader: nil)) + } + + private func context( + sourceMode: ProviderSourceMode = .auto, + environment: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil, + selectedTokenAccountID: UUID? = nil) -> ProviderFetchContext + { + let browser = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browser), + browserDetection: browser, + selectedTokenAccountID: selectedTokenAccountID) + } + + private func strategyIDs(_ context: ProviderFetchContext) async -> [String] { + await ProviderDescriptorRegistry.descriptor(for: .claude) + .fetchPlan.pipeline.resolveStrategies(context).map(\.id) + } + + @Test + func `standalone feed is limited to keychain disabled ambient Auto`() async { + let eligible = self.context(settings: self.settings()) + #expect(await self.strategyIDs(eligible).first == "claude.statusline.standalone") + + let keychainEnabled = self.context(settings: self.settings(keychainDisabled: false)) + let keychainEnabledIDs = await self.strategyIDs(keychainEnabled) + #expect(!keychainEnabledIDs.contains("claude.statusline.standalone")) + + let explicitCLI = self.context(sourceMode: .cli, settings: self.settings(source: .cli)) + let explicitCLIIDs = await self.strategyIDs(explicitCLI) + #expect(!explicitCLIIDs.contains("claude.statusline.standalone")) + + let selectedToken = self.context(settings: self.settings(), selectedTokenAccountID: UUID()) + let selectedTokenIDs = await self.strategyIDs(selectedToken) + #expect(!selectedTokenIDs.contains("claude.statusline.standalone")) + + let multiAccount = self.context(settings: self.settings(standaloneAllowed: false)) + let multiAccountIDs = await self.strategyIDs(multiAccount) + #expect(!multiAccountIDs.contains("claude.statusline.standalone")) + } + + @Test + func `Admin API remains authoritative and never receives an observation overlay`() async { + let env = [ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-ant-admin-test"] + let context = self.context(environment: env, settings: self.settings()) + #expect(await self.strategyIDs(context) == ["claude.admin-api"]) + } + + @Test + func `keychain enabled mode falls through to the existing source plan`() async { + let context = self.context(settings: self.settings(keychainDisabled: false)) + let ids = await self.strategyIDs(context) + #expect(ids.first == "claude.oauth") + #expect(!ids.contains("claude.statusline.standalone")) + } + + @Test + func `standalone mapping is anonymous and reduced fidelity`() async throws { + let context = self.context(settings: self.settings()) + let observation = self.limits() + let loader: ClaudeStatusLineFetchStrategy.ObservationLoader = { _ in observation } + let outcome = await ClaudeStatusLineFetchStrategy.$observationLoaderOverrideForTesting + .withValue(loader) { + await ProviderDescriptorRegistry.descriptor(for: .claude).fetchOutcome(context: context) + } + let result = try outcome.result.get() + #expect(result.sourceLabel == ClaudeStatusLineFeed.standaloneSourceLabel) + #expect(result.usage.primary?.usedPercent == 40) + #expect(result.usage.secondary?.usedPercent == 60) + #expect(result.usage.tertiary == nil) + #expect(result.usage.extraRateWindows == nil) + #expect(result.usage.providerCost == nil) + #expect(result.usage.identity == nil) + } + + @Test + func `weekly only observation stays in the weekly lane`() throws { + let weeklyOnly = self.limits(fiveHour: nil, sevenDay: 55) + let snapshot = try #require(ClaudeStatusLineDropStore.makeSnapshot(from: weeklyOnly)) + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 55) + #expect(snapshot.secondary?.windowMinutes == 10080) + } + + @Test + func `five hour only observation stays in the primary lane`() throws { + let fiveHourOnly = self.limits(fiveHour: 31, sevenDay: nil) + let snapshot = try #require(ClaudeStatusLineDropStore.makeSnapshot(from: fiveHourOnly)) + #expect(snapshot.primary?.usedPercent == 31) + #expect(snapshot.primary?.windowMinutes == 300) + #expect(snapshot.secondary == nil) + } + + @Test + func `store accepts either window and rejects an empty observation`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-statusline-partial-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let weeklyOnly = self.limits(fiveHour: nil, sevenDay: 27) + try ClaudeStatusLineDropStore.write(weeklyOnly, applicationSupport: root) + let loaded = try #require(ClaudeStatusLineDropStore.load( + applicationSupport: root, + expectedProfileID: weeklyOnly.profileID, + now: self.now)) + #expect(loaded.fiveHour == nil) + #expect(loaded.sevenDay?.usedPercent == 27) + + let empty = self.limits(fiveHour: nil, sevenDay: nil) + #expect(throws: ClaudeStatusLineFileError.invalidObservation) { + try ClaudeStatusLineDropStore.write(empty, applicationSupport: root) + } + } + + @Test + func `stale future and wrong profile observations are absence`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-statusline-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + for (observation, expectedProfile) in [ + (self.limits(capturedAt: self.now.addingTimeInterval(-901)), "profile-a"), + (self.limits(capturedAt: self.now.addingTimeInterval(301)), "profile-a"), + (self.limits(profileID: "profile-a"), "profile-b"), + ] { + try ClaudeStatusLineDropStore.write(observation, applicationSupport: root) + #expect(ClaudeStatusLineDropStore.load( + applicationSupport: root, + expectedProfileID: expectedProfile, + now: self.now) == nil) + } + } + + @Test + func `capture persists a minimal private allowlisted observation`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-statusline-capture-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let inputURL = root.appendingPathComponent("input.json") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data(#""" + {"rate_limits":{"five_hour":{"used_percentage":12,"resets_at":1800003600}, + "seven_day":{"used_percentage":34,"resets_at":1800086400}}, + "session_id":"secret","cwd":"/private/repo","cost":{"usd":20},"unknown":"discard"} + """#.utf8).write(to: inputURL) + let handle = try FileHandle(forReadingFrom: inputURL) + CodexBarCLI.runClaudeStatusLineCapture( + input: handle, + environment: [:], + applicationSupport: root, + now: self.now) + try handle.close() + + let profileID = ClaudeStatusLineProfile.identifier(environment: [:]) + let url = ClaudeStatusLineDropStore.observationURL(applicationSupport: root, profileID: profileID) + let data = try Data(contentsOf: url) + let text = try #require(String(data: data, encoding: .utf8)) + for forbidden in ["session_id", "secret", "cwd", "/private/repo", "cost", "unknown"] { + #expect(!text.contains(forbidden)) + } + let fileMode = try #require( + (FileManager.default.attributesOfItem(atPath: url.path)[.posixPermissions] as? NSNumber)?.intValue) + let directoryMode = try #require((FileManager.default.attributesOfItem( + atPath: ClaudeStatusLineDropStore.directoryURL(applicationSupport: root).path)[ + .posixPermissions, + ] as? NSNumber)?.intValue) + #expect(fileMode == 0o600) + #expect(directoryMode == 0o700) + } + + @Test + func `bounded stdin rejects oversized payloads`() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-statusline-large-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: url) } + try Data(repeating: 0x20, count: 17).write(to: url) + let handle = try FileHandle(forReadingFrom: url) + #expect(try CodexBarCLI.readBoundedInput(handle, limit: 16) == nil) + try handle.close() + } + + @Test + func `observation writer rejects a symlink destination`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-statusline-symlink-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let observation = self.limits() + let directory = ClaudeStatusLineDropStore.directoryURL(applicationSupport: root) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let target = root.appendingPathComponent("target.json") + try Data(#"{}"#.utf8).write(to: target) + try FileManager.default.createSymbolicLink( + at: ClaudeStatusLineDropStore.observationURL( + applicationSupport: root, + profileID: observation.profileID), + withDestinationURL: target) + #expect(throws: ClaudeStatusLineFileError.symbolicLink( + ClaudeStatusLineDropStore.observationURL( + applicationSupport: root, + profileID: observation.profileID).path)) + { + try ClaudeStatusLineDropStore.write(observation, applicationSupport: root) + } + } +} + +@MainActor +struct ClaudeStatusLineSettingsTests { + @Test + func `opt in persists across relaunch and reaches the provider snapshot`() throws { + let suite = "ClaudeStatusLineSettings-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + var settings = testSettingsStore(suiteName: suite, userDefaults: defaults) + #expect(!settings.claudeStatusLineFeedEnabled) + settings.claudeStatusLineFeedEnabled = true + settings = testSettingsStore(suiteName: suite, userDefaults: defaults) + #expect(settings.claudeStatusLineFeedEnabled) + #expect(settings.claudeSettingsSnapshot(tokenOverride: nil).statusLineFeedEnabled) + } + + @Test + func `configured token accounts and claude swap disable standalone presentation`() { + let tokenSettings = testSettingsStore(suiteName: "ClaudeStatusLineSettings-token") + tokenSettings.debugDisableKeychainAccess = true + tokenSettings.claudeStatusLineFeedEnabled = true + tokenSettings.addTokenAccount(provider: .claude, label: "Token", token: "Bearer sk-ant-oat-test") + #expect(!tokenSettings.claudeSettingsSnapshot(tokenOverride: nil).statusLineStandaloneAllowed) + + let swapSettings = testSettingsStore(suiteName: "ClaudeStatusLineSettings-swap") + swapSettings.debugDisableKeychainAccess = true + swapSettings.claudeStatusLineFeedEnabled = true + swapSettings.claudeSwapEnabled = true + #expect(!swapSettings.claudeSettingsSnapshot(tokenOverride: nil).statusLineStandaloneAllowed) + } +} diff --git a/Tests/CodexBarTests/ClaudeStatusLineInstallerTests.swift b/Tests/CodexBarTests/ClaudeStatusLineInstallerTests.swift new file mode 100644 index 0000000000..f1e4cef59c --- /dev/null +++ b/Tests/CodexBarTests/ClaudeStatusLineInstallerTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeStatusLineInstallerTests { + private func fixture() throws -> (root: URL, settings: URL, executable: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-statusline-installer-\(UUID().uuidString)", isDirectory: true) + let settings = root.appendingPathComponent(".claude/settings.json") + let executable = root.appendingPathComponent("CodexBar.app/Contents/Helpers/CodexBarCLI") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data().write(to: executable) + return (root, settings, executable) + } + + private func rootObject(_ url: URL) throws -> [String: Any] { + let data = try Data(contentsOf: url) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test + func `absent settings install exact managed object and preserve unrelated JSON`() throws { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try FileManager.default.createDirectory( + at: fixture.settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(#"{"theme":"dark"}"#.utf8).write(to: fixture.settings) + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fixture.settings.path) + + #expect(ClaudeStatusLineInstaller.inspect( + settingsURL: fixture.settings, + executableURL: fixture.executable) == .absent) + try ClaudeStatusLineInstaller.install(settingsURL: fixture.settings, executableURL: fixture.executable) + + let root = try self.rootObject(fixture.settings) + let statusLine = try #require(root["statusLine"] as? [String: Any]) + #expect(root["theme"] as? String == "dark") + #expect(statusLine.count == 2) + #expect(statusLine["type"] as? String == "command") + #expect((statusLine["command"] as? String)?.hasSuffix(" claude statusline capture") == true) + let mode = try #require( + (FileManager.default.attributesOfItem(atPath: fixture.settings.path)[.posixPermissions] as? NSNumber)? + .intValue) + #expect(mode == 0o640) + #expect(ClaudeStatusLineInstaller.inspect( + settingsURL: fixture.settings, + executableURL: fixture.executable) == .installed) + } + + @Test + func `atomic install leaves old settings visible until publish`() throws { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try FileManager.default.createDirectory( + at: fixture.settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + let original = Data(#"{"theme":"light"}"#.utf8) + try original.write(to: fixture.settings) + + try ClaudeStatusLineInstaller.install( + settingsURL: fixture.settings, + executableURL: fixture.executable, + beforePublish: { _ in + let visible = try Data(contentsOf: fixture.settings) + #expect(visible == original) + }) + #expect(try self.rootObject(fixture.settings)["statusLine"] != nil) + } + + @Test + func `owned command at an old app path is repairable`() throws { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try ClaudeStatusLineInstaller.install(settingsURL: fixture.settings, executableURL: fixture.executable) + let moved = fixture.root.appendingPathComponent("Moved.app/Contents/Helpers/CodexBarCLI") + #expect(ClaudeStatusLineInstaller.inspect( + settingsURL: fixture.settings, + executableURL: moved) == .needsRepair) + try ClaudeStatusLineInstaller.install(settingsURL: fixture.settings, executableURL: moved) + #expect(ClaudeStatusLineInstaller.inspect( + settingsURL: fixture.settings, + executableURL: moved) == .installed) + } + + @Test + func `prefixed injected and composed commands remain user owned`() throws { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try FileManager.default.createDirectory( + at: fixture.settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + let cleanCommand = "'\(fixture.executable.path)' claude statusline capture" + let nonAppHelper = fixture.root.appendingPathComponent("NotAnApp/Contents/Helpers/CodexBarCLI").path + let commands = [ + "/usr/bin/env \(cleanCommand)", + "echo injected; \(cleanCommand)", + "input=$(cat); printf '%s' \"$input\" | \(cleanCommand)", + "'\(nonAppHelper)' claude statusline capture", + ] + + for command in commands { + let original = try JSONSerialization.data(withJSONObject: [ + "theme": "dark", + "statusLine": ["type": "command", "command": command], + ], options: [.sortedKeys]) + try original.write(to: fixture.settings) + + #expect(ClaudeStatusLineInstaller.inspect( + settingsURL: fixture.settings, + executableURL: fixture.executable) == .userOwned) + #expect(throws: ClaudeStatusLineInstallerError.userOwned) { + try ClaudeStatusLineInstaller.install( + settingsURL: fixture.settings, + executableURL: fixture.executable) + } + #expect(try Data(contentsOf: fixture.settings) == original) + #expect(throws: ClaudeStatusLineInstallerError.userOwned) { + try ClaudeStatusLineInstaller.uninstall( + settingsURL: fixture.settings, + executableURL: fixture.executable) + } + #expect(try Data(contentsOf: fixture.settings) == original) + } + } + + @Test + func `user owned and malformed settings are never overwritten`() throws { + for contents in [ + #"{"statusLine":{"type":"command","command":"~/mine.sh"},"theme":"dark"}"#, + #"["unexpected-root"]"#, + "{not-json", + ] { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try FileManager.default.createDirectory( + at: fixture.settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + let original = Data(contents.utf8) + try original.write(to: fixture.settings) + #expect(throws: (any Error).self) { + try ClaudeStatusLineInstaller.install( + settingsURL: fixture.settings, + executableURL: fixture.executable) + } + #expect(try Data(contentsOf: fixture.settings) == original) + } + } + + @Test + func `installer rejects settings symlinks`() throws { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try FileManager.default.createDirectory( + at: fixture.settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + let target = fixture.root.appendingPathComponent("target.json") + try Data(#"{}"#.utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: fixture.settings, withDestinationURL: target) + #expect(ClaudeStatusLineInstaller.inspect( + settingsURL: fixture.settings, + executableURL: fixture.executable) == .unsafeSymlink) + #expect(throws: ClaudeStatusLineInstallerError.unsafeSymlink) { + try ClaudeStatusLineInstaller.install(settingsURL: fixture.settings, executableURL: fixture.executable) + } + } + + @Test + func `uninstall removes only an exact CodexBar owned object`() throws { + let fixture = try self.fixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try ClaudeStatusLineInstaller.install(settingsURL: fixture.settings, executableURL: fixture.executable) + var root = try self.rootObject(fixture.settings) + root["theme"] = "dark" + try JSONSerialization.data(withJSONObject: root).write(to: fixture.settings) + try ClaudeStatusLineInstaller.uninstall(settingsURL: fixture.settings, executableURL: fixture.executable) + let after = try self.rootObject(fixture.settings) + #expect(after["statusLine"] == nil) + #expect(after["theme"] as? String == "dark") + + try Data(#"{"statusLine":{"type":"command","command":"~/mine.sh"}}"#.utf8) + .write(to: fixture.settings) + let original = try Data(contentsOf: fixture.settings) + #expect(throws: ClaudeStatusLineInstallerError.userOwned) { + try ClaudeStatusLineInstaller.uninstall(settingsURL: fixture.settings, executableURL: fixture.executable) + } + #expect(try Data(contentsOf: fixture.settings) == original) + } +} diff --git a/Tests/CodexBarTests/ClaudeStatusLinePayloadTests.swift b/Tests/CodexBarTests/ClaudeStatusLinePayloadTests.swift new file mode 100644 index 0000000000..cf9884fdc3 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeStatusLinePayloadTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeStatusLinePayloadTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + private func parse(_ json: String) -> ClaudeStatusLineRateLimits? { + ClaudeStatusLinePayloadParser.parseOfficialPayload( + Data(json.utf8), + capturedAt: self.now, + environment: [ClaudeConfigPaths.configDirectoryEnvironmentKey: "/tmp/profile-a"]) + } + + @Test + func `reads independently available official windows`() throws { + let limits = try #require(self.parse(#""" + { + "rate_limits": { + "five_hour": {"used_percentage": 42.5, "resets_at": 1800003600, "unknown": "ignored"}, + "seven_day": {"used_percentage": 63, "resets_at": "2027-01-22T00:00:00Z"} + }, + "session_id": "must-not-persist", + "cwd": "/private/project", + "cost": {"total_cost_usd": 99}, + "model": {"display_name": "private"} + } + """#)) + #expect(limits.fiveHour?.usedPercent == 42.5) + #expect(limits.sevenDay?.usedPercent == 63) + #expect(limits.fiveHour?.resetsAt == Date(timeIntervalSince1970: 1_800_003_600)) + #expect(limits.sevenDay?.resetsAt == nil) + #expect(limits.capturedAt == self.now) + #expect(limits.profileID == ClaudeStatusLineProfile.identifier(environment: [ + ClaudeConfigPaths.configDirectoryEnvironmentKey: "/tmp/profile-a", + ])) + } + + @Test + func `schema drift with no valid percentage fails soft`() { + let cases = [ + "not json", + "{}", + #"{"rate_limits":[]}"#, + #"{"rate_limits":{"five_hour":{"utilization":12,"resets_at":1800003600}}}"#, + #"{"rate_limits":{"five_hour":{"used_percentage":"12","resets_at":1800003600}}}"#, + #"{"rate_limits":{"five_hour":{"used_percentage":true,"resets_at":1800003600}}}"#, + #"{"rate_limits":{"five_hour":{"used_percentage":101,"resets_at":1800003600}}}"#, + ] + for json in cases { + #expect(self.parse(json) == nil, "drifted payload must be absence: \(json)") + } + } + + @Test + func `missing window and reset preserve a valid percentage`() throws { + let limits = try #require(self.parse(#"{"rate_limits":{"five_hour":{"used_percentage":9}}}"#)) + #expect(limits.fiveHour?.usedPercent == 9) + #expect(limits.fiveHour?.resetsAt == nil) + #expect(limits.sevenDay == nil) + } + + @Test(arguments: [ + #""later""#, + "true", + "946684799", + "4102444801", + ]) + func `invalid reset metadata preserves the weekly percentage`(resetJSON: String) throws { + let limits = try #require(self.parse( + #"{"rate_limits":{"seven_day":{"used_percentage":44,"resets_at":\#(resetJSON)}}}"#)) + #expect(limits.fiveHour == nil) + #expect(limits.sevenDay?.usedPercent == 44) + #expect(limits.sevenDay?.resetsAt == nil) + } + + @Test + func `oversized input is absence`() { + let oversized = Data(repeating: 0x20, count: ClaudeStatusLineFeed.maximumInputBytes + 1) + #expect(ClaudeStatusLinePayloadParser.parseOfficialPayload(oversized) == nil) + } +} diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift index b786b365d0..d5d17fc3bd 100644 --- a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -126,6 +126,22 @@ struct KeychainPromptSafetyAuditTests { #expect(offenders.isEmpty, "Unexpected direct Security item access in tests: \(offenders.map(\.path))") } + @Test + func `Claude statusLine capture path has no Security item APIs`() throws { + let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"] + let paths = [ + "Sources/CodexBarCLI/CLIClaudeStatusLineCommand.swift", + "Sources/CodexBarCore/Providers/Claude/ClaudeStatusLinePayload.swift", + "Sources/CodexBarCore/Providers/Claude/ClaudeStatusLineDropStore.swift", + ] + for path in paths { + let source = try Self.readRepoFile(path) + #expect( + !securityItemCalls.contains(where: source.contains), + "\(path) must not contain a Security item API") + } + } + @Test func `production source routes Security item APIs through the test safety gateway`() throws { let securityItemCalls = ["SecItemCopyMatching", "SecItemUpdate", "SecItemAdd", "SecItemDelete"] diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index fd1ccef3fd..018274ec9d 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -884,13 +884,13 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact provider-owned construct passes a fixed identity to shared infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SettingsStore+MenuObservation.swift", - line: 98, + line: 99, anchor: "_ = self[providerConfig: .synthetic, field: .apiKey]", expectedProviderIDs: ["synthetic"], reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SettingsStore+MenuObservation.swift", - line: 117, + line: 118, anchor: "_ = self[providerConfig: .warp, field: .apiKey]", expectedProviderIDs: ["warp"], reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), @@ -1825,15 +1825,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 251, + line: 257, anchor: "if input.provider == .kiro {", - expectedProviderIDs: ["kilo", "kiro"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["kiro@0", "kilo@4"], + expectedProviderIDs: ["claude", "kilo", "kiro"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["kiro@0", "claude@4", "kilo@11"], reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 269, + line: 282, anchor: "if input.provider == .mimo, input.snapshot != nil {", expectedProviderIDs: ["claude", "mimo", "opencodego"], expectedReferenceCount: 3, @@ -1841,7 +1841,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 478, + line: 491, anchor: "if input.provider == .factory, snapshot.tertiary != nil {", expectedProviderIDs: ["alibabatokenplan", "amp", "crof", "cursor", "doubao", "factory", "grok", "sub2api"], expectedReferenceCount: 12, @@ -1862,7 +1862,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 639, + line: 652, anchor: "case .minimax:", expectedProviderIDs: ["codex", "minimax", "poe"], expectedReferenceCount: 3, @@ -1870,7 +1870,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 843, + line: 856, anchor: "if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {", expectedProviderIDs: ["claude", "codex", "copilot"], expectedReferenceCount: 4, @@ -1878,7 +1878,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 868, + line: 881, anchor: "let resetText = input.provider == .sub2api && namedWindow.window.resetsAt == nil", expectedProviderIDs: ["doubao", "sub2api"], expectedReferenceCount: 3, @@ -1886,7 +1886,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 939, + line: 952, anchor: "if input.provider == .antigravity,", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -1894,7 +1894,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 973, + line: 986, anchor: "if provider == .claude, window.windowMinutes != 10080 {", expectedProviderIDs: ["antigravity", "claude", "codex"], expectedReferenceCount: 4, @@ -1902,7 +1902,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1005, + line: 1018, anchor: "guard input.provider == .antigravity else { return nil }", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -2281,7 +2281,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1073, + line: 1081, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, @@ -3690,6 +3690,30 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], reason: "This exact WidgetKit construct preserves its compile-time provider selection contract."), + AllowedProviderConstruct( + path: "Sources/CodexBarCLI/CLIEntry.swift", + line: 82, + anchor: "case [\"claude\", \"statusline\", \"capture\"]:", + expectedProviderIDs: ["claude"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["claude@0"], + reason: "This CLI dispatcher routes the provider-owned Claude statusLine capture command."), + AllowedProviderConstruct( + path: "Sources/CodexBarCLI/CLIEntry.swift", + line: 290, + anchor: "name: \"claude\",", + expectedProviderIDs: ["claude"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["claude@0"], + reason: "This CLI command registry exposes the provider-owned Claude statusLine integration."), + AllowedProviderConstruct( + path: "Sources/CodexBarCLI/CLIIO.swift", + line: 54, + anchor: "case \"claude\", \"statusline\", \"capture\":", + expectedProviderIDs: ["claude"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["claude@0"], + reason: "This CLI help dispatcher documents the provider-owned Claude statusLine command."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", line: 117, diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 5fa99984e3..0828b198b9 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -124,13 +124,22 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto func testSettingsStore( suiteName: String, tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore(), - config: CodexBarConfig? = nil) -> SettingsStore + config: CodexBarConfig? = nil, + userDefaults: UserDefaults? = nil, + writesLaunchResetsToRawState: Bool = false) -> SettingsStore { let isolatedSuiteName = "\(suiteName)-\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: isolatedSuiteName) else { - preconditionFailure("Could not create test defaults suite") + let defaults: UserDefaults + if let userDefaults { + // Caller pre-seeded the suite (e.g. to exercise a launch-time migration), so leave it intact. + defaults = userDefaults + } else { + guard let created = UserDefaults(suiteName: isolatedSuiteName) else { + preconditionFailure("Could not create test defaults suite") + } + created.removePersistentDomain(forName: isolatedSuiteName) + defaults = created } - defaults.removePersistentDomain(forName: isolatedSuiteName) let configStore = testConfigStore(suiteName: isolatedSuiteName) if let config { do { @@ -155,7 +164,8 @@ func testSettingsStore( augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), - tokenAccountStore: tokenAccountStore) + tokenAccountStore: tokenAccountStore, + writesLaunchResetsToRawState: writesLaunchResetsToRawState) } #if os(macOS) diff --git a/docs/claude-statusline-feed.md b/docs/claude-statusline-feed.md new file mode 100644 index 0000000000..aea58127e1 --- /dev/null +++ b/docs/claude-statusline-feed.md @@ -0,0 +1,82 @@ +# Claude Code statusLine feed + +CodexBar's Claude Code statusLine integration is an explicit, off-by-default local observation path. It is not an +Anthropic API, an OAuth feature, or a credential reader. Claude Code sends JSON to the command configured in the +user's `statusLine` setting; CodexBar's bundled CLI keeps only the official rate-limit windows that are available. + +## What it can show + +The helper accepts only: + +- `rate_limits.five_hour.used_percentage` and `rate_limits.five_hour.resets_at` +- `rate_limits.seven_day.used_percentage` and `rate_limits.seven_day.resets_at` + +Each window is independent. A numeric `used_percentage` in `0...100` makes that window usable even when the other +window is absent. `resets_at` is optional and is kept only when it is a numeric Unix epoch within CodexBar's +2000-through-2100 sanity range; missing, string-valued, or implausible reset metadata is omitted without losing the +percentage. Unknown fields and unrelated statusLine data are discarded. +CodexBar never persists raw stdin, account identity, cwd, repository, prompt/session text, cost, model, or unknown +fields. The observation contains one or both allowlisted windows, capture time, schema version, and a one-way profile +identifier. It expires after 15 minutes; timestamps more than five minutes in the future are rejected. + +The feed is deliberately anonymous. While global **Disable Keychain access** is enabled, a fresh observation may +stand alone as the ambient Claude card. It never carries over or infers email, organization, plan, login method, +model-scoped quotas, Daily Routines/Cowork, extra usage, cost, or any other account-derived value. The card says the +available 5-hour/7-day data came from the user's own Claude Code statusLine configuration and that detailed limits are +unavailable with Keychain access disabled. + +CodexBar does not apply observations to Admin API cards, explicit token accounts, claude-swap cards, selected or +non-Auto sources, or any multi-account presentation. When Keychain access is enabled, the feed is ignored because +Claude Code's statusLine payload has no account identity; OAuth, CLI, and Web continue normally. Direct Claude Code +Keychain access remains separately available, default-off, under its explicit consent toggle. + +## Managed installation + +In **Settings → Providers → Claude**, enable **Use your Claude Code statusLine feed** and choose **Install**. The +installed signed app writes this exact user-level command object, with the app's actual bundled helper path: + +```json +{ + "statusLine": { + "type": "command", + "command": "'/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI' claude statusline capture" + } +} +``` + +Claude Code has one statusLine slot. CodexBar writes only `~/.claude/settings.json` (or the effective +`CLAUDE_CONFIG_DIR/settings.json`) and only when the slot is absent or matches the exact CodexBar-owned object. It +never edits project, local, or managed settings. An existing custom object is left untouched and the UI points to +manual composition below. + +The install is atomic, preserves unrelated JSON and existing file permissions, rejects symbolic links, and refuses +malformed JSON or an unexpected root shape. If the app moves, Settings reports that the managed command needs repair; +it does not silently overwrite it. **Uninstall**, or turning the integration off, removes only an exact +CodexBar-owned object. A user-owned composition is merely disabled and is never edited. Manual deletion is an opt-out +and is never automatically reversed. The managed capture command intentionally writes no stdout, so it does not add +visible text to Claude Code's status line. + +## Manual composition with an existing statusLine + +Do not ask CodexBar to install over an existing command. Create a user-owned wrapper that reads stdin once, forwards +it to CodexBar, then forwards the same in-memory value to the command that renders your existing status line: + +```sh +#!/bin/sh +input=$(cat) +printf '%s' "$input" | '/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI' claude statusline capture +printf '%s' "$input" | "$HOME/.local/bin/my-existing-statusline" +``` + +Make the wrapper executable and configure it as Claude Code's `statusLine.command`. This remains a user-owned object: +CodexBar will not modify or uninstall it. Adjust the existing-command path and arguments to match your setup. The raw +payload stays in process memory and is not written by the example. + +## Capture behavior + +`codexbar claude statusline capture` reads at most 1 MiB from stdin, accepts one JSON object, and exits without stdout. +Malformed JSON, an observation with no valid window, stale or future-skewed observations, profile mismatch, missing +files, and upstream schema drift all mean "no observation." One valid window is still shown in its correct lane; an +invalid sibling or reset value is simply omitted. Missing data never becomes zero usage and never creates a Claude +provider error. The observation file is mode `0600` under a mode `0700` CodexBar Application Support directory. This +path contains no Security.framework item operation and possesses no OAuth token. diff --git a/docs/claude.md b/docs/claude.md index 55bc684f2e..28fdd920a2 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -17,6 +17,27 @@ pending. For the exact current-state parity contract, see When an Anthropic Admin API key is configured, Claude can also show organization-level spend/messages/tokens in the same inline dashboard pattern used by the OpenAI API provider. +## Optional Claude Code statusLine observation + +CodexBar can install an off-by-default command in Claude Code's single user-level `statusLine` slot. The bundled +`CodexBarCLI` helper reads Claude Code's JSON from stdin, allowlists only +`rate_limits.five_hour.used_percentage/resets_at` and +`rate_limits.seven_day.used_percentage/resets_at`, and writes a short-lived local observation. It never reads a +credential or calls the Keychain. + +The two windows are independent: CodexBar shows whichever valid percentages Claude Code provides. Reset timestamps +are optional numeric Unix epochs; invalid or missing reset metadata does not discard a valid percentage. + +This source intentionally has reduced fidelity. It is used only for the anonymous ambient Claude card when +**Settings → Advanced → Disable Keychain access** is enabled, Claude is in Auto mode, and no explicit token, +Admin API, claude-swap, or multi-account presentation is active. The card has no email, organization, plan, login +method, model-scoped quotas, Daily Routines/Cowork, extra usage, cost, or other account-derived fields. When +Keychain access is enabled, CodexBar ignores the observation and continues through OAuth, CLI, and Web normally. + +Enable it under **Settings → Providers → Claude → Use your Claude Code statusLine feed**, then choose **Install**. +CodexBar refuses to replace a custom statusLine. See [the statusLine feed guide](claude-statusline-feed.md) for +installation ownership, manual composition, privacy, and schema limitations. + ## Data sources + selection order ### Default selection (debug menu disabled) diff --git a/docs/cli.md b/docs/cli.md index 1a4f404c23..5107795d9e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -35,6 +35,14 @@ tar -xzf CodexBarCLI-v0.17.0-macos-x86_64.tar.gz - Standalone: `swift build -c release --product CodexBarCLI` (binary at `./.build/release/CodexBarCLI`). - Dependencies: Swift 6.2+, Commander package (`https://github.com/steipete/Commander`). +### Claude Code statusLine capture + +`codexbar claude statusline capture` is the noninteractive stdin helper used by CodexBar's optional Claude Code +statusLine integration. It accepts one JSON object up to 1 MiB, persists only the official 5-hour and 7-day usage +percentages that are present plus sane optional numeric reset times, and writes no stdout so it does not add visible +statusLine text. Each window is independent; invalid fields are ignored and an observation with no valid window is +discarded. The command never reads the Keychain. + ## Configuration CodexBar reads the resolved config file for provider settings, secrets, and ordering. New installs use `~/.config/codexbar/config.json`; absolute `XDG_CONFIG_HOME` paths and `CODEXBAR_CONFIG` are supported, and existing diff --git a/docs/keychain-prompts.md b/docs/keychain-prompts.md index cbc30ec743..4759ab1cbc 100644 --- a/docs/keychain-prompts.md +++ b/docs/keychain-prompts.md @@ -68,6 +68,9 @@ Alternatives depend on the provider: - Configure an API key or OAuth/device flow that does not depend on browser Safe Storage. - Use a supported file-backed or local provider source. - For Claude, leave direct foreign-item consent off and choose a CLI, Web, or usable credentials-file path. +- For a reduced-fidelity Claude fallback, explicitly enable the Claude Code statusLine feed. It reads a sanitized + local observation without Security.framework and may show whichever anonymous 5-hour/7-day windows are available while global + Keychain access is disabled; detailed account limits remain unavailable. ## Safe troubleshooting diff --git a/docs/providers.md b/docs/providers.md index e4d663a99f..c6cbaa3566 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -11,6 +11,10 @@ read_when: CodexBar currently registers 69 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. +Claude also offers an optional, off-by-default Claude Code statusLine observation. It is not a selectable provider +source: it can only supply available anonymous 5-hour/7-day ambient windows while global Keychain access is disabled. See +[Claude statusLine feed](claude-statusline-feed.md). + ## Fetch strategies (current) Legend: web (browser cookies/WebView), cli (RPC/PTy or provider CLI), oauth (provider OAuth), api token, local probe, web dashboard. Source labels (CLI/header): `openai-web`, `web`, `oauth`, `api`, `local`, `cli`, plus provider-specific CLI labels (e.g. `codex-cli`, `claude`).