From 3f7d7f20aaa2b311338aee8f460e8eaf8df6c80d Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:07:04 -0700 Subject: [PATCH 1/2] feat(cache): add cookie cache clearing to Debug tab and CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Clear cookie cache" button to the Debug → Caches section, alongside the existing "Clear cost cache" button. This allows users to clear stale or corrupt browser cookie caches from the GUI without needing to run manual Keychain commands. Also add a `codexbar cache clear` CLI command with --cookies, --cost, and --all flags, plus optional --provider for per-provider clearing. Changes: - CookieHeaderCache: add clearAll() that iterates all providers - PreferencesDebugPane: add "Clear cookie cache" button in Caches section - CLICacheCommand: new file implementing `cache clear` subcommand - CLIEntry: wire up `cache clear` command path - CLIHelp/CLIIO: add cache help text and help routing Closes #591 Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/CodexBar/PreferencesDebugPane.swift | 26 +++- Sources/CodexBarCLI/CLICacheCommand.swift | 135 +++++++++++++++++++ Sources/CodexBarCLI/CLIEntry.swift | 16 +++ Sources/CodexBarCLI/CLIHelp.swift | 30 +++++ Sources/CodexBarCLI/CLIIO.swift | 2 + Sources/CodexBarCore/CookieHeaderCache.swift | 16 +++ 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBarCLI/CLICacheCommand.swift diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index a86a55434..82ce6155b 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -13,6 +13,7 @@ struct DebugPane: View { @State private var logText: String = "" @State private var isClearingCostCache = false @State private var costCacheStatus: String? + @State private var cookieCacheStatus: String? #if DEBUG @State private var currentErrorProvider: UsageProvider = .codex @State private var simulatedErrorText: String = """ @@ -220,7 +221,7 @@ struct DebugPane: View { SettingsSection( title: "Caches", - caption: "Clear cached cost scan results.") + caption: "Clear cached cost scan results or browser cookie caches.") { let isTokenRefreshActive = self.store.isTokenRefreshInFlight(for: .codex) || self.store.isTokenRefreshInFlight(for: .claude) @@ -239,6 +240,20 @@ struct DebugPane: View { .foregroundStyle(.tertiary) } } + + HStack(spacing: 12) { + Button { + self.clearCookieCache() + } label: { + Label("Clear cookie cache", systemImage: "trash") + } + + if let status = self.cookieCacheStatus { + Text(status) + .font(.footnote) + .foregroundStyle(.tertiary) + } + } } SettingsSection( @@ -507,6 +522,15 @@ struct DebugPane: View { self.costCacheStatus = "Cleared." } + private func clearCookieCache() { + let cleared = CookieHeaderCache.clearAll() + if cleared > 0 { + self.cookieCacheStatus = "Cleared \(cleared) provider\(cleared == 1 ? "" : "s")." + } else { + self.cookieCacheStatus = "No cached cookies found." + } + } + private func fetchAttemptsText(for provider: UsageProvider) -> String { let attempts = self.store.fetchAttempts(for: provider) guard !attempts.isEmpty else { return "No fetch attempts yet." } diff --git a/Sources/CodexBarCLI/CLICacheCommand.swift b/Sources/CodexBarCLI/CLICacheCommand.swift new file mode 100644 index 000000000..49ef6dce6 --- /dev/null +++ b/Sources/CodexBarCLI/CLICacheCommand.swift @@ -0,0 +1,135 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runCacheClear(_ values: ParsedValues) { + let output = CLIOutputPreferences.from(values: values) + let cookies = values.flags.contains("cookies") + let cost = values.flags.contains("cost") + let all = values.flags.contains("all") + let rawProvider = values.options["provider"]?.last + + let clearCookies = cookies || all + let clearCost = cost || all + + if !clearCookies && !clearCost { + Self.exit( + code: .failure, + message: "Specify --cookies, --cost, or --all.", + output: output, + kind: .args) + } + + var results: [CacheClearResult] = [] + + if clearCookies { + if let rawProvider { + if let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] { + let had = CookieHeaderCache.load(provider: provider) != nil + CookieHeaderCache.clear(provider: provider) + results.append(CacheClearResult( + cache: "cookies", + provider: provider.rawValue, + cleared: had ? 1 : 0)) + } else { + Self.exit( + code: .failure, + message: "Unknown provider: \(rawProvider)", + output: output, + kind: .args) + } + } else { + let cleared = CookieHeaderCache.clearAll() + results.append(CacheClearResult(cache: "cookies", provider: nil, cleared: cleared)) + } + } + + if clearCost { + let fm = FileManager.default + let cacheDir = Self.costUsageCacheDirectory(fileManager: fm) + var cleared = 0 + var costError: String? + if fm.fileExists(atPath: cacheDir.path) { + do { + try fm.removeItem(at: cacheDir) + cleared = 1 + } catch { + costError = error.localizedDescription + } + } + results.append(CacheClearResult(cache: "cost", provider: nil, cleared: cleared, error: costError)) + } + + switch output.format { + case .text: + for result in results { + let scope = result.provider ?? "all providers" + if let error = result.error { + print("\(result.cache): failed to clear (\(scope)) - \(error)") + } else if result.cleared > 0 { + print("\(result.cache): cleared (\(scope))") + } else { + print("\(result.cache): nothing to clear (\(scope))") + } + } + case .json: + Self.printJSON(results, pretty: output.pretty) + } + + let hasErrors = results.contains(where: { $0.error != nil }) + Self.exit(code: hasErrors ? .failure : .success, output: output, kind: .runtime) + } +} + +struct CacheOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Flag(name: .long("cookies"), help: "Clear browser cookie caches") + var cookies: Bool = false + + @Flag(name: .long("cost"), help: "Clear cost usage caches") + var cost: Bool = false + + @Flag(name: .long("all"), help: "Clear all caches") + var all: Bool = false + + @Option(name: .long("provider"), help: "Clear cache for a specific provider only") + var provider: String? +} + +private struct CacheClearResult: Encodable { + let cache: String + let provider: String? + let cleared: Int + var error: String? +} + +extension CodexBarCLI { + /// Mirrors the cost usage cache directory used by the app (UsageStore.costUsageCacheDirectory). + static func costUsageCacheDirectory(fileManager: FileManager = .default) -> URL { + let root = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("cost-usage", isDirectory: true) + } +} diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 42957f717..38e62236b 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -43,6 +43,8 @@ enum CodexBarCLI { self.runConfigValidate(invocation.parsedValues) case ["config", "dump"]: self.runConfigDump(invocation.parsedValues) + case ["cache", "clear"]: + self.runCacheClear(invocation.parsedValues) default: Self.exit( code: .failure, @@ -61,6 +63,7 @@ enum CodexBarCLI { let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) let configSignature = CommandSignature.describe(ConfigOptions()) + let cacheSignature = CommandSignature.describe(CacheOptions()) return [ CommandDescriptor( @@ -91,6 +94,19 @@ enum CodexBarCLI { signature: configSignature), ], defaultSubcommandName: "validate"), + CommandDescriptor( + name: "cache", + abstract: "Cache management", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "clear", + abstract: "Clear cached data (cookies, cost, or all)", + discussion: nil, + signature: cacheSignature), + ], + defaultSubcommandName: "clear"), ] } diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 41ba92675..9e0d7be36 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -98,6 +98,34 @@ extension CodexBarCLI { """ } + static func cacheHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar cache clear <--cookies|--cost|--all> + [--provider ] + [--format text|json] + [--json] + [--json-only] + [--json-output] [--log-level ] + [-v|--verbose] + [--pretty] + + Description: + Clear cached data. Use --cookies to clear browser cookie caches (stored in Keychain), + --cost to clear cost usage scan caches, or --all for both. + Optionally specify --provider to clear cookies for a single provider only. + + Examples: + codexbar cache clear --cookies + codexbar cache clear --cookies --provider claude + codexbar cache clear --cost + codexbar cache clear --all + codexbar cache clear --all --format json --pretty + """ + } + static func rootHelp(version: String) -> String { """ CodexBar \(version) @@ -122,6 +150,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--pretty] + codexbar cache clear <--cookies|--cost|--all> [--provider ] Global flags: -h, --help Show help @@ -138,6 +167,7 @@ extension CodexBarCLI { codexbar --provider gemini codexbar cost --provider claude --format json --pretty codexbar config validate --format json --pretty + codexbar cache clear --cookies """ } } diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index fe42751cb..89c9acf33 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -29,6 +29,8 @@ extension CodexBarCLI { print(Self.costHelp(version: version)) case "config", "validate", "dump": print(Self.configHelp(version: version)) + case "cache", "clear": + print(Self.cacheHelp(version: version)) default: print(Self.rootHelp(version: version)) } diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index c00610070..54cab5fb0 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -61,6 +61,22 @@ public enum CookieHeaderCache { self.log.debug("Cookie cache cleared", metadata: ["provider": provider.rawValue]) } + /// Clears cookie caches for all providers that have a cached entry. + /// Returns the number of providers whose caches were cleared. + @discardableResult + public static func clearAll() -> Int { + var cleared = 0 + for provider in UsageProvider.allCases { + let key = KeychainCacheStore.Key.cookie(provider: provider) + if case .found = KeychainCacheStore.load(key: key, as: Entry.self) { + self.clear(provider: provider) + cleared += 1 + } + } + self.log.debug("Cookie cache clearAll completed", metadata: ["cleared": "\(cleared)"]) + return cleared + } + static func load(from url: URL) -> Entry? { guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() From cef0db91eb68469e927a28709fabd672deb671cb Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:12:03 -0700 Subject: [PATCH 2/2] fix(cache): clear corrupt and legacy cookie entries in clearAll() clearAll() previously only cleared `.found` entries, skipping `.invalid` (corrupt) keychain entries and legacy-file-only caches. Since the primary use case is recovering from broken cookie state, unconditionally clear both corrupt keychain entries and orphaned legacy files. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/CodexBarCore/CookieHeaderCache.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index 54cab5fb0..2453e7bf4 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -61,16 +61,24 @@ public enum CookieHeaderCache { self.log.debug("Cookie cache cleared", metadata: ["provider": provider.rawValue]) } - /// Clears cookie caches for all providers that have a cached entry. + /// Clears cookie caches for all providers, including corrupt/invalid entries. /// Returns the number of providers whose caches were cleared. @discardableResult public static func clearAll() -> Int { var cleared = 0 for provider in UsageProvider.allCases { let key = KeychainCacheStore.Key.cookie(provider: provider) - if case .found = KeychainCacheStore.load(key: key, as: Entry.self) { + let result = KeychainCacheStore.load(key: key, as: Entry.self) + let hasLegacy = self.loadLegacyEntry(for: provider) != nil + switch result { + case .found, .invalid: self.clear(provider: provider) cleared += 1 + case .missing where hasLegacy: + self.removeLegacyEntry(for: provider) + cleared += 1 + case .missing: + break } } self.log.debug("Cookie cache clearAll completed", metadata: ["cleared": "\(cleared)"])