Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Sources/CodexBar/PreferencesDebugPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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." }
Expand Down
135 changes: 135 additions & 0 deletions Sources/CodexBarCLI/CLICacheCommand.swift
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +13 to +16

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject --provider without cookie-clear flags

--provider is currently accepted whenever any clear action is chosen, but it is only used inside the cookie-clearing branch. In practice, codexbar cache clear --cost --provider claude silently ignores the provider and still exits success after clearing cost cache, which is misleading for users and scripts that expect provider-scoped behavior. Add validation so --provider requires --cookies (or explicitly documented semantics with --all) instead of being ignored.

Useful? React with 👍 / 👎.

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)
Comment on lines +29 to +30

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Report provider clears correctly for invalid cookie entries

The provider-specific path derives cleared from CookieHeaderCache.load(...) != nil, but load returns nil for invalid/corrupt keychain payloads even when those entries are then removed. This causes cache clear --cookies --provider <name> to report cleared: 0/"nothing to clear" in corrupt-entry scenarios, producing incorrect CLI output and unreliable JSON for automation.

Useful? React with 👍 / 👎.

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)
}
}
16 changes: 16 additions & 0 deletions Sources/CodexBarCLI/CLIEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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"),
]
}

Expand Down
30 changes: 30 additions & 0 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,34 @@ extension CodexBarCLI {
"""
}

static func cacheHelp(version: String) -> String {
"""
CodexBar \(version)

Usage:
codexbar cache clear <--cookies|--cost|--all>
[--provider <name>]
[--format text|json]
[--json]
[--json-only]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
[-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)
Expand All @@ -122,6 +150,7 @@ extension CodexBarCLI {
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
[-v|--verbose]
[--pretty]
codexbar cache clear <--cookies|--cost|--all> [--provider <name>]

Global flags:
-h, --help Show help
Expand All @@ -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
"""
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBarCLI/CLIIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/CodexBarCore/CookieHeaderCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down