steipete--codexbar
1354 行
58 KiB
Swift
1354 行
58 KiB
Swift
import Foundation
|
|
|
|
public struct ClaudeStatusSnapshot: Sendable {
|
|
public let sessionPercentLeft: Int?
|
|
public let weeklyPercentLeft: Int?
|
|
public let opusPercentLeft: Int?
|
|
public let extraRateWindows: [NamedRateWindow]
|
|
public let accountEmail: String?
|
|
public let accountOrganization: String?
|
|
public let loginMethod: String?
|
|
public let primaryResetDescription: String?
|
|
public let secondaryResetDescription: String?
|
|
public let opusResetDescription: String?
|
|
public let rawText: String
|
|
|
|
public init(
|
|
sessionPercentLeft: Int?,
|
|
weeklyPercentLeft: Int?,
|
|
opusPercentLeft: Int?,
|
|
accountEmail: String?,
|
|
accountOrganization: String?,
|
|
loginMethod: String?,
|
|
primaryResetDescription: String?,
|
|
secondaryResetDescription: String?,
|
|
opusResetDescription: String?,
|
|
rawText: String,
|
|
extraRateWindows: [NamedRateWindow] = [])
|
|
{
|
|
self.sessionPercentLeft = sessionPercentLeft
|
|
self.weeklyPercentLeft = weeklyPercentLeft
|
|
self.opusPercentLeft = opusPercentLeft
|
|
self.extraRateWindows = extraRateWindows
|
|
self.accountEmail = accountEmail
|
|
self.accountOrganization = accountOrganization
|
|
self.loginMethod = loginMethod
|
|
self.primaryResetDescription = primaryResetDescription
|
|
self.secondaryResetDescription = secondaryResetDescription
|
|
self.opusResetDescription = opusResetDescription
|
|
self.rawText = rawText
|
|
}
|
|
}
|
|
|
|
public struct ClaudeAccountIdentity: Sendable {
|
|
public let accountEmail: String?
|
|
public let accountOrganization: String?
|
|
public let loginMethod: String?
|
|
|
|
public init(accountEmail: String?, accountOrganization: String?, loginMethod: String?) {
|
|
self.accountEmail = accountEmail
|
|
self.accountOrganization = accountOrganization
|
|
self.loginMethod = loginMethod
|
|
}
|
|
}
|
|
|
|
public enum ClaudeStatusProbeError: LocalizedError, Sendable {
|
|
case claudeNotInstalled
|
|
case parseFailed(String)
|
|
case timedOut
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .claudeNotInstalled:
|
|
"Claude CLI is not installed or not on PATH."
|
|
case let .parseFailed(msg):
|
|
"Could not parse Claude usage: \(msg)"
|
|
case .timedOut:
|
|
"Claude usage probe timed out."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs `claude` inside a PTY, sends `/usage`, and parses the rendered text panel.
|
|
public struct ClaudeStatusProbe: Sendable {
|
|
public static let subscriptionQuotaUnavailableDescription =
|
|
"Claude CLI /usage returned a subscription notice without session quota data. " +
|
|
"Local cost and token history remain available."
|
|
|
|
public var claudeBinary: String = "claude"
|
|
public var timeout: TimeInterval = 20.0
|
|
public var keepCLISessionsAlive: Bool = false
|
|
private static let log = CodexBarLog.logger(LogCategories.claudeProbe)
|
|
#if DEBUG
|
|
public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot
|
|
@TaskLocal static var fetchOverride: FetchOverride?
|
|
#endif
|
|
|
|
public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0, keepCLISessionsAlive: Bool = false) {
|
|
self.claudeBinary = claudeBinary
|
|
self.timeout = timeout
|
|
self.keepCLISessionsAlive = keepCLISessionsAlive
|
|
}
|
|
|
|
#if DEBUG
|
|
public static var currentFetchOverrideForTesting: FetchOverride? {
|
|
self.fetchOverride
|
|
}
|
|
|
|
public static func withFetchOverrideForTesting<T>(
|
|
_ override: FetchOverride?,
|
|
operation: () async throws -> T) async rethrows -> T
|
|
{
|
|
try await self.$fetchOverride.withValue(override) {
|
|
try await operation()
|
|
}
|
|
}
|
|
|
|
public static func withFetchOverrideForTesting<T>(
|
|
_ override: FetchOverride?,
|
|
operation: () async -> T) async -> T
|
|
{
|
|
await self.$fetchOverride.withValue(override) {
|
|
await operation()
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public func fetch() async throws -> ClaudeStatusSnapshot {
|
|
let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary)
|
|
guard let resolved, Self.isBinaryAvailable(resolved) else {
|
|
throw ClaudeStatusProbeError.claudeNotInstalled
|
|
}
|
|
|
|
// Run commands sequentially through a shared Claude session to avoid warm-up churn.
|
|
let timeout = self.timeout
|
|
let keepAlive = self.keepCLISessionsAlive
|
|
#if DEBUG
|
|
if let override = Self.fetchOverride {
|
|
return try await override(resolved, timeout, keepAlive)
|
|
}
|
|
#endif
|
|
do {
|
|
var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout)
|
|
if !Self.usageOutputLooksRelevant(usage) {
|
|
Self.log.debug("Claude CLI /usage looked like startup output; retrying once")
|
|
usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: max(timeout, 14))
|
|
}
|
|
// `/status` only enriches a valid usage snapshot with identity. Terminal usage errors and loading stalls
|
|
// cannot be repaired by it, so fail now instead of paying for another interactive CLI round trip.
|
|
try Self.validateUsageBeforeStatusProbe(usage)
|
|
let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12))
|
|
let snap = try Self.parse(text: usage, statusText: status)
|
|
|
|
Self.log.info("Claude CLI scrape ok", metadata: [
|
|
"sessionPercentLeft": "\(snap.sessionPercentLeft ?? -1)",
|
|
"weeklyPercentLeft": "\(snap.weeklyPercentLeft ?? -1)",
|
|
"opusPercentLeft": "\(snap.opusPercentLeft ?? -1)",
|
|
])
|
|
if !keepAlive {
|
|
await Self.resetTransientCLISessionAndCleanupProbeArtifacts()
|
|
}
|
|
return snap
|
|
} catch {
|
|
if !keepAlive {
|
|
await Self.resetTransientCLISessionAndCleanupProbeArtifacts()
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private static func resetTransientCLISessionAndCleanupProbeArtifacts() async {
|
|
await ClaudeCLISession.current.reset()
|
|
let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts()
|
|
guard !removed.isEmpty else { return }
|
|
Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"])
|
|
}
|
|
}
|
|
|
|
extension ClaudeStatusProbe {
|
|
// MARK: - Parsing helpers
|
|
|
|
private struct LabelSearchContext {
|
|
let lines: [String]
|
|
let normalizedLines: [String]
|
|
let normalizedData: Data
|
|
|
|
init(text: String) {
|
|
self.lines = text.components(separatedBy: .newlines)
|
|
self.normalizedLines = self.lines.map { ClaudeStatusProbe.normalizedForLabelSearch($0) }
|
|
let normalized = ClaudeStatusProbe.normalizedForLabelSearch(text)
|
|
self.normalizedData = Data(normalized.utf8)
|
|
}
|
|
|
|
func contains(_ needle: String) -> Bool {
|
|
self.normalizedData.range(of: Data(needle.utf8)) != nil
|
|
}
|
|
}
|
|
|
|
public static func parse(text: String, statusText: String? = nil) throws -> ClaudeStatusSnapshot {
|
|
let clean = TextParsing.stripANSICodes(text)
|
|
let statusClean = statusText.map(TextParsing.stripANSICodes)
|
|
guard !clean.isEmpty else { throw ClaudeStatusProbeError.timedOut }
|
|
|
|
let shouldDump = ProcessInfo.processInfo.environment["DEBUG_CLAUDE_DUMP"] == "1"
|
|
|
|
if let usageError = self.extractUsageError(text: clean) {
|
|
Self.dumpIfNeeded(
|
|
enabled: shouldDump,
|
|
reason: "usageError: \(usageError)",
|
|
usage: clean,
|
|
status: statusText)
|
|
throw ClaudeStatusProbeError.parseFailed(usageError)
|
|
}
|
|
|
|
let latestUsagePanel = self.trimToLatestUsagePanel(clean)
|
|
if self.isUsageStillLoading(text: latestUsagePanel ?? clean) {
|
|
Self.dumpIfNeeded(
|
|
enabled: shouldDump,
|
|
reason: "usage still loading",
|
|
usage: clean,
|
|
status: statusText)
|
|
throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.")
|
|
}
|
|
|
|
// Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status
|
|
// line
|
|
// with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last
|
|
// Settings/Usage panel when present.
|
|
let usagePanelText = latestUsagePanel ?? clean
|
|
let labelContext = LabelSearchContext(text: usagePanelText)
|
|
|
|
var sessionPct = self.extractPercent(labelSubstring: "Current session", context: labelContext)
|
|
let weeklyPct = self.extractPercent(labelSubstring: "Current week (all models)", context: labelContext)
|
|
let opusLabels = [
|
|
"Current week (Opus)",
|
|
"Current week (Sonnet only)",
|
|
"Current week (Sonnet)",
|
|
]
|
|
let opusPct = self.extractPercent(labelSubstrings: opusLabels, context: labelContext)
|
|
|
|
// Fallback: order-based percent scraping when labels are present but the surrounding layout moved.
|
|
// Only apply the fallback when the corresponding label exists in the rendered panel; enterprise accounts
|
|
// may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing.
|
|
let weeklyModels = Set(labelContext.lines.compactMap(self.weeklyModelName).map(self.normalizedForLabelSearch))
|
|
let hasAllModelsWeeklyLabel = weeklyModels.contains("allmodels")
|
|
let opusModels = Set(opusLabels.compactMap(self.weeklyModelName).map(self.normalizedForLabelSearch))
|
|
let hasOpusLabel = !weeklyModels.isDisjoint(with: opusModels)
|
|
|
|
if sessionPct == nil {
|
|
let ordered = self.allPercents(usagePanelText)
|
|
if sessionPct == nil, ordered.indices.contains(0) {
|
|
sessionPct = ordered[0]
|
|
}
|
|
}
|
|
|
|
let identity = Self.parseIdentity(usageText: clean, statusText: statusClean)
|
|
|
|
guard let sessionPct else {
|
|
Self.dumpIfNeeded(
|
|
enabled: shouldDump,
|
|
reason: "missing session label",
|
|
usage: clean,
|
|
status: statusText)
|
|
if shouldDump {
|
|
let tail = usagePanelText.suffix(1800)
|
|
let snippet = tail.isEmpty ? "(empty)" : String(tail)
|
|
throw ClaudeStatusProbeError.parseFailed(
|
|
"Missing Current session.\n\n--- Clean usage tail ---\n\(snippet)")
|
|
}
|
|
throw ClaudeStatusProbeError.parseFailed("Missing Current session.")
|
|
}
|
|
|
|
let sessionReset = self.extractReset(labelSubstring: "Current session", context: labelContext)
|
|
let weeklyReset = hasAllModelsWeeklyLabel
|
|
? self.extractReset(labelSubstring: "Current week (all models)", context: labelContext)
|
|
: nil
|
|
let opusReset = hasOpusLabel
|
|
? self.extractReset(labelSubstrings: opusLabels, context: labelContext)
|
|
: nil
|
|
let scopedWeeklyUsages = self.extractScopedWeeklyUsages(context: labelContext)
|
|
let extraRateWindows = self.extraRateWindows(
|
|
fromScopedWeeklyUsages: scopedWeeklyUsages,
|
|
fallbackResetDescription: weeklyReset)
|
|
|
|
return ClaudeStatusSnapshot(
|
|
sessionPercentLeft: sessionPct,
|
|
weeklyPercentLeft: weeklyPct,
|
|
opusPercentLeft: opusPct,
|
|
accountEmail: identity.accountEmail,
|
|
accountOrganization: identity.accountOrganization,
|
|
loginMethod: identity.loginMethod,
|
|
primaryResetDescription: sessionReset,
|
|
secondaryResetDescription: weeklyReset,
|
|
opusResetDescription: opusReset,
|
|
rawText: text + (statusText ?? ""),
|
|
extraRateWindows: extraRateWindows)
|
|
}
|
|
|
|
public static func parseIdentity(usageText: String?, statusText: String?) -> ClaudeAccountIdentity {
|
|
let usageClean = usageText.map(TextParsing.stripANSICodes) ?? ""
|
|
let statusClean = statusText.map(TextParsing.stripANSICodes)
|
|
return self.extractIdentity(usageText: usageClean, statusText: statusClean)
|
|
}
|
|
|
|
public static func fetchIdentity(
|
|
timeout: TimeInterval = 12.0,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ClaudeAccountIdentity
|
|
{
|
|
let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment)
|
|
guard let resolved, self.isBinaryAvailable(resolved) else {
|
|
throw ClaudeStatusProbeError.claudeNotInstalled
|
|
}
|
|
let statusText = try await Self.capture(subcommand: "/status", binary: resolved, timeout: timeout)
|
|
return Self.parseIdentity(usageText: nil, statusText: statusText)
|
|
}
|
|
|
|
public static func touchOAuthAuthPath(
|
|
timeout: TimeInterval = 8,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) async throws
|
|
{
|
|
let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment)
|
|
guard let resolved, self.isBinaryAvailable(resolved) else {
|
|
throw ClaudeStatusProbeError.claudeNotInstalled
|
|
}
|
|
do {
|
|
// Use a more robust capture configuration than the standard `/status` scrape:
|
|
// - Avoid the short idle-timeout which can terminate the session while CLI auth checks are still running.
|
|
// - We intentionally do not parse output here; success is "the command ran without timing out".
|
|
_ = try await ClaudeCLISession.shared.capture(
|
|
subcommand: "/status",
|
|
binary: resolved,
|
|
timeout: timeout,
|
|
idleTimeout: nil,
|
|
stopOnSubstrings: [],
|
|
settleAfterStop: 0.8,
|
|
sendEnterEvery: 0.8)
|
|
await ClaudeCLISession.shared.reset()
|
|
} catch {
|
|
await ClaudeCLISession.shared.reset()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public static func isClaudeBinaryAvailable(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
|
|
{
|
|
let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment)
|
|
return self.isBinaryAvailable(resolved)
|
|
}
|
|
|
|
private static func extractPercent(labelSubstring: String, context: LabelSearchContext) -> Int? {
|
|
let lines = context.lines
|
|
let label = self.normalizedForLabelSearch(labelSubstring)
|
|
for (idx, line) in lines.enumerated() {
|
|
let normalizedLine = context.normalizedLines[idx]
|
|
guard self.matchesLabel(
|
|
line: line,
|
|
normalizedLine: normalizedLine,
|
|
labelSubstring: labelSubstring,
|
|
normalizedLabel: label)
|
|
else { continue }
|
|
|
|
// Claude's usage panel can take a moment to render percentages (especially on enterprise accounts),
|
|
// so scan a larger window than the original 3–4 lines.
|
|
let window = lines.dropFirst(idx).prefix(12)
|
|
for candidate in window {
|
|
if self.crossesLabelBoundary(
|
|
line: candidate,
|
|
labelSubstring: labelSubstring,
|
|
normalizedLabel: label)
|
|
{
|
|
break
|
|
}
|
|
if let pct = self.percentFromLine(candidate) {
|
|
return pct
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func usageOutputLooksRelevant(_ text: String) -> Bool {
|
|
let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace }
|
|
return normalized.contains("currentsession")
|
|
|| normalized.contains("currentweek")
|
|
|| normalized.contains("loadingusage")
|
|
|| normalized.contains("failedtoloadusagedata")
|
|
|| self.usageCaptureHasSubscriptionNotice(normalized)
|
|
}
|
|
|
|
private static func validateUsageBeforeStatusProbe(_ text: String) throws {
|
|
let clean = TextParsing.stripANSICodes(text)
|
|
if let usageError = self.extractUsageError(text: clean) {
|
|
throw ClaudeStatusProbeError.parseFailed(usageError)
|
|
}
|
|
|
|
let latestUsagePanel = self.trimToLatestUsagePanel(clean)
|
|
if self.isUsageStillLoading(text: latestUsagePanel ?? clean) {
|
|
throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.")
|
|
}
|
|
}
|
|
|
|
private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? {
|
|
for label in labelSubstrings {
|
|
if let value = self.extractPercent(labelSubstring: label, context: context) {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private struct ScopedWeeklyUsage {
|
|
let modelName: String
|
|
let percentLeft: Int
|
|
let resetDescription: String?
|
|
}
|
|
|
|
private static func extractScopedWeeklyUsages(context: LabelSearchContext) -> [ScopedWeeklyUsage] {
|
|
var usageIndexByModel: [String: Int] = [:]
|
|
var usages: [ScopedWeeklyUsage] = []
|
|
for (index, line) in context.lines.enumerated() {
|
|
guard let modelName = self.weeklyModelName(from: line) else { continue }
|
|
let normalizedModel = self.normalizedForLabelSearch(modelName)
|
|
guard normalizedModel != "allmodels", !normalizedModel.isEmpty else { continue }
|
|
|
|
let window = context.lines.dropFirst(index).prefix(14)
|
|
var percentLeft: Int?
|
|
var resetDescription: String?
|
|
for candidate in window {
|
|
if self.crossesLabelBoundary(
|
|
line: candidate,
|
|
labelSubstring: line,
|
|
normalizedLabel: self.normalizedForLabelSearch(line))
|
|
{
|
|
break
|
|
}
|
|
if percentLeft == nil {
|
|
percentLeft = self.percentFromLine(candidate)
|
|
}
|
|
if resetDescription == nil {
|
|
resetDescription = self.resetFromLine(candidate)
|
|
}
|
|
}
|
|
guard let percentLeft else { continue }
|
|
let usage = ScopedWeeklyUsage(
|
|
modelName: modelName,
|
|
percentLeft: percentLeft,
|
|
resetDescription: resetDescription)
|
|
if let existingIndex = usageIndexByModel[normalizedModel] {
|
|
usages[existingIndex] = usage
|
|
} else {
|
|
usageIndexByModel[normalizedModel] = usages.endIndex
|
|
usages.append(usage)
|
|
}
|
|
}
|
|
return usages
|
|
}
|
|
|
|
private static func extraRateWindows(
|
|
fromScopedWeeklyUsages usages: [ScopedWeeklyUsage],
|
|
fallbackResetDescription: String?) -> [NamedRateWindow]
|
|
{
|
|
usages.compactMap { usage in
|
|
let normalizedModel = self.normalizedForLabelSearch(usage.modelName)
|
|
guard normalizedModel != "opus",
|
|
normalizedModel != "sonnet",
|
|
normalizedModel != "sonnetonly"
|
|
else { return nil }
|
|
|
|
let usedPercent = max(0, min(100, 100 - Double(usage.percentLeft)))
|
|
let modelTitle = normalizedModel.hasSuffix("only")
|
|
? usage.modelName
|
|
: "\(usage.modelName) only"
|
|
let resetDescription = usage.resetDescription ?? fallbackResetDescription
|
|
return NamedRateWindow(
|
|
id: "claude-weekly-scoped-\(self.slug(usage.modelName))",
|
|
title: modelTitle,
|
|
window: RateWindow(
|
|
usedPercent: usedPercent,
|
|
windowMinutes: 7 * 24 * 60,
|
|
resetsAt: self.parseResetDate(
|
|
from: resetDescription,
|
|
expectedWindow: 7 * 24 * 60 * 60),
|
|
resetDescription: resetDescription))
|
|
}
|
|
}
|
|
|
|
private static func slug(_ value: String) -> String {
|
|
var result = ""
|
|
var lastWasDash = false
|
|
for scalar in value.lowercased().unicodeScalars {
|
|
if CharacterSet.alphanumerics.contains(scalar) {
|
|
result.unicodeScalars.append(scalar)
|
|
lastWasDash = false
|
|
} else if !lastWasDash {
|
|
result.append("-")
|
|
lastWasDash = true
|
|
}
|
|
}
|
|
return result.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
|
}
|
|
|
|
private static func percentFromLine(_ line: String, assumeRemainingWhenUnclear: Bool = false) -> Int? {
|
|
if self.isLikelyStatusContextLine(line) {
|
|
return nil
|
|
}
|
|
|
|
// Allow optional Unicode whitespace before % to handle CLI formatting changes.
|
|
let pattern = #"([0-9]{1,3}(?:\.[0-9]+)?)\p{Zs}*%"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil }
|
|
let range = NSRange(line.startIndex..<line.endIndex, in: line)
|
|
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
|
match.numberOfRanges >= 2,
|
|
let valRange = Range(match.range(at: 1), in: line)
|
|
else { return nil }
|
|
let rawVal = Double(line[valRange]) ?? 0
|
|
let clamped = max(0, min(100, rawVal))
|
|
let lower = line.lowercased()
|
|
let usedKeywords = ["used", "spent", "consumed"]
|
|
let remainingKeywords = ["left", "remaining", "available"]
|
|
if usedKeywords.contains(where: lower.contains) {
|
|
return Int(max(0, min(100, 100 - clamped)).rounded())
|
|
}
|
|
if remainingKeywords.contains(where: lower.contains) {
|
|
return Int(clamped.rounded())
|
|
}
|
|
return assumeRemainingWhenUnclear ? Int(clamped.rounded()) : nil
|
|
}
|
|
|
|
private static func isLikelyStatusContextLine(_ line: String) -> Bool {
|
|
guard line.contains("|") else { return false }
|
|
let lower = line.lowercased()
|
|
let modelTokens = ["opus", "sonnet", "haiku", "default"]
|
|
return modelTokens.contains(where: lower.contains)
|
|
}
|
|
|
|
private static func extractFirst(pattern: String, text: String) -> String? {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil }
|
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
guard let match = regex.firstMatch(in: text, options: [], range: range),
|
|
match.numberOfRanges >= 2,
|
|
let r = Range(match.range(at: 1), in: text) else { return nil }
|
|
return String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private static func extractIdentity(usageText: String, statusText: String?) -> ClaudeAccountIdentity {
|
|
let emailPatterns = [
|
|
#"(?i)Account:\s+([^\s@]+@[^\s@]+)"#,
|
|
#"(?i)Email:\s+([^\s@]+@[^\s@]+)"#,
|
|
]
|
|
let looseEmailPatterns = [
|
|
#"(?i)Account:\s+(\S+)"#,
|
|
#"(?i)Email:\s+(\S+)"#,
|
|
]
|
|
let email = emailPatterns
|
|
.compactMap { self.extractFirst(pattern: $0, text: usageText) }
|
|
.first
|
|
?? emailPatterns
|
|
.compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") }
|
|
.first
|
|
?? looseEmailPatterns
|
|
.compactMap { self.extractFirst(pattern: $0, text: usageText) }
|
|
.first
|
|
?? looseEmailPatterns
|
|
.compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") }
|
|
.first
|
|
?? self.extractFirst(
|
|
pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#,
|
|
text: usageText)
|
|
?? self.extractFirst(
|
|
pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#,
|
|
text: statusText ?? "")
|
|
let orgPatterns = [
|
|
#"(?i)Org:\s*(.+)"#,
|
|
#"(?i)Organization:\s*(.+)"#,
|
|
]
|
|
let orgRaw = orgPatterns
|
|
.compactMap { self.extractFirst(pattern: $0, text: usageText) }
|
|
.first
|
|
?? orgPatterns
|
|
.compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") }
|
|
.first
|
|
let org: String? = {
|
|
guard let orgText = orgRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !orgText.isEmpty else {
|
|
return nil
|
|
}
|
|
// Suppress org if it’s just the email prefix (common in CLI panels).
|
|
if let email, orgText.lowercased().hasPrefix(email.lowercased()) {
|
|
return nil
|
|
}
|
|
return orgText
|
|
}()
|
|
// Prefer explicit login method from /status, then fall back to /usage header heuristics.
|
|
let login = self.extractLoginMethod(text: statusText ?? "") ?? self.extractLoginMethod(text: usageText)
|
|
return ClaudeAccountIdentity(accountEmail: email, accountOrganization: org, loginMethod: login)
|
|
}
|
|
|
|
private static func extractUsageError(text: String) -> String? {
|
|
if let jsonHint = self.extractUsageErrorJSON(text: text) {
|
|
return jsonHint
|
|
}
|
|
|
|
let lower = text.lowercased()
|
|
let compact = lower.filter { !$0.isWhitespace }
|
|
if lower.contains("do you trust the files in this folder?"), !lower.contains("current session") {
|
|
let folder = self.extractFirst(
|
|
pattern: #"Do you trust the files in this folder\?\s*(?:\r?\n)+\s*([^\r\n]+)"#,
|
|
text: text)
|
|
let folderHint = folder.flatMap { value in
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
if let folderHint {
|
|
return """
|
|
Claude CLI is waiting for a folder trust prompt (\(folderHint)). CodexBar tries to auto-accept this, \
|
|
but if it keeps appearing run: `cd "\(folderHint)" && claude` and choose “Yes, proceed”, then retry.
|
|
"""
|
|
}
|
|
return """
|
|
Claude CLI is waiting for a folder trust prompt. CodexBar tries to auto-accept this, but if it keeps \
|
|
appearing open `claude` once, choose “Yes, proceed”, then retry.
|
|
"""
|
|
}
|
|
if lower.contains("token_expired") || lower.contains("token has expired") {
|
|
return "Claude CLI token expired. Run `claude login` to refresh."
|
|
}
|
|
if lower.contains("authentication_error") {
|
|
return "Claude CLI authentication error. Run `claude login`."
|
|
}
|
|
if lower.contains("rate_limit_error")
|
|
|| lower.contains("rate limited")
|
|
|| compact.contains("ratelimited")
|
|
{
|
|
return "Claude CLI usage endpoint is rate limited right now. Please try again later."
|
|
}
|
|
if self.isSubscriptionNoticeOnly(text: text) {
|
|
return self.subscriptionQuotaUnavailableDescription
|
|
}
|
|
if lower.contains("failed to load usage data") {
|
|
return "Claude CLI could not load usage data. Open the CLI and retry `/usage`."
|
|
}
|
|
if compact.contains("failedtoloadusagedata") {
|
|
return "Claude CLI could not load usage data. Open the CLI and retry `/usage`."
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func isUsageStillLoading(text: String) -> Bool {
|
|
let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace }
|
|
guard normalized.contains("loadingusage") else { return false }
|
|
return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty
|
|
}
|
|
|
|
/// Returns true when the text contains only a subscription notice with no session/weekly quota data.
|
|
/// CLI 2.1+ can return "You are currently using your subscription to power your Claude Code usage"
|
|
/// which lacks the "Current session" / "Current week" labels and percentage values needed for quota display.
|
|
/// A PTY capture may contain both an intermediate "Loading usage data…" panel and the final subscription
|
|
/// notice; `loadingusage` is not treated as quota data in this check so mixed captures surface correctly.
|
|
private static func isSubscriptionNoticeOnly(text: String) -> Bool {
|
|
let normalized = text.lowercased().filter { !$0.isWhitespace }
|
|
guard normalized.contains("currentlyusingyoursubscription") else { return false }
|
|
guard normalized.contains("claudecodeusage") else { return false }
|
|
// Only real session/week labels and actual percentage values count as quota data.
|
|
// `loadingusage` is not quota data — a mixed loading+subscription PTY capture should
|
|
// surface the subscription error, not a still-loading stall.
|
|
let hasQuotaData = normalized.contains("currentsession") || normalized.contains("currentweek")
|
|
|| normalized.contains("%used") || normalized.contains("%left") || normalized.contains("%remaining")
|
|
|| normalized.contains("%available")
|
|
return !hasQuotaData
|
|
}
|
|
|
|
public static func isSubscriptionQuotaUnavailableDescription(_ text: String?) -> Bool {
|
|
text?.localizedCaseInsensitiveContains("subscription notice without session quota data") == true
|
|
}
|
|
|
|
/// Collect remaining percentages in the order they appear; used as a backup when labels move/rename.
|
|
private static func allPercents(_ text: String) -> [Int] {
|
|
let lines = text.components(separatedBy: .newlines)
|
|
let normalized = text.lowercased().filter { !$0.isWhitespace }
|
|
let hasUsageWindows = normalized.contains("currentsession") || normalized.contains("currentweek")
|
|
let hasLoading = normalized.contains("loadingusage")
|
|
let hasUsagePercentKeywords = normalized.contains("used") || normalized.contains("left")
|
|
|| normalized.contains("remaining") || normalized.contains("available")
|
|
let loadingOnly = hasLoading && !hasUsageWindows
|
|
guard hasUsageWindows || hasLoading else { return [] }
|
|
if loadingOnly {
|
|
return []
|
|
}
|
|
guard hasUsagePercentKeywords else { return [] }
|
|
|
|
// Keep this strict to avoid matching Claude's status-line context meter (e.g. "0%") as session usage when the
|
|
// /usage panel is still rendering.
|
|
return lines.compactMap { self.percentFromLine($0, assumeRemainingWhenUnclear: false) }
|
|
}
|
|
|
|
/// Attempts to isolate the most recent /usage panel output from a PTY capture.
|
|
/// The Claude TUI draws a "Settings: … Usage …" header; we slice from its last occurrence to avoid earlier screen
|
|
/// fragments (like the status bar) contaminating percent scraping.
|
|
private static func trimToLatestUsagePanel(_ text: String) -> String? {
|
|
guard let settingsRange = text.range(of: "Settings:", options: [.caseInsensitive, .backwards]) else {
|
|
return nil
|
|
}
|
|
let tail = text[settingsRange.lowerBound...]
|
|
guard tail.range(of: "Usage", options: .caseInsensitive) != nil else { return nil }
|
|
let lower = tail.lowercased()
|
|
let hasPercent = lower.contains("%")
|
|
let hasUsageWords = lower.contains("used") || lower.contains("left") || lower.contains("remaining")
|
|
|| lower.contains("available")
|
|
let hasLoading = lower.contains("loading usage")
|
|
guard (hasPercent && hasUsageWords) || hasLoading else { return nil }
|
|
return String(tail)
|
|
}
|
|
|
|
private static func extractReset(labelSubstring: String, context: LabelSearchContext) -> String? {
|
|
let lines = context.lines
|
|
let label = self.normalizedForLabelSearch(labelSubstring)
|
|
for (idx, line) in lines.enumerated() {
|
|
let normalizedLine = context.normalizedLines[idx]
|
|
guard self.matchesLabel(
|
|
line: line,
|
|
normalizedLine: normalizedLine,
|
|
labelSubstring: labelSubstring,
|
|
normalizedLabel: label)
|
|
else { continue }
|
|
|
|
let window = lines.dropFirst(idx).prefix(14)
|
|
for candidate in window {
|
|
if self.crossesLabelBoundary(
|
|
line: candidate,
|
|
labelSubstring: labelSubstring,
|
|
normalizedLabel: label)
|
|
{
|
|
break
|
|
}
|
|
if let reset = self.resetFromLine(candidate) {
|
|
return reset
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func matchesLabel(
|
|
line: String,
|
|
normalizedLine: String,
|
|
labelSubstring: String,
|
|
normalizedLabel: String) -> Bool
|
|
{
|
|
guard let expectedModel = self.weeklyModelName(from: labelSubstring) else {
|
|
return normalizedLine.contains(normalizedLabel)
|
|
}
|
|
guard let actualModel = self.weeklyModelName(from: line) else { return false }
|
|
return self.normalizedForLabelSearch(actualModel) == self.normalizedForLabelSearch(expectedModel)
|
|
}
|
|
|
|
private static func crossesLabelBoundary(
|
|
line: String,
|
|
labelSubstring: String,
|
|
normalizedLabel: String) -> Bool
|
|
{
|
|
let normalizedLine = self.normalizedForLabelSearch(line)
|
|
guard normalizedLine.hasPrefix("current") else { return false }
|
|
guard let expectedModel = self.weeklyModelName(from: labelSubstring) else {
|
|
return !normalizedLine.contains(normalizedLabel)
|
|
}
|
|
guard let actualModel = self.weeklyModelName(from: line) else { return true }
|
|
return self.normalizedForLabelSearch(actualModel) != self.normalizedForLabelSearch(expectedModel)
|
|
}
|
|
|
|
private static func weeklyModelName(from line: String) -> String? {
|
|
guard let regex = try? NSRegularExpression(
|
|
pattern: #"current\s*week\s*\(([^)]+)\)"#,
|
|
options: [.caseInsensitive])
|
|
else { return nil }
|
|
let range = NSRange(line.startIndex..<line.endIndex, in: line)
|
|
guard let match = regex.firstMatch(in: line, options: [], range: range),
|
|
match.numberOfRanges >= 2,
|
|
let modelRange = Range(match.range(at: 1), in: line)
|
|
else { return nil }
|
|
return String(line[modelRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private static func extractReset(labelSubstrings: [String], context: LabelSearchContext) -> String? {
|
|
for label in labelSubstrings {
|
|
if let value = self.extractReset(labelSubstring: label, context: context) {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func resetFromLine(_ line: String) -> String? {
|
|
let range = line.range(of: #"(?i)\bresets?\b"#, options: .regularExpression)
|
|
?? line.range(of: #"\b(?:Reset|Resets)(?=[A-Z0-9])"#, options: .regularExpression)
|
|
guard let range else { return nil }
|
|
let raw = String(line[range.lowerBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return self.cleanResetLine(raw)
|
|
}
|
|
|
|
private static func normalizedForLabelSearch(_ text: String) -> String {
|
|
String(text.lowercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains))
|
|
}
|
|
|
|
/// Capture all "Reset"/"Resets" strings to surface in the menu.
|
|
private static func allResets(_ text: String) -> [String] {
|
|
let pat = #"\bResets?\b[^\r\n]*"#
|
|
guard let regex = try? NSRegularExpression(pattern: pat, options: [.caseInsensitive]) else { return [] }
|
|
let nsrange = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
var results: [String] = []
|
|
regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in
|
|
guard let match,
|
|
let r = Range(match.range(at: 0), in: text) else { return }
|
|
let raw = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
results.append(self.cleanResetLine(raw))
|
|
}
|
|
return results
|
|
}
|
|
|
|
private static func cleanResetLine(_ raw: String) -> String {
|
|
// TTY capture sometimes appends a stray ")" at line ends; trim it to keep snapshots stable.
|
|
var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: " )"))
|
|
cleaned = cleaned.replacingOccurrences(
|
|
of: #"(?i)\b([A-Za-z]{3}\s+\d{1,2})\s+t\s+(\d)"#,
|
|
with: "$1 at $2",
|
|
options: .regularExpression)
|
|
let openCount = cleaned.count(where: { $0 == "(" })
|
|
let closeCount = cleaned.count(where: { $0 == ")" })
|
|
if openCount > closeCount {
|
|
cleaned.append(")")
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
/// Parses explicit-year resets at their stated occurrence; recurring forms resolve forward in any explicit
|
|
/// timezone.
|
|
public static func parseResetDate(from text: String?, now: Date = .init()) -> Date? {
|
|
self.parseResetDate(from: text, now: now, expectedWindow: nil)
|
|
}
|
|
|
|
/// Quota surfaces may accept a recently stale occurrence when the next occurrence cannot belong to that window.
|
|
package static func parseResetDate(
|
|
from text: String?,
|
|
now: Date = .init(),
|
|
expectedWindow: TimeInterval?) -> Date?
|
|
{
|
|
guard let normalized = self.normalizeResetInput(text) else { return nil }
|
|
let (raw, timeZone) = normalized
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = timeZone ?? TimeZone.current
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.timeZone = formatter.timeZone
|
|
// Parse yearless Feb 29 independently of the current year, then resolve validated calendar occurrences.
|
|
formatter.defaultDate = calendar.date(from: DateComponents(year: 2000, month: 1, day: 1))
|
|
|
|
if let date = self.parseDate(raw, formats: Self.resetDateTimeWithExplicitYear, formatter: formatter) {
|
|
return self.resolveOccurrence(
|
|
self.explicitOccurrences(for: date, calendar: calendar),
|
|
now: now,
|
|
expectedWindow: expectedWindow)
|
|
}
|
|
if let date = self.parseDate(raw, formats: Self.resetDateTimeWithMinutes, formatter: formatter) {
|
|
let comps = calendar.dateComponents([.month, .day, .hour, .minute], from: date)
|
|
return self.resolveOccurrence(
|
|
self.yearlyOccurrences(for: comps, now: now, calendar: calendar),
|
|
now: now,
|
|
expectedWindow: expectedWindow)
|
|
}
|
|
if let date = self.parseDate(raw, formats: Self.resetDateTimeHourOnly, formatter: formatter) {
|
|
let comps = calendar.dateComponents([.month, .day, .hour], from: date)
|
|
return self.resolveOccurrence(
|
|
self.yearlyOccurrences(for: comps, now: now, calendar: calendar),
|
|
now: now,
|
|
expectedWindow: expectedWindow)
|
|
}
|
|
|
|
if let time = self.parseDate(raw, formats: Self.resetTimeWithMinutes, formatter: formatter) {
|
|
let comps = calendar.dateComponents([.hour, .minute], from: time)
|
|
return self.resolveOccurrence(
|
|
self.dailyOccurrences(for: comps, now: now, calendar: calendar),
|
|
now: now,
|
|
expectedWindow: expectedWindow)
|
|
}
|
|
|
|
guard let time = self.parseDate(raw, formats: Self.resetTimeHourOnly, formatter: formatter) else { return nil }
|
|
let comps = calendar.dateComponents([.hour], from: time)
|
|
return self.resolveOccurrence(
|
|
self.dailyOccurrences(for: comps, now: now, calendar: calendar),
|
|
now: now,
|
|
expectedWindow: expectedWindow)
|
|
}
|
|
|
|
private static func explicitOccurrences(for date: Date, calendar: Calendar) -> [Date] {
|
|
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
|
guard let year = components.year,
|
|
let month = components.month,
|
|
let day = components.day,
|
|
let hour = components.hour,
|
|
let minute = components.minute
|
|
else { return [] }
|
|
return self.exactLocalOccurrences(
|
|
DateComponents(
|
|
timeZone: calendar.timeZone,
|
|
year: year,
|
|
month: month,
|
|
day: day,
|
|
hour: hour,
|
|
minute: minute,
|
|
second: 0),
|
|
calendar: calendar)
|
|
}
|
|
|
|
private static func resolveOccurrence(
|
|
_ candidates: [Date],
|
|
now: Date,
|
|
expectedWindow: TimeInterval?) -> Date?
|
|
{
|
|
let sortedCandidates = candidates.sorted()
|
|
guard let future = sortedCandidates.first(where: { $0 >= now }) else { return sortedCandidates.last }
|
|
guard let expectedWindow else { return future }
|
|
|
|
let horizon = max(0, expectedWindow)
|
|
let past = sortedCandidates.last(where: { $0 < now })
|
|
let pastIsPlausible = past.map { now.timeIntervalSince($0) <= horizon } ?? false
|
|
let futureIsPlausible = future.timeIntervalSince(now) <= horizon
|
|
return pastIsPlausible && !futureIsPlausible ? past : future
|
|
}
|
|
|
|
private static func yearlyOccurrences(
|
|
for components: DateComponents,
|
|
now: Date,
|
|
calendar: Calendar) -> [Date]
|
|
{
|
|
guard let month = components.month,
|
|
let day = components.day,
|
|
let hour = components.hour
|
|
else { return [] }
|
|
let currentYear = calendar.component(.year, from: now)
|
|
// Eight years covers the Gregorian century gap between leap days (for example, 2096 to 2104).
|
|
return ((currentYear - 8)...(currentYear + 8)).flatMap { year in
|
|
self.exactLocalOccurrences(
|
|
DateComponents(
|
|
timeZone: calendar.timeZone,
|
|
year: year,
|
|
month: month,
|
|
day: day,
|
|
hour: hour,
|
|
minute: components.minute ?? 0,
|
|
second: 0),
|
|
calendar: calendar)
|
|
}
|
|
}
|
|
|
|
private static func dailyOccurrences(
|
|
for components: DateComponents,
|
|
now: Date,
|
|
calendar: Calendar) -> [Date]
|
|
{
|
|
guard let hour = components.hour else { return [] }
|
|
let startOfToday = calendar.startOfDay(for: now)
|
|
return (-1...1).flatMap { offset -> [Date] in
|
|
guard let day = calendar.date(byAdding: .day, value: offset, to: startOfToday) else { return [] }
|
|
let dayComponents = calendar.dateComponents([.year, .month, .day], from: day)
|
|
guard let year = dayComponents.year,
|
|
let month = dayComponents.month,
|
|
let dayOfMonth = dayComponents.day
|
|
else { return [] }
|
|
return self.exactLocalOccurrences(
|
|
DateComponents(
|
|
timeZone: calendar.timeZone,
|
|
year: year,
|
|
month: month,
|
|
day: dayOfMonth,
|
|
hour: hour,
|
|
minute: components.minute ?? 0,
|
|
second: 0),
|
|
calendar: calendar)
|
|
}
|
|
}
|
|
|
|
/// Returns both instants when a local wall time repeats during a daylight-saving transition.
|
|
private static func exactLocalOccurrences(
|
|
_ target: DateComponents,
|
|
calendar: Calendar) -> [Date]
|
|
{
|
|
guard let year = target.year,
|
|
let month = target.month,
|
|
let day = target.day,
|
|
let hour = target.hour,
|
|
let minute = target.minute
|
|
else { return [] }
|
|
guard let firstApproximation = calendar.date(from: target) else { return [] }
|
|
let approximationComponents = calendar.dateComponents(
|
|
[.year, .month, .day, .hour, .minute, .second],
|
|
from: firstApproximation)
|
|
guard approximationComponents.year == year,
|
|
approximationComponents.month == month,
|
|
approximationComponents.day == day,
|
|
approximationComponents.hour == hour,
|
|
approximationComponents.minute == minute,
|
|
approximationComponents.second == 0
|
|
else { return [] }
|
|
let startOfDay = calendar.startOfDay(for: firstApproximation)
|
|
guard let searchStart = calendar.date(byAdding: .second, value: -1, to: startOfDay) else { return [] }
|
|
|
|
var results: [Date] = []
|
|
for policy in [Calendar.RepeatedTimePolicy.first, .last] {
|
|
guard let date = calendar.nextDate(
|
|
after: searchStart,
|
|
matching: target,
|
|
matchingPolicy: .strict,
|
|
repeatedTimePolicy: policy,
|
|
direction: .forward)
|
|
else { continue }
|
|
let resolved = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
|
|
guard resolved.year == year,
|
|
resolved.month == month,
|
|
resolved.day == day,
|
|
resolved.hour == hour,
|
|
resolved.minute == minute,
|
|
resolved.second == 0,
|
|
!results.contains(date)
|
|
else { continue }
|
|
results.append(date)
|
|
}
|
|
return results
|
|
}
|
|
|
|
private static let resetTimeWithMinutes = ["h:mma", "h:mm a", "HH:mm", "H:mm"]
|
|
private static let resetTimeHourOnly = ["ha", "h a"]
|
|
|
|
private static let resetDateTimeWithExplicitYear = [
|
|
"MMM d, yyyy, h:mma",
|
|
"MMM d, yyyy, h:mm a",
|
|
"MMM d, yyyy, HH:mm",
|
|
"MMM d, yyyy h:mma",
|
|
"MMM d, yyyy h:mm a",
|
|
"MMM d, yyyy HH:mm",
|
|
"MMM d yyyy h:mma",
|
|
"MMM d yyyy h:mm a",
|
|
"MMM d yyyy HH:mm",
|
|
"MMM d, yyyy, ha",
|
|
"MMM d, yyyy, h a",
|
|
"MMM d, yyyy, HH",
|
|
"MMM d, yyyy ha",
|
|
"MMM d, yyyy h a",
|
|
"MMM d, yyyy HH",
|
|
"MMM d yyyy ha",
|
|
"MMM d yyyy h a",
|
|
"MMM d yyyy HH",
|
|
]
|
|
|
|
private static let resetDateTimeWithMinutes = [
|
|
"MMM d, h:mma",
|
|
"MMM d, h:mm a",
|
|
"MMM d h:mma",
|
|
"MMM d h:mm a",
|
|
"MMM d, HH:mm",
|
|
"MMM d HH:mm",
|
|
]
|
|
|
|
private static let resetDateTimeHourOnly = [
|
|
"MMM d, ha",
|
|
"MMM d, h a",
|
|
"MMM d ha",
|
|
"MMM d h a",
|
|
]
|
|
|
|
private static func normalizeResetInput(_ text: String?) -> (String, TimeZone?)? {
|
|
guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
|
raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression)
|
|
raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive)
|
|
raw = raw.replacingOccurrences(of: #"(?i)\b([A-Za-z]{3})(\d)"#, with: "$1 $2", options: .regularExpression)
|
|
raw = raw.replacingOccurrences(of: #",(\d)"#, with: ", $1", options: .regularExpression)
|
|
raw = raw.replacingOccurrences(of: #"(?i)(\d)at(?=\d)"#, with: "$1 ", options: .regularExpression)
|
|
raw = raw.replacingOccurrences(
|
|
of: #"(?<=\d)\.(\d{2})\b"#,
|
|
with: ":$1",
|
|
options: .regularExpression)
|
|
|
|
let timeZone = self.extractTimeZone(from: &raw)
|
|
raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return raw.isEmpty ? nil : (raw, timeZone)
|
|
}
|
|
|
|
private static func extractTimeZone(from text: inout String) -> TimeZone? {
|
|
guard let tzRange = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { return nil }
|
|
let tzID = String(text[tzRange]).trimmingCharacters(in: CharacterSet(charactersIn: "() "))
|
|
text.removeSubrange(tzRange)
|
|
text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return TimeZone(identifier: tzID)
|
|
}
|
|
|
|
private static func parseDate(_ text: String, formats: [String], formatter: DateFormatter) -> Date? {
|
|
for pattern in formats {
|
|
formatter.dateFormat = pattern
|
|
if let date = formatter.date(from: text) {
|
|
return date
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Extract login/plan string from CLI output.
|
|
private static func extractLoginMethod(text: String) -> String? {
|
|
guard !text.isEmpty else { return nil }
|
|
if let explicit = self.extractFirst(pattern: #"(?i)login\s+method:\s*(.+)"#, text: text) {
|
|
return ClaudePlan.cliCompatibilityLoginMethod(self.cleanPlan(explicit))
|
|
}
|
|
// Capture any "Claude <...>" phrase (e.g., Max/Pro/Ultra/Team) to avoid future plan-name churn.
|
|
// Strip any leading ANSI that may have survived (rare) before matching.
|
|
// Use horizontal whitespace ([ \t]) rather than \s so the match stays on a single rendered line:
|
|
// \s spans newlines, which let "…use Claude" bridge into the /usage panel's "d → today" hint and
|
|
// produced a bogus "Dtoday" plan label after ANSI stripping glued the lines together.
|
|
let planPattern = #"(?i)(claude[ \t]+[a-z0-9][a-z0-9 \t._-]{0,24})"#
|
|
var candidates: [String] = []
|
|
if let regex = try? NSRegularExpression(pattern: planPattern, options: []) {
|
|
let nsrange = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in
|
|
guard let match,
|
|
match.numberOfRanges >= 2,
|
|
let r = Range(match.range(at: 1), in: text) else { return }
|
|
let raw = String(text[r])
|
|
let val = ClaudePlan.cliCompatibilityLoginMethod(Self.cleanPlan(raw)) ?? Self.cleanPlan(raw)
|
|
candidates.append(val)
|
|
}
|
|
}
|
|
if let plan = candidates.first(where: { cand in
|
|
let lower = cand.lowercased()
|
|
return !lower.contains("code v") && !lower.contains("code version") && !lower.contains("code")
|
|
}) {
|
|
return plan
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Strips ANSI and stray bracketed codes like "[22m" that can survive CLI output.
|
|
private static func cleanPlan(_ text: String) -> String {
|
|
UsageFormatter.cleanPlanName(text)
|
|
}
|
|
|
|
private static func dumpIfNeeded(enabled: Bool, reason: String, usage: String, status: String?) {
|
|
guard enabled else { return }
|
|
let stamp = ISO8601DateFormatter().string(from: Date())
|
|
var parts = [
|
|
"=== Claude parse dump @ \(stamp) ===",
|
|
"Reason: \(reason)",
|
|
"",
|
|
"--- usage (clean) ---",
|
|
usage,
|
|
"",
|
|
]
|
|
if let status {
|
|
parts.append(contentsOf: [
|
|
"--- status (raw/optional) ---",
|
|
status,
|
|
"",
|
|
])
|
|
}
|
|
let body = parts.joined(separator: "\n")
|
|
Task { @MainActor in self.recordDump(body) }
|
|
}
|
|
|
|
// MARK: - Dump storage (in-memory ring buffer)
|
|
|
|
@MainActor private static var recentDumps: [String] = []
|
|
|
|
@MainActor private static func recordDump(_ text: String) {
|
|
if self.recentDumps.count >= 5 {
|
|
self.recentDumps.removeFirst()
|
|
}
|
|
self.recentDumps.append(text)
|
|
}
|
|
|
|
public static func latestDumps() async -> String {
|
|
await MainActor.run {
|
|
let result = Self.recentDumps.joined(separator: "\n\n---\n\n")
|
|
return result.isEmpty ? "No Claude parse dumps captured yet." : result
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
public static func _replaceDumpsForTesting(_ dumps: [String]) async {
|
|
await MainActor.run {
|
|
self.recentDumps = dumps
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private static func extractUsageErrorJSON(text: String) -> String? {
|
|
let pattern = #"Failed\s*to\s*load\s*usage\s*data:\s*(\{.*\})"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else {
|
|
return nil
|
|
}
|
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
guard let match = regex.firstMatch(in: text, options: [], range: range),
|
|
match.numberOfRanges >= 2,
|
|
let jsonRange = Range(match.range(at: 1), in: text)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let jsonString = String(text[jsonRange])
|
|
let compactJSON = jsonString.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
|
|
let data = (compactJSON.isEmpty ? jsonString : compactJSON).data(using: .utf8)
|
|
guard let data,
|
|
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let error = payload["error"] as? [String: Any]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let message = (error["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let details = error["details"] as? [String: Any]
|
|
let code = (details?["error_code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let type = (error["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
if type == "rate_limit_error" {
|
|
return "Claude CLI usage endpoint is rate limited right now. Please try again later."
|
|
}
|
|
|
|
var parts: [String] = []
|
|
if let message, !message.isEmpty {
|
|
parts.append(message)
|
|
}
|
|
if let code, !code.isEmpty {
|
|
parts.append("(\(code))")
|
|
}
|
|
|
|
guard !parts.isEmpty else { return nil }
|
|
let hint = parts.joined(separator: " ")
|
|
|
|
if let code, code.lowercased().contains("token") {
|
|
return "\(hint). Run `claude login` to refresh."
|
|
}
|
|
return "Claude CLI error: \(hint)"
|
|
}
|
|
|
|
// MARK: - Process helpers
|
|
|
|
private static func resolvedBinaryPath(
|
|
binaryName: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
|
|
{
|
|
if binaryName.contains("/") {
|
|
return binaryName
|
|
}
|
|
return ClaudeCLIResolver.resolvedBinaryPath(environment: environment)
|
|
}
|
|
|
|
private static func isBinaryAvailable(_ binaryPathOrName: String?) -> Bool {
|
|
guard let binaryPathOrName else { return false }
|
|
return FileManager.default.isExecutableFile(atPath: binaryPathOrName)
|
|
|| TTYCommandRunner.which(binaryPathOrName) != nil
|
|
}
|
|
|
|
static func probeWorkingDirectoryURL() -> URL {
|
|
let fm = FileManager.default
|
|
let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory
|
|
let dir = base
|
|
.appendingPathComponent("CodexBar", isDirectory: true)
|
|
.appendingPathComponent("ClaudeProbe", isDirectory: true)
|
|
do {
|
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
} catch {
|
|
return fm.temporaryDirectory
|
|
}
|
|
}
|
|
|
|
static func preparedProbeWorkingDirectoryURL() -> URL {
|
|
let directory = self.probeWorkingDirectoryURL()
|
|
do {
|
|
try self.prepareProbeWorkingDirectory(at: directory)
|
|
} catch {
|
|
Self.log.warning(
|
|
"Claude probe local settings unavailable",
|
|
metadata: ["error": error.localizedDescription])
|
|
}
|
|
return directory
|
|
}
|
|
|
|
static func prepareProbeWorkingDirectory(at directory: URL, fileManager fm: FileManager = .default) throws {
|
|
try fm.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let claudeDirectory = directory.appendingPathComponent(".claude", isDirectory: true)
|
|
try fm.createDirectory(at: claudeDirectory, withIntermediateDirectories: true)
|
|
|
|
let settingsURL = claudeDirectory.appendingPathComponent("settings.local.json")
|
|
var settings = (try? self.readSettingsObject(from: settingsURL, fileManager: fm)) ?? [:]
|
|
settings["disableDeepLinkRegistration"] = "disable"
|
|
let data = try JSONSerialization.data(
|
|
withJSONObject: settings,
|
|
options: [.prettyPrinted, .sortedKeys])
|
|
try data.write(to: settingsURL, options: .atomic)
|
|
}
|
|
|
|
private static func readSettingsObject(from url: URL, fileManager fm: FileManager) throws -> [String: Any] {
|
|
guard fm.fileExists(atPath: url.path) else {
|
|
return [:]
|
|
}
|
|
let data = try Data(contentsOf: url)
|
|
guard !data.isEmpty else {
|
|
return [:]
|
|
}
|
|
let object = try JSONSerialization.jsonObject(with: data)
|
|
return object as? [String: Any] ?? [:]
|
|
}
|
|
|
|
/// Run claude CLI inside a PTY so we can respond to interactive permission prompts.
|
|
private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String {
|
|
let stopOnSubstrings = subcommand == "/usage"
|
|
? [
|
|
"Failed to load usage data",
|
|
"failed to load usage data",
|
|
"Failedto loadusagedata",
|
|
"failedtoloadusagedata",
|
|
]
|
|
: []
|
|
let idleTimeout: TimeInterval? = subcommand == "/usage" ? nil : 3.0
|
|
let sendEnterEvery: TimeInterval? = subcommand == "/usage" ? 0.8 : nil
|
|
let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage"
|
|
? { @Sendable normalizedScan in
|
|
Self.usageCaptureHasSessionValue(normalizedScan)
|
|
|| Self.usageCaptureHasSubscriptionNotice(normalizedScan)
|
|
}
|
|
: nil
|
|
do {
|
|
return try await ClaudeCLISession.current.capture(
|
|
subcommand: subcommand,
|
|
binary: binary,
|
|
timeout: timeout,
|
|
idleTimeout: idleTimeout,
|
|
stopOnSubstrings: stopOnSubstrings,
|
|
stopWhenNormalized: stopWhenNormalized,
|
|
settleAfterStop: subcommand == "/usage" ? 2.0 : 0.25,
|
|
sendEnterEvery: sendEnterEvery)
|
|
} catch ClaudeCLISession.SessionError.processExited {
|
|
await ClaudeCLISession.current.reset()
|
|
throw ClaudeStatusProbeError.timedOut
|
|
} catch ClaudeCLISession.SessionError.timedOut {
|
|
await ClaudeCLISession.current.reset()
|
|
throw ClaudeStatusProbeError.timedOut
|
|
} catch ClaudeCLISession.SessionError.launchFailed(_) {
|
|
throw ClaudeStatusProbeError.claudeNotInstalled
|
|
} catch {
|
|
await ClaudeCLISession.current.reset()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private static func usageCaptureHasSessionValue(_ normalizedText: String) -> Bool {
|
|
guard let labelRange = normalizedText.range(of: "currentsession") else { return false }
|
|
let tail = normalizedText[labelRange.upperBound...]
|
|
return tail.range(of: #"[0-9]{1,3}(?:\.[0-9]+)?%"#, options: .regularExpression) != nil
|
|
}
|
|
|
|
private static func usageCaptureHasSubscriptionNotice(_ normalizedText: String) -> Bool {
|
|
normalizedText.contains("currentlyusingyoursubscription")
|
|
&& normalizedText.contains("claudecodeusage")
|
|
}
|
|
}
|