diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..08de0be
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage-Package.xcscheme
deleted file mode 100644
index 3ac013b..0000000
--- a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage-Package.xcscheme
+++ /dev/null
@@ -1,118 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageCLI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageCLI.xcscheme
deleted file mode 100644
index c31e532..0000000
--- a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageCLI.xcscheme
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageKit.xcscheme
deleted file mode 100644
index 4cda4ee..0000000
--- a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageKit.xcscheme
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsage.xcscheme
similarity index 84%
rename from .swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme
rename to .swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsage.xcscheme
index 11289a8..63ace62 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsage.xcscheme
@@ -15,9 +15,9 @@
buildForAnalyzing = "YES">
@@ -44,9 +44,9 @@
runnableDebuggingMode = "0">
@@ -61,9 +61,9 @@
runnableDebuggingMode = "0">
diff --git a/Package.resolved b/Package.resolved
deleted file mode 100644
index ae921e1..0000000
--- a/Package.resolved
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "pins" : [
- {
- "identity" : "swift-syntax",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/swiftlang/swift-syntax.git",
- "state" : {
- "revision" : "0687f71944021d616d34d922343dcef086855920",
- "version" : "600.0.1"
- }
- }
- ],
- "version" : 2
-}
diff --git a/Package.swift b/Package.swift
index a2ef9fa..5bba133 100644
--- a/Package.swift
+++ b/Package.swift
@@ -3,52 +3,80 @@
import PackageDescription
let package = Package(
- name: "ClaudeCodeUsage",
+ name: "ClaudeUsage",
platforms: [
.macOS(.v15),
],
products: [
+ // Domain layer - pure types, protocols, analytics
.library(
- name: "ClaudeCodeUsageKit",
- targets: ["ClaudeCodeUsageKit"]),
+ name: "ClaudeUsageCore",
+ targets: ["ClaudeUsageCore"]),
+ // Data layer - repository, parsing, monitoring
+ .library(
+ name: "ClaudeUsageData",
+ targets: ["ClaudeUsageData"]),
+ // macOS menu bar app
.executable(
- name: "ClaudeCodeUsage",
- targets: ["ClaudeCodeUsage"])
- ],
- dependencies: [
- .package(path: "Packages/ClaudeLiveMonitor"),
- .package(path: "Packages/TimingMacro")
+ name: "ClaudeUsage",
+ targets: ["ClaudeUsage"]),
+ // CLI monitor
+ .executable(
+ name: "claude-usage",
+ targets: ["ClaudeMonitorCLI"])
],
+ dependencies: [],
targets: [
+ // MARK: - Domain Layer (no dependencies)
+
.target(
- name: "ClaudeCodeUsageKit",
- dependencies: [
- .product(name: "TimingMacro", package: "TimingMacro")
- ],
- path: "Sources/ClaudeCodeUsageKit"),
+ name: "ClaudeUsageCore",
+ dependencies: [],
+ path: "Sources/ClaudeUsageCore"),
+
+ // MARK: - Data Layer (depends on Core)
+
+ .target(
+ name: "ClaudeUsageData",
+ dependencies: ["ClaudeUsageCore"],
+ path: "Sources/ClaudeUsageData"),
+
+ // MARK: - Presentation Layer
+
.executableTarget(
- name: "ClaudeCodeUsage",
+ name: "ClaudeUsage",
dependencies: [
- "ClaudeCodeUsageKit",
- .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor")
+ "ClaudeUsageCore",
+ "ClaudeUsageData"
],
- path: "Sources/ClaudeCodeUsage"),
+ path: "Sources/ClaudeUsage"),
+
+ // MARK: - CLI
+
+ .executableTarget(
+ name: "ClaudeMonitorCLI",
+ dependencies: ["ClaudeUsageData"],
+ path: "Sources/ClaudeMonitorCLI"),
+
+ // MARK: - Tests
+
.testTarget(
- name: "ClaudeCodeUsageKitTests",
- dependencies: ["ClaudeCodeUsageKit"],
- path: "Tests/ClaudeCodeUsageKitTests",
- swiftSettings: [
- .unsafeFlags(["-enable-testing"]),
- .define("ENABLE_CODE_COVERAGE", .when(configuration: .debug))
- ]),
+ name: "ClaudeUsageCoreTests",
+ dependencies: ["ClaudeUsageCore"],
+ path: "Tests/ClaudeUsageCoreTests"),
+
+ .testTarget(
+ name: "ClaudeUsageDataTests",
+ dependencies: ["ClaudeUsageData"],
+ path: "Tests/ClaudeUsageDataTests"),
+
.testTarget(
- name: "ClaudeCodeUsageTests",
+ name: "ClaudeUsageTests",
dependencies: [
- "ClaudeCodeUsage",
- "ClaudeCodeUsageKit",
- .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor")
+ "ClaudeUsage",
+ "ClaudeUsageData"
],
- path: "Tests/ClaudeCodeUsageTests",
+ path: "Tests/ClaudeUsageTests",
swiftSettings: [
.unsafeFlags(["-enable-testing"]),
.define("ENABLE_CODE_COVERAGE", .when(configuration: .debug))
diff --git a/Packages/ClaudeLiveMonitor/Package.swift b/Packages/ClaudeLiveMonitor/Package.swift
deleted file mode 100644
index 40e080d..0000000
--- a/Packages/ClaudeLiveMonitor/Package.swift
+++ /dev/null
@@ -1,37 +0,0 @@
-// swift-tools-version: 5.9
-import PackageDescription
-
-let package = Package(
- name: "ClaudeLiveMonitor",
- platforms: [
- .macOS(.v12)
- ],
- products: [
- .executable(
- name: "claude-monitor",
- targets: ["ClaudeLiveMonitor"]
- ),
- .library(
- name: "ClaudeLiveMonitorLib",
- targets: ["ClaudeLiveMonitorLib"]
- )
- ],
- dependencies: [],
- targets: [
- .executableTarget(
- name: "ClaudeLiveMonitor",
- dependencies: ["ClaudeLiveMonitorLib"],
- path: "Sources/ClaudeLiveMonitor"
- ),
- .target(
- name: "ClaudeLiveMonitorLib",
- dependencies: [],
- path: "Sources/ClaudeLiveMonitorLib"
- ),
- .testTarget(
- name: "ClaudeLiveMonitorTests",
- dependencies: ["ClaudeLiveMonitorLib"],
- path: "Tests/ClaudeLiveMonitorTests"
- )
- ]
-)
\ No newline at end of file
diff --git a/Packages/ClaudeLiveMonitor/README.md b/Packages/ClaudeLiveMonitor/README.md
deleted file mode 100644
index 304c810..0000000
--- a/Packages/ClaudeLiveMonitor/README.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Claude Live Monitor
-
-A Swift implementation of a live token usage monitor for Claude Code, providing real-time tracking of API usage, costs, and burn rates.
-
-## Features
-
-- 📊 **Real-time Monitoring**: Live updates of token usage and costs
-- 🔥 **Burn Rate Analysis**: Track token consumption rate per minute
-- 📈 **Usage Projections**: Predict total usage for the current session
-- 💰 **Cost Tracking**: Calculate costs based on model-specific pricing
-- 🎯 **Token Limit Warnings**: Visual alerts when approaching or exceeding limits
-- 🔄 **Auto-refresh**: Updates every second (configurable)
-- 📦 **Session Blocks**: Groups usage into 5-hour billing periods
-
-## Installation
-
-### Using Swift Package Manager
-
-```bash
-git clone https://github.com/yourusername/ClaudeLiveMonitor.git
-cd ClaudeLiveMonitor
-swift build -c release
-```
-
-The executable will be at `.build/release/claude-monitor`
-
-### System-wide Installation
-
-```bash
-swift build -c release
-sudo cp .build/release/claude-monitor /usr/local/bin/
-```
-
-## Usage
-
-### Basic Usage
-
-```bash
-# Auto-detect token limit from previous sessions
-claude-monitor
-
-# Set a specific token limit
-claude-monitor --token-limit 500000
-
-# Use maximum from previous sessions
-claude-monitor --token-limit max
-```
-
-### Command Line Options
-
-- `-t, --token-limit `: Set token limit for quota warnings (or 'max'/'auto')
-- `-r, --refresh `: Refresh interval (default: 1)
-- `-s, --session `: Session duration in hours (default: 5)
-- `-h, --help`: Show help message
-
-### Environment Variables
-
-- `CLAUDE_CONFIG_DIR`: Comma-separated paths to Claude data directories
-
-## Library Usage
-
-You can also use ClaudeLiveMonitor as a library in your Swift projects:
-
-```swift
-import ClaudeLiveMonitorLib
-
-let config = LiveMonitorConfig(
- claudePaths: ["/path/to/.claude"],
- sessionDurationHours: 5,
- tokenLimit: 500000
-)
-
-let monitor = LiveMonitor(config: config)
-
-if let activeBlock = monitor.getActiveBlock() {
- print("Current tokens: \(activeBlock.tokenCounts.total)")
- print("Burn rate: \(activeBlock.burnRate.tokensPerMinute) tokens/min")
- print("Cost: $\(activeBlock.costUSD)")
-}
-```
-
-## Architecture
-
-The package is organized into several modules:
-
-- **Models**: Data structures for tokens, usage entries, and session blocks
-- **JSONLParser**: Parses Claude's JSONL usage files
-- **LiveMonitor**: Core monitoring logic and session block identification
-- **LiveRenderer**: Terminal UI rendering with ANSI escape codes
-
-## Requirements
-
-- Swift 5.9 or later
-- macOS 13.0 or later
-- Claude Code usage data in `~/.claude/projects/` or `~/.config/claude/projects/`
-
-## License
-
-MIT
\ No newline at end of file
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitor/main.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitor/main.swift
deleted file mode 100644
index f8f5a8d..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitor/main.swift
+++ /dev/null
@@ -1,276 +0,0 @@
-import Foundation
-import ClaudeLiveMonitorLib
-
-// MARK: - Constants
-
-private enum Defaults {
- static let refreshInterval: TimeInterval = 1.0
- static let sessionDurationHours: Double = 5.0
-}
-
-private enum EnvironmentKey {
- static let claudeConfigDir = "CLAUDE_CONFIG_DIR"
-}
-
-private enum ANSICode {
- static let yellow = "\u{001B}[33m"
- static let reset = "\u{001B}[0m"
- static let hideCursor = "\u{001B}[?25l"
- static let showCursor = "\u{001B}[?25h"
-}
-
-// MARK: - Parsed Arguments
-
-struct ParsedArguments {
- let tokenLimit: Int?
- let refreshInterval: TimeInterval
- let sessionDuration: Double
- let shouldShowHelp: Bool
-
- static let `default` = ParsedArguments(
- tokenLimit: nil,
- refreshInterval: Defaults.refreshInterval,
- sessionDuration: Defaults.sessionDurationHours,
- shouldShowHelp: false
- )
-}
-
-// MARK: - Argument Parsing
-
-private enum ArgumentParser {
- static func parse(_ args: [String]) -> ParsedArguments {
- if containsHelpFlag(args) {
- return ParsedArguments(tokenLimit: nil, refreshInterval: 0, sessionDuration: 0, shouldShowHelp: true)
- }
-
- var tokenLimit: Int?
- var refreshInterval = Defaults.refreshInterval
- var sessionDuration = Defaults.sessionDurationHours
- var index = 1
-
- while index < args.count {
- let consumed = parseArgument(
- args: args,
- at: index,
- tokenLimit: &tokenLimit,
- refreshInterval: &refreshInterval,
- sessionDuration: &sessionDuration
- )
- index += consumed
- }
-
- return ParsedArguments(
- tokenLimit: tokenLimit,
- refreshInterval: refreshInterval,
- sessionDuration: sessionDuration,
- shouldShowHelp: false
- )
- }
-
- private static func containsHelpFlag(_ args: [String]) -> Bool {
- args.contains { $0 == "-h" || $0 == "--help" }
- }
-
- private static func parseArgument(
- args: [String],
- at index: Int,
- tokenLimit: inout Int?,
- refreshInterval: inout TimeInterval,
- sessionDuration: inout Double
- ) -> Int {
- let arg = args[index]
- let nextValue = args.indices.contains(index + 1) ? args[index + 1] : nil
-
- switch arg {
- case "-t", "--token-limit":
- tokenLimit = parseTokenLimit(nextValue)
- return 2
-
- case "-r", "--refresh":
- refreshInterval = nextValue.flatMap { Double($0) } ?? refreshInterval
- return 2
-
- case "-s", "--session":
- sessionDuration = nextValue.flatMap { Double($0) } ?? sessionDuration
- return 2
-
- default:
- return 1
- }
- }
-
- private static func parseTokenLimit(_ value: String?) -> Int? {
- guard let value else { return nil }
- return (value == "max" || value == "auto") ? nil : Int(value)
- }
-}
-
-// MARK: - Path Discovery
-
-private enum PathDiscovery {
- static func discoverClaudePaths() -> [String] {
- environmentPaths() ?? defaultPaths()
- }
-
- static func filterExisting(_ paths: [String]) -> [String] {
- paths.filter { FileManager.default.fileExists(atPath: $0) }
- }
-
- private static func environmentPaths() -> [String]? {
- ProcessInfo.processInfo.environment[EnvironmentKey.claudeConfigDir]
- .map { $0.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } }
- }
-
- private static func defaultPaths() -> [String] {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- return [
- "\(home)/.config/claude",
- "\(home)/.claude"
- ]
- }
-}
-
-// MARK: - Terminal Control
-
-private enum Terminal {
- static func hideCursor() {
- print(ANSICode.hideCursor, terminator: "")
- }
-
- static func showCursor() {
- print(ANSICode.showCursor)
- }
-
- static func printYellow(_ message: String) {
- print("\(ANSICode.yellow)\(message)\(ANSICode.reset)")
- }
-}
-
-// MARK: - Help Text
-
-private enum HelpText {
- static let content = """
- Claude Live Token Usage Monitor
-
- Usage: claude-monitor [options]
-
- Options:
- -t, --token-limit Set token limit for quota warnings
- Use 'max' or 'auto' to use maximum from previous sessions
- (default: auto)
- -r, --refresh Refresh interval (default: 1)
- -s, --session Session window duration in hours (default: 5)
- -h, --help Show this help message
-
- Display Sections:
- SESSION Time progress within the current session window
- USAGE Tokens and cost accumulated in current session window
- PROJECTION Estimated totals if current burn rate continues
-
- The session window (default 5h) aligns with Claude's rate limit reset period.
- Cost shown is API cost within this window, not calendar day.
-
- Examples:
- claude-monitor # Auto-detect limit from history
- claude-monitor --token-limit max # Use max from previous sessions
- claude-monitor --token-limit 500000 # Set specific limit
- claude-monitor -t 1000000 -r 2 -s 5 # Multiple options
-
- Environment Variables:
- CLAUDE_CONFIG_DIR Comma-separated paths to Claude data directories
-
- Press Ctrl+C to stop monitoring.
- """
-
- static func print() {
- Swift.print(content)
- }
-}
-
-// MARK: - Application
-
-private enum Application {
- static func run() async {
- let args = ArgumentParser.parse(CommandLine.arguments)
-
- if args.shouldShowHelp {
- HelpText.print()
- exit(0)
- }
-
- guard let paths = validatePaths() else {
- exit(1)
- }
-
- let (monitor, renderer) = await createComponents(args: args, paths: paths)
-
- setupGracefulExit()
- Terminal.hideCursor()
- await runLoop(renderer: renderer, interval: args.refreshInterval)
- }
-
- private static func validatePaths() -> [String]? {
- let candidatePaths = PathDiscovery.discoverClaudePaths()
- let existingPaths = PathDiscovery.filterExisting(candidatePaths)
-
- guard !existingPaths.isEmpty else {
- print("Error: No Claude data directories found.")
- print("Searched paths:", candidatePaths.joined(separator: ", "))
- return nil
- }
-
- print("Found Claude data directories:", existingPaths.joined(separator: ", "))
- return existingPaths
- }
-
- private static func createComponents(
- args: ParsedArguments,
- paths: [String]
- ) async -> (LiveMonitor, LiveRenderer) {
- let config = LiveMonitorConfig(
- claudePaths: paths,
- sessionDurationHours: args.sessionDuration,
- tokenLimit: args.tokenLimit,
- refreshInterval: args.refreshInterval,
- order: .descending
- )
-
- let monitor = LiveMonitor(config: config)
- let effectiveLimit = await resolveTokenLimit(args.tokenLimit, monitor: monitor)
- let renderer = LiveRenderer(monitor: monitor, tokenLimit: effectiveLimit)
-
- return (monitor, renderer)
- }
-
- private static func resolveTokenLimit(_ explicit: Int?, monitor: LiveMonitor) async -> Int? {
- if let explicit { return explicit }
-
- let autoLimit = await monitor.getAutoTokenLimit()
- if let limit = autoLimit {
- Terminal.printYellow("Using max tokens from previous sessions: \(limit)")
- }
- return autoLimit
- }
-
- private static func setupGracefulExit() {
- signal(SIGINT) { _ in
- Terminal.showCursor()
- print("\nMonitoring stopped.")
- exit(0)
- }
- }
-
- private static func runLoop(renderer: LiveRenderer, interval: TimeInterval) async {
- while true {
- await renderer.render()
- try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
- }
- }
-}
-
-// MARK: - Entry Point
-
-Task {
- await Application.run()
-}
-RunLoop.main.run()
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/JSONLParser.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/JSONLParser.swift
deleted file mode 100644
index 10b9165..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/JSONLParser.swift
+++ /dev/null
@@ -1,203 +0,0 @@
-import Foundation
-
-// MARK: - JSONLParser
-
-public struct JSONLParser {
- private let dateFormatter: ISO8601DateFormatter
- private let decoder: JSONDecoder
-
- public init() {
- self.dateFormatter = ISO8601DateFormatter()
- self.dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- self.decoder = JSONDecoder()
- }
-
- // MARK: - Public API
-
- public func parseFile(at path: String, processedHashes: inout Set) -> [UsageEntry] {
- guard let fileData = loadFileData(at: path) else { return [] }
- let lineDataSequence = extractLines(from: fileData)
- return lineDataSequence.compactMap { lineData in
- parseEntry(from: lineData, path: path, processedHashes: &processedHashes)
- }
- }
-
- // MARK: - Orchestration
-
- private func parseEntry(
- from lineData: Data,
- path: String,
- processedHashes: inout Set
- ) -> UsageEntry? {
- guard let usageData = decodeUsageData(from: lineData),
- let validatedData = validateAssistantMessage(usageData),
- isUniqueEntry(usageData, validatedData, processedHashes: &processedHashes),
- let timestamp = parseTimestamp(validatedData.timestampStr),
- let tokenCounts = createTokenCounts(from: validatedData.usage),
- tokenCounts.total > 0 else {
- return nil
- }
-
- let model = validatedData.message.model ?? Constants.syntheticModel
- let cost = calculateCost(usageData: usageData, model: model, tokens: tokenCounts)
-
- return UsageEntry(
- timestamp: timestamp,
- usage: tokenCounts,
- costUSD: cost,
- model: model,
- sourceFile: path,
- messageId: validatedData.message.id,
- requestId: usageData.requestId
- )
- }
-
- // MARK: - File I/O
-
- private func loadFileData(at path: String) -> Data? {
- try? Data(contentsOf: URL(fileURLWithPath: path))
- }
-
- private func extractLines(from data: Data) -> [Data] {
- extractLineRanges(from: data).map { data[$0] }
- }
-
- // MARK: - Decoding
-
- private func decodeUsageData(from lineData: Data) -> JSONLUsageData? {
- guard !lineData.isEmpty else { return nil }
- return try? decoder.decode(JSONLUsageData.self, from: lineData)
- }
-
- // MARK: - Validation
-
- private func validateAssistantMessage(_ usageData: JSONLUsageData) -> ValidatedUsageData? {
- guard let message = usageData.message,
- let usage = message.usage,
- usageData.type == Constants.assistantType,
- let timestampStr = usageData.timestamp else {
- return nil
- }
- return ValidatedUsageData(message: message, usage: usage, timestampStr: timestampStr)
- }
-
- private func isUniqueEntry(
- _ usageData: JSONLUsageData,
- _ data: ValidatedUsageData,
- processedHashes: inout Set
- ) -> Bool {
- guard let hash = createDeduplicationHash(
- messageId: data.message.id,
- requestId: usageData.requestId
- ) else {
- return true
- }
- return processedHashes.insert(hash).inserted
- }
-
- // MARK: - Pure Transformations
-
- private func parseTimestamp(_ timestampStr: String) -> Date? {
- dateFormatter.date(from: timestampStr)
- }
-
- private func createTokenCounts(from usage: JSONLUsageData.Message.Usage) -> TokenCounts? {
- TokenCounts(
- inputTokens: usage.input_tokens ?? 0,
- outputTokens: usage.output_tokens ?? 0,
- cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
- cacheReadInputTokens: usage.cache_read_input_tokens ?? 0
- )
- }
-
- private func createDeduplicationHash(messageId: String?, requestId: String?) -> String? {
- guard let messageId, let requestId else { return nil }
- return "\(messageId):\(requestId)"
- }
-
- private func calculateCost(
- usageData: JSONLUsageData,
- model: String,
- tokens: TokenCounts
- ) -> Double {
- if let costUSD = usageData.costUSD {
- return costUSD
- }
- let pricing = ModelPricing.getPricing(for: model)
- return pricing.calculateCost(tokens: tokens)
- }
-
- // MARK: - Line Extraction (SIMD-optimized)
-
- private func extractLineRanges(from data: Data) -> [Range] {
- let count = data.count
- guard count > 0 else { return [] }
-
- return [UInt8](data).withUnsafeBufferPointer { buffer in
- guard let ptr = buffer.baseAddress else { return [] }
- return buildLineRanges(ptr: ptr, count: count)
- }
- }
-
- private func buildLineRanges(ptr: UnsafePointer, count: Int) -> [Range] {
- var ranges: [Range] = []
- var offset = 0
-
- while offset < count {
- let lineEnd = findLineEnd(ptr: ptr, offset: offset, count: count)
- if lineEnd > offset {
- ranges.append(offset.., offset: Int, count: Int) -> Int {
- let remaining = count - offset
- if let found = memchr(ptr + offset, Constants.newlineByte, remaining) {
- return UnsafePointer(found.assumingMemoryBound(to: UInt8.self)) - ptr
- }
- return count
- }
-}
-
-// MARK: - Constants
-
-private extension JSONLParser {
- enum Constants {
- static let assistantType = "assistant"
- static let syntheticModel = ""
- static let newlineByte: Int32 = 0x0A
- }
-}
-
-// MARK: - Supporting Types
-
-private struct ValidatedUsageData {
- let message: JSONLUsageData.Message
- let usage: JSONLUsageData.Message.Usage
- let timestampStr: String
-}
-
-struct JSONLUsageData: Codable {
- let timestamp: String?
- let message: Message?
- let costUSD: Double?
- let type: String?
- let requestId: String?
-
- struct Message: Codable {
- let usage: Usage?
- let model: String?
- let id: String?
-
- struct Usage: Codable {
- let input_tokens: Int?
- let output_tokens: Int?
- let cache_creation_input_tokens: Int?
- let cache_read_input_tokens: Int?
- }
- }
-}
\ No newline at end of file
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+FileOperations.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+FileOperations.swift
deleted file mode 100644
index d794a76..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+FileOperations.swift
+++ /dev/null
@@ -1,63 +0,0 @@
-//
-// LiveMonitor+FileOperations.swift
-//
-// File discovery and loading operations for LiveMonitor.
-//
-
-import Foundation
-
-// MARK: - File Discovery
-
-extension LiveMonitor {
-
- func findUsageFiles() -> [String] {
- config.claudePaths.flatMap { findJSONLFiles(in: $0) }
- }
-
- private func findJSONLFiles(in claudePath: String) -> [String] {
- let projectsPath = "\(claudePath)/projects"
- let fileManager = FileManager.default
-
- guard fileManager.fileExists(atPath: projectsPath),
- let enumerator = fileManager.enumerator(atPath: projectsPath) else {
- return []
- }
-
- return enumerator
- .compactMap { $0 as? String }
- .filter { $0.hasSuffix(".jsonl") }
- .map { "\(projectsPath)/\($0)" }
- }
-}
-
-// MARK: - File Loading
-
-extension LiveMonitor {
-
- func loadModifiedFiles(_ files: [String]) {
- let filesToRead = files.filter { isFileModified($0) }
- guard !filesToRead.isEmpty else { return }
-
- loadEntriesFromFiles(filesToRead)
- }
-
- private func isFileModified(_ file: String) -> Bool {
- guard let timestamp = fileModificationTime(file) else { return false }
- let wasModified = lastFileTimestamps[file].map { timestamp > $0 } ?? true
- if wasModified {
- lastFileTimestamps[file] = timestamp
- }
- return wasModified
- }
-
- private func fileModificationTime(_ path: String) -> Date? {
- try? FileManager.default
- .attributesOfItem(atPath: path)[.modificationDate] as? Date
- }
-
- private func loadEntriesFromFiles(_ files: [String]) {
- let newEntries = files.flatMap { parser.parseFile(at: $0, processedHashes: &processedHashes) }
- allEntries.append(contentsOf: newEntries)
- allEntries.sort { $0.timestamp < $1.timestamp }
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+SessionBlocks.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+SessionBlocks.swift
deleted file mode 100644
index 561d56d..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+SessionBlocks.swift
+++ /dev/null
@@ -1,172 +0,0 @@
-//
-// LiveMonitor+SessionBlocks.swift
-//
-// Session block identification, creation, and calculation logic.
-//
-
-import Foundation
-
-// MARK: - Session Block Identification
-
-extension LiveMonitor {
-
- func identifySessionBlocks(entries: [UsageEntry]) -> [SessionBlock] {
- guard !entries.isEmpty else { return [] }
-
- let sessionDurationSeconds = config.sessionDurationHours * 60 * 60
- let sortedEntries = entries.sorted { $0.timestamp < $1.timestamp }
- let now = Date()
-
- return buildBlocks(from: sortedEntries, sessionDuration: sessionDurationSeconds, now: now)
- }
-
- private func buildBlocks(from entries: [UsageEntry], sessionDuration: TimeInterval, now: Date) -> [SessionBlock] {
- var blocks: [SessionBlock] = []
- var currentBlockStart: Date?
- var currentBlockEntries: [UsageEntry] = []
-
- for entry in entries {
- let shouldStartNewBlock = currentBlockStart.map { blockStart in
- shouldSplitBlock(
- entryTime: entry.timestamp,
- blockStart: blockStart,
- lastEntryTime: currentBlockEntries.last?.timestamp,
- sessionDuration: sessionDuration
- )
- } ?? true
-
- if shouldStartNewBlock {
- if let blockStart = currentBlockStart, !currentBlockEntries.isEmpty {
- if let block = createBlock(startTime: blockStart, entries: currentBlockEntries, now: now, sessionDuration: sessionDuration) {
- blocks.append(block)
- }
- }
- currentBlockStart = floorToHour(entry.timestamp)
- currentBlockEntries = [entry]
- } else {
- currentBlockEntries.append(entry)
- }
- }
-
- if let blockStart = currentBlockStart, !currentBlockEntries.isEmpty {
- if let block = createBlock(startTime: blockStart, entries: currentBlockEntries, now: now, sessionDuration: sessionDuration) {
- blocks.append(block)
- }
- }
-
- return blocks
- }
-
- private func shouldSplitBlock(entryTime: Date, blockStart: Date, lastEntryTime: Date?, sessionDuration: TimeInterval) -> Bool {
- let timeSinceBlockStart = entryTime.timeIntervalSince(blockStart)
- let timeSinceLastEntry = lastEntryTime.map { entryTime.timeIntervalSince($0) } ?? 0
- return timeSinceBlockStart > sessionDuration || timeSinceLastEntry > sessionDuration
- }
-}
-
-// MARK: - Block Creation
-
-extension LiveMonitor {
-
- private func createBlock(startTime: Date, entries: [UsageEntry], now: Date, sessionDuration: TimeInterval) -> SessionBlock? {
- guard !entries.isEmpty else { return nil }
-
- let endTime = startTime.addingTimeInterval(sessionDuration)
- let actualEndTime = entries.last?.timestamp
- let isActive = computeIsActive(actualEndTime: actualEndTime, now: now, endTime: endTime, sessionDuration: sessionDuration)
-
- let aggregated = aggregateEntries(entries)
- let burnRate = computeBurnRate(tokens: aggregated.tokenCounts.total, cost: aggregated.costUSD, startTime: startTime, actualEndTime: actualEndTime, now: now)
- let projectedUsage = computeProjectedUsage(currentTokens: aggregated.tokenCounts.total, currentCost: aggregated.costUSD, burnRate: burnRate, actualEndTime: actualEndTime, endTime: endTime, now: now)
-
- return SessionBlock(
- id: UUID().uuidString,
- startTime: startTime,
- endTime: endTime,
- actualEndTime: actualEndTime,
- isActive: isActive,
- isGap: false,
- entries: entries,
- tokenCounts: aggregated.tokenCounts,
- costUSD: aggregated.costUSD,
- models: aggregated.models,
- usageLimitResetTime: aggregated.usageLimitResetTime,
- burnRate: burnRate,
- projectedUsage: projectedUsage
- )
- }
-}
-
-// MARK: - Pure Calculations
-
-extension LiveMonitor {
-
- private func computeIsActive(actualEndTime: Date?, now: Date, endTime: Date, sessionDuration: TimeInterval) -> Bool {
- guard let actualEndTime else { return false }
- return now.timeIntervalSince(actualEndTime) < sessionDuration && now < endTime
- }
-
- private func aggregateEntries(_ entries: [UsageEntry]) -> (tokenCounts: TokenCounts, costUSD: Double, models: [String], usageLimitResetTime: Date?) {
- let tokenCounts = entries.reduce(TokenCounts.zero) { accumulated, entry in
- TokenCounts(
- inputTokens: accumulated.inputTokens + entry.usage.inputTokens,
- outputTokens: accumulated.outputTokens + entry.usage.outputTokens,
- cacheCreationInputTokens: accumulated.cacheCreationInputTokens + entry.usage.cacheCreationInputTokens,
- cacheReadInputTokens: accumulated.cacheReadInputTokens + entry.usage.cacheReadInputTokens
- )
- }
-
- let costUSD = entries.reduce(0.0) { $0 + $1.costUSD }
- let models = Array(Set(entries.map(\.model)))
- let usageLimitResetTime = entries.lazy.compactMap(\.usageLimitResetTime).last
-
- return (tokenCounts, costUSD, models, usageLimitResetTime)
- }
-
- private func computeBurnRate(tokens: Int, cost: Double, startTime: Date, actualEndTime: Date?, now: Date) -> BurnRate {
- let elapsedMinutes = (actualEndTime ?? now).timeIntervalSince(startTime) / 60
- let tokensPerMinute = elapsedMinutes > 0 ? Int(Double(tokens) / elapsedMinutes) : 0
- let costPerHour = elapsedMinutes > 0 ? (cost / elapsedMinutes) * 60 : 0
-
- return BurnRate(
- tokensPerMinute: tokensPerMinute,
- tokensPerMinuteForIndicator: tokensPerMinute,
- costPerHour: costPerHour
- )
- }
-
- private func computeProjectedUsage(currentTokens: Int, currentCost: Double, burnRate: BurnRate, actualEndTime: Date?, endTime: Date, now: Date) -> ProjectedUsage {
- let remainingMinutes = endTime.timeIntervalSince(actualEndTime ?? now) / 60
- let projectedTokens = currentTokens + Int(Double(burnRate.tokensPerMinute) * remainingMinutes)
- let projectedCost = currentCost + (burnRate.costPerHour * remainingMinutes / 60)
-
- return ProjectedUsage(
- totalTokens: projectedTokens,
- totalCost: projectedCost,
- remainingMinutes: remainingMinutes
- )
- }
-}
-
-// MARK: - Date Utilities
-
-extension LiveMonitor {
-
- func floorToHour(_ date: Date) -> Date {
- let secondsSinceEpoch = date.timeIntervalSince1970
- let secondsInHour = 3600.0
- let flooredSeconds = floor(secondsSinceEpoch / secondsInHour) * secondsInHour
- return Date(timeIntervalSince1970: flooredSeconds)
- }
-}
-
-// MARK: - TokenCounts Extension
-
-extension TokenCounts {
- static let zero = TokenCounts(
- inputTokens: 0,
- outputTokens: 0,
- cacheCreationInputTokens: 0,
- cacheReadInputTokens: 0
- )
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor.swift
deleted file mode 100644
index e51b288..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-//
-// LiveMonitor.swift
-//
-// Manages reading and processing of Claude usage files with thread-safe access.
-// Split into extensions for focused responsibilities:
-// - +FileOperations: File discovery and loading
-// - +SessionBlocks: Session block identification, creation, and calculations
-//
-
-import Foundation
-
-// MARK: - Live Monitor
-
-/// LiveMonitor manages the reading and processing of Claude usage files.
-/// Uses Swift actor for thread-safe access - no manual locking needed.
-public actor LiveMonitor {
- let config: LiveMonitorConfig
- var lastFileTimestamps: [String: Date] = [:]
- var processedHashes: Set = Set()
- var allEntries: [UsageEntry] = []
- var maxTokensFromPreviousSessions: Int = 0
- let parser = JSONLParser()
-
- public init(config: LiveMonitorConfig) {
- self.config = config
- }
-
- // MARK: - Public API
-
- public func getActiveBlock() -> SessionBlock? {
- let files = findUsageFiles()
- guard !files.isEmpty else { return nil }
-
- loadModifiedFiles(files)
-
- let blocks = identifySessionBlocks(entries: allEntries)
- maxTokensFromPreviousSessions = maxTokensFromCompletedBlocks(blocks)
-
- return mostRecentActiveBlock(from: blocks)
- }
-
- public func getAutoTokenLimit() -> Int? {
- _ = getActiveBlock()
- return maxTokensFromPreviousSessions > 0 ? maxTokensFromPreviousSessions : nil
- }
-
- public func clearCache() {
- lastFileTimestamps.removeAll()
- processedHashes.removeAll()
- allEntries.removeAll()
- maxTokensFromPreviousSessions = 0
- }
-
- // MARK: - Block Selection
-
- func maxTokensFromCompletedBlocks(_ blocks: [SessionBlock]) -> Int {
- blocks
- .filter { !$0.isActive && !$0.isGap }
- .map(\.tokenCounts.total)
- .max() ?? 0
- }
-
- func mostRecentActiveBlock(from blocks: [SessionBlock]) -> SessionBlock? {
- blocks
- .filter(\.isActive)
- .max { ($0.actualEndTime ?? $0.startTime) < ($1.actualEndTime ?? $1.startTime) }
- }
-}
-
-// MARK: - Configuration
-
-public struct LiveMonitorConfig {
- public let claudePaths: [String]
- public let sessionDurationHours: Double
- public let tokenLimit: Int?
- public let refreshInterval: TimeInterval
- public let order: SortOrder
-
- public enum SortOrder {
- case ascending
- case descending
- }
-
- public init(claudePaths: [String], sessionDurationHours: Double = 5,
- tokenLimit: Int? = nil, refreshInterval: TimeInterval = 1.0,
- order: SortOrder = .descending) {
- self.claudePaths = claudePaths
- self.sessionDurationHours = sessionDurationHours
- self.tokenLimit = tokenLimit
- self.refreshInterval = refreshInterval
- self.order = order
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitorActor.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitorActor.swift
deleted file mode 100644
index ce469c9..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitorActor.swift
+++ /dev/null
@@ -1,292 +0,0 @@
-import Foundation
-
-// MARK: - Configuration Constants
-
-private enum Timing {
- static let gapThresholdSeconds: TimeInterval = 1800 // 30 minutes
- static let activityThresholdSeconds: TimeInterval = 300 // 5 minutes
- static let secondsPerHour: TimeInterval = 3600
-}
-
-// MARK: - Actor-based Live Monitor
-
-/// LiveMonitorActor is a thread-safe, actor-based implementation for monitoring Claude usage files.
-/// This implementation uses Swift's modern concurrency features for better performance and safety.
-public actor LiveMonitorActor {
- private let config: LiveMonitorConfig
- private var lastFileTimestamps: [String: Date] = [:]
- private var processedHashes: Set = Set()
- private var allEntries: [UsageEntry] = []
- private var maxTokensFromPreviousSessions: Int = 0
-
- private nonisolated let parser = JSONLParser()
-
- public init(config: LiveMonitorConfig) {
- self.config = config
- }
-
- // MARK: - Public API
-
- public func getActiveBlock() -> SessionBlock? {
- refreshEntriesIfNeeded()
- let blocks = identifySessionBlocks(entries: allEntries)
- updateMaxTokensFromCompletedSessions(blocks)
- return findMostRecentActiveBlock(from: blocks)
- }
-
- public func getAutoTokenLimit() -> Int? {
- _ = getActiveBlock()
- return maxTokensFromPreviousSessions > 0 ? maxTokensFromPreviousSessions : nil
- }
-
- public func clearCache() {
- lastFileTimestamps.removeAll()
- processedHashes.removeAll()
- allEntries.removeAll()
- maxTokensFromPreviousSessions = 0
- }
-
- // MARK: - Orchestration
-
- private func refreshEntriesIfNeeded() {
- let modifiedFiles = findModifiedUsageFiles()
- guard !modifiedFiles.isEmpty else { return }
- loadEntriesFromFiles(modifiedFiles)
- }
-
- private func findModifiedUsageFiles() -> [String] {
- findUsageFiles().filter { checkAndUpdateTimestamp(for: $0) }
- }
-
- private func checkAndUpdateTimestamp(for file: String) -> Bool {
- guard let currentTimestamp = getFileModificationTime(file) else { return false }
- let lastTimestamp = lastFileTimestamps[file]
- let isModified = lastTimestamp.map { currentTimestamp > $0 } ?? true
- if isModified {
- lastFileTimestamps[file] = currentTimestamp
- }
- return isModified
- }
-
- private func updateMaxTokensFromCompletedSessions(_ blocks: [SessionBlock]) {
- maxTokensFromPreviousSessions = blocks
- .filter { !$0.isActive && !$0.isGap }
- .map(\.tokenCounts.total)
- .max() ?? 0
- }
-
- private func findMostRecentActiveBlock(from blocks: [SessionBlock]) -> SessionBlock? {
- blocks
- .filter(\.isActive)
- .max { $0.startTime < $1.startTime }
- }
-
- // MARK: - File Operations
-
- private func findUsageFiles() -> [String] {
- config.claudePaths.flatMap { findJSONFilesInProjects(basePath: $0) }
- }
-
- private func findJSONFilesInProjects(basePath: String) -> [String] {
- let projectsPath = basePath.appending("/projects")
- let fileManager = FileManager.default
-
- guard let projectDirs = try? fileManager.contentsOfDirectory(atPath: projectsPath) else {
- return []
- }
-
- return projectDirs.flatMap { projectDir -> [String] in
- let projectPath = projectsPath.appending("/\(projectDir)")
- guard let files = try? fileManager.contentsOfDirectory(atPath: projectPath) else {
- return []
- }
- return files
- .filter { $0.hasSuffix(".json") }
- .map { projectPath.appending("/\($0)") }
- }
- }
-
- private nonisolated func getFileModificationTime(_ path: String) -> Date? {
- try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date
- }
-
- // MARK: - Entry Loading
-
- private func loadEntriesFromFiles(_ files: [String]) {
- let newEntries = files.flatMap { parser.parseFile(at: $0, processedHashes: &processedHashes) }
- allEntries.append(contentsOf: newEntries)
- allEntries.sort { $0.timestamp < $1.timestamp }
- }
-
- // MARK: - Session Block Identification
-
- private func identifySessionBlocks(entries: [UsageEntry]) -> [SessionBlock] {
- guard !entries.isEmpty else { return [] }
-
- let sessionDurationSeconds = config.sessionDurationHours * Timing.secondsPerHour
- let sortedEntries = entries.sorted { $0.timestamp < $1.timestamp }
-
- return buildSessionBlocks(from: sortedEntries, sessionDuration: sessionDurationSeconds)
- }
-
- private func buildSessionBlocks(
- from sortedEntries: [UsageEntry],
- sessionDuration: TimeInterval
- ) -> [SessionBlock] {
- var blocks: [SessionBlock] = []
- var currentBlockStart: Date?
- var currentBlockEntries: [UsageEntry] = []
- let now = Date()
-
- for entry in sortedEntries {
- if let blockStart = currentBlockStart {
- if shouldStartNewBlock(entry, blockStart: blockStart, lastEntry: currentBlockEntries.last, sessionDuration: sessionDuration) {
- if !currentBlockEntries.isEmpty {
- blocks.append(createSessionBlock(
- entries: currentBlockEntries,
- startTime: blockStart,
- sessionDuration: sessionDuration,
- now: now
- ))
- }
- currentBlockStart = entry.timestamp
- currentBlockEntries = [entry]
- } else {
- currentBlockEntries.append(entry)
- }
- } else {
- currentBlockStart = entry.timestamp
- currentBlockEntries = [entry]
- }
- }
-
- if let blockStart = currentBlockStart, !currentBlockEntries.isEmpty {
- blocks.append(createSessionBlock(
- entries: currentBlockEntries,
- startTime: blockStart,
- sessionDuration: sessionDuration,
- now: now
- ))
- }
-
- return blocks
- }
-
- private func shouldStartNewBlock(
- _ entry: UsageEntry,
- blockStart: Date,
- lastEntry: UsageEntry?,
- sessionDuration: TimeInterval
- ) -> Bool {
- let timeSinceBlockStart = entry.timestamp.timeIntervalSince(blockStart)
- let timeSinceLastEntry = lastEntry.map { entry.timestamp.timeIntervalSince($0.timestamp) } ?? 0
- return timeSinceBlockStart > sessionDuration || timeSinceLastEntry > Timing.gapThresholdSeconds
- }
-
- // MARK: - Session Block Creation
-
- private func createSessionBlock(
- entries: [UsageEntry],
- startTime: Date,
- sessionDuration: TimeInterval,
- now: Date
- ) -> SessionBlock {
- let endTime = startTime.addingTimeInterval(sessionDuration)
- let isActive = isSessionActive(lastEntryTime: entries.last?.timestamp, now: now)
- let tokenCounts = aggregateTokenCounts(entries)
- let costsByModel = aggregateCostsByModel(entries)
- let totalCost = costsByModel.values.reduce(0, +)
- let models = Set(entries.map(\.model))
- let elapsed = calculateElapsedTime(isActive: isActive, startTime: startTime, endTime: endTime, now: now)
- let burnRate = calculateBurnRate(tokenCounts: tokenCounts, totalCost: totalCost, elapsed: elapsed)
- let projectedUsage = calculateProjectedUsage(
- tokenCounts: tokenCounts,
- totalCost: totalCost,
- elapsed: elapsed,
- remainingTime: max(0, endTime.timeIntervalSince(now))
- )
-
- return SessionBlock(
- id: UUID().uuidString,
- startTime: startTime,
- endTime: endTime,
- actualEndTime: isActive ? nil : entries.last?.timestamp,
- isActive: isActive,
- isGap: false,
- entries: config.order == .ascending ? entries : entries.reversed(),
- tokenCounts: tokenCounts,
- costUSD: totalCost,
- models: Array(models),
- usageLimitResetTime: entries.compactMap(\.usageLimitResetTime).last,
- burnRate: burnRate,
- projectedUsage: projectedUsage
- )
- }
-
- // MARK: - Pure Calculations
-
- private func isSessionActive(lastEntryTime: Date?, now: Date) -> Bool {
- guard let lastEntryTime else { return false }
- return now.timeIntervalSince(lastEntryTime) < Timing.activityThresholdSeconds
- }
-
- private func aggregateTokenCounts(_ entries: [UsageEntry]) -> TokenCounts {
- entries.reduce(
- TokenCounts(inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0)
- ) { result, entry in
- TokenCounts(
- inputTokens: result.inputTokens + entry.usage.inputTokens,
- outputTokens: result.outputTokens + entry.usage.outputTokens,
- cacheCreationInputTokens: result.cacheCreationInputTokens + entry.usage.cacheCreationInputTokens,
- cacheReadInputTokens: result.cacheReadInputTokens + entry.usage.cacheReadInputTokens
- )
- }
- }
-
- private func aggregateCostsByModel(_ entries: [UsageEntry]) -> [String: Double] {
- entries.reduce(into: [:]) { costs, entry in
- costs[entry.model, default: 0] += entry.costUSD
- }
- }
-
- private func calculateElapsedTime(
- isActive: Bool,
- startTime: Date,
- endTime: Date,
- now: Date
- ) -> TimeInterval {
- isActive ? now.timeIntervalSince(startTime) : endTime.timeIntervalSince(startTime)
- }
-
- private func calculateBurnRate(
- tokenCounts: TokenCounts,
- totalCost: Double,
- elapsed: TimeInterval
- ) -> BurnRate {
- let tokensPerSecond = elapsed > 0 ? Double(tokenCounts.total) / elapsed : 0
- let tokensPerMinute = Int(tokensPerSecond * 60)
- let costPerHour = elapsed > 0 ? (totalCost / elapsed) * Timing.secondsPerHour : 0
-
- return BurnRate(
- tokensPerMinute: tokensPerMinute,
- tokensPerMinuteForIndicator: tokensPerMinute,
- costPerHour: costPerHour
- )
- }
-
- private func calculateProjectedUsage(
- tokenCounts: TokenCounts,
- totalCost: Double,
- elapsed: TimeInterval,
- remainingTime: TimeInterval
- ) -> ProjectedUsage {
- let tokensPerSecond = elapsed > 0 ? Double(tokenCounts.total) / elapsed : 0
- let costPerHour = elapsed > 0 ? (totalCost / elapsed) * Timing.secondsPerHour : 0
-
- return ProjectedUsage(
- totalTokens: tokenCounts.total + Int(tokensPerSecond * remainingTime),
- totalCost: totalCost + costPerHour * (remainingTime / Timing.secondsPerHour),
- remainingMinutes: remainingTime / 60
- )
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Metrics.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Metrics.swift
deleted file mode 100644
index cfcb4c1..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Metrics.swift
+++ /dev/null
@@ -1,131 +0,0 @@
-//
-// LiveRenderer+Metrics.swift
-//
-// Session, usage, and projection metrics models for the live renderer.
-//
-
-import Foundation
-
-// MARK: - Session Metrics
-
-struct SessionMetrics {
- let percentage: Double
- let startTimeFormatted: String
- let endTimeFormatted: String
- let elapsedFormatted: String
- let remainingFormatted: String
-
- init(block: SessionBlock) {
- let elapsed = Date().timeIntervalSince(block.startTime)
- let total = block.endTime.timeIntervalSince(block.startTime)
- let remaining = max(0, block.endTime.timeIntervalSince(Date()))
-
- self.percentage = min((elapsed / total) * 100, 100)
- self.startTimeFormatted = Self.formatTime(block.startTime)
- self.endTimeFormatted = Self.formatTime(block.endTime)
- self.elapsedFormatted = Self.formatDuration(elapsed)
- self.remainingFormatted = Self.formatDuration(remaining)
- }
-
- private static func formatTime(_ date: Date) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "HH:mm:ss"
- formatter.timeZone = TimeZone(secondsFromGMT: 0)
- return formatter.string(from: date) + " UTC"
- }
-
- private static func formatDuration(_ interval: TimeInterval) -> String {
- let hours = Int(interval / 3600)
- let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60)
- return "\(hours)h \(minutes)m"
- }
-}
-
-// MARK: - Usage Metrics
-
-struct UsageMetrics {
- let percentage: Double
- let tokensFormatted: String
- let tokensShort: String
- let limitShort: String
- let burnRateFormatted: String
- let burnIndicator: String
-
- init(block: SessionBlock, tokenLimit: Int) {
- let tokens = block.tokenCounts.total
- let burnRate = block.burnRate.tokensPerMinute
-
- self.percentage = tokenLimit > 0 ? min(Double(tokens) * 100 / Double(tokenLimit), 100) : 0
- self.tokensFormatted = tokens.formattedWithCommas
- self.tokensShort = tokens.formattedShort
- self.limitShort = tokenLimit.formattedShort
- self.burnRateFormatted = burnRate.formattedWithCommas
- self.burnIndicator = Self.burnIndicator(for: burnRate)
- }
-
- private static func burnIndicator(for rate: Int) -> String {
- switch rate {
- case 500_001...: ANSIColor.red.wrap("\u{26A1} HIGH")
- case 200_001...500_000: ANSIColor.yellow.wrap("\u{26A1} MEDIUM")
- default: ANSIColor.green.wrap("\u{2713} NORMAL")
- }
- }
-}
-
-// MARK: - Projection Metrics
-
-struct ProjectionMetrics {
- let percentage: Double
- let tokensFormatted: String
- let tokensShort: String
- let limitShort: String
- let status: String
-
- init(block: SessionBlock, tokenLimit: Int) {
- let projectedTokens = block.projectedUsage.totalTokens
-
- self.percentage = tokenLimit > 0 ? Double(projectedTokens) * 100 / Double(tokenLimit) : 0
- self.tokensFormatted = projectedTokens.formattedWithCommas
- self.tokensShort = projectedTokens.formattedShort
- self.limitShort = tokenLimit.formattedShort
- self.status = Self.status(for: percentage)
- }
-
- private static func status(for percentage: Double) -> String {
- switch percentage {
- case 100.01...: ANSIColor.red.wrap("\u{274C} WILL EXCEED LIMIT")
- case 90.01...100: ANSIColor.yellow.wrap("\u{26A0}\u{FE0F} APPROACHING LIMIT")
- default: ANSIColor.green.wrap("\u{2705} WITHIN LIMIT")
- }
- }
-}
-
-// MARK: - Number Formatting Extensions
-
-extension Int {
- var formattedWithCommas: String {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.groupingSeparator = ","
- return formatter.string(from: NSNumber(value: self)) ?? String(self)
- }
-
- var formattedShort: String {
- guard self >= 1000 else { return String(self) }
- return String(format: "%.1fk", Double(self) / 1000.0)
- }
-}
-
-extension Double {
- var formatted: String {
- String(format: "%5.1f", self)
- }
-
- var progressColor: ANSIColor {
- switch self {
- case 90.01...: .red
- case 75.01...90: .yellow
- default: .green
- }
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Terminal.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Terminal.swift
deleted file mode 100644
index 2ceda56..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Terminal.swift
+++ /dev/null
@@ -1,95 +0,0 @@
-//
-// LiveRenderer+Terminal.swift
-//
-// Terminal display infrastructure: layout constants, ANSI colors,
-// progress bar builder, and string formatting utilities.
-//
-
-import Foundation
-
-// MARK: - Layout Constants
-
-enum Layout {
- static let width = 80
- static let contentWidth = width - 2
- static let divider = String(repeating: "\u{2500}", count: contentWidth)
- static let verticalBorder = "\u{2502}"
-
- static let title = "CLAUDE CODE - LIVE TOKEN USAGE MONITOR"
- static let footerText = "\u{21BB} Refreshing every 1s \u{2022} Press Ctrl+C to stop"
-
- static let sessionIcon = "\u{23F1}\u{FE0F}"
- static let usageIcon = "\u{1F525}"
- static let projectionIcon = "\u{1F4C8}"
- static let modelsIcon = "\u{2699}\u{FE0F}"
-}
-
-// MARK: - Border Position
-
-enum BorderPosition {
- case top, middle, bottom
-
- var corners: (String, String) {
- switch self {
- case .top: ("\u{250C}", "\u{2510}")
- case .middle: ("\u{251C}", "\u{2524}")
- case .bottom: ("\u{2514}", "\u{2518}")
- }
- }
-}
-
-// MARK: - Terminal Control
-
-enum Terminal {
- static let clearScreenSequence = "\u{001B}[2J\u{001B}[H"
-
- static func clearScreen() {
- print(clearScreenSequence, terminator: "")
- }
-}
-
-// MARK: - ANSI Colors
-
-enum ANSIColor: String {
- case red = "\u{001B}[31m"
- case yellow = "\u{001B}[33m"
- case green = "\u{001B}[32m"
- case gray = "\u{001B}[90m"
- case reset = "\u{001B}[0m"
-
- func wrap(_ text: String) -> String {
- "\(rawValue)\(text)\(ANSIColor.reset.rawValue)"
- }
-}
-
-// MARK: - Progress Bar Builder
-
-enum ProgressBar {
- static let width = 30
-
- static func build(percentage: Double, color: ANSIColor) -> String {
- let clampedPercentage = min(max(percentage, 0), 100)
- let filled = Int(Double(width) * clampedPercentage / 100)
- let empty = width - filled
-
- let filledPart = color.wrap(String(repeating: "\u{2588}", count: filled))
- let emptyPart = ANSIColor.gray.wrap(String(repeating: "\u{2591}", count: empty))
-
- return filledPart + emptyPart
- }
-}
-
-// MARK: - String Formatting Extensions
-
-extension String {
- func padded(to width: Int) -> String {
- padding(toLength: width, withPad: " ", startingAt: 0)
- }
-
- func centered(in width: Int) -> String {
- let padding = max(0, width - count)
- let leftPad = padding / 2
- let rightPad = padding - leftPad
- return String(repeating: " ", count: leftPad) + self + String(repeating: " ", count: rightPad)
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer.swift
deleted file mode 100644
index 4d7f385..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer.swift
+++ /dev/null
@@ -1,125 +0,0 @@
-//
-// LiveRenderer.swift
-//
-// Terminal-based live usage dashboard renderer.
-// Split into extensions for focused responsibilities:
-// - +Terminal: Layout constants, ANSI colors, progress bar
-// - +Metrics: Session, usage, and projection metrics
-//
-
-import Foundation
-
-// MARK: - LiveRenderer
-
-public class LiveRenderer {
- private let monitor: LiveMonitor
- private let tokenLimit: Int?
-
- public init(monitor: LiveMonitor, tokenLimit: Int?) {
- self.monitor = monitor
- self.tokenLimit = tokenLimit
- }
-
- public func render() async {
- guard let block = await monitor.getActiveBlock() else {
- print("No active session found.")
- return
- }
-
- let autoLimit = await monitor.getAutoTokenLimit()
- let effectiveLimit = tokenLimit ?? autoLimit ?? 0
-
- Terminal.clearScreen()
- renderDashboard(block: block, tokenLimit: effectiveLimit)
- }
-
- private func renderDashboard(block: SessionBlock, tokenLimit: Int) {
- renderHeader()
- renderSection { renderSessionContent(block: block) }
- renderSection { renderUsageContent(block: block, tokenLimit: tokenLimit) }
- renderSection { renderProjectionContent(block: block, tokenLimit: tokenLimit) }
- renderModelsRow(models: block.models)
- renderFooter()
- }
-}
-
-// MARK: - Dashboard Layout
-
-extension LiveRenderer {
- func renderHeader() {
- printBorderRow(.top)
- printContentRow(Layout.title.centered(in: Layout.contentWidth))
- printBorderRow(.middle)
- }
-
- func renderSection(_ content: () -> Void) {
- printEmptyRow()
- content()
- printEmptyRow()
- printBorderRow(.middle)
- }
-
- func renderModelsRow(models: [String]) {
- let modelsText = models.joined(separator: ", ")
- let formatted = " \(Layout.modelsIcon) Models: \(modelsText)"
- printContentRow(formatted.padded(to: Layout.contentWidth))
- printBorderRow(.middle)
- }
-
- func renderFooter() {
- printContentRow(Layout.footerText.centered(in: Layout.contentWidth))
- printBorderRow(.bottom)
- }
-}
-
-// MARK: - Section Content Renderers
-
-extension LiveRenderer {
- func renderSessionContent(block: SessionBlock) {
- let session = SessionMetrics(block: block)
- let progressBar = ProgressBar.build(
- percentage: session.percentage,
- color: .green
- )
- printContentRow(" \(Layout.sessionIcon) SESSION [\(progressBar)] \(session.percentage.formatted)%")
- printContentRow(" Started: \(session.startTimeFormatted) Elapsed: \(session.elapsedFormatted) Remaining: \(session.remainingFormatted) (\(session.endTimeFormatted))")
- }
-
- func renderUsageContent(block: SessionBlock, tokenLimit: Int) {
- let usage = UsageMetrics(block: block, tokenLimit: tokenLimit)
- let progressBar = ProgressBar.build(
- percentage: usage.percentage,
- color: usage.percentage.progressColor
- )
- printContentRow(" \(Layout.usageIcon) USAGE (session) [\(progressBar)] \(usage.percentage.formatted)% (\(usage.tokensShort))")
- printContentRow(" Tokens: \(usage.tokensFormatted) Burn Rate: \(usage.burnRateFormatted) token/min \(usage.burnIndicator)")
- printContentRow(" Cost: $\(String(format: "%.2f", block.costUSD))")
- }
-
- func renderProjectionContent(block: SessionBlock, tokenLimit: Int) {
- let projection = ProjectionMetrics(block: block, tokenLimit: tokenLimit)
- let progressBar = ProgressBar.build(
- percentage: projection.percentage,
- color: .red
- )
- printContentRow(" \(Layout.projectionIcon) PROJECTION [\(progressBar)] \(projection.percentage.formatted)% (\(projection.tokensShort)/\(projection.limitShort))")
- printContentRow(" Status: \(projection.status) Tokens: \(projection.tokensFormatted) Cost: $\(String(format: "%.2f", block.projectedUsage.totalCost))")
- }
-}
-
-// MARK: - Print Helpers
-
-extension LiveRenderer {
- func printBorderRow(_ position: BorderPosition) {
- let (left, right) = position.corners
- print(" \(left)\(Layout.divider)\(right)")
- }
-
- func printContentRow(_ content: String) {
- print(" \(Layout.verticalBorder)\(content.padded(to: Layout.contentWidth))\(Layout.verticalBorder)")
- }
-
- func printEmptyRow() {
- printContentRow("")
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models+Pricing.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models+Pricing.swift
deleted file mode 100644
index c30c0ed..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models+Pricing.swift
+++ /dev/null
@@ -1,135 +0,0 @@
-//
-// Models+Pricing.swift
-//
-// Model pricing configurations and cost calculations.
-//
-
-import Foundation
-
-// MARK: - ModelPricing
-
-public struct ModelPricing: Sendable {
- public let inputCostPerToken: Double
- public let outputCostPerToken: Double
- public let cacheCreationCostPerToken: Double
- public let cacheReadCostPerToken: Double
-
- public init(
- inputCostPerToken: Double,
- outputCostPerToken: Double,
- cacheCreationCostPerToken: Double,
- cacheReadCostPerToken: Double
- ) {
- self.inputCostPerToken = inputCostPerToken
- self.outputCostPerToken = outputCostPerToken
- self.cacheCreationCostPerToken = cacheCreationCostPerToken
- self.cacheReadCostPerToken = cacheReadCostPerToken
- }
-
- public func calculateCost(tokens: TokenCounts) -> Double {
- inputCost(for: tokens) + outputCost(for: tokens) + cacheCost(for: tokens)
- }
-}
-
-// MARK: - ModelPricing Pricing Configurations
-
-public extension ModelPricing {
- /// Claude Opus 4.5 pricing (November 2025)
- static let claudeOpus45 = ModelPricing(
- inputCostPerToken: 0.000005, // $5/MTok
- outputCostPerToken: 0.000025, // $25/MTok
- cacheCreationCostPerToken: 0.00000625, // $6.25/MTok
- cacheReadCostPerToken: 0.0000005 // $0.50/MTok
- )
-
- /// Claude Sonnet 4/4.5 pricing
- static let claudeSonnet4 = ModelPricing(
- inputCostPerToken: 0.000003, // $3/MTok
- outputCostPerToken: 0.000015, // $15/MTok
- cacheCreationCostPerToken: 0.00000375, // $3.75/MTok
- cacheReadCostPerToken: 0.0000003 // $0.30/MTok
- )
-
- /// Claude Haiku 4.5 pricing
- static let claudeHaiku45 = ModelPricing(
- inputCostPerToken: 0.000001, // $1/MTok
- outputCostPerToken: 0.000005, // $5/MTok
- cacheCreationCostPerToken: 0.00000125, // $1.25/MTok
- cacheReadCostPerToken: 0.0000001 // $0.10/MTok
- )
-
- static let `default` = claudeSonnet4
-}
-
-// MARK: - ModelPricing Lookup
-
-public extension ModelPricing {
- static func getPricing(for model: String) -> ModelPricing {
- ModelFamily(from: model).pricing
- }
-}
-
-// MARK: - ModelPricing Cost Calculation
-
-private extension ModelPricing {
- func inputCost(for tokens: TokenCounts) -> Double {
- Double(tokens.inputTokens) * inputCostPerToken
- }
-
- func outputCost(for tokens: TokenCounts) -> Double {
- Double(tokens.outputTokens) * outputCostPerToken
- }
-
- func cacheCost(for tokens: TokenCounts) -> Double {
- cacheCreationCost(for: tokens) + cacheReadCost(for: tokens)
- }
-
- func cacheCreationCost(for tokens: TokenCounts) -> Double {
- Double(tokens.cacheCreationInputTokens) * cacheCreationCostPerToken
- }
-
- func cacheReadCost(for tokens: TokenCounts) -> Double {
- Double(tokens.cacheReadInputTokens) * cacheReadCostPerToken
- }
-}
-
-// MARK: - ModelFamily
-
-private enum ModelFamily {
- case opus
- case sonnet
- case haiku
- case unknown
-
- init(from modelName: String) {
- self = Self.allKnownFamilies.first { $0.matches(modelName) } ?? .unknown
- }
-
- var pricing: ModelPricing {
- switch self {
- case .opus: .claudeOpus45
- case .sonnet: .claudeSonnet4
- case .haiku: .claudeHaiku45
- case .unknown: .default
- }
- }
-}
-
-// MARK: - ModelFamily Matching
-
-private extension ModelFamily {
- static let allKnownFamilies: [ModelFamily] = [.opus, .sonnet, .haiku]
-
- var identifier: String {
- switch self {
- case .opus: "opus"
- case .sonnet: "sonnet"
- case .haiku: "haiku"
- case .unknown: ""
- }
- }
-
- func matches(_ modelName: String) -> Bool {
- modelName.lowercased().contains(identifier)
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models.swift
deleted file mode 100644
index 87c7366..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models.swift
+++ /dev/null
@@ -1,143 +0,0 @@
-//
-// Models.swift
-//
-// Core data models for ClaudeLiveMonitor.
-// Split into extensions for focused responsibilities:
-// - +Pricing: Model pricing configurations and cost calculations
-//
-
-import Foundation
-
-// MARK: - TokenCounts
-
-public struct TokenCounts: Sendable {
- public let inputTokens: Int
- public let outputTokens: Int
- public let cacheCreationInputTokens: Int
- public let cacheReadInputTokens: Int
-
- public var total: Int {
- inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens
- }
-
- public init(
- inputTokens: Int = 0,
- outputTokens: Int = 0,
- cacheCreationInputTokens: Int = 0,
- cacheReadInputTokens: Int = 0
- ) {
- self.inputTokens = inputTokens
- self.outputTokens = outputTokens
- self.cacheCreationInputTokens = cacheCreationInputTokens
- self.cacheReadInputTokens = cacheReadInputTokens
- }
-}
-
-// MARK: - UsageEntry
-
-public struct UsageEntry: Sendable {
- public let timestamp: Date
- public let usage: TokenCounts
- public let costUSD: Double
- public let model: String
- public let sourceFile: String
- public let messageId: String?
- public let requestId: String?
- public let usageLimitResetTime: Date?
-
- public init(
- timestamp: Date,
- usage: TokenCounts,
- costUSD: Double,
- model: String,
- sourceFile: String,
- messageId: String? = nil,
- requestId: String? = nil,
- usageLimitResetTime: Date? = nil
- ) {
- self.timestamp = timestamp
- self.usage = usage
- self.costUSD = costUSD
- self.model = model
- self.sourceFile = sourceFile
- self.messageId = messageId
- self.requestId = requestId
- self.usageLimitResetTime = usageLimitResetTime
- }
-}
-
-// MARK: - BurnRate
-
-public struct BurnRate: Sendable {
- public let tokensPerMinute: Int
- public let tokensPerMinuteForIndicator: Int
- public let costPerHour: Double
-
- public init(tokensPerMinute: Int, tokensPerMinuteForIndicator: Int, costPerHour: Double) {
- self.tokensPerMinute = tokensPerMinute
- self.tokensPerMinuteForIndicator = tokensPerMinuteForIndicator
- self.costPerHour = costPerHour
- }
-}
-
-// MARK: - ProjectedUsage
-
-public struct ProjectedUsage: Sendable {
- public let totalTokens: Int
- public let totalCost: Double
- public let remainingMinutes: Double
-
- public init(totalTokens: Int, totalCost: Double, remainingMinutes: Double) {
- self.totalTokens = totalTokens
- self.totalCost = totalCost
- self.remainingMinutes = remainingMinutes
- }
-}
-
-// MARK: - SessionBlock
-
-public struct SessionBlock: Sendable {
- public let id: String
- public let startTime: Date
- public let endTime: Date
- public let actualEndTime: Date?
- public let isActive: Bool
- public let isGap: Bool
- public let entries: [UsageEntry]
- public let tokenCounts: TokenCounts
- public let costUSD: Double
- public let models: [String]
- public let usageLimitResetTime: Date?
- public let burnRate: BurnRate
- public let projectedUsage: ProjectedUsage
-
- public init(
- id: String,
- startTime: Date,
- endTime: Date,
- actualEndTime: Date?,
- isActive: Bool,
- isGap: Bool,
- entries: [UsageEntry],
- tokenCounts: TokenCounts,
- costUSD: Double,
- models: [String],
- usageLimitResetTime: Date?,
- burnRate: BurnRate,
- projectedUsage: ProjectedUsage
- ) {
- self.id = id
- self.startTime = startTime
- self.endTime = endTime
- self.actualEndTime = actualEndTime
- self.isActive = isActive
- self.isGap = isGap
- self.entries = entries
- self.tokenCounts = tokenCounts
- self.costUSD = costUSD
- self.models = models
- self.usageLimitResetTime = usageLimitResetTime
- self.burnRate = burnRate
- self.projectedUsage = projectedUsage
- }
-}
diff --git a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/TypeAliases.swift b/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/TypeAliases.swift
deleted file mode 100644
index 99f57dc..0000000
--- a/Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/TypeAliases.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-// TypeAliases.swift
-// ClaudeLiveMonitorLib
-//
-
-import Foundation
-
-// MARK: - Public Type Aliases
-
-/// Live monitor's usage entry type for real-time session monitoring.
-///
-/// When importing both ClaudeCodeUsage and ClaudeLiveMonitorLib, use this
-/// alias to distinguish from `ClaudeCodeUsage.UsageEntry`:
-/// ```swift
-/// import ClaudeCodeUsage
-/// import ClaudeLiveMonitorLib
-///
-/// let historicalEntry: ClaudeCodeUsage.UsageEntry = ...
-/// let liveEntry: LiveUsageEntry = ...
-/// ```
-public typealias LiveUsageEntry = UsageEntry
-
-/// Live monitor's session block type for grouping related usage entries.
-///
-/// Use this alias when both modules are imported to avoid naming conflicts
-/// with `ClaudeCodeUsage.SessionBlock`.
-public typealias LiveSessionBlock = SessionBlock
-
-/// Live monitor's token counts type for tracking input/output/cache tokens.
-///
-/// Use this alias when both modules are imported to avoid naming conflicts
-/// with `ClaudeCodeUsage.TokenCounts`.
-public typealias LiveTokenCounts = TokenCounts
\ No newline at end of file
diff --git a/Packages/TimingMacro/Package.resolved b/Packages/TimingMacro/Package.resolved
deleted file mode 100644
index 57847a9..0000000
--- a/Packages/TimingMacro/Package.resolved
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "originHash" : "3fdfe5abc3dbf16145535b0634df8e7ee734967935ed465a9b68d9294145bb6c",
- "pins" : [
- {
- "identity" : "swift-syntax",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/swiftlang/swift-syntax.git",
- "state" : {
- "revision" : "0687f71944021d616d34d922343dcef086855920",
- "version" : "600.0.1"
- }
- }
- ],
- "version" : 3
-}
diff --git a/Packages/TimingMacro/Package.swift b/Packages/TimingMacro/Package.swift
deleted file mode 100644
index 90d2869..0000000
--- a/Packages/TimingMacro/Package.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-// swift-tools-version: 6.0
-
-import PackageDescription
-import CompilerPluginSupport
-
-let package = Package(
- name: "TimingMacro",
- platforms: [.macOS(.v14), .iOS(.v17)],
- products: [
- .library(name: "TimingMacro", targets: ["TimingMacro"])
- ],
- dependencies: [
- .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0")
- ],
- targets: [
- .macro(
- name: "TimingMacroMacros",
- dependencies: [
- .product(name: "SwiftParser", package: "swift-syntax"),
- .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
- .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
- ]
- ),
- .target(
- name: "TimingMacro",
- dependencies: ["TimingMacroMacros"]
- ),
- .testTarget(
- name: "TimingMacroTests",
- dependencies: [
- "TimingMacroMacros",
- .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
- ]
- )
- ]
-)
diff --git a/Packages/TimingMacro/Sources/TimingMacro/TimingMacro.swift b/Packages/TimingMacro/Sources/TimingMacro/TimingMacro.swift
deleted file mode 100644
index d1932f6..0000000
--- a/Packages/TimingMacro/Sources/TimingMacro/TimingMacro.swift
+++ /dev/null
@@ -1,13 +0,0 @@
-/// Measures and logs function execution time.
-///
-/// Usage:
-/// ```swift
-/// @Timed
-/// func expensiveOperation() {
-/// // ...
-/// }
-/// ```
-///
-/// Output: `[TimingMacro] expensiveOperation() took 0.123s`
-@attached(body)
-public macro Timed() = #externalMacro(module: "TimingMacroMacros", type: "TimedMacro")
diff --git a/Packages/TimingMacro/Sources/TimingMacroMacros/TimedMacro.swift b/Packages/TimingMacro/Sources/TimingMacroMacros/TimedMacro.swift
deleted file mode 100644
index 4d2291a..0000000
--- a/Packages/TimingMacro/Sources/TimingMacroMacros/TimedMacro.swift
+++ /dev/null
@@ -1,139 +0,0 @@
-import Foundation
-import SwiftCompilerPlugin
-import SwiftParser
-import SwiftSyntax
-import SwiftSyntaxMacros
-
-// MARK: - Public API
-
-public struct TimedMacro: BodyMacro {
- public static func expansion(
- of node: AttributeSyntax,
- providingBodyFor declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax,
- in context: some MacroExpansionContext
- ) throws -> [CodeBlockItemSyntax] {
- let funcDecl = try extractFunctionDecl(from: declaration)
- let signature = FunctionSignature(from: funcDecl)
- return buildTimedBody(for: funcDecl, signature: signature)
- }
-}
-
-// MARK: - Function Signature Extraction
-
-private struct FunctionSignature {
- let isAsync: Bool
- let isThrowing: Bool
- let hasReturn: Bool
-
- init(from funcDecl: FunctionDeclSyntax) {
- let effects = funcDecl.signature.effectSpecifiers
- self.isAsync = effects?.asyncSpecifier != nil
- self.isThrowing = effects?.throwsClause != nil
- self.hasReturn = Self.detectReturn(from: funcDecl)
- }
-
- private static func detectReturn(from funcDecl: FunctionDeclSyntax) -> Bool {
- guard let returnType = funcDecl.signature.returnClause?.type.description
- .trimmingCharacters(in: .whitespaces) else { return false }
- return returnType != "Void" && returnType != "()"
- }
-
- var effectPrefix: String {
- [
- isThrowing ? "try" : nil,
- isAsync ? "await" : nil
- ]
- .compactMap { $0 }
- .joined(separator: " ")
- }
-}
-
-// MARK: - Validation
-
-private func extractFunctionDecl(
- from declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax
-) throws -> FunctionDeclSyntax {
- guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
- throw MacroError.notAFunction
- }
- guard funcDecl.body != nil else {
- throw MacroError.missingBody
- }
- return funcDecl
-}
-
-// MARK: - Code Generation
-
-private func buildTimedBody(
- for funcDecl: FunctionDeclSyntax,
- signature: FunctionSignature
-) -> [CodeBlockItemSyntax] {
- let originalBody = extractBody(from: funcDecl)
-
- let code = signature.hasReturn
- ? CodeTemplate.withReturn(body: originalBody, effectPrefix: signature.effectPrefix)
- : CodeTemplate.voidReturn(body: originalBody)
-
- return parseStatements(code)
-}
-
-private func extractBody(from funcDecl: FunctionDeclSyntax) -> String {
- funcDecl.body!.statements.map(\.description).joined()
-}
-
-private func parseStatements(_ code: String) -> [CodeBlockItemSyntax] {
- Parser.parse(source: code).statements.map { $0 }
-}
-
-// MARK: - Code Templates
-
-private enum CodeTemplate {
- static func voidReturn(body: String) -> String {
- """
- let _startTime = ContinuousClock.now
- defer {
- let _elapsed = ContinuousClock.now - _startTime
- print("[Timed] \\(#function) took \\(_elapsed)")
- }
- \(body)
- """
- }
-
- static func withReturn(body: String, effectPrefix: String) -> String {
- let prefix = effectPrefix.isEmpty ? "" : "\(effectPrefix) "
- return """
- let _startTime = ContinuousClock.now
- let _result = \(prefix){
- \(body)
- }()
- let _elapsed = ContinuousClock.now - _startTime
- print("[Timed] \\(#function) took \\(_elapsed)")
- return _result
- """
- }
-}
-
-// MARK: - Errors
-
-enum MacroError: Error, CustomStringConvertible {
- case notAFunction
- case missingBody
-
- var description: String {
- switch self {
- case .notAFunction:
- return "@Timed can only be applied to functions"
- case .missingBody:
- return "@Timed requires a function with a body"
- }
- }
-}
-
-// MARK: - Plugin
-
-@main
-struct TimingMacroPlugin: CompilerPlugin {
- let providingMacros: [Macro.Type] = [
- TimedMacro.self
- ]
-}
diff --git a/Packages/TimingMacro/Tests/TimingMacroTests/TimedMacroTests.swift b/Packages/TimingMacro/Tests/TimingMacroTests/TimedMacroTests.swift
deleted file mode 100644
index b86f1d1..0000000
--- a/Packages/TimingMacro/Tests/TimingMacroTests/TimedMacroTests.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-import SwiftSyntaxMacros
-import SwiftSyntaxMacrosTestSupport
-import Testing
-
-@testable import TimingMacroMacros
-
-struct TimedMacroTests {
- let testMacros: [String: Macro.Type] = [
- "Timed": TimedMacro.self
- ]
-
- @Test func timedVoidFunction() {
- assertMacroExpansion(
- """
- @Timed
- func doWork() {
- print("working")
- }
- """,
- expandedSource: """
- func doWork() {
- let _startTime = ContinuousClock.now
- defer {
- let _elapsed = ContinuousClock.now - _startTime
- print("[Timed] \\(#function) took \\(_elapsed)")
- }
- print("working")
- }
- """,
- macros: testMacros
- )
- }
-
- @Test func timedFunctionWithReturn() {
- assertMacroExpansion(
- """
- @Timed
- func calculate() -> Int {
- return 42
- }
- """,
- expandedSource: """
- func calculate() -> Int {
- let _startTime = ContinuousClock.now
- let _result = {
- return 42
- }()
- let _elapsed = ContinuousClock.now - _startTime
- print("[Timed] \\(#function) took \\(_elapsed)")
- return _result
- }
- """,
- macros: testMacros
- )
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/ClaudeCodeUsageKit.swift b/Sources/ClaudeCodeUsageKit/ClaudeCodeUsageKit.swift
deleted file mode 100644
index 94b4e69..0000000
--- a/Sources/ClaudeCodeUsageKit/ClaudeCodeUsageKit.swift
+++ /dev/null
@@ -1,46 +0,0 @@
-//
-// ClaudeCodeUsageKit.swift
-// ClaudeCodeUsageKit
-//
-// Main SDK entry point - exports all public APIs
-//
-
-import Foundation
-
-/// ClaudeCodeUsageKit
-///
-/// A Swift SDK for accessing and analyzing Claude Code usage data.
-///
-/// ## Quick Start
-/// ```swift
-/// import ClaudeCodeUsageKit
-///
-/// let repository = UsageRepository()
-/// let stats = try await repository.getUsageStats()
-/// print("Total cost: \(stats.totalCost)")
-/// ```
-public struct ClaudeCodeUsageKit {
- /// SDK Version
- public static let version = "1.0.0"
-
- /// SDK Build Date
- public static let buildDate = "2025-08-07"
-
- /// Check if the SDK is compatible with the current platform
- public static var isSupported: Bool {
- #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS)
- return true
- #else
- return false
- #endif
- }
-
- /// Get SDK information
- public static var info: String {
- """
- ClaudeCodeUsageKit v\(version)
- Build Date: \(buildDate)
- Platform Support: \(isSupported ? "Yes" : "No")
- """
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Aggregation.swift b/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Aggregation.swift
deleted file mode 100644
index 7e55096..0000000
--- a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Aggregation.swift
+++ /dev/null
@@ -1,172 +0,0 @@
-//
-// UsageRepository+Aggregation.swift
-//
-// Statistics aggregation, filtering, and sorting.
-//
-
-import Foundation
-
-// MARK: - Aggregation
-
-enum Aggregator {
- static func aggregate(_ entries: [UsageEntry], sessionCount: Int) -> UsageStats {
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = RepositoryDateFormat.dayString
- let calendar = Calendar.current
-
- let result = entries.reduce(into: AggregationState()) { state, entry in
- state.addEntry(entry, dateFormatter: dateFormatter, calendar: calendar)
- }
-
- return UsageStats(
- totalCost: result.totalCost,
- totalTokens: result.totalTokens,
- totalInputTokens: result.totalInputTokens,
- totalOutputTokens: result.totalOutputTokens,
- totalCacheCreationTokens: result.totalCacheWriteTokens,
- totalCacheReadTokens: result.totalCacheReadTokens,
- totalSessions: sessionCount,
- byModel: Array(result.modelStats.values),
- byDate: result.buildDailyUsage(),
- byProject: Array(result.projectStats.values)
- )
- }
-}
-
-// MARK: - Aggregation State
-
-private struct AggregationState {
- var totalCost: Double = 0
- var totalInputTokens: Int = 0
- var totalOutputTokens: Int = 0
- var totalCacheWriteTokens: Int = 0
- var totalCacheReadTokens: Int = 0
- var modelStats: [String: ModelUsage] = [:]
- var dailyStats: [String: DailyUsageBuilder] = [:]
- var projectStats: [String: ProjectUsage] = [:]
-
- var totalTokens: Int {
- totalInputTokens + totalOutputTokens + totalCacheWriteTokens + totalCacheReadTokens
- }
-
- mutating func addEntry(_ entry: UsageEntry, dateFormatter: DateFormatter, calendar: Calendar) {
- totalCost += entry.cost
- totalInputTokens += entry.inputTokens
- totalOutputTokens += entry.outputTokens
- totalCacheWriteTokens += entry.cacheWriteTokens
- totalCacheReadTokens += entry.cacheReadTokens
-
- updateModelStats(entry)
- updateDailyStats(entry, dateFormatter: dateFormatter, calendar: calendar)
- updateProjectStats(entry)
- }
-
- private mutating func updateModelStats(_ entry: UsageEntry) {
- let existing = modelStats[entry.model]
- modelStats[entry.model] = ModelUsage(
- model: entry.model,
- totalCost: (existing?.totalCost ?? 0) + entry.cost,
- totalTokens: (existing?.totalTokens ?? 0) + entry.totalTokens,
- inputTokens: (existing?.inputTokens ?? 0) + entry.inputTokens,
- outputTokens: (existing?.outputTokens ?? 0) + entry.outputTokens,
- cacheCreationTokens: (existing?.cacheCreationTokens ?? 0) + entry.cacheWriteTokens,
- cacheReadTokens: (existing?.cacheReadTokens ?? 0) + entry.cacheReadTokens,
- sessionCount: (existing?.sessionCount ?? 0) + 1
- )
- }
-
- private mutating func updateDailyStats(_ entry: UsageEntry, dateFormatter: DateFormatter, calendar: Calendar) {
- guard let date = entry.date else { return }
- let dateString = dateFormatter.string(from: date)
- let hour = calendar.component(.hour, from: date)
-
- var builder = dailyStats[dateString] ?? DailyUsageBuilder()
- builder.totalCost += entry.cost
- builder.totalTokens += entry.totalTokens
- builder.models.insert(entry.model)
- builder.hourlyCosts[hour] += entry.cost
- dailyStats[dateString] = builder
- }
-
- private mutating func updateProjectStats(_ entry: UsageEntry) {
- let existing = projectStats[entry.project]
- projectStats[entry.project] = ProjectUsage(
- projectPath: entry.project,
- projectName: URL(fileURLWithPath: entry.project).lastPathComponent,
- totalCost: (existing?.totalCost ?? 0) + entry.cost,
- totalTokens: (existing?.totalTokens ?? 0) + entry.totalTokens,
- sessionCount: existing?.sessionCount ?? 1,
- lastUsed: max(existing?.lastUsed ?? "", entry.timestamp)
- )
- }
-
- func buildDailyUsage() -> [DailyUsage] {
- dailyStats.map { date, builder in
- DailyUsage(
- date: date,
- totalCost: builder.totalCost,
- totalTokens: builder.totalTokens,
- modelsUsed: Array(builder.models),
- hourlyCosts: builder.hourlyCosts
- )
- }.sorted { $0.date < $1.date }
- }
-}
-
-// MARK: - Daily Usage Builder
-
-private struct DailyUsageBuilder {
- var totalCost: Double = 0
- var totalTokens: Int = 0
- var models: Set = []
- var hourlyCosts: [Double] = Array(repeating: 0, count: 24)
-}
-
-// MARK: - Filtering
-
-enum Filter {
- static func byDateRange(_ stats: UsageStats, start: Date, end: Date) -> UsageStats {
- guard start.timeIntervalSince1970 >= 0 else { return stats }
-
- let formatter = DateFormatter()
- formatter.dateFormat = RepositoryDateFormat.dayString
- let startString = formatter.string(from: start)
- let endString = formatter.string(from: end)
-
- let filtered = stats.byDate.filter { $0.date >= startString && $0.date <= endString }
- guard !filtered.isEmpty else { return stats }
-
- let (totalCost, totalTokens) = filtered.reduce((0.0, 0)) { ($0.0 + $1.totalCost, $0.1 + $1.totalTokens) }
-
- return UsageStats(
- totalCost: totalCost,
- totalTokens: totalTokens,
- totalInputTokens: stats.totalInputTokens,
- totalOutputTokens: stats.totalOutputTokens,
- totalCacheCreationTokens: stats.totalCacheCreationTokens,
- totalCacheReadTokens: stats.totalCacheReadTokens,
- totalSessions: stats.totalSessions,
- byModel: stats.byModel,
- byDate: filtered,
- byProject: stats.byProject
- )
- }
-
- static func byDateRange(_ projects: [ProjectUsage], since: Date?, until: Date?) -> [ProjectUsage] {
- guard let since = since, let until = until else { return projects }
- return projects.filter { project in
- project.lastUsedDate.map { $0 >= since && $0 <= until } ?? false
- }
- }
-}
-
-// MARK: - Sorting
-
-enum Sort {
- static func byCost(_ projects: [ProjectUsage], order: SortOrder?) -> [ProjectUsage] {
- guard let order = order else { return projects }
- return projects.sorted { a, b in
- order == .ascending ? a.totalCost < b.totalCost : a.totalCost > b.totalCost
- }
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+FileDiscovery.swift b/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+FileDiscovery.swift
deleted file mode 100644
index 42185fd..0000000
--- a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+FileDiscovery.swift
+++ /dev/null
@@ -1,138 +0,0 @@
-//
-// UsageRepository+FileDiscovery.swift
-//
-// File discovery and metadata extraction for usage repository.
-//
-
-import Foundation
-
-// MARK: - File Discovery
-
-extension UsageRepository {
- func discoverFiles(in projectsPath: String) throws -> [FileMetadata] {
- let todayStart = Calendar.current.startOfDay(for: Date())
-
- return try FileManager.default.contentsOfDirectory(atPath: projectsPath)
- .filter { !DirectoryFilter.shouldSkip($0) }
- .flatMap { projectDir in
- jsonlFiles(in: projectsPath + "/" + projectDir, projectDir: projectDir, todayStart: todayStart)
- }
- .sorted { $0.earliestTimestamp < $1.earliestTimestamp }
- }
-
- func jsonlFiles(in projectPath: String, projectDir: String, todayStart: Date) -> [FileMetadata] {
- (try? FileManager.default.contentsOfDirectory(atPath: projectPath))
- .map { files in
- files
- .filter { $0.hasSuffix(".jsonl") }
- .compactMap { buildMetadata(for: $0, in: projectPath, projectDir: projectDir, todayStart: todayStart) }
- } ?? []
- }
-
- func buildMetadata(
- for file: String,
- in projectPath: String,
- projectDir: String,
- todayStart: Date
- ) -> FileMetadata? {
- let filePath = projectPath + "/" + file
-
- if let cached = cachedMetadataForOldFile(at: filePath, projectDir: projectDir, todayStart: todayStart) {
- return cached
- }
-
- return freshMetadata(at: filePath, projectDir: projectDir)
- }
-
- func cachedMetadataForOldFile(at filePath: String, projectDir: String, todayStart: Date) -> FileMetadata? {
- guard let cached = fileCache[filePath],
- cached.modificationDate < todayStart else {
- return nil
- }
-
- // Validate cache: ensure file hasn't been modified since caching
- guard let actualModDate = FileTimestamp.modificationDate(of: filePath),
- actualModDate == cached.modificationDate else {
- return nil
- }
-
- return FileMetadata(
- path: filePath,
- projectDir: projectDir,
- earliestTimestamp: ISO8601DateFormatter().string(from: cached.modificationDate),
- modificationDate: cached.modificationDate
- )
- }
-
- func freshMetadata(at filePath: String, projectDir: String) -> FileMetadata? {
- guard let (timestamp, modDate) = FileTimestamp.extract(from: filePath) else {
- return nil
- }
- return FileMetadata(
- path: filePath,
- projectDir: projectDir,
- earliestTimestamp: timestamp,
- modificationDate: modDate
- )
- }
-
- func filterFilesModifiedToday(_ files: [FileMetadata]) -> [FileMetadata] {
- let todayStart = Calendar.current.startOfDay(for: Date())
- let formatter = ISO8601DateFormatter()
- return files.filter { file in
- formatter.date(from: file.earliestTimestamp).map { $0 >= todayStart } ?? false
- }
- }
-
- func countSessions(in files: [FileMetadata]) -> Int {
- Set(
- files.compactMap { file in
- let filename = URL(fileURLWithPath: file.path).lastPathComponent
- return filename.hasSuffix(".jsonl") ? String(filename.dropLast(6)) : nil
- }
- ).count
- }
-}
-
-// MARK: - Directory Filter
-
-enum DirectoryFilter {
- static func shouldSkip(_ name: String) -> Bool {
- // Only skip hidden directories (starting with .)
- // Include all other directories including -private-var-folders-*
- // to capture usage from RPLY/claude-kit and other integrations
- name.hasPrefix(".")
- }
-}
-
-// MARK: - Path Decoder
-
-enum PathDecoder {
- static func decode(_ encodedPath: String) -> String {
- if encodedPath.hasPrefix("-") {
- return "/" + String(encodedPath.dropFirst()).replacingOccurrences(of: "-", with: "/")
- }
- return encodedPath.replacingOccurrences(of: "-", with: "/")
- }
-}
-
-// MARK: - File Timestamp
-
-enum FileTimestamp {
- /// Extract both timestamp string and modification date from file
- static func extract(from path: String) -> (timestamp: String, modificationDate: Date)? {
- guard let modDate = modificationDate(of: path) else {
- return nil
- }
- return (ISO8601DateFormatter().string(from: modDate), modDate)
- }
-
- /// Get modification date for cache validation
- static func modificationDate(of path: String) -> Date? {
- guard let attributes = try? FileManager.default.attributesOfItem(atPath: path),
- let modDate = attributes[.modificationDate] as? Date else {
- return nil
- }
- return modDate
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Parsing.swift b/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Parsing.swift
deleted file mode 100644
index cf05a28..0000000
--- a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Parsing.swift
+++ /dev/null
@@ -1,246 +0,0 @@
-//
-// UsageRepository+Parsing.swift
-//
-// Entry loading, parsing, and transformation utilities.
-//
-
-import Foundation
-
-// MARK: - Entry Loading
-
-extension UsageRepository {
- func loadEntries(from files: [FileMetadata]) async -> [UsageEntry] {
- let (cachedFiles, dirtyFiles) = partitionByCache(files)
- let cachedEntries = cachedFiles.flatMap { fileCache[$0.path]?.entries ?? [] }
- let newEntries = await loadNewEntries(from: dirtyFiles, deduplication: globalDeduplication)
- return cachedEntries + newEntries
- }
-
- func partitionByCache(_ files: [FileMetadata]) -> (cached: [FileMetadata], dirty: [FileMetadata]) {
- files.reduce(into: (cached: [FileMetadata](), dirty: [FileMetadata]())) { result, file in
- if isCacheHit(for: file) {
- result.cached.append(file)
- } else {
- result.dirty.append(file)
- }
- }
- }
-
- func isCacheHit(for file: FileMetadata) -> Bool {
- guard let cached = fileCache[file.path] else { return false }
- return cached.modificationDate == file.modificationDate && cached.version == CacheVersion.current
- }
-
- func loadNewEntries(from files: [FileMetadata], deduplication: Deduplication) async -> [UsageEntry] {
- switch files.count {
- case 0:
- return []
- case 1...RepositoryThreshold.parallelProcessing:
- return loadEntriesSequentially(from: files, deduplication: deduplication)
- case (RepositoryThreshold.parallelProcessing + 1)...RepositoryThreshold.batchProcessing:
- return await loadEntriesInParallel(from: files, deduplication: deduplication)
- default:
- return await loadEntriesInBatches(from: files, deduplication: deduplication)
- }
- }
-
- func loadEntriesSequentially(
- from files: [FileMetadata],
- deduplication: Deduplication
- ) -> [UsageEntry] {
- files.flatMap { parseFile($0, deduplication: deduplication) }
- }
-
- func loadEntriesInParallel(
- from files: [FileMetadata],
- deduplication: Deduplication
- ) async -> [UsageEntry] {
- let results = await parseFilesInParallel(files, deduplication: deduplication)
- return cacheAndExtractEntries(from: results)
- }
-
- func parseFilesInParallel(
- _ files: [FileMetadata],
- deduplication: Deduplication
- ) async -> [(FileMetadata, [UsageEntry])] {
- await withTaskGroup(of: (FileMetadata, [UsageEntry]).self) { group in
- files.forEach { file in
- group.addTask { (file, FileParser.parse(file, deduplication: deduplication)) }
- }
- return await group.reduce(into: []) { $0.append($1) }
- }
- }
-
- func cacheAndExtractEntries(from results: [(FileMetadata, [UsageEntry])]) -> [UsageEntry] {
- results.flatMap { file, entries in
- fileCache[file.path] = CachedFile(modificationDate: file.modificationDate, entries: entries, version: CacheVersion.current)
- return entries
- }
- }
-
- func loadEntriesInBatches(
- from files: [FileMetadata],
- deduplication: Deduplication
- ) async -> [UsageEntry] {
- await batches(of: files, size: RepositoryThreshold.batchSize)
- .asyncFlatMap { [self] in await loadEntriesInParallel(from: $0, deduplication: deduplication) }
- }
-
- func batches(of files: [FileMetadata], size: Int) -> [[FileMetadata]] {
- stride(from: 0, to: files.count, by: size).map { startIndex in
- Array(files[startIndex.. [UsageEntry] {
- let entries = FileParser.parse(file, deduplication: deduplication)
- fileCache[file.path] = CachedFile(modificationDate: file.modificationDate, entries: entries, version: CacheVersion.current)
- return entries
- }
-}
-
-// MARK: - JSON Validator
-
-enum JSONValidator {
- static func isValidObject(_ data: Data) -> Bool {
- data.count > 2 &&
- data.first == ByteValue.openBrace &&
- data.last == ByteValue.closeBrace
- }
-}
-
-// MARK: - Line Scanner
-
-enum LineScanner {
- static func extractRanges(from data: Data) -> [Range] {
- guard data.count > 0 else { return [] }
-
- return [UInt8](data).withUnsafeBufferPointer { buffer in
- guard let ptr = buffer.baseAddress else { return [] }
- var ranges: [Range] = []
- var offset = 0
-
- while offset < data.count {
- let remaining = data.count - offset
- let lineEnd = memchr(ptr + offset, ByteValue.newline, remaining)
- .map { UnsafePointer($0.assumingMemoryBound(to: UInt8.self)) - ptr }
- ?? data.count
-
- if lineEnd > offset {
- ranges.append(offset.. UsageEntry? {
- guard let message = json["message"] as? [String: Any],
- let usage = message["usage"] as? [String: Any] else {
- return nil
- }
-
- let tokens = extractTokens(from: usage)
- guard tokens.hasUsage else { return nil }
-
- let model = message["model"] as? String ?? "unknown"
- let cost = calculateCost(json: json, model: model, tokens: tokens)
-
- return UsageEntry(
- project: projectPath,
- timestamp: json["timestamp"] as? String ?? "",
- model: model,
- inputTokens: tokens.input,
- outputTokens: tokens.output,
- cacheWriteTokens: tokens.cacheWrite,
- cacheReadTokens: tokens.cacheRead,
- cost: cost,
- sessionId: json["sessionId"] as? String
- )
- }
-
- private static func extractTokens(from usage: [String: Any]) -> TokenCounts {
- TokenCounts(
- input: usage["input_tokens"] as? Int ?? 0,
- output: usage["output_tokens"] as? Int ?? 0,
- cacheWrite: usage["cache_creation_input_tokens"] as? Int ?? 0,
- cacheRead: usage["cache_read_input_tokens"] as? Int ?? 0
- )
- }
-
- private static func calculateCost(json: [String: Any], model: String, tokens: TokenCounts) -> Double {
- if let cost = json["costUSD"] as? Double, cost > 0 {
- return cost
- }
- return ModelPricing.pricing(for: model)?.calculateCost(
- inputTokens: tokens.input,
- outputTokens: tokens.output,
- cacheWriteTokens: tokens.cacheWrite,
- cacheReadTokens: tokens.cacheRead
- ) ?? 0.0
- }
-
- struct TokenCounts {
- let input: Int
- let output: Int
- let cacheWrite: Int
- let cacheRead: Int
-
- var hasUsage: Bool {
- input > 0 || output > 0 || cacheWrite > 0 || cacheRead > 0
- }
- }
-}
-
-// MARK: - File Parser
-
-enum FileParser {
- /// Pure file parsing function - no actor isolation, safe for concurrent execution
- static func parse(_ file: FileMetadata, deduplication: Deduplication) -> [UsageEntry] {
- guard let fileData = try? Data(contentsOf: URL(fileURLWithPath: file.path)) else {
- return []
- }
-
- let projectPath = PathDecoder.decode(file.projectDir)
- return LineScanner.extractRanges(from: fileData)
- .compactMap { range -> UsageEntry? in
- let lineData = fileData[range]
- guard JSONValidator.isValidObject(lineData),
- let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
- deduplication.shouldInclude(json: json) else {
- return nil
- }
- return EntryParser.parse(json, projectPath: projectPath)
- }
- }
-}
-
-// MARK: - Deduplication
-
-final class Deduplication: @unchecked Sendable {
- private var processedHashes: Set = []
- private let queue = DispatchQueue(label: "com.claudeusage.deduplication", attributes: .concurrent)
-
- func shouldInclude(json: [String: Any]) -> Bool {
- guard let message = json["message"] as? [String: Any],
- let messageId = message["id"] as? String,
- let requestId = json["requestId"] as? String else {
- return true
- }
-
- let uniqueHash = "\(messageId):\(requestId)"
- var shouldInclude = false
- queue.sync(flags: .barrier) {
- if !processedHashes.contains(uniqueHash) {
- processedHashes.insert(uniqueHash)
- shouldInclude = true
- }
- }
- return shouldInclude
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository.swift b/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository.swift
deleted file mode 100644
index 6094744..0000000
--- a/Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository.swift
+++ /dev/null
@@ -1,232 +0,0 @@
-//
-// UsageRepository.swift
-// ClaudeCodeUsage
-//
-// Repository for accessing and processing Claude Code usage data.
-// Architecture: FP + SLAP (Functional Programming + Single Level of Abstraction Principle)
-//
-// Split into extensions for focused responsibilities:
-// - +FileDiscovery: File discovery and metadata
-// - +Parsing: Entry parsing and transformation
-// - +Aggregation: Stats aggregation, filtering, sorting
-//
-
-import Foundation
-import OSLog
-
-private let logger = Logger(subsystem: "com.claudecodeusage", category: "Repository")
-
-// MARK: - Repository
-
-/// Repository for accessing and processing usage data.
-/// Uses actor isolation for thread safety.
-/// Implements file-level caching based on modification time for optimal performance.
-public actor UsageRepository {
- public let basePath: String
-
- /// Cache of parsed entries by file path, keyed on modification date
- var fileCache: [String: CachedFile] = [:]
-
- /// Persistent deduplication state across loads
- var globalDeduplication = Deduplication()
-
- /// Shared instance using default path
- public static let shared = UsageRepository()
-
- /// Initialize with base path (defaults to ~/.claude)
- public init(basePath: String = NSHomeDirectory() + "/.claude") {
- self.basePath = basePath
- }
-
- /// Clear cache (useful for testing or memory pressure)
- public func clearCache() {
- fileCache.removeAll()
- globalDeduplication = Deduplication()
- }
-
- // MARK: - Public API
-
- /// Get overall usage statistics
- public func getUsageStats() async throws -> UsageStats {
- let projectsPath = basePath + "/projects"
- guard FileManager.default.fileExists(atPath: projectsPath) else {
- return UsageStats.empty
- }
-
- let files = try discoverFiles(in: projectsPath)
- let entries = await loadEntries(from: files)
-
- logger.debug("Entries: \(entries.count) from \(files.count) files")
-
- return Aggregator.aggregate(entries, sessionCount: countSessions(in: files))
- }
-
- /// Get detailed usage entries
- public func getUsageEntries(limit: Int? = nil) async throws -> [UsageEntry] {
- let projectsPath = basePath + "/projects"
- guard FileManager.default.fileExists(atPath: projectsPath) else {
- return []
- }
-
- let files = try discoverFiles(in: projectsPath)
- let entries = await loadEntries(from: files)
- .sorted { $0.timestamp > $1.timestamp }
-
- return limit.map { Array(entries.prefix($0)) } ?? entries
- }
-
- /// Get today's usage entries only - optimized for fast initial load
- public func getTodayUsageEntries() async throws -> [UsageEntry] {
- let projectsPath = basePath + "/projects"
- guard FileManager.default.fileExists(atPath: projectsPath) else {
- return []
- }
-
- let allFiles = try discoverFiles(in: projectsPath)
- let todayFiles = filterFilesModifiedToday(allFiles)
-
- logger.debug("Files: \(todayFiles.count) today / \(allFiles.count) total")
-
- let entries = await loadEntriesWithFreshDeduplication(from: todayFiles)
- return filterEntriesToday(entries)
- }
-
- /// Get today's stats - fast path for initial load
- public func getTodayUsageStats() async throws -> UsageStats {
- let entries = try await getTodayUsageEntries()
- let sessionCount = Set(entries.compactMap(\.sessionId)).count
- return Aggregator.aggregate(entries, sessionCount: sessionCount)
- }
-
- /// Get usage statistics filtered by date range
- public func getUsageByDateRange(startDate: Date, endDate: Date) async throws -> UsageStats {
- let allStats = try await getUsageStats()
- return Filter.byDateRange(allStats, start: startDate, end: endDate)
- }
-
- /// Get session-level statistics with optional filtering and sorting
- public func getSessionStats(
- since: Date? = nil,
- until: Date? = nil,
- order: SortOrder? = nil
- ) async throws -> [ProjectUsage] {
- try await getUsageStats().byProject
- |> { Filter.byDateRange($0, since: since, until: until) }
- |> { Sort.byCost($0, order: order) }
- }
-
- /// Load entries for a specific date
- public func loadEntriesForDate(_ date: Date) async throws -> [UsageEntry] {
- let calendar = Calendar.current
- let targetDay = calendar.startOfDay(for: date)
-
- return try await getUsageEntries().filter { entry in
- entry.date.map { calendar.startOfDay(for: $0) == targetDay } ?? false
- }
- }
-
- // MARK: - Internal Helpers
-
- func loadEntriesWithFreshDeduplication(from files: [FileMetadata]) async -> [UsageEntry] {
- await loadEntriesForToday(from: files)
- }
-
- func filterEntriesToday(_ entries: [UsageEntry]) -> [UsageEntry] {
- let calendar = Calendar.current
- let today = calendar.startOfDay(for: Date())
- return entries.filter { entry in
- entry.date.map { calendar.isDate($0, inSameDayAs: today) } ?? false
- }
- }
-
- func loadEntriesForToday(from files: [FileMetadata]) async -> [UsageEntry] {
- let (cachedFiles, dirtyFiles) = partitionByCache(files)
- let cachedEntries = cachedFiles.flatMap { fileCache[$0.path]?.entries ?? [] }
- let freshDeduplication = Deduplication()
- let newEntries = await loadNewEntries(from: dirtyFiles, deduplication: freshDeduplication)
- return cachedEntries + newEntries
- }
-}
-
-// MARK: - Extensions
-
-extension UsageStats {
- static let empty = UsageStats(
- totalCost: 0,
- totalTokens: 0,
- totalInputTokens: 0,
- totalOutputTokens: 0,
- totalCacheCreationTokens: 0,
- totalCacheReadTokens: 0,
- totalSessions: 0,
- byModel: [],
- byDate: [],
- byProject: []
- )
-}
-
-// MARK: - Async Helpers
-
-extension Array where Element: Sendable {
- func asyncFlatMap(_ transform: @escaping @Sendable (Element) async -> [T]) async -> [T] {
- var results: [T] = []
- for element in self {
- results.append(contentsOf: await transform(element))
- }
- return results
- }
-}
-
-// MARK: - Pipe Operator
-
-infix operator |>: AdditionPrecedence
-func |> (value: T, transform: (T) -> U) -> U {
- transform(value)
-}
-
-// MARK: - Constants
-
-enum RepositoryThreshold {
- static let parallelProcessing = 5
- static let batchProcessing = 500
- static let batchSize = 100
-}
-
-enum ByteValue {
- static let openBrace: UInt8 = 0x7B // '{'
- static let closeBrace: UInt8 = 0x7D // '}'
- static let newline: Int32 = 0x0A // '\n'
-}
-
-enum RepositoryDateFormat {
- static let dayString = "yyyy-MM-dd"
-}
-
-// MARK: - Supporting Types
-
-/// Sort order for queries
-public enum SortOrder: String, Sendable {
- case ascending = "asc"
- case descending = "desc"
-}
-
-struct FileMetadata: Sendable {
- let path: String
- let projectDir: String
- let earliestTimestamp: String
- let modificationDate: Date
-}
-
-struct CachedFile {
- let modificationDate: Date
- let entries: [UsageEntry]
- let version: Int
-}
-
-/// Cache version tracking - increment when pricing or parsing logic changes
-enum CacheVersion {
- /// Current cache version - bump this when pricing or cost calculation changes
- /// v2: Fixed Haiku 4.5 pricing ($1/$5 instead of $0.80/$4)
- /// v3: Include -private-var-folders-* directories for complete cost tracking
- static let current = 3
-}
diff --git a/Sources/ClaudeCodeUsageKit/Domain/SendableModels.swift b/Sources/ClaudeCodeUsageKit/Domain/SendableModels.swift
deleted file mode 100644
index 1bf96a5..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/SendableModels.swift
+++ /dev/null
@@ -1,328 +0,0 @@
-//
-// SendableModels.swift
-// Swift 6 Sendable conformance for thread-safe data types
-//
-
-import Foundation
-
-// MARK: - Sendable Data Models
-
-/// Thread-safe usage entry
-public struct SendableUsageEntry: Codable, Sendable, Equatable {
- public let id: String
- public let timestamp: Date
- public let model: String
- public let inputTokens: Int
- public let outputTokens: Int
- public let cacheCreationTokens: Int
- public let cacheReadTokens: Int
- public let cost: Double
- public let projectPath: String?
- public let sessionId: String?
-
- public init(
- id: String = UUID().uuidString,
- timestamp: Date,
- model: String,
- inputTokens: Int,
- outputTokens: Int,
- cacheCreationTokens: Int = 0,
- cacheReadTokens: Int = 0,
- cost: Double,
- projectPath: String? = nil,
- sessionId: String? = nil
- ) {
- self.id = id
- self.timestamp = timestamp
- self.model = model
- self.inputTokens = inputTokens
- self.outputTokens = outputTokens
- self.cacheCreationTokens = cacheCreationTokens
- self.cacheReadTokens = cacheReadTokens
- self.cost = cost
- self.projectPath = projectPath
- self.sessionId = sessionId
- }
-
- public var totalTokens: Int {
- inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens
- }
-}
-
-/// Thread-safe usage statistics
-public struct SendableUsageStats: Sendable, Equatable {
- public let totalCost: Double
- public let totalTokens: Int
- public let totalInputTokens: Int
- public let totalOutputTokens: Int
- public let totalCacheCreationTokens: Int
- public let totalCacheReadTokens: Int
- public let totalSessions: Int
- public let byModel: [ModelUsage]
- public let byDate: [DailyUsage]
- public let byProject: [ProjectUsage]
- public let dateRange: DateRange?
-
- public init(
- totalCost: Double = 0,
- totalTokens: Int = 0,
- totalInputTokens: Int = 0,
- totalOutputTokens: Int = 0,
- totalCacheCreationTokens: Int = 0,
- totalCacheReadTokens: Int = 0,
- totalSessions: Int = 0,
- byModel: [ModelUsage] = [],
- byDate: [DailyUsage] = [],
- byProject: [ProjectUsage] = [],
- dateRange: DateRange? = nil
- ) {
- self.totalCost = totalCost
- self.totalTokens = totalTokens
- self.totalInputTokens = totalInputTokens
- self.totalOutputTokens = totalOutputTokens
- self.totalCacheCreationTokens = totalCacheCreationTokens
- self.totalCacheReadTokens = totalCacheReadTokens
- self.totalSessions = totalSessions
- self.byModel = byModel
- self.byDate = byDate
- self.byProject = byProject
- self.dateRange = dateRange
- }
-
- public struct ModelUsage: Sendable, Equatable {
- public let model: String
- public let totalCost: Double
- public let totalTokens: Int
- public let sessionCount: Int
-
- public init(model: String, totalCost: Double, totalTokens: Int, sessionCount: Int) {
- self.model = model
- self.totalCost = totalCost
- self.totalTokens = totalTokens
- self.sessionCount = sessionCount
- }
- }
-
- public struct DailyUsage: Sendable, Equatable {
- public let date: String
- public let totalCost: Double
- public let totalTokens: Int
- public let modelsUsed: [String]
-
- public init(date: String, totalCost: Double, totalTokens: Int, modelsUsed: [String]) {
- self.date = date
- self.totalCost = totalCost
- self.totalTokens = totalTokens
- self.modelsUsed = modelsUsed
- }
- }
-
- public struct ProjectUsage: Sendable, Equatable {
- public let projectName: String
- public let totalCost: Double
- public let totalTokens: Int
- public let lastUsed: Date
-
- public init(projectName: String, totalCost: Double, totalTokens: Int, lastUsed: Date) {
- self.projectName = projectName
- self.totalCost = totalCost
- self.totalTokens = totalTokens
- self.lastUsed = lastUsed
- }
- }
-
- public struct DateRange: Sendable, Equatable {
- public let start: Date
- public let end: Date
-
- public init(start: Date, end: Date) {
- self.start = start
- self.end = end
- }
- }
-}
-
-// MARK: - Session Models
-
-/// Thread-safe session block
-public struct SendableSessionBlock: Sendable, Equatable {
- public let id: String
- public let startTime: Date
- public let endTime: Date?
- public let model: String
- public let tokenCounts: TokenCounts
- public let costUSD: Double
- public let projectPath: String?
-
- public init(
- id: String = UUID().uuidString,
- startTime: Date,
- endTime: Date? = nil,
- model: String,
- tokenCounts: TokenCounts,
- costUSD: Double,
- projectPath: String? = nil
- ) {
- self.id = id
- self.startTime = startTime
- self.endTime = endTime
- self.model = model
- self.tokenCounts = tokenCounts
- self.costUSD = costUSD
- self.projectPath = projectPath
- }
-
- public struct TokenCounts: Sendable, Equatable {
- public let input: Int
- public let output: Int
- public let cacheCreation: Int
- public let cacheRead: Int
-
- public init(
- input: Int = 0,
- output: Int = 0,
- cacheCreation: Int = 0,
- cacheRead: Int = 0
- ) {
- self.input = input
- self.output = output
- self.cacheCreation = cacheCreation
- self.cacheRead = cacheRead
- }
-
- public var total: Int {
- input + output + cacheCreation + cacheRead
- }
- }
-
- public var duration: TimeInterval {
- guard let endTime = endTime else {
- return Date().timeIntervalSince(startTime)
- }
- return endTime.timeIntervalSince(startTime)
- }
-
- public var isActive: Bool {
- endTime == nil
- }
-}
-
-/// Thread-safe burn rate
-public struct SendableBurnRate: Sendable, Equatable {
- public let tokensPerMinute: Int
- public let costPerHour: Double
- public let projectedDailyCost: Double
-
- public init(
- tokensPerMinute: Int,
- costPerHour: Double,
- projectedDailyCost: Double
- ) {
- self.tokensPerMinute = tokensPerMinute
- self.costPerHour = costPerHour
- self.projectedDailyCost = projectedDailyCost
- }
-}
-
-// MARK: - Chart Data Models
-
-/// Thread-safe chart data point
-public struct SendableChartPoint: Sendable, Equatable, Identifiable {
- public let id = UUID()
- public let date: Date
- public let value: Double
- public let label: String
-
- public init(date: Date, value: Double, label: String = "") {
- self.date = date
- self.value = value
- self.label = label
- }
-}
-
-/// Thread-safe chart dataset
-public struct SendableChartDataset: Sendable, Equatable {
- public let points: [SendableChartPoint]
- public let title: String
- public let color: String
-
- public init(points: [SendableChartPoint], title: String, color: String = "blue") {
- self.points = points
- self.title = title
- self.color = color
- }
-
- public var isEmpty: Bool {
- points.isEmpty
- }
-
- public var totalValue: Double {
- points.reduce(0) { $0 + $1.value }
- }
-}
-
-// MARK: - Configuration Models
-
-/// Thread-safe app configuration
-public struct SendableAppConfiguration: Sendable, Equatable {
- public let basePath: String
- public let refreshInterval: TimeInterval
- public let sessionDurationHours: Double
- public let dailyCostThreshold: Double
- public let minimumRefreshInterval: TimeInterval
- public let enableAutoRefresh: Bool
- public let enableNotifications: Bool
-
- public init(
- basePath: String,
- refreshInterval: TimeInterval = 30.0,
- sessionDurationHours: Double = 5.0,
- dailyCostThreshold: Double = 10.0,
- minimumRefreshInterval: TimeInterval = 5.0,
- enableAutoRefresh: Bool = true,
- enableNotifications: Bool = false
- ) {
- self.basePath = basePath
- self.refreshInterval = refreshInterval
- self.sessionDurationHours = sessionDurationHours
- self.dailyCostThreshold = dailyCostThreshold
- self.minimumRefreshInterval = minimumRefreshInterval
- self.enableAutoRefresh = enableAutoRefresh
- self.enableNotifications = enableNotifications
- }
-}
-
-// MARK: - Request/Response Models
-
-/// Thread-safe data request
-public struct SendableDataRequest: Sendable {
- public let dateRange: SendableUsageStats.DateRange?
- public let projectFilter: String?
- public let modelFilter: String?
- public let limit: Int?
-
- public init(
- dateRange: SendableUsageStats.DateRange? = nil,
- projectFilter: String? = nil,
- modelFilter: String? = nil,
- limit: Int? = nil
- ) {
- self.dateRange = dateRange
- self.projectFilter = projectFilter
- self.modelFilter = modelFilter
- self.limit = limit
- }
-}
-
-/// Thread-safe data response
-public struct SendableDataResponse: Sendable {
- public let data: T
- public let timestamp: Date
- public let cached: Bool
-
- public init(data: T, timestamp: Date = Date(), cached: Bool = false) {
- self.data = data
- self.timestamp = timestamp
- self.cached = cached
- }
-}
\ No newline at end of file
diff --git a/Sources/ClaudeCodeUsageKit/Domain/TypeAliases.swift b/Sources/ClaudeCodeUsageKit/Domain/TypeAliases.swift
deleted file mode 100644
index 5ab02b8..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/TypeAliases.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-//
-// TypeAliases.swift
-// ClaudeCodeUsage
-//
-// Type aliases to avoid naming conflicts between modules
-//
-
-import Foundation
-
-// MARK: - Type Aliases for Disambiguation
-
-/// Main usage entry type from ClaudeCodeUsage module
-/// Used for historical data from Claude's usage files
-public typealias ClaudeUsageEntry = UsageEntry
-
-/// Type alias for the main module's usage stats
-public typealias ClaudeUsageStats = UsageStats
-
-/// Type alias for the main module's model usage
-public typealias ClaudeModelUsage = ModelUsage
-
-/// Type alias for the main module's daily usage
-public typealias ClaudeDailyUsage = DailyUsage
-
-/// Type alias for the main module's project usage
-public typealias ClaudeProjectUsage = ProjectUsage
-
-// Note: When importing both ClaudeCodeUsage and ClaudeLiveMonitorLib,
-// use these type aliases to explicitly reference types from this module:
-//
-// Example:
-// import ClaudeCodeUsageKit
-// import ClaudeLiveMonitorLib
-//
-// let historicalEntry: ClaudeCodeUsage.ClaudeUsageEntry = ...
-// let liveEntry: ClaudeLiveMonitorLib.UsageEntry = ...
-//
-// Or use module-qualified names directly:
-// let entry: ClaudeCodeUsage.UsageEntry = ...
\ No newline at end of file
diff --git a/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+Pricing.swift b/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+Pricing.swift
deleted file mode 100644
index 0fcb0c6..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+Pricing.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-//
-// UsageModels+Pricing.swift
-//
-// Model pricing information for cost calculations.
-//
-
-import Foundation
-
-// MARK: - Model Pricing
-
-/// Claude model pricing information
-public struct ModelPricing: Sendable {
- public let model: String
- public let inputPricePerMillion: Double
- public let outputPricePerMillion: Double
- public let cacheWritePricePerMillion: Double
- public let cacheReadPricePerMillion: Double
-
- /// Predefined pricing for Claude Opus 4/4.5
- public static let opus4 = ModelPricing(
- model: "claude-opus-4-5-20251101",
- inputPricePerMillion: 5.0,
- outputPricePerMillion: 25.0,
- cacheWritePricePerMillion: 6.25,
- cacheReadPricePerMillion: 0.50
- )
-
- /// Predefined pricing for Claude Sonnet 4/4.5
- public static let sonnet4 = ModelPricing(
- model: "claude-sonnet-4-5-20250929",
- inputPricePerMillion: 3.0,
- outputPricePerMillion: 15.0,
- cacheWritePricePerMillion: 3.75,
- cacheReadPricePerMillion: 0.30
- )
-
- /// Predefined pricing for Claude Haiku 4.5
- public static let haiku4 = ModelPricing(
- model: "claude-haiku-4-5-20251001",
- inputPricePerMillion: 1.0,
- outputPricePerMillion: 5.0,
- cacheWritePricePerMillion: 1.25,
- cacheReadPricePerMillion: 0.10
- )
-
- /// All available model pricing
- public static let all = [opus4, sonnet4, haiku4]
-
- /// Find pricing for a model name
- public static func pricing(for model: String) -> ModelPricing? {
- let modelLower = model.lowercased()
-
- if modelLower.contains("opus") {
- return opus4
- }
-
- if modelLower.contains("sonnet") {
- return sonnet4
- }
-
- if modelLower.contains("haiku") {
- return haiku4
- }
-
- return sonnet4
- }
-
- /// Calculate cost for given token counts
- /// Note: Cache read tokens are included to match Claude's Rust backend calculation
- public func calculateCost(inputTokens: Int, outputTokens: Int, cacheWriteTokens: Int = 0, cacheReadTokens: Int = 0) -> Double {
- let inputCost = (Double(inputTokens) / 1_000_000) * inputPricePerMillion
- let outputCost = (Double(outputTokens) / 1_000_000) * outputPricePerMillion
- let cacheWriteCost = (Double(cacheWriteTokens) / 1_000_000) * cacheWritePricePerMillion
- let cacheReadCost = (Double(cacheReadTokens) / 1_000_000) * cacheReadPricePerMillion
-
- return inputCost + outputCost + cacheWriteCost + cacheReadCost // Including all costs like Rust backend
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+TimeRange.swift b/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+TimeRange.swift
deleted file mode 100644
index 8934b03..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+TimeRange.swift
+++ /dev/null
@@ -1,87 +0,0 @@
-//
-// UsageModels+TimeRange.swift
-//
-// Time range filtering enum for usage queries.
-//
-
-import Foundation
-
-// MARK: - Time Range Filters
-
-/// Predefined time ranges for filtering
-public enum TimeRange: Hashable, Identifiable {
- case allTime
- case last7Days
- case last30Days
- case lastMonth
- case last90Days
- case lastYear
- case custom(start: Date, end: Date)
-
- public var id: String {
- switch self {
- case .allTime: return "allTime"
- case .last7Days: return "last7Days"
- case .last30Days: return "last30Days"
- case .lastMonth: return "lastMonth"
- case .last90Days: return "last90Days"
- case .lastYear: return "lastYear"
- case .custom(let start, let end): return "custom_\(start.timeIntervalSince1970)_\(end.timeIntervalSince1970)"
- }
- }
-
- public var displayName: String {
- switch self {
- case .allTime: return "All Time"
- case .last7Days: return "Last 7 Days"
- case .last30Days: return "Last 30 Days"
- case .lastMonth: return "Last Month"
- case .last90Days: return "Last 90 Days"
- case .lastYear: return "Last Year"
- case .custom(let start, let end):
- let formatter = DateFormatter()
- formatter.dateStyle = .short
- return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
- }
- }
-
- /// Standard time ranges (without custom)
- public static var allCases: [TimeRange] {
- [.allTime, .last7Days, .last30Days, .lastMonth, .last90Days, .lastYear]
- }
-
- /// Get the date range for this time period
- public var dateRange: (start: Date, end: Date) {
- let now = Date()
- let calendar = Calendar.current
-
- switch self {
- case .allTime:
- return (Date.distantPast, now)
- case .last7Days:
- let start = calendar.date(byAdding: .day, value: -7, to: now) ?? now
- return (start, now)
- case .last30Days:
- let start = calendar.date(byAdding: .day, value: -30, to: now) ?? now
- return (start, now)
- case .lastMonth:
- let start = calendar.date(byAdding: .month, value: -1, to: now) ?? now
- return (start, now)
- case .last90Days:
- let start = calendar.date(byAdding: .day, value: -90, to: now) ?? now
- return (start, now)
- case .lastYear:
- let start = calendar.date(byAdding: .year, value: -1, to: now) ?? now
- return (start, now)
- case .custom(let start, let end):
- return (start, end)
- }
- }
-
- /// Format dates for API calls
- public var apiDateStrings: (start: String, end: String) {
- let formatter = ISO8601DateFormatter()
- let range = dateRange
- return (formatter.string(from: range.start), formatter.string(from: range.end))
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels.swift b/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels.swift
deleted file mode 100644
index 1a0a0a5..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels.swift
+++ /dev/null
@@ -1,276 +0,0 @@
-//
-// UsageModels.swift
-// ClaudeCodeUsage
-//
-// Data models for Claude Code usage statistics
-//
-// Split into extensions for focused responsibilities:
-// - +TimeRange: Time range filtering enum
-// - +Pricing: Model pricing information
-//
-
-import Foundation
-
-// MARK: - Usage Entry
-
-/// Represents a single usage entry
-public struct UsageEntry: Codable, Sendable {
- public let project: String
- public let timestamp: String
- public let model: String
- public let inputTokens: Int
- public let outputTokens: Int
- public let cacheWriteTokens: Int
- public let cacheReadTokens: Int
- public let cost: Double
- public let sessionId: String?
-
- public init(project: String, timestamp: String, model: String,
- inputTokens: Int, outputTokens: Int,
- cacheWriteTokens: Int, cacheReadTokens: Int,
- cost: Double, sessionId: String?) {
- self.project = project
- self.timestamp = timestamp
- self.model = model
- self.inputTokens = inputTokens
- self.outputTokens = outputTokens
- self.cacheWriteTokens = cacheWriteTokens
- self.cacheReadTokens = cacheReadTokens
- self.cost = cost
- self.sessionId = sessionId
- }
-
- private enum CodingKeys: String, CodingKey {
- case project
- case timestamp
- case model
- case inputTokens = "input_tokens"
- case outputTokens = "output_tokens"
- case cacheWriteTokens = "cache_write_tokens"
- case cacheReadTokens = "cache_read_tokens"
- case cost
- case sessionId = "session_id"
- }
-
- /// Total tokens used in this entry
- public var totalTokens: Int {
- inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens
- }
-
- /// Parsed timestamp as Date (uses cached formatters for performance)
- public var date: Date? {
- // Try with fractional seconds first (most common format)
- if let date = DateFormatters.withFractionalSeconds.date(from: timestamp) {
- return date
- }
-
- // Fallback to basic ISO8601
- if let date = DateFormatters.basic.date(from: timestamp) {
- return date
- }
-
- // Last resort: strip milliseconds and try again
- let cleanTimestamp = timestamp.replacingOccurrences(of: "\\.\\d{3}", with: "", options: .regularExpression)
- return DateFormatters.basic.date(from: cleanTimestamp)
- }
-}
-
-// MARK: - Model Usage
-
-/// Usage statistics aggregated by model
-public struct ModelUsage: Codable, Sendable {
- public let model: String
- public let totalCost: Double
- public let totalTokens: Int
- public let inputTokens: Int
- public let outputTokens: Int
- public let cacheCreationTokens: Int
- public let cacheReadTokens: Int
- public let sessionCount: Int
-
- private enum CodingKeys: String, CodingKey {
- case model
- case totalCost = "total_cost"
- case totalTokens = "total_tokens"
- case inputTokens = "input_tokens"
- case outputTokens = "output_tokens"
- case cacheCreationTokens = "cache_creation_tokens"
- case cacheReadTokens = "cache_read_tokens"
- case sessionCount = "session_count"
- }
-
- /// Average cost per session
- public var averageCostPerSession: Double {
- sessionCount > 0 ? totalCost / Double(sessionCount) : 0
- }
-
- /// Average tokens per session
- public var averageTokensPerSession: Int {
- sessionCount > 0 ? totalTokens / sessionCount : 0
- }
-}
-
-// MARK: - Daily Usage
-
-/// Daily usage statistics
-public struct DailyUsage: Codable, Sendable {
- public let date: String
- public let totalCost: Double
- public let totalTokens: Int
- public let modelsUsed: [String]
- public let hourlyCosts: [Double]
-
- public init(date: String, totalCost: Double, totalTokens: Int, modelsUsed: [String], hourlyCosts: [Double] = []) {
- self.date = date
- self.totalCost = totalCost
- self.totalTokens = totalTokens
- self.modelsUsed = modelsUsed
- self.hourlyCosts = hourlyCosts.isEmpty ? Array(repeating: 0, count: 24) : hourlyCosts
- }
-
- private enum CodingKeys: String, CodingKey {
- case date
- case totalCost = "total_cost"
- case totalTokens = "total_tokens"
- case modelsUsed = "models_used"
- case hourlyCosts = "hourly_costs"
- }
-
- /// Parsed date
- public var parsedDate: Date? {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd"
- return formatter.date(from: date)
- }
-
- /// Number of different models used
- public var modelCount: Int {
- modelsUsed.count
- }
-}
-
-// MARK: - Project Usage
-
-/// Project usage statistics
-public struct ProjectUsage: Codable, Sendable {
- public let projectPath: String
- public let projectName: String
- public let totalCost: Double
- public let totalTokens: Int
- public let sessionCount: Int
- public let lastUsed: String
-
- private enum CodingKeys: String, CodingKey {
- case projectPath = "project_path"
- case projectName = "project_name"
- case totalCost = "total_cost"
- case totalTokens = "total_tokens"
- case sessionCount = "session_count"
- case lastUsed = "last_used"
- }
-
- /// Average cost per session
- public var averageCostPerSession: Double {
- sessionCount > 0 ? totalCost / Double(sessionCount) : 0
- }
-
- /// Last used date
- public var lastUsedDate: Date? {
- ISO8601DateFormatter().date(from: lastUsed)
- }
-}
-
-// MARK: - Usage Stats
-
-/// Overall usage statistics
-public struct UsageStats: Codable, Sendable {
- public let totalCost: Double
- public let totalTokens: Int
- public let totalInputTokens: Int
- public let totalOutputTokens: Int
- public let totalCacheCreationTokens: Int
- public let totalCacheReadTokens: Int
- public let totalSessions: Int
- public let byModel: [ModelUsage]
- public let byDate: [DailyUsage]
- public let byProject: [ProjectUsage]
-
- public init(totalCost: Double, totalTokens: Int, totalInputTokens: Int,
- totalOutputTokens: Int, totalCacheCreationTokens: Int,
- totalCacheReadTokens: Int, totalSessions: Int,
- byModel: [ModelUsage], byDate: [DailyUsage], byProject: [ProjectUsage]) {
- self.totalCost = totalCost
- self.totalTokens = totalTokens
- self.totalInputTokens = totalInputTokens
- self.totalOutputTokens = totalOutputTokens
- self.totalCacheCreationTokens = totalCacheCreationTokens
- self.totalCacheReadTokens = totalCacheReadTokens
- self.totalSessions = totalSessions
- self.byModel = byModel
- self.byDate = byDate
- self.byProject = byProject
- }
-
- private enum CodingKeys: String, CodingKey {
- case totalCost = "total_cost"
- case totalTokens = "total_tokens"
- case totalInputTokens = "total_input_tokens"
- case totalOutputTokens = "total_output_tokens"
- case totalCacheCreationTokens = "total_cache_creation_tokens"
- case totalCacheReadTokens = "total_cache_read_tokens"
- case totalSessions = "total_sessions"
- case byModel = "by_model"
- case byDate = "by_date"
- case byProject = "by_project"
- }
-
- /// Average cost per session
- public var averageCostPerSession: Double {
- totalSessions > 0 ? totalCost / Double(totalSessions) : 0
- }
-
- /// Average tokens per session
- public var averageTokensPerSession: Int {
- totalSessions > 0 ? totalTokens / totalSessions : 0
- }
-
- /// Cost per million tokens
- public var costPerMillionTokens: Double {
- totalTokens > 0 ? (totalCost / Double(totalTokens)) * 1_000_000 : 0
- }
-}
-
-// MARK: - Identifiable Conformance
-
-extension UsageEntry: Identifiable {
- public var id: String { "\(timestamp)-\(sessionId ?? "")" }
-}
-
-extension ModelUsage: Identifiable {
- public var id: String { model }
-}
-
-extension DailyUsage: Identifiable {
- public var id: String { date }
-}
-
-extension ProjectUsage: Identifiable {
- public var id: String { projectPath }
-}
-
-// MARK: - Cached Date Formatters
-
-/// Static formatters to avoid re-allocation on every date parse (16,600+ calls)
-enum DateFormatters {
- nonisolated(unsafe) static let withFractionalSeconds: ISO8601DateFormatter = {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- return formatter
- }()
-
- nonisolated(unsafe) static let basic: ISO8601DateFormatter = {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime]
- return formatter
- }()
-}
diff --git a/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Aggregator.swift b/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Aggregator.swift
deleted file mode 100644
index 33410e7..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Aggregator.swift
+++ /dev/null
@@ -1,65 +0,0 @@
-//
-// UsageRepositoryError+Aggregator.swift
-//
-// Error aggregation for batch operations.
-//
-
-import Foundation
-
-// MARK: - Error Aggregator
-
-/// Error aggregator for batch operations
-public actor ErrorAggregator {
- private var errors: [Error] = []
- private let maxErrors: Int
-
- public init(maxErrors: Int = 100) {
- self.maxErrors = maxErrors
- }
-
- public func record(_ error: Error) {
- errors.append(error)
- if errors.count > maxErrors {
- errors.removeFirst()
- }
- }
-
- public func getErrors() -> [Error] {
- errors
- }
-
- public func getSummary() -> String {
- guard !errors.isEmpty else {
- return "No errors recorded"
- }
- return buildSummary(from: groupedErrorTypes)
- }
-
- public func clear() {
- errors.removeAll()
- }
-
- public func hasErrors() -> Bool {
- !errors.isEmpty
- }
-
- // MARK: - Summary Building Helpers
-
- private var groupedErrorTypes: [String: [Error]] {
- Dictionary(grouping: errors) { error in
- String(describing: type(of: error))
- }
- }
-
- private func buildSummary(from errorTypes: [String: [Error]]) -> String {
- let header = "Error Summary (\(errors.count) total):\n"
- let details = errorTypes
- .map { formatErrorType(name: $0.key, count: $0.value.count) }
- .joined()
- return header + details
- }
-
- private func formatErrorType(name: String, count: Int) -> String {
- " \(name): \(count) occurrences\n"
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Recovery.swift b/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Recovery.swift
deleted file mode 100644
index f405e60..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Recovery.swift
+++ /dev/null
@@ -1,185 +0,0 @@
-//
-// UsageRepositoryError+Recovery.swift
-//
-// Error recovery strategies and execution.
-//
-
-import Foundation
-
-// MARK: - Error Recovery Strategy
-
-/// Error recovery strategies
-public enum ErrorRecoveryStrategy: Sendable {
- case retry(maxAttempts: Int, delay: TimeInterval)
- case skip
- case fallback(handler: @Sendable () async throws -> Void)
- case abort
-
- /// Execute recovery strategy
- public func execute(
- operation: @Sendable () async throws -> T,
- onError: @Sendable (Error) -> Void = { _ in }
- ) async throws -> T? {
- switch self {
- case .retry(let maxAttempts, let delay):
- return try await executeWithRetry(
- maxAttempts: maxAttempts,
- delay: delay,
- operation: operation,
- onError: onError
- )
-
- case .skip:
- return try await executeWithSkip(operation: operation, onError: onError)
-
- case .fallback(let handler):
- return try await executeWithFallback(
- handler: handler,
- operation: operation,
- onError: onError
- )
-
- case .abort:
- return try await operation()
- }
- }
-
- // MARK: - Strategy Execution Helpers
-
- private func executeWithRetry(
- maxAttempts: Int,
- delay: TimeInterval,
- operation: @Sendable () async throws -> T,
- onError: @Sendable (Error) -> Void
- ) async throws -> T {
- let attempts = (1...maxAttempts).map { $0 }
- var lastError: Error?
-
- for attempt in attempts {
- let result = await captureResult(operation)
- switch result {
- case .success(let value):
- return value
- case .failure(let error):
- lastError = error
- onError(error)
- await sleepIfNotLastAttempt(attempt: attempt, maxAttempts: maxAttempts, delay: delay)
- }
- }
-
- throw lastError ?? timeoutError(maxAttempts: maxAttempts, delay: delay)
- }
-
- private func captureResult(
- _ operation: @Sendable () async throws -> T
- ) async -> Result {
- do {
- return .success(try await operation())
- } catch {
- return .failure(error)
- }
- }
-
- private func sleepIfNotLastAttempt(attempt: Int, maxAttempts: Int, delay: TimeInterval) async {
- guard attempt < maxAttempts else { return }
- try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
- }
-
- private func timeoutError(maxAttempts: Int, delay: TimeInterval) -> UsageRepositoryError {
- .timeout(operation: "retry", duration: Double(maxAttempts) * delay)
- }
-
- private func executeWithSkip(
- operation: @Sendable () async throws -> T,
- onError: @Sendable (Error) -> Void
- ) async throws -> T? {
- let result = await captureResult(operation)
- switch result {
- case .success(let value):
- return value
- case .failure(let error):
- onError(error)
- return nil
- }
- }
-
- private func executeWithFallback(
- handler: @Sendable () async throws -> Void,
- operation: @Sendable () async throws -> T,
- onError: @Sendable (Error) -> Void
- ) async throws -> T? {
- let result = await captureResult(operation)
- switch result {
- case .success(let value):
- return value
- case .failure(let error):
- onError(error)
- try await handler()
- return nil
- }
- }
-}
-
-// MARK: - Retry Executor
-
-enum RetryExecutor {
- static func execute(
- operation: @escaping @Sendable () async throws -> T,
- maxRetryCount: Int,
- initialDelay: TimeInterval
- ) async throws -> T {
- let delays = exponentialDelays(initialDelay: initialDelay, count: maxRetryCount)
- return try await executeWithDelays(operation: operation, delays: delays)
- }
-
- private static func exponentialDelays(initialDelay: TimeInterval, count: Int) -> [TimeInterval] {
- (0..(
- operation: @escaping @Sendable () async throws -> T,
- delays: [TimeInterval]
- ) async throws -> T {
- var lastError: Error?
-
- for (index, delay) in delays.enumerated() {
- let result = await attemptExecution(operation: operation)
-
- switch result {
- case .success(let value):
- return value
- case .failure(let error):
- lastError = error
- let isLastAttempt = index == delays.count - 1
- if !isLastAttempt {
- try await sleep(for: delay)
- }
- }
- }
-
- throw lastError ?? timeoutError(delays: delays)
- }
-
- private static func attemptExecution(
- operation: @escaping @Sendable () async throws -> T
- ) async -> Result {
- do {
- return .success(try await operation())
- } catch {
- return .failure(error)
- }
- }
-
- private static func sleep(for delay: TimeInterval) async throws {
- try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
- }
-
- private static func timeoutError(delays: [TimeInterval]) -> UsageRepositoryError {
- .timeout(
- operation: "retry",
- duration: delays.reduce(0, +)
- )
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError.swift b/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError.swift
deleted file mode 100644
index fc19e65..0000000
--- a/Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError.swift
+++ /dev/null
@@ -1,209 +0,0 @@
-//
-// UsageRepositoryError.swift
-// ClaudeCodeUsage
-//
-// Comprehensive error types for better error handling
-//
-// Split into extensions for focused responsibilities:
-// - +Recovery: Recovery strategies and execution
-// - +Aggregator: Error aggregation for batch operations
-//
-
-import Foundation
-
-// MARK: - Usage Repository Error
-
-/// Comprehensive error types for UsageRepository operations
-public enum UsageRepositoryError: LocalizedError {
- case invalidPath(String)
- case directoryNotFound(path: String)
- case fileReadFailed(path: String, underlyingError: Error)
- case parsingFailed(file: String, line: Int?, reason: String)
- case batchProcessingFailed(batch: Int, filesProcessed: Int, error: Error)
- case decodingFailed(path: String, error: Error)
- case permissionDenied(path: String)
- case quotaExceeded(limit: Int, attempted: Int)
- case corruptedData(file: String, details: String)
- case networkError(Error)
- case timeout(operation: String, duration: TimeInterval)
-
- public var errorDescription: String? {
- switch self {
- case .invalidPath(let path):
- return "Invalid path: '\(path)'. Please ensure the path exists and is accessible."
-
- case .directoryNotFound(let path):
- return "Directory not found: '\(path)'. Check if Claude Code data exists at this location."
-
- case .fileReadFailed(let path, let error):
- return "Failed to read file at '\(path)': \(error.localizedDescription)"
-
- case .parsingFailed(let file, let line, let reason):
- if let line = line {
- return "Failed to parse '\(file)' at line \(line): \(reason)"
- } else {
- return "Failed to parse '\(file)': \(reason)"
- }
-
- case .batchProcessingFailed(let batch, let filesProcessed, let error):
- return "Batch \(batch) failed after processing \(filesProcessed) files: \(error.localizedDescription)"
-
- case .decodingFailed(let path, let error):
- return "Failed to decode data from '\(path)': \(error.localizedDescription)"
-
- case .permissionDenied(let path):
- return "Permission denied accessing '\(path)'. Please check file permissions."
-
- case .quotaExceeded(let limit, let attempted):
- return "Quota exceeded: attempted to process \(attempted) items (limit: \(limit))"
-
- case .corruptedData(let file, let details):
- return "Corrupted data in '\(file)': \(details)"
-
- case .networkError(let error):
- return "Network error: \(error.localizedDescription)"
-
- case .timeout(let operation, let duration):
- return "Operation '\(operation)' timed out after \(String(format: "%.2f", duration)) seconds"
- }
- }
-
- public var recoverySuggestion: String? {
- switch self {
- case .invalidPath, .directoryNotFound:
- return "Ensure Claude Code is installed and has been used at least once."
-
- case .fileReadFailed, .permissionDenied:
- return "Check file permissions and ensure the application has read access to the Claude Code data directory."
-
- case .parsingFailed, .decodingFailed, .corruptedData:
- return "The data file may be corrupted. Try removing the affected file and letting Claude Code regenerate it."
-
- case .batchProcessingFailed:
- return "Some files could not be processed. Try reducing the batch size or processing fewer files at once."
-
- case .quotaExceeded:
- return "Too many items to process. Try filtering the data or processing in smaller chunks."
-
- case .networkError:
- return "Check your internet connection and try again."
-
- case .timeout:
- return "The operation took too long. Try processing fewer items or check system resources."
- }
- }
-
- /// Whether this error is recoverable
- public var isRecoverable: Bool {
- switch self {
- case .networkError, .timeout, .batchProcessingFailed:
- return true
- case .invalidPath, .directoryNotFound, .permissionDenied:
- return false
- case .fileReadFailed, .parsingFailed, .decodingFailed, .corruptedData:
- return true // Can skip bad files and continue
- case .quotaExceeded:
- return true // Can process fewer items
- }
- }
-
- /// Suggested retry delay if recoverable
- public var suggestedRetryDelay: TimeInterval? {
- switch self {
- case .networkError:
- return 2.0
- case .timeout:
- return 5.0
- case .batchProcessingFailed:
- return 1.0
- default:
- return nil
- }
- }
-}
-
-// MARK: - Error Context
-
-/// Error context for detailed debugging
-public struct ErrorContext: Sendable {
- public let file: String
- public let function: String
- public let line: Int
- public let additionalInfo: [String: String]
-
- public init(
- file: String = #file,
- function: String = #function,
- line: Int = #line,
- additionalInfo: [String: String] = [:]
- ) {
- self.file = URL(fileURLWithPath: file).lastPathComponent
- self.function = function
- self.line = line
- self.additionalInfo = additionalInfo
- }
-
- public var description: String {
- var desc = "[\(file):\(line)] in \(function)"
- if !additionalInfo.isEmpty {
- let info = additionalInfo.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
- desc += " | \(info)"
- }
- return desc
- }
-}
-
-// MARK: - Enhanced Error
-
-/// Enhanced error with context
-public struct EnhancedError: LocalizedError, @unchecked Sendable {
- public let baseError: Error
- public let context: ErrorContext
-
- public init(_ error: Error, context: ErrorContext) {
- self.baseError = error
- self.context = context
- }
-
- public var errorDescription: String? {
- if let localizedError = baseError as? LocalizedError {
- return localizedError.errorDescription
- }
- return baseError.localizedDescription
- }
-
- public var failureReason: String? {
- context.description
- }
-}
-
-// MARK: - Result Extension
-
-public extension Result {
- /// Convert Result to async throwing
- func asyncGet() async throws -> Success {
- switch self {
- case .success(let value):
- return value
- case .failure(let error):
- throw error
- }
- }
-}
-
-// MARK: - Task Extension
-
-public extension Task where Failure == Error {
- /// Retry a task with exponential backoff
- static func retrying(
- maxRetryCount: Int = 3,
- initialDelay: TimeInterval = 1.0,
- operation: @escaping @Sendable () async throws -> Success
- ) async throws -> Success {
- try await RetryExecutor.execute(
- operation: operation,
- maxRetryCount: maxRetryCount,
- initialDelay: initialDelay
- )
- }
-}
diff --git a/Sources/ClaudeCodeUsageKit/Testing/TestUtilities.swift b/Sources/ClaudeCodeUsageKit/Testing/TestUtilities.swift
deleted file mode 100644
index d15ed1d..0000000
--- a/Sources/ClaudeCodeUsageKit/Testing/TestUtilities.swift
+++ /dev/null
@@ -1,116 +0,0 @@
-//
-// TestUtilities.swift
-// Testing utilities and supporting types for TDD tests
-//
-
-import Foundation
-
-// MARK: - Retry Infrastructure
-
-/// Exponential backoff retry policy
-public struct ExponentialBackoff {
- public let maxRetries: Int
- public let baseDelay: TimeInterval
-
- public init(maxRetries: Int = 3, baseDelay: TimeInterval = 0.1) {
- self.maxRetries = maxRetries
- self.baseDelay = baseDelay
- }
-
- public func delay(for attempt: Int) -> TimeInterval {
- return baseDelay * pow(2.0, Double(attempt))
- }
-}
-
-// MARK: - Test Data Structures
-
-/// Extended UsageEntry initializer for testing
-extension UsageEntry {
- public init(
- id: String = UUID().uuidString,
- timestamp: Date,
- cost: Double,
- model: String = "test-model",
- inputTokens: Int = 100,
- outputTokens: Int = 50,
- cacheWriteTokens: Int = 0,
- cacheReadTokens: Int = 0,
- sessionId: String? = nil
- ) {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
- self.init(
- project: "test-project",
- timestamp: formatter.string(from: timestamp),
- model: model,
- inputTokens: inputTokens,
- outputTokens: outputTokens,
- cacheWriteTokens: cacheWriteTokens,
- cacheReadTokens: cacheReadTokens,
- cost: cost,
- sessionId: sessionId
- )
- }
-}
-
-// MARK: - UsageEntry Extensions
-
-extension UsageEntry {
- /// Safe method to sanitize extreme values for testing
- public func sanitized() -> UsageEntry {
- let maxCost = 999_999.99
- let maxTokens = 1_000_000_000
-
- let sanitizedCost = cost.isInfinite || cost.isNaN ? maxCost : min(cost, maxCost)
- let sanitizedInputTokens = min(inputTokens, maxTokens)
- let sanitizedOutputTokens = min(outputTokens, maxTokens)
- let sanitizedCacheWrite = min(cacheWriteTokens, maxTokens)
- let sanitizedCacheRead = min(cacheReadTokens, maxTokens)
-
- return UsageEntry(
- project: project,
- timestamp: timestamp,
- model: model,
- inputTokens: sanitizedInputTokens,
- outputTokens: sanitizedOutputTokens,
- cacheWriteTokens: sanitizedCacheWrite,
- cacheReadTokens: sanitizedCacheRead,
- cost: sanitizedCost,
- sessionId: sessionId
- )
- }
-}
-
-// MARK: - Supporting Types for Error Tests
-
-/// Mock usage data parser for testing data corruption scenarios
-public final class MockUsageDataParser {
- public var corruptFiles: [String] = []
- public var validFiles: [String: String] = [:]
- public var skippedFiles: [String] = []
-
- public init() {}
-
- public func parse(_ jsonString: String) -> UsageEntry? {
- guard let data = jsonString.data(using: .utf8) else { return nil }
- return try? JSONDecoder().decode(UsageEntry.self, from: data)
- }
-}
-
-/// Valid usage data helper for tests
-public func validUsageData() -> String {
- return """
- {
- "project": "test-project",
- "timestamp": "2025-01-15T14:30:00Z",
- "model": "claude-3",
- "input_tokens": 100,
- "output_tokens": 50,
- "cache_write_tokens": 0,
- "cache_read_tokens": 0,
- "cost": 10.0,
- "session_id": "test-session"
- }
- """
-}
\ No newline at end of file
diff --git a/Sources/ClaudeMonitorCLI/main.swift b/Sources/ClaudeMonitorCLI/main.swift
new file mode 100644
index 0000000..7a89296
--- /dev/null
+++ b/Sources/ClaudeMonitorCLI/main.swift
@@ -0,0 +1,45 @@
+//
+// main.swift
+// ClaudeMonitorCLI
+//
+// Command-line interface for Claude usage monitoring
+//
+
+import Foundation
+import ClaudeUsageData
+
+@main
+struct ClaudeMonitorCLI {
+ static func main() async {
+ print("Claude Usage Monitor")
+ print("====================")
+
+ let repository = UsageRepository()
+ let sessionMonitor = SessionMonitor()
+
+ do {
+ // Get today's stats
+ let entries = try await repository.getTodayEntries()
+ let stats = UsageAggregator.aggregate(entries)
+
+ print("\nToday's Usage:")
+ print(" Cost: $\(String(format: "%.2f", stats.totalCost))")
+ print(" Tokens: \(stats.totalTokens)")
+ print(" Sessions: \(stats.sessionCount)")
+
+ // Check for active session
+ if let session = await sessionMonitor.getActiveSession() {
+ print("\nActive Session:")
+ print(" Duration: \(Int(session.durationMinutes)) min")
+ print(" Cost: $\(String(format: "%.2f", session.costUSD))")
+ print(" Tokens: \(session.tokens.total)")
+ print(" Burn Rate: \(session.burnRate.tokensPerMinute) tok/min")
+ } else {
+ print("\nNo active session")
+ }
+
+ } catch {
+ print("Error: \(error)")
+ }
+ }
+}
diff --git a/Sources/ClaudeCodeUsage/AppLifecycleManager.swift b/Sources/ClaudeUsage/AppLifecycleManager.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/AppLifecycleManager.swift
rename to Sources/ClaudeUsage/AppLifecycleManager.swift
diff --git a/Sources/ClaudeCodeUsage/ClaudeCodeUsageApp.swift b/Sources/ClaudeUsage/ClaudeCodeUsageApp.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/ClaudeCodeUsageApp.swift
rename to Sources/ClaudeUsage/ClaudeCodeUsageApp.swift
index 214fdb0..a9a6d72 100644
--- a/Sources/ClaudeCodeUsage/ClaudeCodeUsageApp.swift
+++ b/Sources/ClaudeUsage/ClaudeCodeUsageApp.swift
@@ -5,7 +5,7 @@
import SwiftUI
import Observation
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - App Entry Point
@main
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsCard.swift b/Sources/ClaudeUsage/Main/Analytics/AnalyticsCard.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsCard.swift
rename to Sources/ClaudeUsage/Main/Analytics/AnalyticsCard.swift
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsRows.swift b/Sources/ClaudeUsage/Main/Analytics/AnalyticsRows.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsRows.swift
rename to Sources/ClaudeUsage/Main/Analytics/AnalyticsRows.swift
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsView.swift b/Sources/ClaudeUsage/Main/Analytics/AnalyticsView.swift
similarity index 98%
rename from Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsView.swift
rename to Sources/ClaudeUsage/Main/Analytics/AnalyticsView.swift
index bc10269..2493c07 100644
--- a/Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsView.swift
+++ b/Sources/ClaudeUsage/Main/Analytics/AnalyticsView.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct AnalyticsView: View {
@Environment(UsageStore.self) private var store
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/PredictionsCard.swift b/Sources/ClaudeUsage/Main/Analytics/PredictionsCard.swift
similarity index 98%
rename from Sources/ClaudeCodeUsage/Main/Analytics/PredictionsCard.swift
rename to Sources/ClaudeUsage/Main/Analytics/PredictionsCard.swift
index 0625dfd..b936e4f 100644
--- a/Sources/ClaudeCodeUsage/Main/Analytics/PredictionsCard.swift
+++ b/Sources/ClaudeUsage/Main/Analytics/PredictionsCard.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct PredictionsCard: View {
let stats: UsageStats
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/TokenDistributionCard.swift b/Sources/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift
similarity index 98%
rename from Sources/ClaudeCodeUsage/Main/Analytics/TokenDistributionCard.swift
rename to Sources/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift
index a54c0c9..ad965e5 100644
--- a/Sources/ClaudeCodeUsage/Main/Analytics/TokenDistributionCard.swift
+++ b/Sources/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct TokenDistributionCard: View {
let stats: UsageStats
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/UsageTrendsCard.swift b/Sources/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/Main/Analytics/UsageTrendsCard.swift
rename to Sources/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift
index 02f90ba..0765f36 100644
--- a/Sources/ClaudeCodeUsage/Main/Analytics/UsageTrendsCard.swift
+++ b/Sources/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Trends Card
diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/YearlyCostHeatmapCard.swift b/Sources/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift
similarity index 98%
rename from Sources/ClaudeCodeUsage/Main/Analytics/YearlyCostHeatmapCard.swift
rename to Sources/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift
index 3e7a1d5..727c172 100644
--- a/Sources/ClaudeCodeUsage/Main/Analytics/YearlyCostHeatmapCard.swift
+++ b/Sources/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct YearlyCostHeatmapCard: View {
let stats: UsageStats
diff --git a/Sources/ClaudeCodeUsage/Main/Components/EmptyStateView.swift b/Sources/ClaudeUsage/Main/Components/EmptyStateView.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Main/Components/EmptyStateView.swift
rename to Sources/ClaudeUsage/Main/Components/EmptyStateView.swift
diff --git a/Sources/ClaudeCodeUsage/Main/Components/MetricCard.swift b/Sources/ClaudeUsage/Main/Components/MetricCard.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Main/Components/MetricCard.swift
rename to Sources/ClaudeUsage/Main/Components/MetricCard.swift
diff --git a/Sources/ClaudeCodeUsage/Main/DailyUsageView.swift b/Sources/ClaudeUsage/Main/DailyUsageView.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/Main/DailyUsageView.swift
rename to Sources/ClaudeUsage/Main/DailyUsageView.swift
index 7007a18..597ee95 100644
--- a/Sources/ClaudeCodeUsage/Main/DailyUsageView.swift
+++ b/Sources/ClaudeUsage/Main/DailyUsageView.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct DailyUsageView: View {
@Environment(UsageStore.self) private var store
diff --git a/Sources/ClaudeCodeUsage/Main/MainView.swift b/Sources/ClaudeUsage/Main/MainView.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/Main/MainView.swift
rename to Sources/ClaudeUsage/Main/MainView.swift
index 90b35c8..0ed902c 100644
--- a/Sources/ClaudeCodeUsage/Main/MainView.swift
+++ b/Sources/ClaudeUsage/Main/MainView.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Main View
struct MainView: View {
diff --git a/Sources/ClaudeCodeUsage/Main/ModelsView.swift b/Sources/ClaudeUsage/Main/ModelsView.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/Main/ModelsView.swift
rename to Sources/ClaudeUsage/Main/ModelsView.swift
index 21e38c9..27f832c 100644
--- a/Sources/ClaudeCodeUsage/Main/ModelsView.swift
+++ b/Sources/ClaudeUsage/Main/ModelsView.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct ModelsView: View {
@Environment(UsageStore.self) private var store
diff --git a/Sources/ClaudeCodeUsage/Main/OverviewView.swift b/Sources/ClaudeUsage/Main/OverviewView.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/Main/OverviewView.swift
rename to Sources/ClaudeUsage/Main/OverviewView.swift
index efba276..203e5b7 100644
--- a/Sources/ClaudeCodeUsage/Main/OverviewView.swift
+++ b/Sources/ClaudeUsage/Main/OverviewView.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct OverviewView: View {
@Environment(UsageStore.self) private var store
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+Accessibility.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+Accessibility.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+Accessibility.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+Accessibility.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+ColorThemes.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+ColorThemes.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+ColorThemes.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+ColorThemes.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapData.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapData.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Models/HeatmapData.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Models/HeatmapData.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/ColorScheme.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/ColorScheme.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/ColorScheme.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/ColorScheme.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/DateConstants.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/DateConstants.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/DateConstants.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/DateConstants.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/DateRangeValidation.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/DateRangeValidation.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/DateRangeValidation.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/DateRangeValidation.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/HeatmapDateCalculator.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/HeatmapDateCalculator.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/HeatmapDateCalculator.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/HeatmapDateCalculator.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/MonthOperations.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/MonthOperations.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/MonthOperations.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/MonthOperations.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/WeekOperations.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/WeekOperations.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Utilities/WeekOperations.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Utilities/WeekOperations.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift
index 1fe2362..9b1332b 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift
@@ -5,7 +5,7 @@
//
import Foundation
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Data Generation
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+SupportingTypes.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+SupportingTypes.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+SupportingTypes.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+SupportingTypes.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift
index 7bdf33b..e850cbd 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift
@@ -13,7 +13,7 @@
import SwiftUI
import Foundation
import Observation
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Heatmap View Model
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/BorderStyle.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/BorderStyle.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/BorderStyle.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/BorderStyle.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/DaySquare.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/DaySquare.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/DaySquare.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/DaySquare.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGrid.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGrid.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGrid.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGrid.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridLayout.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridLayout.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridLayout.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridLayout.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridPerformance.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridPerformance.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridPerformance.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridPerformance.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/WeekColumn.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/WeekColumn.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/WeekColumn.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapGrid/WeekColumn.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/ActivityLevelLabels.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/ActivityLevelLabels.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/ActivityLevelLabels.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/ActivityLevelLabels.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend+Factory.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend+Factory.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend+Factory.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend+Factory.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegendBuilder.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegendBuilder.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegendBuilder.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegendBuilder.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/LegendSquare.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/LegendSquare.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/LegendSquare.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapLegend/LegendSquare.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/ActivityLevel.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/ActivityLevel.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/ActivityLevel.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/ActivityLevel.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip+Factory.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip+Factory.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip+Factory.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip+Factory.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltipBuilder.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltipBuilder.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltipBuilder.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltipBuilder.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipConfiguration.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipConfiguration.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipConfiguration.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipConfiguration.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipPositioning.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipPositioning.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipPositioning.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipPositioning.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift
index dccbbb5..b474f51 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift
@@ -5,7 +5,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Legacy Compatibility
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift
index 4291289..774ff8c 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift
@@ -5,7 +5,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Preview
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift
index 055eaa8..dca0c01 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift
@@ -11,7 +11,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
import Foundation
// MARK: - Yearly Cost Heatmap
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift
index 3c87505..70c5510 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift
@@ -4,7 +4,7 @@
//
import Foundation
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - TDD Hourly Chart Data Models
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift
index a8e88db..af98ea8 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift
+++ b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift
@@ -5,7 +5,7 @@
import SwiftUI
import Charts
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Simple Hourly Cost Chart
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyTooltipViews.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyTooltipViews.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyTooltipViews.swift
rename to Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyTooltipViews.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift b/Sources/ClaudeUsage/MenuBar/Components/ActionButtons.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift
rename to Sources/ClaudeUsage/MenuBar/Components/ActionButtons.swift
index a51a018..fc8f0e2 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift
+++ b/Sources/ClaudeUsage/MenuBar/Components/ActionButtons.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - ActionButtons
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Components/GraphView.swift b/Sources/ClaudeUsage/MenuBar/Components/GraphView.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Components/GraphView.swift
rename to Sources/ClaudeUsage/MenuBar/Components/GraphView.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Components/MetricRow.swift b/Sources/ClaudeUsage/MenuBar/Components/MetricRow.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Components/MetricRow.swift
rename to Sources/ClaudeUsage/MenuBar/Components/MetricRow.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift b/Sources/ClaudeUsage/MenuBar/Components/ProgressBar.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift
rename to Sources/ClaudeUsage/MenuBar/Components/ProgressBar.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift b/Sources/ClaudeUsage/MenuBar/Components/SectionHeader.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift
rename to Sources/ClaudeUsage/MenuBar/Components/SectionHeader.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift b/Sources/ClaudeUsage/MenuBar/Components/SettingsMenu.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift
rename to Sources/ClaudeUsage/MenuBar/Components/SettingsMenu.swift
diff --git a/Sources/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift b/Sources/ClaudeUsage/MenuBar/MenuBarContentView.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift
rename to Sources/ClaudeUsage/MenuBar/MenuBarContentView.swift
index 7dfddce..403c97a 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift
+++ b/Sources/ClaudeUsage/MenuBar/MenuBarContentView.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Main Menu Bar Content View
struct MenuBarContentView: View {
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift
rename to Sources/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift
index f39837e..c5f1e9e 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift
+++ b/Sources/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift
@@ -1,6 +1,6 @@
import SwiftUI
import Charts
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
struct CostMetricsSection: View {
@Environment(UsageStore.self) private var store
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift
similarity index 94%
rename from Sources/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift
rename to Sources/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift
index 6f8ada2..8b29f6d 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift
+++ b/Sources/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift
@@ -4,8 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
-import ClaudeLiveMonitorLib
+import ClaudeUsageCore
struct SessionMetricsSection: View {
@Environment(UsageStore.self) private var store
diff --git a/Sources/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift
similarity index 94%
rename from Sources/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift
rename to Sources/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift
index 2ef4f96..8a0a5da 100644
--- a/Sources/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift
+++ b/Sources/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift
@@ -4,8 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
-import ClaudeLiveMonitorLib
+import ClaudeUsageCore
struct UsageMetricsSection: View {
@Environment(UsageStore.self) private var store
@@ -16,7 +15,7 @@ struct UsageMetricsSection: View {
// Token usage - show raw count only (no fake percentage)
// Claude's actual rate limit is not exposed in usage data
if let session = store.activeSession {
- TokenDisplay(tokens: session.tokenCounts.total)
+ TokenDisplay(tokens: session.tokens.total)
}
// Burn rate
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView+BarView.swift b/Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView+BarView.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView+BarView.swift
rename to Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView+BarView.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView+Grid.swift b/Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView+Grid.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView+Grid.swift
rename to Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView+Grid.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView+Tooltip.swift b/Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView+Tooltip.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView+Tooltip.swift
rename to Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView+Tooltip.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView.swift b/Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Components/BarChartView/BarChartView.swift
rename to Sources/ClaudeUsage/Shared/Components/BarChartView/BarChartView.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/EmptyStateViews.swift b/Sources/ClaudeUsage/Shared/Components/EmptyStateViews.swift
similarity index 99%
rename from Sources/ClaudeCodeUsage/Shared/Components/EmptyStateViews.swift
rename to Sources/ClaudeUsage/Shared/Components/EmptyStateViews.swift
index 3ed1bbc..431056b 100644
--- a/Sources/ClaudeCodeUsage/Shared/Components/EmptyStateViews.swift
+++ b/Sources/ClaudeUsage/Shared/Components/EmptyStateViews.swift
@@ -4,7 +4,7 @@
//
import SwiftUI
-import ClaudeCodeUsageKit
+import ClaudeUsageCore
// MARK: - Shared Components
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/MemoryMonitorView.swift b/Sources/ClaudeUsage/Shared/Components/MemoryMonitorView.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Components/MemoryMonitorView.swift
rename to Sources/ClaudeUsage/Shared/Components/MemoryMonitorView.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Components/OpenAtLoginToggle.swift b/Sources/ClaudeUsage/Shared/Components/OpenAtLoginToggle.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Components/OpenAtLoginToggle.swift
rename to Sources/ClaudeUsage/Shared/Components/OpenAtLoginToggle.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Protocols/ClockProtocol.swift b/Sources/ClaudeUsage/Shared/Protocols/ClockProtocol.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Protocols/ClockProtocol.swift
rename to Sources/ClaudeUsage/Shared/Protocols/ClockProtocol.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/AppConfiguration.swift b/Sources/ClaudeUsage/Shared/Services/AppConfiguration.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/AppConfiguration.swift
rename to Sources/ClaudeUsage/Shared/Services/AppConfiguration.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/AppSettingsService.swift b/Sources/ClaudeUsage/Shared/Services/AppSettingsService.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/AppSettingsService.swift
rename to Sources/ClaudeUsage/Shared/Services/AppSettingsService.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/ColorService.swift b/Sources/ClaudeUsage/Shared/Services/ColorService.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/ColorService.swift
rename to Sources/ClaudeUsage/Shared/Services/ColorService.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/FormatterService.swift b/Sources/ClaudeUsage/Shared/Services/FormatterService.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/FormatterService.swift
rename to Sources/ClaudeUsage/Shared/Services/FormatterService.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/LoadTrace.swift b/Sources/ClaudeUsage/Shared/Services/LoadTrace.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/LoadTrace.swift
rename to Sources/ClaudeUsage/Shared/Services/LoadTrace.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+System.swift b/Sources/ClaudeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+System.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+System.swift
rename to Sources/ClaudeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+System.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+Types.swift b/Sources/ClaudeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+Types.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+Types.swift
rename to Sources/ClaudeUsage/Shared/Services/MemoryMonitor/MemoryMonitor+Types.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/MemoryMonitor/MemoryMonitor.swift b/Sources/ClaudeUsage/Shared/Services/MemoryMonitor/MemoryMonitor.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Services/MemoryMonitor/MemoryMonitor.swift
rename to Sources/ClaudeUsage/Shared/Services/MemoryMonitor/MemoryMonitor.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Services/SessionMonitorService.swift b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift
similarity index 76%
rename from Sources/ClaudeCodeUsage/Shared/Services/SessionMonitorService.swift
rename to Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift
index 140bf89..630b1a5 100644
--- a/Sources/ClaudeCodeUsage/Shared/Services/SessionMonitorService.swift
+++ b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift
@@ -4,10 +4,8 @@
//
import Foundation
-import struct ClaudeLiveMonitorLib.SessionBlock
-import struct ClaudeLiveMonitorLib.BurnRate
-import class ClaudeLiveMonitorLib.LiveMonitor
-import struct ClaudeLiveMonitorLib.LiveMonitorConfig
+import ClaudeUsageCore
+import ClaudeUsageData
// MARK: - Protocol
@@ -20,20 +18,16 @@ protocol SessionMonitorService: Sendable {
// MARK: - Default Implementation
actor DefaultSessionMonitorService: SessionMonitorService {
- private let monitor: LiveMonitor
+ private let monitor: SessionMonitor
private var cachedSession: (session: SessionBlock?, timestamp: Date)?
private var cachedTokenLimit: (limit: Int?, timestamp: Date)?
init(configuration: AppConfiguration) {
- let config = LiveMonitorConfig(
- claudePaths: [configuration.basePath],
- sessionDurationHours: configuration.sessionDurationHours,
- tokenLimit: nil,
- refreshInterval: 2.0,
- order: .descending
+ self.monitor = SessionMonitor(
+ basePath: configuration.basePath,
+ sessionDurationHours: configuration.sessionDurationHours
)
- self.monitor = LiveMonitor(config: config)
}
func getActiveSession() async -> SessionBlock? {
@@ -41,9 +35,9 @@ actor DefaultSessionMonitorService: SessionMonitorService {
return cached.session
}
- let result = await monitor.getActiveBlock()
- cachedSession = (result, Date())
- return result
+ let session = await monitor.getActiveSession()
+ cachedSession = (session, Date())
+ return session
}
func getBurnRate() async -> BurnRate? {
diff --git a/Sources/ClaudeCodeUsage/Shared/Store/DirectoryMonitor.swift b/Sources/ClaudeUsage/Shared/Store/DirectoryMonitor.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Store/DirectoryMonitor.swift
rename to Sources/ClaudeUsage/Shared/Store/DirectoryMonitor.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Store/RefreshCoordinator.swift b/Sources/ClaudeUsage/Shared/Store/RefreshCoordinator.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Store/RefreshCoordinator.swift
rename to Sources/ClaudeUsage/Shared/Store/RefreshCoordinator.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Store/UsageDataLoader.swift b/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift
similarity index 78%
rename from Sources/ClaudeCodeUsage/Shared/Store/UsageDataLoader.swift
rename to Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift
index 9ac45c7..1adeaad 100644
--- a/Sources/ClaudeCodeUsage/Shared/Store/UsageDataLoader.swift
+++ b/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift
@@ -4,17 +4,15 @@
//
import Foundation
-import ClaudeCodeUsageKit
-import struct ClaudeLiveMonitorLib.SessionBlock
-import struct ClaudeLiveMonitorLib.BurnRate
+import ClaudeUsageCore
// MARK: - UsageDataLoader
actor UsageDataLoader {
- private let repository: UsageRepository
+ private let repository: any UsageDataSource
private let sessionMonitorService: SessionMonitorService
- init(repository: UsageRepository, sessionMonitorService: SessionMonitorService) {
+ init(repository: any UsageDataSource, sessionMonitorService: SessionMonitorService) {
self.repository = repository
self.sessionMonitorService = sessionMonitorService
}
@@ -24,7 +22,7 @@ actor UsageDataLoader {
await LoadTrace.shared.phaseStart(.today)
// Load entries once, derive stats from them (avoid duplicate fetch)
- async let todayEntriesTask = repository.getTodayUsageEntries()
+ async let todayEntriesTask = repository.getTodayEntries()
async let sessionTask = fetchSession()
async let tokenLimitTask = fetchTokenLimit()
@@ -100,19 +98,7 @@ actor UsageDataLoader {
// MARK: - Stats Derivation
private func deriveStats(from entries: [UsageEntry]) -> UsageStats {
- let sessionCount = Set(entries.compactMap(\.sessionId)).count
- return UsageStats(
- totalCost: entries.reduce(0) { $0 + $1.cost },
- totalTokens: entries.reduce(0) { $0 + $1.totalTokens },
- totalInputTokens: entries.reduce(0) { $0 + $1.inputTokens },
- totalOutputTokens: entries.reduce(0) { $0 + $1.outputTokens },
- totalCacheCreationTokens: entries.reduce(0) { $0 + $1.cacheWriteTokens },
- totalCacheReadTokens: entries.reduce(0) { $0 + $1.cacheReadTokens },
- totalSessions: sessionCount,
- byModel: [],
- byDate: [],
- byProject: []
- )
+ UsageAggregator.aggregate(entries)
}
}
diff --git a/Sources/ClaudeCodeUsage/Shared/Store/UsageStore.swift b/Sources/ClaudeUsage/Shared/Store/UsageStore.swift
similarity index 96%
rename from Sources/ClaudeCodeUsage/Shared/Store/UsageStore.swift
rename to Sources/ClaudeUsage/Shared/Store/UsageStore.swift
index d402585..2efc099 100644
--- a/Sources/ClaudeCodeUsage/Shared/Store/UsageStore.swift
+++ b/Sources/ClaudeUsage/Shared/Store/UsageStore.swift
@@ -5,9 +5,8 @@
import SwiftUI
import Observation
-import ClaudeCodeUsageKit
-import struct ClaudeLiveMonitorLib.SessionBlock
-import struct ClaudeLiveMonitorLib.BurnRate
+import ClaudeUsageCore
+import ClaudeUsageData
// MARK: - Usage Store
@@ -51,7 +50,7 @@ final class UsageStore {
}
var todayHourlyCosts: [Double] {
- UsageAnalytics.todayHourlyCosts(from: todayEntries, referenceDate: clock.now)
+ UsageAggregator.todayHourlyCosts(from: todayEntries, referenceDate: clock.now)
}
var formattedTodaysCost: String {
@@ -75,7 +74,7 @@ final class UsageStore {
// MARK: - Initialization
init(
- repository: UsageRepository? = nil,
+ repository: (any UsageDataSource)? = nil,
sessionMonitorService: SessionMonitorService? = nil,
configurationService: ConfigurationService? = nil,
clock: any ClockProtocol = SystemClock()
diff --git a/Sources/ClaudeCodeUsage/Shared/Theme/MenuBarStyles.swift b/Sources/ClaudeUsage/Shared/Theme/MenuBarStyles.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Theme/MenuBarStyles.swift
rename to Sources/ClaudeUsage/Shared/Theme/MenuBarStyles.swift
diff --git a/Sources/ClaudeCodeUsage/Shared/Theme/MenuBarTheme.swift b/Sources/ClaudeUsage/Shared/Theme/MenuBarTheme.swift
similarity index 100%
rename from Sources/ClaudeCodeUsage/Shared/Theme/MenuBarTheme.swift
rename to Sources/ClaudeUsage/Shared/Theme/MenuBarTheme.swift
diff --git a/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift b/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift
new file mode 100644
index 0000000..c6fd18b
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift
@@ -0,0 +1,93 @@
+//
+// PricingCalculator.swift
+// ClaudeUsageCore
+//
+// Pure functions for cost calculations
+//
+
+import Foundation
+
+// MARK: - Pricing Calculator
+
+public enum PricingCalculator {
+ /// Calculate cost for token usage on a specific model
+ public static func calculateCost(
+ tokens: TokenCounts,
+ model: String
+ ) -> Double {
+ let pricing = modelPricing(for: model)
+ return calculateCost(tokens: tokens, pricing: pricing)
+ }
+
+ /// Calculate cost with explicit pricing
+ public static func calculateCost(
+ tokens: TokenCounts,
+ pricing: ModelPricing
+ ) -> Double {
+ let inputCost = Double(tokens.input) * pricing.inputPerToken
+ let outputCost = Double(tokens.output) * pricing.outputPerToken
+ let cacheWriteCost = Double(tokens.cacheCreation) * pricing.cacheWritePerToken
+ let cacheReadCost = Double(tokens.cacheRead) * pricing.cacheReadPerToken
+ return inputCost + outputCost + cacheWriteCost + cacheReadCost
+ }
+
+ /// Get pricing for a model
+ public static func modelPricing(for model: String) -> ModelPricing {
+ let normalizedModel = model.lowercased()
+
+ if normalizedModel.contains("opus") {
+ return .opus
+ } else if normalizedModel.contains("sonnet") {
+ return .sonnet
+ } else if normalizedModel.contains("haiku") {
+ return .haiku
+ }
+
+ // Default to sonnet pricing for unknown models
+ return .sonnet
+ }
+}
+
+// MARK: - Model Pricing
+
+public struct ModelPricing: Sendable, Hashable {
+ public let inputPerToken: Double
+ public let outputPerToken: Double
+ public let cacheWritePerToken: Double
+ public let cacheReadPerToken: Double
+
+ public init(
+ inputPerMillion: Double,
+ outputPerMillion: Double,
+ cacheWritePerMillion: Double,
+ cacheReadPerMillion: Double
+ ) {
+ self.inputPerToken = inputPerMillion / 1_000_000
+ self.outputPerToken = outputPerMillion / 1_000_000
+ self.cacheWritePerToken = cacheWritePerMillion / 1_000_000
+ self.cacheReadPerToken = cacheReadPerMillion / 1_000_000
+ }
+
+ // Claude 4/4.5 pricing (December 2025)
+ // Cache: write = 1.25x input, read = 0.1x input
+ public static let opus = ModelPricing(
+ inputPerMillion: 5.0,
+ outputPerMillion: 25.0,
+ cacheWritePerMillion: 6.25,
+ cacheReadPerMillion: 0.50
+ )
+
+ public static let sonnet = ModelPricing(
+ inputPerMillion: 3.0,
+ outputPerMillion: 15.0,
+ cacheWritePerMillion: 3.75,
+ cacheReadPerMillion: 0.30
+ )
+
+ public static let haiku = ModelPricing(
+ inputPerMillion: 1.0,
+ outputPerMillion: 5.0,
+ cacheWritePerMillion: 1.25,
+ cacheReadPerMillion: 0.10
+ )
+}
diff --git a/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift b/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift
new file mode 100644
index 0000000..2240837
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift
@@ -0,0 +1,128 @@
+//
+// UsageAggregator.swift
+// ClaudeUsageCore
+//
+// Pure functions for aggregating usage entries into statistics
+//
+
+import Foundation
+
+// MARK: - Usage Aggregator
+
+public enum UsageAggregator {
+ /// Aggregate entries into usage statistics
+ public static func aggregate(_ entries: [UsageEntry]) -> UsageStats {
+ guard !entries.isEmpty else { return .empty }
+
+ let totalCost = entries.reduce(0.0) { $0 + $1.costUSD }
+ let tokens = entries.reduce(.zero) { $0 + $1.tokens }
+ let sessionCount = Set(entries.compactMap(\.sessionId)).count
+
+ return UsageStats(
+ totalCost: totalCost,
+ tokens: tokens,
+ sessionCount: max(1, sessionCount),
+ byModel: aggregateByModel(entries),
+ byDate: aggregateByDate(entries),
+ byProject: aggregateByProject(entries)
+ )
+ }
+
+ /// Aggregate entries by model
+ public static func aggregateByModel(_ entries: [UsageEntry]) -> [ModelUsage] {
+ Dictionary(grouping: entries, by: \.model)
+ .map { model, modelEntries in
+ ModelUsage(
+ model: model,
+ totalCost: modelEntries.reduce(0.0) { $0 + $1.costUSD },
+ tokens: modelEntries.reduce(.zero) { $0 + $1.tokens },
+ sessionCount: Set(modelEntries.compactMap(\.sessionId)).count
+ )
+ }
+ .sorted { $0.totalCost > $1.totalCost }
+ }
+
+ /// Aggregate entries by date
+ public static func aggregateByDate(_ entries: [UsageEntry]) -> [DailyUsage] {
+ let calendar = Calendar.current
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyy-MM-dd"
+
+ let grouped = Dictionary(grouping: entries) { entry in
+ dateFormatter.string(from: entry.timestamp)
+ }
+
+ return grouped.map { date, dayEntries in
+ let hourlyCosts = calculateHourlyCosts(dayEntries, calendar: calendar)
+ return DailyUsage(
+ date: date,
+ totalCost: dayEntries.reduce(0.0) { $0 + $1.costUSD },
+ totalTokens: dayEntries.reduce(0) { $0 + $1.totalTokens },
+ modelsUsed: Array(Set(dayEntries.map(\.model))),
+ hourlyCosts: hourlyCosts
+ )
+ }
+ .sorted { $0.date < $1.date }
+ }
+
+ /// Aggregate entries by project
+ public static func aggregateByProject(_ entries: [UsageEntry]) -> [ProjectUsage] {
+ Dictionary(grouping: entries, by: \.project)
+ .map { project, projectEntries in
+ let lastUsed = projectEntries.map(\.timestamp).max() ?? Date()
+ let projectName = extractProjectName(from: project)
+ return ProjectUsage(
+ projectPath: project,
+ projectName: projectName,
+ totalCost: projectEntries.reduce(0.0) { $0 + $1.costUSD },
+ totalTokens: projectEntries.reduce(0) { $0 + $1.totalTokens },
+ sessionCount: Set(projectEntries.compactMap(\.sessionId)).count,
+ lastUsed: lastUsed
+ )
+ }
+ .sorted { $0.totalCost > $1.totalCost }
+ }
+
+ // MARK: - Helpers
+
+ private static func calculateHourlyCosts(
+ _ entries: [UsageEntry],
+ calendar: Calendar
+ ) -> [Double] {
+ var hourlyCosts = Array(repeating: 0.0, count: 24)
+ for entry in entries {
+ let hour = calendar.component(.hour, from: entry.timestamp)
+ hourlyCosts[hour] += entry.costUSD
+ }
+ return hourlyCosts
+ }
+
+ private static func extractProjectName(from path: String) -> String {
+ // Extract the last path component as project name
+ let components = path.split(separator: "/")
+ return components.last.map(String.init) ?? path
+ }
+}
+
+// MARK: - Today Filtering
+
+public extension UsageAggregator {
+ /// Filter entries to today only
+ static func filterToday(_ entries: [UsageEntry], referenceDate: Date = Date()) -> [UsageEntry] {
+ let calendar = Calendar.current
+ let today = calendar.startOfDay(for: referenceDate)
+ return entries.filter { entry in
+ calendar.startOfDay(for: entry.timestamp) == today
+ }
+ }
+
+ /// Calculate hourly costs for today
+ static func todayHourlyCosts(
+ from entries: [UsageEntry],
+ referenceDate: Date = Date()
+ ) -> [Double] {
+ let calendar = Calendar.current
+ let todayEntries = filterToday(entries, referenceDate: referenceDate)
+ return calculateHourlyCosts(todayEntries, calendar: calendar)
+ }
+}
diff --git a/Sources/ClaudeCodeUsageKit/Analytics/UsageAnalytics.swift b/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift
similarity index 76%
rename from Sources/ClaudeCodeUsageKit/Analytics/UsageAnalytics.swift
rename to Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift
index 7b0c528..df1902b 100644
--- a/Sources/ClaudeCodeUsageKit/Analytics/UsageAnalytics.swift
+++ b/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift
@@ -1,6 +1,8 @@
//
// UsageAnalytics.swift
-// ClaudeCodeUsage
+// ClaudeUsageCore
+//
+// Pure functions for usage analytics and calculations
//
import Foundation
@@ -12,7 +14,7 @@ public enum UsageAnalytics {
// MARK: - Cost Calculations
public static func totalCost(from entries: [UsageEntry]) -> Double {
- entries.reduce(0) { $0 + $1.cost }
+ entries.reduce(0) { $0 + $1.costUSD }
}
public static func averageCostPerSession(from entries: [UsageEntry]) -> Double {
@@ -53,10 +55,10 @@ public enum UsageAnalytics {
private static func aggregateTokensByType(_ models: [ModelUsage]) -> (input: Int, output: Int, cacheWrite: Int, cacheRead: Int) {
models.reduce((0, 0, 0, 0)) { acc, model in
- (acc.0 + model.inputTokens,
- acc.1 + model.outputTokens,
- acc.2 + model.cacheCreationTokens,
- acc.3 + model.cacheReadTokens)
+ (acc.0 + model.tokens.input,
+ acc.1 + model.tokens.output,
+ acc.2 + model.tokens.cacheCreation,
+ acc.3 + model.tokens.cacheRead)
}
}
@@ -77,19 +79,14 @@ public enum UsageAnalytics {
}
private static func isWithinRange(_ entry: UsageEntry, startDate: Date, endDate: Date) -> Bool {
- guard let date = entry.date else { return false }
- return date >= startDate && date <= endDate
+ entry.timestamp >= startDate && entry.timestamp <= endDate
}
public static func groupByDate(_ entries: [UsageEntry]) -> [String: [UsageEntry]] {
let formatter = dateFormatter()
- let entriesWithDates = entries.compactMap { pairWithDateString($0, formatter: formatter) }
- return Dictionary(grouping: entriesWithDates, by: \.0).mapValues { $0.map(\.1) }
- }
-
- private static func pairWithDateString(_ entry: UsageEntry, formatter: DateFormatter) -> (String, UsageEntry)? {
- guard let date = entry.date else { return nil }
- return (formatter.string(from: date), entry)
+ return Dictionary(grouping: entries) { entry in
+ formatter.string(from: entry.timestamp)
+ }
}
private static func dateFormatter() -> DateFormatter {
@@ -132,16 +129,15 @@ public enum UsageAnalytics {
}
private static func calculateHoursElapsed(_ entries: [UsageEntry]) -> Double {
- let timeRange = entries
- .compactMap { $0.date }
- .reduce((min: Date.distantFuture, max: Date.distantPast)) { (min($0.min, $1), max($0.max, $1)) }
- return timeRange.max.timeIntervalSince(timeRange.min) / 3600
+ let timestamps = entries.map(\.timestamp)
+ guard let minTime = timestamps.min(), let maxTime = timestamps.max() else { return 0 }
+ return maxTime.timeIntervalSince(minTime) / 3600
}
// MARK: - Cache Efficiency
public static func cacheSavings(from stats: UsageStats) -> CacheSavings {
- let cacheReadTokens = stats.byModel.reduce(0) { $0 + $1.cacheReadTokens }
+ let cacheReadTokens = stats.byModel.reduce(0) { $0 + $1.tokens.cacheRead }
let estimatedSaved = estimateCacheSavings(cacheReadTokens)
return CacheSavings(
tokensSaved: cacheReadTokens,
@@ -161,6 +157,36 @@ public enum UsageAnalytics {
}
}
+// MARK: - Hourly Accumulation
+
+public extension UsageAnalytics {
+
+ static func todayHourlyAccumulation(from entries: [UsageEntry], referenceDate: Date = Date()) -> [Double] {
+ let calendar = Calendar.current
+ let currentHour = calendar.component(.hour, from: referenceDate)
+
+ let todayEntries = UsageAggregator.filterToday(entries, referenceDate: referenceDate)
+ let hourlyCosts = groupCostsByHour(todayEntries, calendar: calendar)
+ return buildCumulativeArray(from: hourlyCosts, throughHour: currentHour)
+ }
+
+ private static func groupCostsByHour(_ entries: [UsageEntry], calendar: Calendar) -> [Int: Double] {
+ entries.reduce(into: [Int: Double]()) { result, entry in
+ let hour = calendar.component(.hour, from: entry.timestamp)
+ result[hour, default: 0] += entry.costUSD
+ }
+ }
+
+ private static func buildCumulativeArray(from hourlyCosts: [Int: Double], throughHour: Int) -> [Double] {
+ (0...throughHour)
+ .map { hourlyCosts[$0] ?? 0 }
+ .reduce(into: [Double]()) { cumulative, cost in
+ let runningTotal = (cumulative.last ?? 0) + cost
+ cumulative.append(runningTotal)
+ }
+ }
+}
+
// MARK: - Formatting Extensions
public extension Int {
@@ -194,60 +220,14 @@ public extension Double {
// MARK: - Supporting Types
-public struct CacheSavings {
+public struct CacheSavings: Sendable {
public let tokensSaved: Int
public let estimatedSaved: Double
public let description: String
-}
-
-// MARK: - Hourly Accumulation
-
-public extension UsageAnalytics {
- static func todayHourlyAccumulation(from entries: [UsageEntry], referenceDate: Date = Date()) -> [Double] {
- let calendar = Calendar.current
- let today = calendar.startOfDay(for: referenceDate)
- let currentHour = calendar.component(.hour, from: referenceDate)
-
- let todayEntries = filterEntriesToday(entries, calendar: calendar, today: today)
- let hourlyCosts = groupCostsByHour(todayEntries, calendar: calendar)
- return buildCumulativeArray(from: hourlyCosts, throughHour: currentHour)
- }
-
- static func todayHourlyCosts(from entries: [UsageEntry], referenceDate: Date = Date()) -> [Double] {
- let calendar = Calendar.current
- let today = calendar.startOfDay(for: referenceDate)
-
- let todayEntries = filterEntriesToday(entries, calendar: calendar, today: today)
- let hourlyCosts = groupCostsByHour(todayEntries, calendar: calendar)
- return buildHourlyArray(from: hourlyCosts)
- }
-
- private static func filterEntriesToday(_ entries: [UsageEntry], calendar: Calendar, today: Date) -> [UsageEntry] {
- entries.filter { entry in
- guard let date = entry.date else { return false }
- return calendar.isDate(date, inSameDayAs: today)
- }
- }
-
- private static func groupCostsByHour(_ entries: [UsageEntry], calendar: Calendar) -> [Int: Double] {
- entries.reduce(into: [Int: Double]()) { result, entry in
- guard let date = entry.date else { return }
- let hour = calendar.component(.hour, from: date)
- result[hour, default: 0] += entry.cost
- }
- }
-
- private static func buildHourlyArray(from hourlyCosts: [Int: Double]) -> [Double] {
- (0..<24).map { hourlyCosts[$0] ?? 0 }
- }
-
- private static func buildCumulativeArray(from hourlyCosts: [Int: Double], throughHour: Int) -> [Double] {
- (0...throughHour)
- .map { hourlyCosts[$0] ?? 0 }
- .reduce(into: [Double]()) { cumulative, cost in
- let runningTotal = (cumulative.last ?? 0) + cost
- cumulative.append(runningTotal)
- }
+ public init(tokensSaved: Int, estimatedSaved: Double, description: String) {
+ self.tokensSaved = tokensSaved
+ self.estimatedSaved = estimatedSaved
+ self.description = description
}
}
diff --git a/Sources/ClaudeUsageCore/ClaudeUsageCore.swift b/Sources/ClaudeUsageCore/ClaudeUsageCore.swift
new file mode 100644
index 0000000..375608c
--- /dev/null
+++ b/Sources/ClaudeUsageCore/ClaudeUsageCore.swift
@@ -0,0 +1,18 @@
+//
+// ClaudeUsageCore.swift
+// ClaudeUsageCore
+//
+// Domain layer for Claude usage tracking.
+// Contains models, protocols, and pure analytics functions.
+//
+// Modules:
+// - Models: UsageEntry, TokenCounts, SessionBlock, UsageStats, etc.
+// - Protocols: UsageDataSource, SessionDataSource
+// - Analytics: PricingCalculator, UsageAggregator
+//
+
+import Foundation
+
+// Re-export for convenience
+public typealias Cost = Double
+public typealias TokenCount = Int
diff --git a/Sources/ClaudeUsageCore/Extensions/UsageEntry+Compatibility.swift b/Sources/ClaudeUsageCore/Extensions/UsageEntry+Compatibility.swift
new file mode 100644
index 0000000..2ce4b32
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Extensions/UsageEntry+Compatibility.swift
@@ -0,0 +1,25 @@
+//
+// UsageEntry+Compatibility.swift
+// ClaudeUsageCore
+//
+// Compatibility extensions for Kit API parity
+// Allows views to use familiar property names during migration
+//
+
+import Foundation
+
+// MARK: - Kit API Compatibility
+
+public extension UsageEntry {
+ /// Kit-compatible cost property
+ var cost: Double { costUSD }
+
+ /// Kit-compatible date property (returns optional for API parity)
+ var date: Date? { timestamp }
+
+ /// Kit-compatible individual token accessors
+ var inputTokens: Int { tokens.input }
+ var outputTokens: Int { tokens.output }
+ var cacheWriteTokens: Int { tokens.cacheCreation }
+ var cacheReadTokens: Int { tokens.cacheRead }
+}
diff --git a/Sources/ClaudeUsageCore/Extensions/UsageStats+Compatibility.swift b/Sources/ClaudeUsageCore/Extensions/UsageStats+Compatibility.swift
new file mode 100644
index 0000000..450c87b
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Extensions/UsageStats+Compatibility.swift
@@ -0,0 +1,67 @@
+//
+// UsageStats+Compatibility.swift
+// ClaudeUsageCore
+//
+// Compatibility extensions for Kit API parity
+//
+
+import Foundation
+
+// MARK: - UsageStats Kit Compatibility
+
+public extension UsageStats {
+ /// Kit-compatible individual token accessors
+ var totalInputTokens: Int { tokens.input }
+ var totalOutputTokens: Int { tokens.output }
+ var totalCacheCreationTokens: Int { tokens.cacheCreation }
+ var totalCacheReadTokens: Int { tokens.cacheRead }
+
+ /// Kit-compatible session count accessor
+ var totalSessions: Int { sessionCount }
+
+ /// Kit-compatible initializer with individual token fields
+ init(
+ totalCost: Double,
+ totalTokens: Int,
+ totalInputTokens: Int,
+ totalOutputTokens: Int,
+ totalCacheCreationTokens: Int,
+ totalCacheReadTokens: Int,
+ totalSessions: Int,
+ byModel: [ModelUsage],
+ byDate: [DailyUsage],
+ byProject: [ProjectUsage]
+ ) {
+ self.init(
+ totalCost: totalCost,
+ tokens: TokenCounts(
+ input: totalInputTokens,
+ output: totalOutputTokens,
+ cacheCreation: totalCacheCreationTokens,
+ cacheRead: totalCacheReadTokens
+ ),
+ sessionCount: totalSessions,
+ byModel: byModel,
+ byDate: byDate,
+ byProject: byProject
+ )
+ }
+}
+
+// MARK: - ModelUsage Kit Compatibility
+
+public extension ModelUsage {
+ /// Kit-compatible individual token accessors
+ var inputTokens: Int { tokens.input }
+ var outputTokens: Int { tokens.output }
+ var cacheCreationTokens: Int { tokens.cacheCreation }
+ var cacheReadTokens: Int { tokens.cacheRead }
+ var totalTokens: Int { tokens.total }
+}
+
+// MARK: - DailyUsage Kit Compatibility
+
+public extension DailyUsage {
+ /// Number of different models used (Kit compatibility)
+ var modelCount: Int { modelsUsed.count }
+}
diff --git a/Sources/ClaudeUsageCore/Models/BurnRate.swift b/Sources/ClaudeUsageCore/Models/BurnRate.swift
new file mode 100644
index 0000000..e56c99c
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Models/BurnRate.swift
@@ -0,0 +1,47 @@
+//
+// BurnRate.swift
+// ClaudeUsageCore
+//
+// Token consumption rate metrics
+//
+
+import Foundation
+
+// MARK: - BurnRate
+
+public struct BurnRate: Sendable, Hashable {
+ public let tokensPerMinute: Int
+ public let costPerHour: Double
+
+ public init(tokensPerMinute: Int, costPerHour: Double) {
+ self.tokensPerMinute = tokensPerMinute
+ self.costPerHour = costPerHour
+ }
+
+ public static var zero: BurnRate {
+ BurnRate(tokensPerMinute: 0, costPerHour: 0)
+ }
+}
+
+// MARK: - Derived Metrics
+
+public extension BurnRate {
+ var tokensPerHour: Int {
+ tokensPerMinute * 60
+ }
+
+ var costPerMinute: Double {
+ costPerHour / 60.0
+ }
+
+ /// Indicator level (0-4) for UI display
+ var indicatorLevel: Int {
+ switch tokensPerMinute {
+ case 0: return 0
+ case 1..<1000: return 1
+ case 1000..<5000: return 2
+ case 5000..<10000: return 3
+ default: return 4
+ }
+ }
+}
diff --git a/Sources/ClaudeUsageCore/Models/SessionBlock.swift b/Sources/ClaudeUsageCore/Models/SessionBlock.swift
new file mode 100644
index 0000000..a039e9b
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Models/SessionBlock.swift
@@ -0,0 +1,81 @@
+//
+// SessionBlock.swift
+// ClaudeUsageCore
+//
+// Represents a continuous usage session with projections
+//
+
+import Foundation
+
+// MARK: - SessionBlock
+
+public struct SessionBlock: Sendable, Hashable, Identifiable {
+ public let id: String
+ public let startTime: Date
+ public let endTime: Date
+ public let actualEndTime: Date?
+ public let isActive: Bool
+ public let entries: [UsageEntry]
+ public let tokens: TokenCounts
+ public let costUSD: Double
+ public let models: [String]
+ public let burnRate: BurnRate
+ public let tokenLimit: Int?
+
+ public init(
+ id: String,
+ startTime: Date,
+ endTime: Date,
+ actualEndTime: Date? = nil,
+ isActive: Bool,
+ entries: [UsageEntry],
+ tokens: TokenCounts,
+ costUSD: Double,
+ models: [String],
+ burnRate: BurnRate,
+ tokenLimit: Int? = nil
+ ) {
+ self.id = id
+ self.startTime = startTime
+ self.endTime = endTime
+ self.actualEndTime = actualEndTime
+ self.isActive = isActive
+ self.entries = entries
+ self.tokens = tokens
+ self.costUSD = costUSD
+ self.models = models
+ self.burnRate = burnRate
+ self.tokenLimit = tokenLimit
+ }
+}
+
+// MARK: - Derived Properties
+
+public extension SessionBlock {
+ var duration: TimeInterval {
+ (actualEndTime ?? endTime).timeIntervalSince(startTime)
+ }
+
+ var durationMinutes: Double {
+ duration / 60.0
+ }
+
+ var entryCount: Int {
+ entries.count
+ }
+
+ var projectedTokens: Int? {
+ guard isActive, let limit = tokenLimit else { return nil }
+ return limit
+ }
+
+ var remainingTokens: Int? {
+ guard let limit = tokenLimit else { return nil }
+ return max(0, limit - tokens.total)
+ }
+
+ var tokenProgress: Double? {
+ guard let limit = tokenLimit, limit > 0 else { return nil }
+ return Double(tokens.total) / Double(limit)
+ }
+}
diff --git a/Sources/ClaudeUsageCore/Models/TokenCounts.swift b/Sources/ClaudeUsageCore/Models/TokenCounts.swift
new file mode 100644
index 0000000..80f5314
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Models/TokenCounts.swift
@@ -0,0 +1,50 @@
+//
+// TokenCounts.swift
+// ClaudeUsageCore
+//
+// Unified token counts for all usage tracking
+//
+
+import Foundation
+
+// MARK: - TokenCounts
+
+public struct TokenCounts: Sendable, Hashable, Codable {
+ public let input: Int
+ public let output: Int
+ public let cacheCreation: Int
+ public let cacheRead: Int
+
+ public var total: Int {
+ input + output + cacheCreation + cacheRead
+ }
+
+ public init(
+ input: Int = 0,
+ output: Int = 0,
+ cacheCreation: Int = 0,
+ cacheRead: Int = 0
+ ) {
+ self.input = input
+ self.output = output
+ self.cacheCreation = cacheCreation
+ self.cacheRead = cacheRead
+ }
+}
+
+// MARK: - Arithmetic
+
+public extension TokenCounts {
+ static func + (lhs: TokenCounts, rhs: TokenCounts) -> TokenCounts {
+ TokenCounts(
+ input: lhs.input + rhs.input,
+ output: lhs.output + rhs.output,
+ cacheCreation: lhs.cacheCreation + rhs.cacheCreation,
+ cacheRead: lhs.cacheRead + rhs.cacheRead
+ )
+ }
+
+ static var zero: TokenCounts {
+ TokenCounts()
+ }
+}
diff --git a/Sources/ClaudeUsageCore/Models/UsageEntry.swift b/Sources/ClaudeUsageCore/Models/UsageEntry.swift
new file mode 100644
index 0000000..3644649
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Models/UsageEntry.swift
@@ -0,0 +1,61 @@
+//
+// UsageEntry.swift
+// ClaudeUsageCore
+//
+// Single source of truth for Claude usage entries
+//
+
+import Foundation
+
+// MARK: - UsageEntry
+
+public struct UsageEntry: Sendable, Hashable, Identifiable {
+ public let id: String
+ public let timestamp: Date
+ public let model: String
+ public let tokens: TokenCounts
+ public let costUSD: Double
+ public let project: String
+ public let sourceFile: String
+ public let sessionId: String?
+ public let messageId: String?
+ public let requestId: String?
+
+ public init(
+ id: String? = nil,
+ timestamp: Date,
+ model: String,
+ tokens: TokenCounts,
+ costUSD: Double,
+ project: String,
+ sourceFile: String,
+ sessionId: String? = nil,
+ messageId: String? = nil,
+ requestId: String? = nil
+ ) {
+ self.id = id ?? "\(timestamp.timeIntervalSince1970)-\(messageId ?? UUID().uuidString)"
+ self.timestamp = timestamp
+ self.model = model
+ self.tokens = tokens
+ self.costUSD = costUSD
+ self.project = project
+ self.sourceFile = sourceFile
+ self.sessionId = sessionId
+ self.messageId = messageId
+ self.requestId = requestId
+ }
+}
+
+// MARK: - Convenience
+
+public extension UsageEntry {
+ var totalTokens: Int { tokens.total }
+}
+
+// MARK: - Comparable (by timestamp)
+
+extension UsageEntry: Comparable {
+ public static func < (lhs: UsageEntry, rhs: UsageEntry) -> Bool {
+ lhs.timestamp < rhs.timestamp
+ }
+}
diff --git a/Sources/ClaudeUsageCore/Models/UsageStats.swift b/Sources/ClaudeUsageCore/Models/UsageStats.swift
new file mode 100644
index 0000000..c656e24
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Models/UsageStats.swift
@@ -0,0 +1,153 @@
+//
+// UsageStats.swift
+// ClaudeUsageCore
+//
+// Aggregated usage statistics
+//
+
+import Foundation
+
+// MARK: - UsageStats
+
+public struct UsageStats: Sendable, Hashable {
+ public let totalCost: Double
+ public let tokens: TokenCounts
+ public let sessionCount: Int
+ public let byModel: [ModelUsage]
+ public let byDate: [DailyUsage]
+ public let byProject: [ProjectUsage]
+
+ public init(
+ totalCost: Double,
+ tokens: TokenCounts,
+ sessionCount: Int,
+ byModel: [ModelUsage] = [],
+ byDate: [DailyUsage] = [],
+ byProject: [ProjectUsage] = []
+ ) {
+ self.totalCost = totalCost
+ self.tokens = tokens
+ self.sessionCount = sessionCount
+ self.byModel = byModel
+ self.byDate = byDate
+ self.byProject = byProject
+ }
+
+ public static var empty: UsageStats {
+ UsageStats(totalCost: 0, tokens: .zero, sessionCount: 0)
+ }
+}
+
+// MARK: - Derived Properties
+
+public extension UsageStats {
+ var totalTokens: Int { tokens.total }
+
+ var averageCostPerSession: Double {
+ sessionCount > 0 ? totalCost / Double(sessionCount) : 0
+ }
+
+ var averageTokensPerSession: Int {
+ sessionCount > 0 ? totalTokens / sessionCount : 0
+ }
+
+ var costPerMillionTokens: Double {
+ totalTokens > 0 ? (totalCost / Double(totalTokens)) * 1_000_000 : 0
+ }
+}
+
+// MARK: - ModelUsage
+
+public struct ModelUsage: Sendable, Hashable, Identifiable {
+ public let model: String
+ public let totalCost: Double
+ public let tokens: TokenCounts
+ public let sessionCount: Int
+
+ public var id: String { model }
+
+ public init(
+ model: String,
+ totalCost: Double,
+ tokens: TokenCounts,
+ sessionCount: Int
+ ) {
+ self.model = model
+ self.totalCost = totalCost
+ self.tokens = tokens
+ self.sessionCount = sessionCount
+ }
+
+ public var averageCostPerSession: Double {
+ sessionCount > 0 ? totalCost / Double(sessionCount) : 0
+ }
+}
+
+// MARK: - DailyUsage
+
+public struct DailyUsage: Sendable, Hashable, Identifiable {
+ public let date: String
+ public let totalCost: Double
+ public let totalTokens: Int
+ public let modelsUsed: [String]
+ public let hourlyCosts: [Double]
+
+ public var id: String { date }
+
+ public init(
+ date: String,
+ totalCost: Double,
+ totalTokens: Int,
+ modelsUsed: [String] = [],
+ hourlyCosts: [Double] = []
+ ) {
+ self.date = date
+ self.totalCost = totalCost
+ self.totalTokens = totalTokens
+ self.modelsUsed = modelsUsed
+ self.hourlyCosts = hourlyCosts.isEmpty ? Array(repeating: 0, count: 24) : hourlyCosts
+ }
+
+ public var parsedDate: Date? {
+ Self.dateFormatter.date(from: date)
+ }
+
+ private static let dateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ return f
+ }()
+}
+
+// MARK: - ProjectUsage
+
+public struct ProjectUsage: Sendable, Hashable, Identifiable {
+ public let projectPath: String
+ public let projectName: String
+ public let totalCost: Double
+ public let totalTokens: Int
+ public let sessionCount: Int
+ public let lastUsed: Date
+
+ public var id: String { projectPath }
+
+ public init(
+ projectPath: String,
+ projectName: String,
+ totalCost: Double,
+ totalTokens: Int,
+ sessionCount: Int,
+ lastUsed: Date
+ ) {
+ self.projectPath = projectPath
+ self.projectName = projectName
+ self.totalCost = totalCost
+ self.totalTokens = totalTokens
+ self.sessionCount = sessionCount
+ self.lastUsed = lastUsed
+ }
+
+ public var averageCostPerSession: Double {
+ sessionCount > 0 ? totalCost / Double(sessionCount) : 0
+ }
+}
diff --git a/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift b/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift
new file mode 100644
index 0000000..6beb75b
--- /dev/null
+++ b/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift
@@ -0,0 +1,36 @@
+//
+// UsageDataSource.swift
+// ClaudeUsageCore
+//
+// Protocols defining usage data access capabilities
+//
+
+import Foundation
+
+// MARK: - UsageDataSource
+
+/// Provides access to usage data
+public protocol UsageDataSource: Sendable {
+ /// Get all usage entries for today
+ func getTodayEntries() async throws -> [UsageEntry]
+
+ /// Get aggregated usage statistics
+ func getUsageStats() async throws -> UsageStats
+
+ /// Get all raw usage entries (for detailed analysis)
+ func getAllEntries() async throws -> [UsageEntry]
+}
+
+// MARK: - SessionDataSource
+
+/// Provides access to live session data
+public protocol SessionDataSource: Sendable {
+ /// Get the currently active session block, if any
+ func getActiveSession() async -> SessionBlock?
+
+ /// Get the current burn rate
+ func getBurnRate() async -> BurnRate?
+
+ /// Get the auto-detected token limit
+ func getAutoTokenLimit() async -> Int?
+}
diff --git a/Sources/ClaudeUsageData/ClaudeUsageData.swift b/Sources/ClaudeUsageData/ClaudeUsageData.swift
new file mode 100644
index 0000000..69b0ab7
--- /dev/null
+++ b/Sources/ClaudeUsageData/ClaudeUsageData.swift
@@ -0,0 +1,15 @@
+//
+// ClaudeUsageData.swift
+// ClaudeUsageData
+//
+// Data layer for Claude usage tracking.
+// Provides repository implementation, file parsing, and session monitoring.
+//
+// Components:
+// - Repository: UsageRepository
+// - Parsing: JSONLParser
+// - Monitoring: DirectoryMonitor, SessionMonitor
+//
+
+import Foundation
+@_exported import ClaudeUsageCore
diff --git a/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift b/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift
new file mode 100644
index 0000000..20c196e
--- /dev/null
+++ b/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift
@@ -0,0 +1,86 @@
+//
+// DirectoryMonitor.swift
+// ClaudeUsageData
+//
+// File system monitoring for usage data changes
+//
+
+import Foundation
+
+// MARK: - DirectoryMonitor
+
+public final class DirectoryMonitor: @unchecked Sendable {
+ private let path: String
+ private let debounceInterval: TimeInterval
+ private var source: DispatchSourceFileSystemObject?
+ private var fileDescriptor: Int32 = -1
+ private var debounceTask: Task?
+ private let queue = DispatchQueue(label: "DirectoryMonitor", qos: .utility)
+
+ /// Called when directory contents change (debounced)
+ public var onChange: (() -> Void)?
+
+ public init(path: String, debounceInterval: TimeInterval = 1.0) {
+ self.path = path
+ self.debounceInterval = debounceInterval
+ }
+
+ deinit {
+ stop()
+ }
+
+ // MARK: - Public API
+
+ public func start() {
+ stop()
+
+ fileDescriptor = open(path, O_EVTONLY)
+ guard fileDescriptor >= 0 else {
+ print("[DirectoryMonitor] Failed to open \(path)")
+ return
+ }
+
+ source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fileDescriptor,
+ eventMask: [.write, .extend, .attrib, .link, .rename, .revoke],
+ queue: queue
+ )
+
+ source?.setEventHandler { [weak self] in
+ self?.handleEvent()
+ }
+
+ source?.setCancelHandler { [weak self] in
+ guard let self, self.fileDescriptor >= 0 else { return }
+ close(self.fileDescriptor)
+ self.fileDescriptor = -1
+ }
+
+ source?.resume()
+ }
+
+ public func stop() {
+ debounceTask?.cancel()
+ debounceTask = nil
+
+ source?.cancel()
+ source = nil
+ }
+
+ // MARK: - Private
+
+ private func handleEvent() {
+ debounceTask?.cancel()
+ debounceTask = Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ do {
+ try await Task.sleep(for: .seconds(self.debounceInterval))
+ guard !Task.isCancelled else { return }
+ self.onChange?()
+ } catch {
+ // Task cancelled
+ }
+ }
+ }
+}
diff --git a/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift b/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift
new file mode 100644
index 0000000..2aa4c00
--- /dev/null
+++ b/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift
@@ -0,0 +1,192 @@
+//
+// SessionMonitor.swift
+// ClaudeUsageData
+//
+// Monitor for detecting active Claude sessions
+//
+
+import Foundation
+import ClaudeUsageCore
+
+// MARK: - SessionMonitor
+
+public actor SessionMonitor: SessionDataSource {
+ private let basePath: String
+ private let sessionDurationHours: Double
+ private let parser = JSONLParser()
+
+ private var lastFileTimestamps: [String: Date] = [:]
+ private var processedHashes = Set()
+ private var allEntries: [UsageEntry] = []
+ private var cachedTokenLimit: Int = 0
+
+ public init(basePath: String = NSHomeDirectory() + "/.claude", sessionDurationHours: Double = 5.0) {
+ self.basePath = basePath
+ self.sessionDurationHours = sessionDurationHours
+ }
+
+ // MARK: - SessionDataSource
+
+ public func getActiveSession() async -> SessionBlock? {
+ loadModifiedFiles()
+ let blocks = identifySessionBlocks()
+ cachedTokenLimit = maxTokensFromCompletedBlocks(blocks)
+ return mostRecentActiveBlock(from: blocks)
+ }
+
+ public func getBurnRate() async -> BurnRate? {
+ await getActiveSession()?.burnRate
+ }
+
+ public func getAutoTokenLimit() async -> Int? {
+ _ = await getActiveSession()
+ return cachedTokenLimit > 0 ? cachedTokenLimit : nil
+ }
+
+ // MARK: - File Loading
+
+ private func loadModifiedFiles() {
+ let files = findUsageFiles()
+
+ for file in files {
+ guard shouldReloadFile(file) else { continue }
+
+ var localHashes = processedHashes
+ let entries = parser.parseFile(
+ at: file.path,
+ project: file.projectName,
+ processedHashes: &localHashes
+ )
+ processedHashes = localHashes
+
+ allEntries.append(contentsOf: entries)
+ lastFileTimestamps[file.path] = file.modificationDate
+ }
+
+ allEntries.sort()
+ }
+
+ private func findUsageFiles() -> [FileMetadata] {
+ (try? FileDiscovery.discoverFiles(in: basePath)) ?? []
+ }
+
+ private func shouldReloadFile(_ file: FileMetadata) -> Bool {
+ guard let lastTimestamp = lastFileTimestamps[file.path] else {
+ return true
+ }
+ return file.modificationDate > lastTimestamp
+ }
+
+ // MARK: - Session Block Detection
+
+ private func identifySessionBlocks() -> [SessionBlock] {
+ guard !allEntries.isEmpty else { return [] }
+
+ let sessionDuration = sessionDurationHours * 3600
+ var blocks: [SessionBlock] = []
+ var currentBlockEntries: [UsageEntry] = []
+ var blockStartTime: Date?
+
+ for entry in allEntries {
+ if let start = blockStartTime {
+ let gap = entry.timestamp.timeIntervalSince(currentBlockEntries.last?.timestamp ?? start)
+ if gap > sessionDuration {
+ // End current block, start new one
+ if let block = createBlock(entries: currentBlockEntries, startTime: start, isActive: false) {
+ blocks.append(block)
+ }
+ currentBlockEntries = [entry]
+ blockStartTime = entry.timestamp
+ } else {
+ currentBlockEntries.append(entry)
+ }
+ } else {
+ blockStartTime = entry.timestamp
+ currentBlockEntries = [entry]
+ }
+ }
+
+ // Handle final block
+ if let start = blockStartTime, !currentBlockEntries.isEmpty {
+ let lastEntryTime = currentBlockEntries.last?.timestamp ?? start
+ let isActive = Date().timeIntervalSince(lastEntryTime) < sessionDuration
+ if let block = createBlock(entries: currentBlockEntries, startTime: start, isActive: isActive) {
+ blocks.append(block)
+ }
+ }
+
+ return blocks
+ }
+
+ private func createBlock(entries: [UsageEntry], startTime: Date, isActive: Bool) -> SessionBlock? {
+ guard !entries.isEmpty else { return nil }
+
+ let tokens = entries.reduce(TokenCounts.zero) { $0 + $1.tokens }
+ let cost = entries.reduce(0.0) { $0 + $1.costUSD }
+ let models = Array(Set(entries.map(\.model)))
+ let actualEndTime = entries.last?.timestamp
+
+ let burnRate = calculateBurnRate(entries: entries)
+ let endTime = isActive
+ ? Date().addingTimeInterval(sessionDurationHours * 3600)
+ : (actualEndTime ?? startTime)
+
+ return SessionBlock(
+ id: UUID().uuidString,
+ startTime: startTime,
+ endTime: endTime,
+ actualEndTime: actualEndTime,
+ isActive: isActive,
+ entries: entries,
+ tokens: tokens,
+ costUSD: cost,
+ models: models,
+ burnRate: burnRate,
+ tokenLimit: cachedTokenLimit > 0 ? cachedTokenLimit : nil
+ )
+ }
+
+ private func calculateBurnRate(entries: [UsageEntry]) -> BurnRate {
+ guard entries.count >= 2,
+ let first = entries.first,
+ let last = entries.last else {
+ return .zero
+ }
+
+ let duration = last.timestamp.timeIntervalSince(first.timestamp)
+ guard duration > 60 else { return .zero } // Need at least 1 minute
+
+ let totalTokens = entries.reduce(0) { $0 + $1.totalTokens }
+ let totalCost = entries.reduce(0.0) { $0 + $1.costUSD }
+
+ let minutes = duration / 60.0
+ let tokensPerMinute = Int(Double(totalTokens) / minutes)
+ let costPerHour = (totalCost / duration) * 3600
+
+ return BurnRate(tokensPerMinute: tokensPerMinute, costPerHour: costPerHour)
+ }
+
+ // MARK: - Helpers
+
+ private func maxTokensFromCompletedBlocks(_ blocks: [SessionBlock]) -> Int {
+ blocks
+ .filter { !$0.isActive }
+ .map { $0.tokens.total }
+ .max() ?? 0
+ }
+
+ private func mostRecentActiveBlock(from blocks: [SessionBlock]) -> SessionBlock? {
+ blocks
+ .filter(\.isActive)
+ .max { ($0.actualEndTime ?? $0.startTime) < ($1.actualEndTime ?? $1.startTime) }
+ }
+
+ // MARK: - Cache Management
+
+ public func clearCache() {
+ lastFileTimestamps.removeAll()
+ processedHashes.removeAll()
+ allEntries.removeAll()
+ cachedTokenLimit = 0
+ }
+}
diff --git a/Sources/ClaudeUsageData/Parsing/JSONLParser.swift b/Sources/ClaudeUsageData/Parsing/JSONLParser.swift
new file mode 100644
index 0000000..b4f350f
--- /dev/null
+++ b/Sources/ClaudeUsageData/Parsing/JSONLParser.swift
@@ -0,0 +1,194 @@
+//
+// JSONLParser.swift
+// ClaudeUsageData
+//
+// Parses Claude JSONL usage files into UsageEntry models
+//
+
+import Foundation
+import ClaudeUsageCore
+
+// MARK: - JSONLParser
+
+public struct JSONLParser: Sendable {
+ public init() {}
+
+ // Thread-safe cached formatter (read-only after init)
+ nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+
+ // MARK: - Public API
+
+ public func parseFile(
+ at path: String,
+ project: String,
+ processedHashes: inout Set
+ ) -> [UsageEntry] {
+ guard let fileData = loadFileData(at: path) else { return [] }
+ let lines = extractLines(from: fileData)
+ return lines.compactMap { lineData in
+ parseEntry(from: lineData, path: path, project: project, processedHashes: &processedHashes)
+ }
+ }
+
+ // MARK: - Entry Parsing
+
+ private func parseEntry(
+ from lineData: Data,
+ path: String,
+ project: String,
+ processedHashes: inout Set
+ ) -> UsageEntry? {
+ guard let rawData = decodeRawData(from: lineData),
+ let validated = validateAssistantMessage(rawData),
+ isUniqueEntry(rawData, validated, processedHashes: &processedHashes),
+ let timestamp = parseTimestamp(validated.timestampStr) else {
+ return nil
+ }
+
+ let tokens = createTokenCounts(from: validated.usage)
+ guard tokens.total > 0 else { return nil }
+
+ let model = validated.message.model ?? ""
+ let cost = rawData.costUSD ?? PricingCalculator.calculateCost(tokens: tokens, model: model)
+
+ return UsageEntry(
+ id: createEntryId(validated.message.id, rawData.requestId, timestamp),
+ timestamp: timestamp,
+ model: model,
+ tokens: tokens,
+ costUSD: cost,
+ project: project,
+ sourceFile: path,
+ sessionId: nil,
+ messageId: validated.message.id,
+ requestId: rawData.requestId
+ )
+ }
+
+ // MARK: - File I/O
+
+ private func loadFileData(at path: String) -> Data? {
+ try? Data(contentsOf: URL(fileURLWithPath: path))
+ }
+
+ private func extractLines(from data: Data) -> [Data] {
+ guard !data.isEmpty else { return [] }
+ return [UInt8](data).withUnsafeBufferPointer { buffer in
+ guard let ptr = buffer.baseAddress else { return [] }
+ return buildLineRanges(ptr: ptr, count: data.count).map { data[$0] }
+ }
+ }
+
+ // MARK: - Decoding
+
+ private func decodeRawData(from lineData: Data) -> RawJSONLData? {
+ guard !lineData.isEmpty else { return nil }
+ return try? JSONDecoder().decode(RawJSONLData.self, from: lineData)
+ }
+
+ private func validateAssistantMessage(_ raw: RawJSONLData) -> ValidatedData? {
+ guard let message = raw.message,
+ let usage = message.usage,
+ raw.type == "assistant",
+ let timestampStr = raw.timestamp else {
+ return nil
+ }
+ return ValidatedData(message: message, usage: usage, timestampStr: timestampStr)
+ }
+
+ private func isUniqueEntry(
+ _ raw: RawJSONLData,
+ _ validated: ValidatedData,
+ processedHashes: inout Set
+ ) -> Bool {
+ guard let hash = createDeduplicationHash(validated.message.id, raw.requestId) else {
+ return true
+ }
+ return processedHashes.insert(hash).inserted
+ }
+
+ // MARK: - Transformations
+
+ private func parseTimestamp(_ str: String) -> Date? {
+ Self.dateFormatter.date(from: str)
+ }
+
+ private func createTokenCounts(from usage: RawJSONLData.Message.Usage) -> TokenCounts {
+ TokenCounts(
+ input: usage.input_tokens ?? 0,
+ output: usage.output_tokens ?? 0,
+ cacheCreation: usage.cache_creation_input_tokens ?? 0,
+ cacheRead: usage.cache_read_input_tokens ?? 0
+ )
+ }
+
+ private func createDeduplicationHash(_ messageId: String?, _ requestId: String?) -> String? {
+ guard let messageId, let requestId else { return nil }
+ return "\(messageId):\(requestId)"
+ }
+
+ private func createEntryId(_ messageId: String?, _ requestId: String?, _ timestamp: Date) -> String {
+ if let messageId, let requestId {
+ return "\(messageId):\(requestId)"
+ }
+ return "\(timestamp.timeIntervalSince1970)-\(UUID().uuidString)"
+ }
+
+ // MARK: - Line Extraction
+
+ private func buildLineRanges(ptr: UnsafePointer, count: Int) -> [Range] {
+ var ranges: [Range] = []
+ var offset = 0
+
+ while offset < count {
+ let lineEnd = findLineEnd(ptr: ptr, offset: offset, count: count)
+ if lineEnd > offset {
+ ranges.append(offset.., offset: Int, count: Int) -> Int {
+ let remaining = count - offset
+ if let found = memchr(ptr + offset, 0x0A, remaining) {
+ return UnsafePointer(found.assumingMemoryBound(to: UInt8.self)) - ptr
+ }
+ return count
+ }
+}
+
+// MARK: - Raw JSONL Data Model
+
+struct RawJSONLData: Codable {
+ let timestamp: String?
+ let message: Message?
+ let costUSD: Double?
+ let type: String?
+ let requestId: String?
+
+ struct Message: Codable {
+ let usage: Usage?
+ let model: String?
+ let id: String?
+
+ struct Usage: Codable {
+ let input_tokens: Int?
+ let output_tokens: Int?
+ let cache_creation_input_tokens: Int?
+ let cache_read_input_tokens: Int?
+ }
+ }
+}
+
+private struct ValidatedData {
+ let message: RawJSONLData.Message
+ let usage: RawJSONLData.Message.Usage
+ let timestampStr: String
+}
diff --git a/Sources/ClaudeUsageData/Repository/FileDiscovery.swift b/Sources/ClaudeUsageData/Repository/FileDiscovery.swift
new file mode 100644
index 0000000..a7f06a8
--- /dev/null
+++ b/Sources/ClaudeUsageData/Repository/FileDiscovery.swift
@@ -0,0 +1,138 @@
+//
+// FileDiscovery.swift
+// ClaudeUsageData
+//
+// Discovers and manages Claude usage files
+//
+
+import Foundation
+
+// MARK: - FileDiscovery
+
+public enum FileDiscovery {
+ /// Discover all JSONL files in the projects directory
+ public static func discoverFiles(in basePath: String) throws -> [FileMetadata] {
+ let projectsPath = basePath + "/projects"
+ guard FileManager.default.fileExists(atPath: projectsPath) else {
+ return []
+ }
+
+ return try discoverProjectDirectories(in: projectsPath)
+ .flatMap { projectDir in
+ discoverJSONLFiles(in: projectDir)
+ }
+ }
+
+ /// Filter files modified today
+ public static func filterFilesModifiedToday(_ files: [FileMetadata]) -> [FileMetadata] {
+ let calendar = Calendar.current
+ let today = calendar.startOfDay(for: Date())
+ return files.filter { file in
+ calendar.startOfDay(for: file.modificationDate) >= today
+ }
+ }
+
+ // MARK: - Private
+
+ private static func discoverProjectDirectories(in path: String) throws -> [String] {
+ let fileManager = FileManager.default
+ let contents = try fileManager.contentsOfDirectory(atPath: path)
+
+ return contents.compactMap { item in
+ let fullPath = path + "/" + item
+ var isDirectory: ObjCBool = false
+ guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory),
+ isDirectory.boolValue else {
+ return nil
+ }
+ return fullPath
+ }
+ }
+
+ private static func discoverJSONLFiles(in projectDir: String) -> [FileMetadata] {
+ let fileManager = FileManager.default
+
+ guard let enumerator = fileManager.enumerator(
+ at: URL(fileURLWithPath: projectDir),
+ includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
+ options: [.skipsHiddenFiles]
+ ) else {
+ return []
+ }
+
+ var files: [FileMetadata] = []
+ let projectName = extractProjectName(from: projectDir)
+
+ for case let fileURL as URL in enumerator {
+ guard fileURL.pathExtension == "jsonl",
+ let metadata = createMetadata(for: fileURL, projectDir: projectDir, projectName: projectName) else {
+ continue
+ }
+ files.append(metadata)
+ }
+
+ return files
+ }
+
+ private static func createMetadata(
+ for url: URL,
+ projectDir: String,
+ projectName: String
+ ) -> FileMetadata? {
+ guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]),
+ values.isRegularFile == true,
+ let modDate = values.contentModificationDate else {
+ return nil
+ }
+
+ return FileMetadata(
+ path: url.path,
+ projectDir: projectDir,
+ projectName: projectName,
+ modificationDate: modDate
+ )
+ }
+
+ private static func extractProjectName(from path: String) -> String {
+ // Project dirs are hashed, e.g., "/Users/Projects/MyApp" -> "-Users-Projects-MyApp"
+ // Extract the last meaningful component
+ let components = path.split(separator: "/")
+ guard let last = components.last else { return path }
+
+ // The hash format uses dashes as path separators
+ let parts = last.split(separator: "-")
+ return parts.last.map(String.init) ?? String(last)
+ }
+}
+
+// MARK: - FileMetadata
+
+public struct FileMetadata: Sendable, Hashable {
+ public let path: String
+ public let projectDir: String
+ public let projectName: String
+ public let modificationDate: Date
+
+ public init(path: String, projectDir: String, projectName: String, modificationDate: Date) {
+ self.path = path
+ self.projectDir = projectDir
+ self.projectName = projectName
+ self.modificationDate = modificationDate
+ }
+}
+
+// MARK: - CachedFile
+
+public struct CachedFile: Sendable {
+ public let modificationDate: Date
+ public let entries: [ClaudeUsageCore.UsageEntry]
+ public let version: Int
+
+ public init(modificationDate: Date, entries: [ClaudeUsageCore.UsageEntry], version: Int) {
+ self.modificationDate = modificationDate
+ self.entries = entries
+ self.version = version
+ }
+
+ public static let currentVersion = 1
+}
diff --git a/Sources/ClaudeUsageData/Repository/UsageRepository.swift b/Sources/ClaudeUsageData/Repository/UsageRepository.swift
new file mode 100644
index 0000000..eddb5f6
--- /dev/null
+++ b/Sources/ClaudeUsageData/Repository/UsageRepository.swift
@@ -0,0 +1,95 @@
+//
+// UsageRepository.swift
+// ClaudeUsageData
+//
+// Repository for accessing Claude usage data
+//
+
+import Foundation
+import ClaudeUsageCore
+import OSLog
+
+private let logger = Logger(subsystem: "com.claudeusage", category: "Repository")
+
+// MARK: - UsageRepository
+
+public actor UsageRepository: UsageDataSource {
+ public let basePath: String
+
+ private let parser = JSONLParser()
+ private var fileCache: [String: CachedFile] = [:]
+ private var processedHashes = Set()
+
+ public init(basePath: String = NSHomeDirectory() + "/.claude") {
+ self.basePath = basePath
+ }
+
+ // MARK: - UsageDataSource
+
+ public func getTodayEntries() async throws -> [UsageEntry] {
+ let allFiles = try FileDiscovery.discoverFiles(in: basePath)
+ let todayFiles = FileDiscovery.filterFilesModifiedToday(allFiles)
+
+ logger.debug("Files: \(todayFiles.count) today / \(allFiles.count) total")
+
+ let entries = await loadEntries(from: todayFiles)
+ return UsageAggregator.filterToday(entries)
+ }
+
+ public func getUsageStats() async throws -> UsageStats {
+ let entries = try await getAllEntries()
+ return UsageAggregator.aggregate(entries)
+ }
+
+ public func getAllEntries() async throws -> [UsageEntry] {
+ let files = try FileDiscovery.discoverFiles(in: basePath)
+ return await loadEntries(from: files)
+ }
+
+ // MARK: - Additional Methods
+
+ public func clearCache() {
+ fileCache.removeAll()
+ processedHashes.removeAll()
+ }
+
+ // MARK: - Private Loading
+
+ private func loadEntries(from files: [FileMetadata]) async -> [UsageEntry] {
+ var allEntries: [UsageEntry] = []
+
+ for file in files {
+ let entries = loadEntriesFromFile(file)
+ allEntries.append(contentsOf: entries)
+ }
+
+ return allEntries.sorted()
+ }
+
+ private func loadEntriesFromFile(_ file: FileMetadata) -> [UsageEntry] {
+ // Check cache
+ if let cached = fileCache[file.path],
+ cached.modificationDate >= file.modificationDate,
+ cached.version == CachedFile.currentVersion {
+ return cached.entries
+ }
+
+ // Parse file
+ var localHashes = processedHashes
+ let entries = parser.parseFile(
+ at: file.path,
+ project: file.projectName,
+ processedHashes: &localHashes
+ )
+ processedHashes = localHashes
+
+ // Cache results
+ fileCache[file.path] = CachedFile(
+ modificationDate: file.modificationDate,
+ entries: entries,
+ version: CachedFile.currentVersion
+ )
+
+ return entries
+ }
+}
diff --git a/Tests/ClaudeCodeUsageKitTests/UsageRepositoryCacheTests.swift b/Tests/ClaudeCodeUsageKitTests/UsageRepositoryCacheTests.swift
deleted file mode 100644
index 02f5fbc..0000000
--- a/Tests/ClaudeCodeUsageKitTests/UsageRepositoryCacheTests.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-//
-// UsageRepositoryCacheTests.swift
-// ClaudeCodeUsageTests
-//
-// Tests for file-level caching in UsageRepository
-//
-
-import Testing
-import Foundation
-@testable import ClaudeCodeUsageKit
-
-@Suite("UsageRepository Cache Tests", .serialized) // Run serially to avoid shared state issues
-struct UsageRepositoryCacheTests {
-
- @Test("Should cache entries across multiple loads")
- func testCacheAcrossLoads() async throws {
- // Given - a fresh repository instance (avoids shared state issues)
- let repository = UsageRepository()
-
- // When - first load
- let start1 = Date()
- let stats1 = try await repository.getUsageStats()
- let time1 = Date().timeIntervalSince(start1)
-
- // When - second load (should be cached)
- let start2 = Date()
- let stats2 = try await repository.getUsageStats()
- let time2 = Date().timeIntervalSince(start2)
-
- // Then - results should be identical (within floating point tolerance)
- #expect(abs(stats1.totalCost - stats2.totalCost) < 0.01)
- #expect(stats1.totalTokens == stats2.totalTokens)
-
- // And - second load should be significantly faster
- print("First load: \(String(format: "%.3f", time1))s")
- print("Second load: \(String(format: "%.3f", time2))s")
- print("Speedup: \(String(format: "%.1f", time1 / max(time2, 0.001)))x")
-
- // Second load should be at least 5x faster if caching works
- if time1 > 0.5 {
- #expect(time2 < time1 * 0.5, "Cached load should be significantly faster")
- }
- }
-
- @Test("Should clear cache when requested")
- func testCacheClear() async throws {
- // Given - a repository with cached data
- let repository = UsageRepository()
- _ = try await repository.getUsageStats()
-
- // When - clear the cache
- await repository.clearCache()
-
- // Then - next load should work (no crash)
- let stats = try await repository.getUsageStats()
- #expect(stats.totalTokens >= 0)
- }
-
- @Test("Should detect modified files even after day rollover")
- func testModifiedFileDetectionAfterDayRollover() async throws {
- // This test verifies the fix for: "today's cost becomes $0 after day rollover"
- // Bug scenario:
- // 1. Day 1: File cached with modDate = Day 1
- // 2. Day 2: File modified but cache still returns stale Day 1 metadata
- // 3. filterFilesModifiedToday filters out the file → $0 cost
-
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("UsageRepoTest-\(UUID().uuidString)")
- let projectsDir = tempDir.appendingPathComponent("projects")
- let projectDir = projectsDir.appendingPathComponent("test-project")
-
- try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let repository = UsageRepository(basePath: tempDir.path)
- let sessionFile = projectDir.appendingPathComponent("test-session.jsonl")
-
- // Create initial entry (simulates Day 1 usage)
- let entry1 = createUsageEntry(cost: 5.0, timestamp: ISO8601DateFormatter().string(from: Date()))
- try entry1.write(to: sessionFile, atomically: true, encoding: .utf8)
-
- // First load - populates cache
- let stats1 = try await repository.getUsageStats()
- #expect(abs(stats1.totalCost - 5.0) < 0.01, "First load should see $5.00")
-
- // Simulate file modification (Day 2 - file completely replaced with new entry)
- // This simulates the real bug: cached file is modified, repository must detect it
- try await Task.sleep(for: .milliseconds(100)) // Ensure modification time changes
- let entry2 = createUsageEntry(cost: 20.0, timestamp: ISO8601DateFormatter().string(from: Date()))
- try entry2.write(to: sessionFile, atomically: true, encoding: .utf8)
-
- // Second load - should detect file modification and re-read the NEW content
- let stats2 = try await repository.getUsageStats()
- #expect(abs(stats2.totalCost - 20.0) < 0.01, "Should see new entry ($20.00) after file modification")
- }
-
- private func createUsageEntry(cost: Double, timestamp: String) -> String {
- """
- {"timestamp":"\(timestamp)","sessionId":"test-session","requestId":"req-\(UUID().uuidString)","message":{"id":"msg-\(UUID().uuidString)","model":"claude-3-5-sonnet-20241022","usage":{"input_tokens":1000,"output_tokens":500}},"costUSD":\(cost)}
- """
- }
-}
diff --git a/Tests/ClaudeCodeUsageKitTests/UsageRepositoryErrorTests.swift b/Tests/ClaudeCodeUsageKitTests/UsageRepositoryErrorTests.swift
deleted file mode 100644
index 3a2591c..0000000
--- a/Tests/ClaudeCodeUsageKitTests/UsageRepositoryErrorTests.swift
+++ /dev/null
@@ -1,382 +0,0 @@
-//
-// UsageRepositoryErrorTests.swift
-// ClaudeCodeUsageTests
-//
-// Tests for comprehensive error handling
-//
-
-import Testing
-import Foundation
-@testable import ClaudeCodeUsageKit
-
-@Suite("UsageRepositoryError Tests")
-struct UsageRepositoryErrorTests {
-
- // MARK: - Error Description Tests
-
- @Test("Should provide descriptive error messages")
- func testErrorDescriptions() {
- // Given
- let errors: [UsageRepositoryError] = [
- .invalidPath("/invalid/path"),
- .directoryNotFound(path: "/missing"),
- .fileReadFailed(path: "/file.txt", underlyingError: NSError(domain: "test", code: 1)),
- .parsingFailed(file: "data.json", line: 42, reason: "invalid JSON"),
- .batchProcessingFailed(batch: 3, filesProcessed: 10, error: NSError(domain: "test", code: 2)),
- .permissionDenied(path: "/private"),
- .quotaExceeded(limit: 100, attempted: 150),
- .corruptedData(file: "corrupt.json", details: "unexpected EOF"),
- .timeout(operation: "fetch", duration: 30.5)
- ]
-
- // Then
- for error in errors {
- let description = error.errorDescription
- #expect(description != nil)
- #expect(!description!.isEmpty)
- }
- }
-
- @Test("Should provide recovery suggestions")
- func testRecoverySuggestions() {
- // Given
- let errors: [UsageRepositoryError] = [
- .invalidPath("/path"),
- .fileReadFailed(path: "/file", underlyingError: NSError(domain: "test", code: 1)),
- .parsingFailed(file: "file", line: nil, reason: "error"),
- .batchProcessingFailed(batch: 1, filesProcessed: 5, error: NSError(domain: "test", code: 2)),
- .quotaExceeded(limit: 10, attempted: 20),
- .networkError(NSError(domain: "network", code: 3)),
- .timeout(operation: "op", duration: 10)
- ]
-
- // Then
- for error in errors {
- let suggestion = error.recoverySuggestion
- #expect(suggestion != nil)
- #expect(!suggestion!.isEmpty)
- }
- }
-
- @Test("Should identify recoverable errors")
- func testRecoverableErrors() {
- // Given
- let recoverableErrors: [UsageRepositoryError] = [
- .networkError(NSError(domain: "test", code: 1)),
- .timeout(operation: "test", duration: 5),
- .batchProcessingFailed(batch: 1, filesProcessed: 0, error: NSError(domain: "test", code: 2)),
- .fileReadFailed(path: "/file", underlyingError: NSError(domain: "test", code: 3)),
- .quotaExceeded(limit: 100, attempted: 200)
- ]
-
- let nonRecoverableErrors: [UsageRepositoryError] = [
- .invalidPath("/path"),
- .directoryNotFound(path: "/missing"),
- .permissionDenied(path: "/private")
- ]
-
- // Then
- for error in recoverableErrors {
- #expect(error.isRecoverable == true)
- }
-
- for error in nonRecoverableErrors {
- #expect(error.isRecoverable == false)
- }
- }
-
- @Test("Should suggest retry delays")
- func testRetryDelays() {
- // Given
- let errorsWithDelay: [(UsageRepositoryError, TimeInterval?)] = [
- (.networkError(NSError(domain: "test", code: 1)), 2.0),
- (.timeout(operation: "test", duration: 10), 5.0),
- (.batchProcessingFailed(batch: 1, filesProcessed: 0, error: NSError(domain: "test", code: 2)), 1.0),
- (.invalidPath("/path"), nil),
- (.permissionDenied(path: "/private"), nil)
- ]
-
- // Then
- for (error, expectedDelay) in errorsWithDelay {
- #expect(error.suggestedRetryDelay == expectedDelay)
- }
- }
-
- // MARK: - Error Context Tests
-
- @Test("Should create error context correctly")
- func testErrorContext() {
- // Given
- let context = ErrorContext(
- file: "/path/to/file.swift",
- function: "testFunction()",
- line: 42,
- additionalInfo: ["key": "value", "count": "10"]
- )
-
- // Then
- #expect(context.file == "file.swift")
- #expect(context.function == "testFunction()")
- #expect(context.line == 42)
- #expect(context.additionalInfo["key"] == "value")
- #expect(context.additionalInfo["count"] == "10")
-
- let description = context.description
- #expect(description.contains("file.swift"))
- #expect(description.contains("42"))
- #expect(description.contains("testFunction"))
- }
-
- @Test("Should create enhanced error with context")
- func testEnhancedError() {
- // Given
- let baseError = UsageRepositoryError.invalidPath("/test")
- let context = ErrorContext(file: "test.swift", function: "test()", line: 10)
- let enhancedError = EnhancedError(baseError, context: context)
-
- // Then
- #expect(enhancedError.errorDescription != nil)
- #expect(enhancedError.failureReason != nil)
- #expect(enhancedError.failureReason!.contains("test.swift"))
- }
-
- // MARK: - Error Recovery Strategy Tests
-
- @Test("Should execute retry strategy")
- func testRetryStrategy() async throws {
- // Given
- final class Counter: @unchecked Sendable {
- var count = 0
- }
- let counter = Counter()
- let strategy = ErrorRecoveryStrategy.retry(maxAttempts: 3, delay: 0.01)
-
- // When - Operation that fails twice then succeeds
- let result = try await strategy.execute(
- operation: {
- counter.count += 1
- if counter.count < 3 {
- throw TestError.temporary
- }
- return 42
- },
- onError: { _ in }
- )
-
- // Then
- #expect(result == 42)
- #expect(counter.count == 3)
- }
-
- @Test("Should fail after max retry attempts")
- func testRetryStrategyFailure() async {
- // Given
- final class Counter: @unchecked Sendable {
- var count = 0
- }
- let counter = Counter()
- let strategy = ErrorRecoveryStrategy.retry(maxAttempts: 2, delay: 0.01)
-
- // When/Then
- await #expect(throws: TestError.self) {
- _ = try await strategy.execute(
- operation: {
- throw TestError.permanent
- },
- onError: { _ in
- counter.count += 1
- }
- )
- }
-
- #expect(counter.count == 2)
- }
-
- @Test("Should execute skip strategy")
- func testSkipStrategy() async throws {
- // Given
- final class FlagHolder: @unchecked Sendable {
- var handled = false
- }
- let holder = FlagHolder()
- let strategy = ErrorRecoveryStrategy.skip
-
- // When
- let result: Void? = try await strategy.execute(
- operation: {
- throw TestError.temporary
- },
- onError: { _ in
- holder.handled = true
- }
- )
-
- // Then
- #expect(result == nil)
- #expect(holder.handled == true)
- }
-
- @Test("Should execute fallback strategy")
- func testFallbackStrategy() async throws {
- // Given
- final class FlagHolder: @unchecked Sendable {
- var executed = false
- }
- let holder = FlagHolder()
- let strategy = ErrorRecoveryStrategy.fallback {
- holder.executed = true
- }
-
- // When
- let result: Void? = try await strategy.execute(
- operation: {
- throw TestError.temporary
- },
- onError: { _ in }
- )
-
- // Then
- #expect(result == nil)
- #expect(holder.executed == true)
- }
-
- @Test("Should abort on error with abort strategy")
- func testAbortStrategy() async {
- // Given
- let strategy = ErrorRecoveryStrategy.abort
-
- // When/Then
- await #expect(throws: TestError.self) {
- _ = try await strategy.execute(
- operation: {
- throw TestError.permanent
- },
- onError: { _ in }
- )
- }
- }
-
- // MARK: - Error Aggregator Tests
-
- @Test("Should aggregate errors")
- func testErrorAggregator() async {
- // Given
- let aggregator = ErrorAggregator(maxErrors: 5)
-
- // When
- await aggregator.record(TestError.temporary)
- await aggregator.record(TestError.permanent)
- await aggregator.record(UsageRepositoryError.invalidPath("/test"))
-
- // Then
- let errors = await aggregator.getErrors()
- #expect(errors.count == 3)
- #expect(await aggregator.hasErrors() == true)
- }
-
- @Test("Should limit aggregated errors")
- func testErrorAggregatorLimit() async {
- // Given
- let aggregator = ErrorAggregator(maxErrors: 3)
-
- // When - Add more than max
- for i in 1...5 {
- await aggregator.record(TestError.numbered(i))
- }
-
- // Then
- let errors = await aggregator.getErrors()
- #expect(errors.count == 3)
- }
-
- @Test("Should generate error summary")
- func testErrorSummary() async {
- // Given
- let aggregator = ErrorAggregator()
-
- await aggregator.record(TestError.temporary)
- await aggregator.record(TestError.temporary)
- await aggregator.record(TestError.permanent)
- await aggregator.record(UsageRepositoryError.invalidPath("/test"))
-
- // When
- let summary = await aggregator.getSummary()
-
- // Then
- #expect(summary.contains("Error Summary"))
- #expect(summary.contains("4 total"))
- #expect(summary.contains("TestError"))
- #expect(summary.contains("UsageRepositoryError"))
- }
-
- @Test("Should clear aggregated errors")
- func testClearErrors() async {
- // Given
- let aggregator = ErrorAggregator()
- await aggregator.record(TestError.temporary)
-
- // When
- await aggregator.clear()
-
- // Then
- #expect(await aggregator.hasErrors() == false)
- #expect(await aggregator.getErrors().isEmpty)
- }
-
- // MARK: - Task Retry Extension Tests
-
- @Test("Should retry task with exponential backoff")
- func testTaskRetrying() async throws {
- // Given
- final class Counter: @unchecked Sendable {
- var count = 0
- }
- let counter = Counter()
-
- // When
- let result = try await Task.retrying(
- maxRetryCount: 3,
- initialDelay: 0.01
- ) {
- counter.count += 1
- if counter.count < 2 {
- throw TestError.temporary
- }
- return "success"
- }
-
- // Then
- #expect(result == "success")
- #expect(counter.count == 2)
- }
-
- @Test("Should fail task after max retries")
- func testTaskRetryingFailure() async {
- // Given
- final class Counter: @unchecked Sendable {
- var count = 0
- }
- let counter = Counter()
-
- // When/Then
- await #expect(throws: TestError.self) {
- _ = try await Task.retrying(
- maxRetryCount: 2,
- initialDelay: 0.01
- ) {
- counter.count += 1
- throw TestError.permanent
- }
- }
-
- #expect(counter.count == 2)
- }
-}
-
-// MARK: - Test Helpers
-
-private enum TestError: Error, Equatable {
- case temporary
- case permanent
- case numbered(Int)
-}
\ No newline at end of file
diff --git a/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift b/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift
new file mode 100644
index 0000000..789f2da
--- /dev/null
+++ b/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift
@@ -0,0 +1,32 @@
+//
+// TokenCountsTests.swift
+// ClaudeUsageCoreTests
+//
+
+import Testing
+@testable import ClaudeUsageCore
+
+@Suite("TokenCounts")
+struct TokenCountsTests {
+ @Test("calculates total correctly")
+ func totalCalculation() {
+ let tokens = TokenCounts(input: 100, output: 50, cacheCreation: 25, cacheRead: 10)
+ #expect(tokens.total == 185)
+ }
+
+ @Test("addition works correctly")
+ func addition() {
+ let a = TokenCounts(input: 100, output: 50)
+ let b = TokenCounts(input: 50, output: 25)
+ let sum = a + b
+ #expect(sum.input == 150)
+ #expect(sum.output == 75)
+ }
+
+ @Test("zero is identity for addition")
+ func zeroIdentity() {
+ let tokens = TokenCounts(input: 100, output: 50)
+ let sum = tokens + .zero
+ #expect(sum == tokens)
+ }
+}
diff --git a/Tests/ClaudeUsageDataTests/JSONLParserTests.swift b/Tests/ClaudeUsageDataTests/JSONLParserTests.swift
new file mode 100644
index 0000000..1ead501
--- /dev/null
+++ b/Tests/ClaudeUsageDataTests/JSONLParserTests.swift
@@ -0,0 +1,17 @@
+//
+// JSONLParserTests.swift
+// ClaudeUsageDataTests
+//
+
+import Testing
+@testable import ClaudeUsageData
+@testable import ClaudeUsageCore
+
+@Suite("JSONLParser")
+struct JSONLParserTests {
+ @Test("parser initializes correctly")
+ func initialization() {
+ let parser = JSONLParser()
+ #expect(parser != nil)
+ }
+}
diff --git a/Tests/ClaudeCodeUsageTests/HeatmapViewModelTests.swift b/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift
similarity index 99%
rename from Tests/ClaudeCodeUsageTests/HeatmapViewModelTests.swift
rename to Tests/ClaudeUsageTests/HeatmapViewModelTests.swift
index 09d89fb..26afbbc 100644
--- a/Tests/ClaudeCodeUsageTests/HeatmapViewModelTests.swift
+++ b/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift
@@ -6,8 +6,8 @@
import Testing
import Foundation
-@testable import ClaudeCodeUsageKit
-@testable import ClaudeCodeUsage
+@testable import ClaudeUsageCore
+@testable import ClaudeUsage
// MARK: - User Story: Yearly Cost Heatmap Visualization
diff --git a/Tests/ClaudeCodeUsageTests/MemoryMonitorTests.swift b/Tests/ClaudeUsageTests/MemoryMonitorTests.swift
similarity index 99%
rename from Tests/ClaudeCodeUsageTests/MemoryMonitorTests.swift
rename to Tests/ClaudeUsageTests/MemoryMonitorTests.swift
index 70e2af8..999ba44 100644
--- a/Tests/ClaudeCodeUsageTests/MemoryMonitorTests.swift
+++ b/Tests/ClaudeUsageTests/MemoryMonitorTests.swift
@@ -1,13 +1,13 @@
//
// MemoryMonitorTests.swift
-// ClaudeCodeUsageTests
+// ClaudeUsageTests
//
// Tests for memory monitoring functionality
//
import Foundation
import Testing
-@testable import ClaudeCodeUsage
+@testable import ClaudeUsage
@Suite("Memory Monitor Tests")
struct MemoryMonitorTests {