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..2453e7bf4 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -61,6 +61,30 @@ public enum CookieHeaderCache { self.log.debug("Cookie cache cleared", metadata: ["provider": provider.rawValue]) } + /// 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) + 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)"]) + return cleared + } + static func load(from url: URL) -> Entry? { guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder()