From 227eee29bbf8b76e6de3abe2e4fa337a7039bf35 Mon Sep 17 00:00:00 2001 From: webcpu Date: Mon, 29 Dec 2025 14:28:20 +0100 Subject: [PATCH 1/6] refactor(arch): introduce clean architecture with layered packages - Add ClaudeUsageCore: domain layer with unified models and protocols - Add ClaudeUsageData: data layer with repository and monitoring - Add ClaudeMonitorCLI: new unified CLI using ClaudeUsageData - Rename app from ClaudeCodeUsage to ClaudeUsage - Unify UsageEntry, TokenCounts, SessionBlock into single definitions - Establish clean downward dependency flow: App -> Data -> Core --- .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../ClaudeCodeUsage-Package.xcscheme | 118 ----------- .../xcschemes/ClaudeCodeUsageCLI.xcscheme | 78 ------- .../xcschemes/ClaudeCodeUsageKit.xcscheme | 67 ------ ...odeUsage.xcscheme => ClaudeUsage.xcscheme} | 18 +- Package.swift | 83 ++++++-- Sources/ClaudeMonitorCLI/main.swift | 45 ++++ .../AppLifecycleManager.swift | 0 .../ClaudeCodeUsageApp.swift | 0 .../Main/Analytics/AnalyticsCard.swift | 0 .../Main/Analytics/AnalyticsRows.swift | 0 .../Main/Analytics/AnalyticsView.swift | 0 .../Main/Analytics/PredictionsCard.swift | 0 .../Analytics/TokenDistributionCard.swift | 0 .../Main/Analytics/UsageTrendsCard.swift | 0 .../Analytics/YearlyCostHeatmapCard.swift | 0 .../Main/Components/EmptyStateView.swift | 0 .../Main/Components/MetricCard.swift | 0 .../Main/DailyUsageView.swift | 0 .../Main/MainView.swift | 0 .../Main/ModelsView.swift | 0 .../Main/OverviewView.swift | 0 .../HeatmapConfiguration+Accessibility.swift | 0 .../HeatmapConfiguration+ColorThemes.swift | 0 .../HeatmapConfiguration.swift | 0 .../Charts/Heatmap/Models/HeatmapData.swift | 0 .../Heatmap/Utilities/ColorScheme.swift | 0 .../Heatmap/Utilities/DateConstants.swift | 0 .../Utilities/DateRangeValidation.swift | 0 .../Utilities/HeatmapDateCalculator.swift | 0 .../Heatmap/Utilities/MonthOperations.swift | 0 .../Heatmap/Utilities/WeekOperations.swift | 0 .../HeatmapViewModel+DataGeneration.swift | 0 .../HeatmapViewModel+SupportingTypes.swift | 0 .../HeatmapViewModel/HeatmapViewModel.swift | 0 .../Views/HeatmapGrid/BorderStyle.swift | 0 .../Heatmap/Views/HeatmapGrid/DaySquare.swift | 0 .../Views/HeatmapGrid/HeatmapGrid.swift | 0 .../Views/HeatmapGrid/HeatmapGridLayout.swift | 0 .../HeatmapGrid/HeatmapGridPerformance.swift | 0 .../Views/HeatmapGrid/WeekColumn.swift | 0 .../HeatmapLegend/ActivityLevelLabels.swift | 0 .../HeatmapLegend/HeatmapLegend+Factory.swift | 0 .../Views/HeatmapLegend/HeatmapLegend.swift | 0 .../HeatmapLegend/HeatmapLegendBuilder.swift | 0 .../Views/HeatmapLegend/LegendSquare.swift | 0 .../Views/HeatmapTooltip/ActivityLevel.swift | 0 .../HeatmapTooltip+Factory.swift | 0 .../Views/HeatmapTooltip/HeatmapTooltip.swift | 0 .../HeatmapTooltipBuilder.swift | 0 .../HeatmapTooltip/TooltipConfiguration.swift | 0 .../HeatmapTooltip/TooltipPositioning.swift | 0 .../YearlyCostHeatmap+Factories.swift | 0 .../YearlyCostHeatmap+Preview.swift | 0 .../YearlyCostHeatmap/YearlyCostHeatmap.swift | 0 .../HourlyChart/HourlyChartModels.swift | 0 .../HourlyChart/HourlyCostChartSimple.swift | 0 .../HourlyChart/HourlyTooltipViews.swift | 0 .../MenuBar/Components/ActionButtons.swift | 0 .../MenuBar/Components/GraphView.swift | 0 .../MenuBar/Components/MetricRow.swift | 0 .../MenuBar/Components/ProgressBar.swift | 0 .../MenuBar/Components/SectionHeader.swift | 0 .../MenuBar/Components/SettingsMenu.swift | 0 .../MenuBar/MenuBarContentView.swift | 0 .../MenuBar/Sections/CostMetricsSection.swift | 0 .../Sections/SessionMetricsSection.swift | 0 .../Sections/UsageMetricsSection.swift | 0 .../BarChartView/BarChartView+BarView.swift | 0 .../BarChartView/BarChartView+Grid.swift | 0 .../BarChartView/BarChartView+Tooltip.swift | 0 .../BarChartView/BarChartView.swift | 0 .../Shared/Components/EmptyStateViews.swift | 0 .../Shared/Components/MemoryMonitorView.swift | 0 .../Shared/Components/OpenAtLoginToggle.swift | 0 .../Shared/Protocols/ClockProtocol.swift | 0 .../Shared/Services/AppConfiguration.swift | 0 .../Shared/Services/AppSettingsService.swift | 0 .../Shared/Services/ColorService.swift | 0 .../Shared/Services/FormatterService.swift | 0 .../Shared/Services/LoadTrace.swift | 0 .../MemoryMonitor/MemoryMonitor+System.swift | 0 .../MemoryMonitor/MemoryMonitor+Types.swift | 0 .../MemoryMonitor/MemoryMonitor.swift | 0 .../Services/SessionMonitorService.swift | 0 .../Shared/Store/DirectoryMonitor.swift | 0 .../Shared/Store/RefreshCoordinator.swift | 0 .../Shared/Store/UsageDataLoader.swift | 0 .../Shared/Store/UsageStore.swift | 0 .../Shared/Theme/MenuBarStyles.swift | 0 .../Shared/Theme/MenuBarTheme.swift | 0 .../Analytics/PricingCalculator.swift | 92 +++++++++ .../Analytics/UsageAggregator.swift | 128 ++++++++++++ Sources/ClaudeUsageCore/ClaudeUsageCore.swift | 18 ++ Sources/ClaudeUsageCore/Models/BurnRate.swift | 47 +++++ .../ClaudeUsageCore/Models/SessionBlock.swift | 81 ++++++++ .../ClaudeUsageCore/Models/TokenCounts.swift | 50 +++++ .../ClaudeUsageCore/Models/UsageEntry.swift | 61 ++++++ .../ClaudeUsageCore/Models/UsageStats.swift | 153 ++++++++++++++ .../Protocols/UsageRepository.swift | 34 +++ Sources/ClaudeUsageData/ClaudeUsageData.swift | 15 ++ .../Monitoring/DirectoryMonitor.swift | 86 ++++++++ .../Monitoring/SessionMonitorImpl.swift | 192 +++++++++++++++++ .../ClaudeUsageData/Parsing/JSONLParser.swift | 194 ++++++++++++++++++ .../Repository/FileDiscovery.swift | 138 +++++++++++++ .../Repository/UsageRepositoryImpl.swift | 95 +++++++++ .../TokenCountsTests.swift | 32 +++ .../JSONLParserTests.swift | 17 ++ .../HeatmapViewModelTests.swift | 2 +- .../MemoryMonitorTests.swift | 4 +- 110 files changed, 1568 insertions(+), 288 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsage-Package.xcscheme delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageCLI.xcscheme delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/ClaudeCodeUsageKit.xcscheme rename .swiftpm/xcode/xcshareddata/xcschemes/{ClaudeCodeUsage.xcscheme => ClaudeUsage.xcscheme} (84%) create mode 100644 Sources/ClaudeMonitorCLI/main.swift rename Sources/{ClaudeCodeUsage => ClaudeUsage}/AppLifecycleManager.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/ClaudeCodeUsageApp.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/AnalyticsCard.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/AnalyticsRows.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/AnalyticsView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/PredictionsCard.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/TokenDistributionCard.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/UsageTrendsCard.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Analytics/YearlyCostHeatmapCard.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Components/EmptyStateView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/Components/MetricCard.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/DailyUsageView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/MainView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/ModelsView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Main/OverviewView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+Accessibility.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration+ColorThemes.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Models/HeatmapConfiguration/HeatmapConfiguration.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Models/HeatmapData.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Utilities/ColorScheme.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Utilities/DateConstants.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Utilities/DateRangeValidation.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Utilities/HeatmapDateCalculator.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Utilities/MonthOperations.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Utilities/WeekOperations.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+SupportingTypes.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapGrid/BorderStyle.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapGrid/DaySquare.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGrid.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridLayout.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapGrid/HeatmapGridPerformance.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapGrid/WeekColumn.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapLegend/ActivityLevelLabels.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend+Factory.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegend.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapLegend/HeatmapLegendBuilder.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapLegend/LegendSquare.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/ActivityLevel.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip+Factory.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltip.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/HeatmapTooltipBuilder.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipConfiguration.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/HeatmapTooltip/TooltipPositioning.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/HourlyChart/HourlyChartModels.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Charts/HourlyChart/HourlyTooltipViews.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Components/ActionButtons.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Components/GraphView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Components/MetricRow.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Components/ProgressBar.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Components/SectionHeader.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Components/SettingsMenu.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/MenuBarContentView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Sections/CostMetricsSection.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Sections/SessionMetricsSection.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/MenuBar/Sections/UsageMetricsSection.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/BarChartView/BarChartView+BarView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/BarChartView/BarChartView+Grid.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/BarChartView/BarChartView+Tooltip.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/BarChartView/BarChartView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/EmptyStateViews.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/MemoryMonitorView.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Components/OpenAtLoginToggle.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Protocols/ClockProtocol.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/AppConfiguration.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/AppSettingsService.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/ColorService.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/FormatterService.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/LoadTrace.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/MemoryMonitor/MemoryMonitor+System.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/MemoryMonitor/MemoryMonitor+Types.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/MemoryMonitor/MemoryMonitor.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Services/SessionMonitorService.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Store/DirectoryMonitor.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Store/RefreshCoordinator.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Store/UsageDataLoader.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Store/UsageStore.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Theme/MenuBarStyles.swift (100%) rename Sources/{ClaudeCodeUsage => ClaudeUsage}/Shared/Theme/MenuBarTheme.swift (100%) create mode 100644 Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift create mode 100644 Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift create mode 100644 Sources/ClaudeUsageCore/ClaudeUsageCore.swift create mode 100644 Sources/ClaudeUsageCore/Models/BurnRate.swift create mode 100644 Sources/ClaudeUsageCore/Models/SessionBlock.swift create mode 100644 Sources/ClaudeUsageCore/Models/TokenCounts.swift create mode 100644 Sources/ClaudeUsageCore/Models/UsageEntry.swift create mode 100644 Sources/ClaudeUsageCore/Models/UsageStats.swift create mode 100644 Sources/ClaudeUsageCore/Protocols/UsageRepository.swift create mode 100644 Sources/ClaudeUsageData/ClaudeUsageData.swift create mode 100644 Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift create mode 100644 Sources/ClaudeUsageData/Monitoring/SessionMonitorImpl.swift create mode 100644 Sources/ClaudeUsageData/Parsing/JSONLParser.swift create mode 100644 Sources/ClaudeUsageData/Repository/FileDiscovery.swift create mode 100644 Sources/ClaudeUsageData/Repository/UsageRepositoryImpl.swift create mode 100644 Tests/ClaudeUsageCoreTests/TokenCountsTests.swift create mode 100644 Tests/ClaudeUsageDataTests/JSONLParserTests.swift rename Tests/{ClaudeCodeUsageTests => ClaudeUsageTests}/HeatmapViewModelTests.swift (99%) rename Tests/{ClaudeCodeUsageTests => ClaudeUsageTests}/MemoryMonitorTests.swift (99%) 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.swift b/Package.swift index a2ef9fa..e93dabe 100644 --- a/Package.swift +++ b/Package.swift @@ -3,36 +3,90 @@ import PackageDescription let package = Package( - name: "ClaudeCodeUsage", + name: "ClaudeUsage", platforms: [ .macOS(.v15), ], products: [ + // Domain layer - pure types, protocols, analytics + .library( + name: "ClaudeUsageCore", + targets: ["ClaudeUsageCore"]), + // Data layer - repository, parsing, monitoring + .library( + name: "ClaudeUsageData", + targets: ["ClaudeUsageData"]), + // Legacy SDK (deprecated, use ClaudeUsageData) .library( name: "ClaudeCodeUsageKit", targets: ["ClaudeCodeUsageKit"]), + // macOS menu bar app + .executable( + name: "ClaudeUsage", + targets: ["ClaudeUsage"]), + // CLI monitor (new unified CLI) .executable( - name: "ClaudeCodeUsage", - targets: ["ClaudeCodeUsage"]) + name: "claude-usage", + targets: ["ClaudeMonitorCLI"]) ], dependencies: [ - .package(path: "Packages/ClaudeLiveMonitor"), .package(path: "Packages/TimingMacro") ], targets: [ + // MARK: - Domain Layer (no dependencies) + + .target( + name: "ClaudeUsageCore", + dependencies: [], + path: "Sources/ClaudeUsageCore"), + + // MARK: - Data Layer (depends on Core) + + .target( + name: "ClaudeUsageData", + dependencies: ["ClaudeUsageCore"], + path: "Sources/ClaudeUsageData"), + + // MARK: - Legacy SDK (transitional, will be removed) + .target( name: "ClaudeCodeUsageKit", dependencies: [ + "ClaudeUsageCore", .product(name: "TimingMacro", package: "TimingMacro") ], path: "Sources/ClaudeCodeUsageKit"), + + // MARK: - Presentation Layer + .executableTarget( - name: "ClaudeCodeUsage", + name: "ClaudeUsage", dependencies: [ - "ClaudeCodeUsageKit", - .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") + "ClaudeUsageData", + "ClaudeCodeUsageKit", // Transitional + .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") // Transitional ], - path: "Sources/ClaudeCodeUsage"), + path: "Sources/ClaudeUsage"), + + // MARK: - CLI + + .executableTarget( + name: "ClaudeMonitorCLI", + dependencies: ["ClaudeUsageData"], + path: "Sources/ClaudeMonitorCLI"), + + // MARK: - Tests + + .testTarget( + name: "ClaudeUsageCoreTests", + dependencies: ["ClaudeUsageCore"], + path: "Tests/ClaudeUsageCoreTests"), + + .testTarget( + name: "ClaudeUsageDataTests", + dependencies: ["ClaudeUsageData"], + path: "Tests/ClaudeUsageDataTests"), + .testTarget( name: "ClaudeCodeUsageKitTests", dependencies: ["ClaudeCodeUsageKit"], @@ -41,17 +95,20 @@ let package = Package( .unsafeFlags(["-enable-testing"]), .define("ENABLE_CODE_COVERAGE", .when(configuration: .debug)) ]), + .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)) ]), ] ) + +// Add ClaudeLiveMonitor as local dependency (transitional - will be merged into ClaudeUsageData) +package.dependencies.append(.package(path: "Packages/ClaudeLiveMonitor")) diff --git a/Sources/ClaudeMonitorCLI/main.swift b/Sources/ClaudeMonitorCLI/main.swift new file mode 100644 index 0000000..b5d43fd --- /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 = UsageRepositoryImpl() + let sessionMonitor = SessionMonitorImpl() + + 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 100% rename from Sources/ClaudeCodeUsage/ClaudeCodeUsageApp.swift rename to Sources/ClaudeUsage/ClaudeCodeUsageApp.swift 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 100% rename from Sources/ClaudeCodeUsage/Main/Analytics/AnalyticsView.swift rename to Sources/ClaudeUsage/Main/Analytics/AnalyticsView.swift diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/PredictionsCard.swift b/Sources/ClaudeUsage/Main/Analytics/PredictionsCard.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/Analytics/PredictionsCard.swift rename to Sources/ClaudeUsage/Main/Analytics/PredictionsCard.swift diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/TokenDistributionCard.swift b/Sources/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/Analytics/TokenDistributionCard.swift rename to Sources/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/UsageTrendsCard.swift b/Sources/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/Analytics/UsageTrendsCard.swift rename to Sources/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift diff --git a/Sources/ClaudeCodeUsage/Main/Analytics/YearlyCostHeatmapCard.swift b/Sources/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/Analytics/YearlyCostHeatmapCard.swift rename to Sources/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift 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 100% rename from Sources/ClaudeCodeUsage/Main/DailyUsageView.swift rename to Sources/ClaudeUsage/Main/DailyUsageView.swift diff --git a/Sources/ClaudeCodeUsage/Main/MainView.swift b/Sources/ClaudeUsage/Main/MainView.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/MainView.swift rename to Sources/ClaudeUsage/Main/MainView.swift diff --git a/Sources/ClaudeCodeUsage/Main/ModelsView.swift b/Sources/ClaudeUsage/Main/ModelsView.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/ModelsView.swift rename to Sources/ClaudeUsage/Main/ModelsView.swift diff --git a/Sources/ClaudeCodeUsage/Main/OverviewView.swift b/Sources/ClaudeUsage/Main/OverviewView.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Main/OverviewView.swift rename to Sources/ClaudeUsage/Main/OverviewView.swift 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 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift 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 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift 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 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift 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 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift similarity index 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift rename to Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift similarity index 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift rename to Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift diff --git a/Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift similarity index 100% rename from Sources/ClaudeCodeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift rename to Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift 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 100% rename from Sources/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift rename to Sources/ClaudeUsage/MenuBar/Components/ActionButtons.swift 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 100% rename from Sources/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift rename to Sources/ClaudeUsage/MenuBar/MenuBarContentView.swift diff --git a/Sources/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift similarity index 100% rename from Sources/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift rename to Sources/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift diff --git a/Sources/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift similarity index 100% rename from Sources/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift rename to Sources/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift diff --git a/Sources/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift similarity index 100% rename from Sources/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift rename to Sources/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift 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 100% rename from Sources/ClaudeCodeUsage/Shared/Components/EmptyStateViews.swift rename to Sources/ClaudeUsage/Shared/Components/EmptyStateViews.swift 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 100% rename from Sources/ClaudeCodeUsage/Shared/Services/SessionMonitorService.swift rename to Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift 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 100% rename from Sources/ClaudeCodeUsage/Shared/Store/UsageDataLoader.swift rename to Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift diff --git a/Sources/ClaudeCodeUsage/Shared/Store/UsageStore.swift b/Sources/ClaudeUsage/Shared/Store/UsageStore.swift similarity index 100% rename from Sources/ClaudeCodeUsage/Shared/Store/UsageStore.swift rename to Sources/ClaudeUsage/Shared/Store/UsageStore.swift 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..5bb41b7 --- /dev/null +++ b/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift @@ -0,0 +1,92 @@ +// +// 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 + } + + // Current pricing as of 2024 + public static let opus = ModelPricing( + inputPerMillion: 15.0, + outputPerMillion: 75.0, + cacheWritePerMillion: 18.75, + cacheReadPerMillion: 1.50 + ) + + public static let sonnet = ModelPricing( + inputPerMillion: 3.0, + outputPerMillion: 15.0, + cacheWritePerMillion: 3.75, + cacheReadPerMillion: 0.30 + ) + + public static let haiku = ModelPricing( + inputPerMillion: 0.25, + outputPerMillion: 1.25, + cacheWritePerMillion: 0.30, + cacheReadPerMillion: 0.03 + ) +} 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/ClaudeUsageCore/ClaudeUsageCore.swift b/Sources/ClaudeUsageCore/ClaudeUsageCore.swift new file mode 100644 index 0000000..17219a7 --- /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: UsageRepository, SessionMonitor +// - Analytics: PricingCalculator, UsageAggregator +// + +import Foundation + +// Re-export for convenience +public typealias Cost = Double +public typealias TokenCount = Int 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/UsageRepository.swift b/Sources/ClaudeUsageCore/Protocols/UsageRepository.swift new file mode 100644 index 0000000..0c621bf --- /dev/null +++ b/Sources/ClaudeUsageCore/Protocols/UsageRepository.swift @@ -0,0 +1,34 @@ +// +// UsageRepository.swift +// ClaudeUsageCore +// +// Protocol defining usage data access +// + +import Foundation + +// MARK: - UsageRepository Protocol + +public protocol UsageRepository: 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: - SessionMonitor Protocol + +public protocol SessionMonitor: 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..e5e6b96 --- /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: UsageRepositoryImpl +// - Parsing: JSONLParser +// - Monitoring: DirectoryMonitor, SessionMonitorImpl +// + +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/SessionMonitorImpl.swift b/Sources/ClaudeUsageData/Monitoring/SessionMonitorImpl.swift new file mode 100644 index 0000000..9f95cfd --- /dev/null +++ b/Sources/ClaudeUsageData/Monitoring/SessionMonitorImpl.swift @@ -0,0 +1,192 @@ +// +// SessionMonitorImpl.swift +// ClaudeUsageData +// +// Implementation of SessionMonitor protocol for live session detection +// + +import Foundation +import ClaudeUsageCore + +// MARK: - SessionMonitorImpl + +public actor SessionMonitorImpl: SessionMonitor { + 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: - SessionMonitor Protocol + + 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/UsageRepositoryImpl.swift b/Sources/ClaudeUsageData/Repository/UsageRepositoryImpl.swift new file mode 100644 index 0000000..5dad1a5 --- /dev/null +++ b/Sources/ClaudeUsageData/Repository/UsageRepositoryImpl.swift @@ -0,0 +1,95 @@ +// +// UsageRepositoryImpl.swift +// ClaudeUsageData +// +// Implementation of UsageRepository protocol +// + +import Foundation +import ClaudeUsageCore +import OSLog + +private let logger = Logger(subsystem: "com.claudeusage", category: "Repository") + +// MARK: - UsageRepositoryImpl + +public actor UsageRepositoryImpl: UsageRepository { + 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: - UsageRepository Protocol + + 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/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..d371b54 100644 --- a/Tests/ClaudeCodeUsageTests/HeatmapViewModelTests.swift +++ b/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift @@ -7,7 +7,7 @@ import Testing import Foundation @testable import ClaudeCodeUsageKit -@testable import ClaudeCodeUsage +@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 { From bc47dc677f05f8d80e0cdec34b4987771a90b4df Mon Sep 17 00:00:00 2001 From: webcpu Date: Mon, 29 Dec 2025 14:46:14 +0100 Subject: [PATCH 2/6] refactor(app): migrate from ClaudeCodeUsageKit to ClaudeUsageCore/Data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove ClaudeCodeUsageKit dependency from app target - Add compatibility extensions for Kit API parity: - UsageEntry+Compatibility: .cost, .date, individual token fields - UsageStats+Compatibility: token accessors, modelCount, legacy init - Move UsageAnalytics to Core with pure functions - Add LiveMonitorConversion for SessionBlock/BurnRate boundary conversion - Update UsageDataLoader to use any UsageRepository protocol - Update SessionMonitorService to return Core types - Update all 24 view files to import ClaudeUsageCore Architecture now flows: App → Core/Data → [LiveMonitor at boundary] ClaudeCodeUsageKit is no longer required by app (still used by tests) --- Package.swift | 4 +- Sources/ClaudeUsage/ClaudeCodeUsageApp.swift | 2 +- .../Main/Analytics/AnalyticsView.swift | 2 +- .../Main/Analytics/PredictionsCard.swift | 2 +- .../Analytics/TokenDistributionCard.swift | 2 +- .../Main/Analytics/UsageTrendsCard.swift | 2 +- .../Analytics/YearlyCostHeatmapCard.swift | 2 +- Sources/ClaudeUsage/Main/DailyUsageView.swift | 2 +- Sources/ClaudeUsage/Main/MainView.swift | 2 +- Sources/ClaudeUsage/Main/ModelsView.swift | 2 +- Sources/ClaudeUsage/Main/OverviewView.swift | 2 +- .../HeatmapViewModel+DataGeneration.swift | 2 +- .../HeatmapViewModel/HeatmapViewModel.swift | 2 +- .../YearlyCostHeatmap+Factories.swift | 2 +- .../YearlyCostHeatmap+Preview.swift | 2 +- .../YearlyCostHeatmap/YearlyCostHeatmap.swift | 2 +- .../HourlyChart/HourlyChartModels.swift | 2 +- .../HourlyChart/HourlyCostChartSimple.swift | 2 +- .../MenuBar/Components/ActionButtons.swift | 2 +- .../MenuBar/MenuBarContentView.swift | 2 +- .../MenuBar/Sections/CostMetricsSection.swift | 2 +- .../Sections/SessionMetricsSection.swift | 3 +- .../Sections/UsageMetricsSection.swift | 5 +- .../Shared/Components/EmptyStateViews.swift | 2 +- .../Conversion/LiveMonitorConversion.swift | 76 ++++++ .../Services/SessionMonitorService.swift | 31 ++- .../Shared/Store/UsageDataLoader.swift | 24 +- .../ClaudeUsage/Shared/Store/UsageStore.swift | 11 +- .../Analytics/UsageAnalytics.swift | 233 ++++++++++++++++++ .../Extensions/UsageEntry+Compatibility.swift | 25 ++ .../Extensions/UsageStats+Compatibility.swift | 67 +++++ .../HeatmapViewModelTests.swift | 2 +- 32 files changed, 453 insertions(+), 70 deletions(-) create mode 100644 Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift create mode 100644 Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift create mode 100644 Sources/ClaudeUsageCore/Extensions/UsageEntry+Compatibility.swift create mode 100644 Sources/ClaudeUsageCore/Extensions/UsageStats+Compatibility.swift diff --git a/Package.swift b/Package.swift index e93dabe..df2f94d 100644 --- a/Package.swift +++ b/Package.swift @@ -62,9 +62,9 @@ let package = Package( .executableTarget( name: "ClaudeUsage", dependencies: [ + "ClaudeUsageCore", "ClaudeUsageData", - "ClaudeCodeUsageKit", // Transitional - .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") // Transitional + .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") // Transitional - for SessionMonitorService ], path: "Sources/ClaudeUsage"), diff --git a/Sources/ClaudeUsage/ClaudeCodeUsageApp.swift b/Sources/ClaudeUsage/ClaudeCodeUsageApp.swift index 214fdb0..a9a6d72 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/Analytics/AnalyticsView.swift b/Sources/ClaudeUsage/Main/Analytics/AnalyticsView.swift index bc10269..2493c07 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/Analytics/PredictionsCard.swift b/Sources/ClaudeUsage/Main/Analytics/PredictionsCard.swift index 0625dfd..b936e4f 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift b/Sources/ClaudeUsage/Main/Analytics/TokenDistributionCard.swift index a54c0c9..ad965e5 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift b/Sources/ClaudeUsage/Main/Analytics/UsageTrendsCard.swift index 02f90ba..0765f36 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift b/Sources/ClaudeUsage/Main/Analytics/YearlyCostHeatmapCard.swift index 3e7a1d5..727c172 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/DailyUsageView.swift b/Sources/ClaudeUsage/Main/DailyUsageView.swift index 7007a18..597ee95 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/MainView.swift b/Sources/ClaudeUsage/Main/MainView.swift index 90b35c8..0ed902c 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/ModelsView.swift b/Sources/ClaudeUsage/Main/ModelsView.swift index 21e38c9..27f832c 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Main/OverviewView.swift b/Sources/ClaudeUsage/Main/OverviewView.swift index efba276..203e5b7 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel+DataGeneration.swift index 1fe2362..9b1332b 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/ViewModels/HeatmapViewModel/HeatmapViewModel.swift index 7bdf33b..e850cbd 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Factories.swift index dccbbb5..b474f51 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap+Preview.swift index 4291289..774ff8c 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift b/Sources/ClaudeUsage/MenuBar/Charts/Heatmap/Views/YearlyCostHeatmap/YearlyCostHeatmap.swift index 055eaa8..dca0c01 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyChartModels.swift index 3c87505..70c5510 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift b/Sources/ClaudeUsage/MenuBar/Charts/HourlyChart/HourlyCostChartSimple.swift index a8e88db..af98ea8 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Components/ActionButtons.swift b/Sources/ClaudeUsage/MenuBar/Components/ActionButtons.swift index a51a018..fc8f0e2 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/MenuBarContentView.swift b/Sources/ClaudeUsage/MenuBar/MenuBarContentView.swift index 7dfddce..403c97a 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/CostMetricsSection.swift index f39837e..c5f1e9e 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/SessionMetricsSection.swift index 6f8ada2..8b29f6d 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift b/Sources/ClaudeUsage/MenuBar/Sections/UsageMetricsSection.swift index 2ef4f96..8a0a5da 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Shared/Components/EmptyStateViews.swift b/Sources/ClaudeUsage/Shared/Components/EmptyStateViews.swift index 3ed1bbc..431056b 100644 --- a/Sources/ClaudeUsage/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/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift b/Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift new file mode 100644 index 0000000..6de845d --- /dev/null +++ b/Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift @@ -0,0 +1,76 @@ +// +// LiveMonitorConversion.swift +// ClaudeUsage +// +// Converts ClaudeLiveMonitorLib types to ClaudeUsageCore types +// Used at the boundary between LiveMonitor and the app +// + +import Foundation +import ClaudeUsageCore +import ClaudeLiveMonitorLib + +// MARK: - SessionBlock Conversion + +extension ClaudeUsageCore.SessionBlock { + /// Convert from ClaudeLiveMonitorLib.SessionBlock + init(from lm: ClaudeLiveMonitorLib.SessionBlock) { + self.init( + id: lm.id, + startTime: lm.startTime, + endTime: lm.endTime, + actualEndTime: lm.actualEndTime, + isActive: lm.isActive, + entries: lm.entries.map { ClaudeUsageCore.UsageEntry(from: $0) }, + tokens: ClaudeUsageCore.TokenCounts(from: lm.tokenCounts), + costUSD: lm.costUSD, + models: lm.models, + burnRate: ClaudeUsageCore.BurnRate(from: lm.burnRate), + tokenLimit: nil + ) + } +} + +// MARK: - BurnRate Conversion + +extension ClaudeUsageCore.BurnRate { + /// Convert from ClaudeLiveMonitorLib.BurnRate + init(from lm: ClaudeLiveMonitorLib.BurnRate) { + self.init( + tokensPerMinute: lm.tokensPerMinute, + costPerHour: lm.costPerHour + ) + } +} + +// MARK: - TokenCounts Conversion + +extension ClaudeUsageCore.TokenCounts { + /// Convert from ClaudeLiveMonitorLib.TokenCounts + init(from lm: ClaudeLiveMonitorLib.TokenCounts) { + self.init( + input: lm.inputTokens, + output: lm.outputTokens, + cacheCreation: lm.cacheCreationInputTokens, + cacheRead: lm.cacheReadInputTokens + ) + } +} + +// MARK: - UsageEntry Conversion + +extension ClaudeUsageCore.UsageEntry { + /// Convert from ClaudeLiveMonitorLib.UsageEntry + init(from lm: ClaudeLiveMonitorLib.UsageEntry) { + self.init( + timestamp: lm.timestamp, + model: lm.model, + tokens: ClaudeUsageCore.TokenCounts(from: lm.usage), + costUSD: lm.costUSD, + project: "", // LiveMonitor entries don't have project + sourceFile: lm.sourceFile, + messageId: lm.messageId, + requestId: lm.requestId + ) + } +} diff --git a/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift index 140bf89..b24eb3d 100644 --- a/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift +++ b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift @@ -4,16 +4,14 @@ // import Foundation -import struct ClaudeLiveMonitorLib.SessionBlock -import struct ClaudeLiveMonitorLib.BurnRate -import class ClaudeLiveMonitorLib.LiveMonitor -import struct ClaudeLiveMonitorLib.LiveMonitorConfig +import ClaudeUsageCore +import ClaudeLiveMonitorLib // MARK: - Protocol protocol SessionMonitorService: Sendable { - func getActiveSession() async -> SessionBlock? - func getBurnRate() async -> BurnRate? + func getActiveSession() async -> ClaudeUsageCore.SessionBlock? + func getBurnRate() async -> ClaudeUsageCore.BurnRate? func getAutoTokenLimit() async -> Int? } @@ -22,7 +20,7 @@ protocol SessionMonitorService: Sendable { actor DefaultSessionMonitorService: SessionMonitorService { private let monitor: LiveMonitor - private var cachedSession: (session: SessionBlock?, timestamp: Date)? + private var cachedSession: (session: ClaudeUsageCore.SessionBlock?, timestamp: Date)? private var cachedTokenLimit: (limit: Int?, timestamp: Date)? init(configuration: AppConfiguration) { @@ -36,17 +34,18 @@ actor DefaultSessionMonitorService: SessionMonitorService { self.monitor = LiveMonitor(config: config) } - func getActiveSession() async -> SessionBlock? { + func getActiveSession() async -> ClaudeUsageCore.SessionBlock? { if let cached = cachedSession, isCacheValid(timestamp: cached.timestamp) { return cached.session } - let result = await monitor.getActiveBlock() - cachedSession = (result, Date()) - return result + let lmSession = await monitor.getActiveBlock() + let session = lmSession.map { ClaudeUsageCore.SessionBlock(from: $0) } + cachedSession = (session, Date()) + return session } - func getBurnRate() async -> BurnRate? { + func getBurnRate() async -> ClaudeUsageCore.BurnRate? { await getActiveSession()?.burnRate } @@ -77,12 +76,12 @@ private func isCacheValid(timestamp: Date, ttl: TimeInterval = CacheConfig.ttl) #if DEBUG final class MockSessionMonitorService: SessionMonitorService, @unchecked Sendable { - var mockSession: SessionBlock? - var mockBurnRate: BurnRate? + var mockSession: ClaudeUsageCore.SessionBlock? + var mockBurnRate: ClaudeUsageCore.BurnRate? var mockTokenLimit: Int? - func getActiveSession() async -> SessionBlock? { mockSession } - func getBurnRate() async -> BurnRate? { mockBurnRate } + func getActiveSession() async -> ClaudeUsageCore.SessionBlock? { mockSession } + func getBurnRate() async -> ClaudeUsageCore.BurnRate? { mockBurnRate } func getAutoTokenLimit() async -> Int? { mockTokenLimit } } #endif diff --git a/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift b/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift index 9ac45c7..69b22ff 100644 --- a/Sources/ClaudeUsage/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 UsageRepository private let sessionMonitorService: SessionMonitorService - init(repository: UsageRepository, sessionMonitorService: SessionMonitorService) { + init(repository: any UsageRepository, 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/ClaudeUsage/Shared/Store/UsageStore.swift b/Sources/ClaudeUsage/Shared/Store/UsageStore.swift index d402585..c85bddf 100644 --- a/Sources/ClaudeUsage/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,13 +74,13 @@ final class UsageStore { // MARK: - Initialization init( - repository: UsageRepository? = nil, + repository: (any UsageRepository)? = nil, sessionMonitorService: SessionMonitorService? = nil, configurationService: ConfigurationService? = nil, clock: any ClockProtocol = SystemClock() ) { let config = configurationService ?? DefaultConfigurationService() - let repo = repository ?? UsageRepository(basePath: config.configuration.basePath) + let repo = repository ?? UsageRepositoryImpl(basePath: config.configuration.basePath) let sessionService = sessionMonitorService ?? DefaultSessionMonitorService(configuration: config.configuration) self.dataLoader = UsageDataLoader(repository: repo, sessionMonitorService: sessionService) diff --git a/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift b/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift new file mode 100644 index 0000000..df1902b --- /dev/null +++ b/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift @@ -0,0 +1,233 @@ +// +// UsageAnalytics.swift +// ClaudeUsageCore +// +// Pure functions for usage analytics and calculations +// + +import Foundation + +// MARK: - UsageAnalytics + +public enum UsageAnalytics { + + // MARK: - Cost Calculations + + public static func totalCost(from entries: [UsageEntry]) -> Double { + entries.reduce(0) { $0 + $1.costUSD } + } + + public static func averageCostPerSession(from entries: [UsageEntry]) -> Double { + let sessionCount = Set(entries.compactMap { $0.sessionId }).count + guard sessionCount > 0 else { return 0 } + return totalCost(from: entries) / Double(sessionCount) + } + + public static func costBreakdown(from stats: UsageStats) -> [(model: String, cost: Double, percentage: Double)] { + guard stats.totalCost > 0 else { return [] } + return stats.byModel + .sorted { $0.totalCost > $1.totalCost } + .map { modelToCostPercentage($0, totalCost: stats.totalCost) } + } + + private static func modelToCostPercentage(_ model: ModelUsage, totalCost: Double) -> (model: String, cost: Double, percentage: Double) { + (model: model.model, cost: model.totalCost, percentage: (model.totalCost / totalCost) * 100) + } + + // MARK: - Token Analysis + + public static func totalTokens(from entries: [UsageEntry]) -> Int { + entries.reduce(0) { $0 + $1.totalTokens } + } + + public static func tokenBreakdown(from stats: UsageStats) -> ( + inputPercentage: Double, + outputPercentage: Double, + cacheWritePercentage: Double, + cacheReadPercentage: Double + ) { + let total = Double(stats.totalTokens) + guard total > 0 else { return (0, 0, 0, 0) } + + let tokens = aggregateTokensByType(stats.byModel) + return tokenPercentages(from: tokens, total: total) + } + + 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.tokens.input, + acc.1 + model.tokens.output, + acc.2 + model.tokens.cacheCreation, + acc.3 + model.tokens.cacheRead) + } + } + + private static func tokenPercentages( + from tokens: (input: Int, output: Int, cacheWrite: Int, cacheRead: Int), + total: Double + ) -> (inputPercentage: Double, outputPercentage: Double, cacheWritePercentage: Double, cacheReadPercentage: Double) { + (inputPercentage: (Double(tokens.input) / total) * 100, + outputPercentage: (Double(tokens.output) / total) * 100, + cacheWritePercentage: (Double(tokens.cacheWrite) / total) * 100, + cacheReadPercentage: (Double(tokens.cacheRead) / total) * 100) + } + + // MARK: - Time-based Analysis + + public static func filterByDateRange(_ entries: [UsageEntry], from startDate: Date, to endDate: Date) -> [UsageEntry] { + entries.filter { isWithinRange($0, startDate: startDate, endDate: endDate) } + } + + private static func isWithinRange(_ entry: UsageEntry, startDate: Date, endDate: Date) -> Bool { + entry.timestamp >= startDate && entry.timestamp <= endDate + } + + public static func groupByDate(_ entries: [UsageEntry]) -> [String: [UsageEntry]] { + let formatter = dateFormatter() + return Dictionary(grouping: entries) { entry in + formatter.string(from: entry.timestamp) + } + } + + private static func dateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone.current + return formatter + } + + public static func dailyUsage(from entries: [UsageEntry]) -> [DailyUsage] { + groupByDate(entries) + .map { toDailyUsage(date: $0, entries: $1) } + .sorted { $0.date < $1.date } + } + + private static func toDailyUsage(date: String, entries: [UsageEntry]) -> DailyUsage { + DailyUsage( + date: date, + totalCost: totalCost(from: entries), + totalTokens: totalTokens(from: entries), + modelsUsed: Array(Set(entries.map { $0.model })) + ) + } + + // MARK: - Predictions + + public static func predictMonthlyCost(from stats: UsageStats, daysElapsed: Int) -> Double { + guard daysElapsed > 0 else { return 0 } + return (stats.totalCost / Double(daysElapsed)) * 30 + } + + public static func burnRate(from entries: [UsageEntry], hours: Int = 24) -> Double { + let recentEntries = Array(entries.suffix(100)) + guard !recentEntries.isEmpty else { return 0 } + + let hoursElapsed = calculateHoursElapsed(recentEntries) + guard hoursElapsed > 0 else { return 0 } + + return totalCost(from: recentEntries) / hoursElapsed + } + + private static func calculateHoursElapsed(_ entries: [UsageEntry]) -> Double { + 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.tokens.cacheRead } + let estimatedSaved = estimateCacheSavings(cacheReadTokens) + return CacheSavings( + tokensSaved: cacheReadTokens, + estimatedSaved: estimatedSaved, + description: cacheSavingsDescription(tokens: cacheReadTokens, saved: estimatedSaved) + ) + } + + private static func estimateCacheSavings(_ cacheReadTokens: Int) -> Double { + max(0, Double(cacheReadTokens) * 0.000009) + } + + private static func cacheSavingsDescription(tokens: Int, saved: Double) -> String { + tokens > 0 + ? "Saved ~$\(String(format: "%.2f", saved)) with cache (\(tokens.abbreviated) tokens)" + : "No cache usage yet" + } +} + +// 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 { + var abbreviated: String { + abbreviatedNumber(self) + } +} + +private func abbreviatedNumber(_ value: Int) -> String { + switch value { + case 1_000_000_000...: + return String(format: "%.1fB", Double(value) / 1_000_000_000) + case 1_000_000...: + return String(format: "%.1fM", Double(value) / 1_000_000) + case 1_000...: + return String(format: "%.1fK", Double(value) / 1_000) + default: + return "\(value)" + } +} + +public extension Double { + var asCurrency: String { + String(format: "$%.2f", self) + } + + var asPercentage: String { + String(format: "%.1f%%", self) + } +} + +// MARK: - Supporting Types + +public struct CacheSavings: Sendable { + public let tokensSaved: Int + public let estimatedSaved: Double + public let description: String + + public init(tokensSaved: Int, estimatedSaved: Double, description: String) { + self.tokensSaved = tokensSaved + self.estimatedSaved = estimatedSaved + self.description = description + } +} 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/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift b/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift index d371b54..26afbbc 100644 --- a/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift +++ b/Tests/ClaudeUsageTests/HeatmapViewModelTests.swift @@ -6,7 +6,7 @@ import Testing import Foundation -@testable import ClaudeCodeUsageKit +@testable import ClaudeUsageCore @testable import ClaudeUsage // MARK: - User Story: Yearly Cost Heatmap Visualization From 236e7a95c77953691d5a9f7a32816e817bdef3ea Mon Sep 17 00:00:00 2001 From: webcpu Date: Mon, 29 Dec 2025 14:53:27 +0100 Subject: [PATCH 3/6] fix(pricing): update to Claude 4/4.5 pricing (December 2025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Opus: / → / per MTok - Haiku: /bin/zsh.25/.25 → / per MTok - Sonnet: unchanged at / per MTok - Cache pricing: write=1.25x input, read=0.1x input --- .../Analytics/PricingCalculator.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift b/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift index 5bb41b7..c6fd18b 100644 --- a/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift +++ b/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift @@ -68,12 +68,13 @@ public struct ModelPricing: Sendable, Hashable { self.cacheReadPerToken = cacheReadPerMillion / 1_000_000 } - // Current pricing as of 2024 + // Claude 4/4.5 pricing (December 2025) + // Cache: write = 1.25x input, read = 0.1x input public static let opus = ModelPricing( - inputPerMillion: 15.0, - outputPerMillion: 75.0, - cacheWritePerMillion: 18.75, - cacheReadPerMillion: 1.50 + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheWritePerMillion: 6.25, + cacheReadPerMillion: 0.50 ) public static let sonnet = ModelPricing( @@ -84,9 +85,9 @@ public struct ModelPricing: Sendable, Hashable { ) public static let haiku = ModelPricing( - inputPerMillion: 0.25, - outputPerMillion: 1.25, - cacheWritePerMillion: 0.30, - cacheReadPerMillion: 0.03 + inputPerMillion: 1.0, + outputPerMillion: 5.0, + cacheWritePerMillion: 1.25, + cacheReadPerMillion: 0.10 ) } From 8fb925a3aee3823e875e0753da5b85c4bdf986b2 Mon Sep 17 00:00:00 2001 From: webcpu Date: Mon, 29 Dec 2025 15:01:15 +0100 Subject: [PATCH 4/6] refactor: remove legacy ClaudeCodeUsageKit package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete Sources/ClaudeCodeUsageKit/ (15 files, ~1500 lines) - Delete Tests/ClaudeCodeUsageKitTests/ (2 test files) - Delete Packages/TimingMacro/ (unused after Kit removal) - Simplify Package.swift (remove Kit product, target, tests) Architecture now: ClaudeUsageCore (domain) ← ClaudeUsageData (data) ← ClaudeUsage (app) --- Package.resolved | 14 - Package.swift | 32 +- Packages/TimingMacro/Package.resolved | 15 - Packages/TimingMacro/Package.swift | 36 -- .../Sources/TimingMacro/TimingMacro.swift | 13 - .../TimingMacroMacros/TimedMacro.swift | 139 ------- .../TimingMacroTests/TimedMacroTests.swift | 56 --- .../Analytics/UsageAnalytics.swift | 253 ------------ .../ClaudeCodeUsageKit.swift | 46 --- .../UsageRepository+Aggregation.swift | 172 -------- .../UsageRepository+FileDiscovery.swift | 138 ------- .../UsageRepository+Parsing.swift | 246 ----------- .../UsageRepository/UsageRepository.swift | 232 ----------- .../Domain/SendableModels.swift | 328 --------------- .../Domain/TypeAliases.swift | 39 -- .../UsageModels/UsageModels+Pricing.swift | 78 ---- .../UsageModels/UsageModels+TimeRange.swift | 87 ---- .../Domain/UsageModels/UsageModels.swift | 276 ------------- .../UsageRepositoryError+Aggregator.swift | 65 --- .../UsageRepositoryError+Recovery.swift | 185 --------- .../UsageRepositoryError.swift | 209 ---------- .../Testing/TestUtilities.swift | 116 ------ .../UsageRepositoryCacheTests.swift | 102 ----- .../UsageRepositoryErrorTests.swift | 382 ------------------ 24 files changed, 3 insertions(+), 3256 deletions(-) delete mode 100644 Package.resolved delete mode 100644 Packages/TimingMacro/Package.resolved delete mode 100644 Packages/TimingMacro/Package.swift delete mode 100644 Packages/TimingMacro/Sources/TimingMacro/TimingMacro.swift delete mode 100644 Packages/TimingMacro/Sources/TimingMacroMacros/TimedMacro.swift delete mode 100644 Packages/TimingMacro/Tests/TimingMacroTests/TimedMacroTests.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Analytics/UsageAnalytics.swift delete mode 100644 Sources/ClaudeCodeUsageKit/ClaudeCodeUsageKit.swift delete mode 100644 Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Aggregation.swift delete mode 100644 Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+FileDiscovery.swift delete mode 100644 Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository+Parsing.swift delete mode 100644 Sources/ClaudeCodeUsageKit/DataAccess/UsageRepository/UsageRepository.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/SendableModels.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/TypeAliases.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+Pricing.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels+TimeRange.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/UsageModels/UsageModels.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Aggregator.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError+Recovery.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Domain/UsageRepositoryError/UsageRepositoryError.swift delete mode 100644 Sources/ClaudeCodeUsageKit/Testing/TestUtilities.swift delete mode 100644 Tests/ClaudeCodeUsageKitTests/UsageRepositoryCacheTests.swift delete mode 100644 Tests/ClaudeCodeUsageKitTests/UsageRepositoryErrorTests.swift 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 df2f94d..9beb1c7 100644 --- a/Package.swift +++ b/Package.swift @@ -16,21 +16,17 @@ let package = Package( .library( name: "ClaudeUsageData", targets: ["ClaudeUsageData"]), - // Legacy SDK (deprecated, use ClaudeUsageData) - .library( - name: "ClaudeCodeUsageKit", - targets: ["ClaudeCodeUsageKit"]), // macOS menu bar app .executable( name: "ClaudeUsage", targets: ["ClaudeUsage"]), - // CLI monitor (new unified CLI) + // CLI monitor .executable( name: "claude-usage", targets: ["ClaudeMonitorCLI"]) ], dependencies: [ - .package(path: "Packages/TimingMacro") + .package(path: "Packages/ClaudeLiveMonitor") // Transitional - will be merged into ClaudeUsageData ], targets: [ // MARK: - Domain Layer (no dependencies) @@ -47,16 +43,6 @@ let package = Package( dependencies: ["ClaudeUsageCore"], path: "Sources/ClaudeUsageData"), - // MARK: - Legacy SDK (transitional, will be removed) - - .target( - name: "ClaudeCodeUsageKit", - dependencies: [ - "ClaudeUsageCore", - .product(name: "TimingMacro", package: "TimingMacro") - ], - path: "Sources/ClaudeCodeUsageKit"), - // MARK: - Presentation Layer .executableTarget( @@ -64,7 +50,7 @@ let package = Package( dependencies: [ "ClaudeUsageCore", "ClaudeUsageData", - .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") // Transitional - for SessionMonitorService + .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") ], path: "Sources/ClaudeUsage"), @@ -87,15 +73,6 @@ let package = Package( dependencies: ["ClaudeUsageData"], path: "Tests/ClaudeUsageDataTests"), - .testTarget( - name: "ClaudeCodeUsageKitTests", - dependencies: ["ClaudeCodeUsageKit"], - path: "Tests/ClaudeCodeUsageKitTests", - swiftSettings: [ - .unsafeFlags(["-enable-testing"]), - .define("ENABLE_CODE_COVERAGE", .when(configuration: .debug)) - ]), - .testTarget( name: "ClaudeUsageTests", dependencies: [ @@ -109,6 +86,3 @@ let package = Package( ]), ] ) - -// Add ClaudeLiveMonitor as local dependency (transitional - will be merged into ClaudeUsageData) -package.dependencies.append(.package(path: "Packages/ClaudeLiveMonitor")) 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/Analytics/UsageAnalytics.swift b/Sources/ClaudeCodeUsageKit/Analytics/UsageAnalytics.swift deleted file mode 100644 index 7b0c528..0000000 --- a/Sources/ClaudeCodeUsageKit/Analytics/UsageAnalytics.swift +++ /dev/null @@ -1,253 +0,0 @@ -// -// UsageAnalytics.swift -// ClaudeCodeUsage -// - -import Foundation - -// MARK: - UsageAnalytics - -public enum UsageAnalytics { - - // MARK: - Cost Calculations - - public static func totalCost(from entries: [UsageEntry]) -> Double { - entries.reduce(0) { $0 + $1.cost } - } - - public static func averageCostPerSession(from entries: [UsageEntry]) -> Double { - let sessionCount = Set(entries.compactMap { $0.sessionId }).count - guard sessionCount > 0 else { return 0 } - return totalCost(from: entries) / Double(sessionCount) - } - - public static func costBreakdown(from stats: UsageStats) -> [(model: String, cost: Double, percentage: Double)] { - guard stats.totalCost > 0 else { return [] } - return stats.byModel - .sorted { $0.totalCost > $1.totalCost } - .map { modelToCostPercentage($0, totalCost: stats.totalCost) } - } - - private static func modelToCostPercentage(_ model: ModelUsage, totalCost: Double) -> (model: String, cost: Double, percentage: Double) { - (model: model.model, cost: model.totalCost, percentage: (model.totalCost / totalCost) * 100) - } - - // MARK: - Token Analysis - - public static func totalTokens(from entries: [UsageEntry]) -> Int { - entries.reduce(0) { $0 + $1.totalTokens } - } - - public static func tokenBreakdown(from stats: UsageStats) -> ( - inputPercentage: Double, - outputPercentage: Double, - cacheWritePercentage: Double, - cacheReadPercentage: Double - ) { - let total = Double(stats.totalTokens) - guard total > 0 else { return (0, 0, 0, 0) } - - let tokens = aggregateTokensByType(stats.byModel) - return tokenPercentages(from: tokens, total: total) - } - - 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) - } - } - - private static func tokenPercentages( - from tokens: (input: Int, output: Int, cacheWrite: Int, cacheRead: Int), - total: Double - ) -> (inputPercentage: Double, outputPercentage: Double, cacheWritePercentage: Double, cacheReadPercentage: Double) { - (inputPercentage: (Double(tokens.input) / total) * 100, - outputPercentage: (Double(tokens.output) / total) * 100, - cacheWritePercentage: (Double(tokens.cacheWrite) / total) * 100, - cacheReadPercentage: (Double(tokens.cacheRead) / total) * 100) - } - - // MARK: - Time-based Analysis - - public static func filterByDateRange(_ entries: [UsageEntry], from startDate: Date, to endDate: Date) -> [UsageEntry] { - entries.filter { isWithinRange($0, startDate: startDate, endDate: endDate) } - } - - private static func isWithinRange(_ entry: UsageEntry, startDate: Date, endDate: Date) -> Bool { - guard let date = entry.date else { return false } - return date >= startDate && date <= 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) - } - - private static func dateFormatter() -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone.current - return formatter - } - - public static func dailyUsage(from entries: [UsageEntry]) -> [DailyUsage] { - groupByDate(entries) - .map { toDailyUsage(date: $0, entries: $1) } - .sorted { $0.date < $1.date } - } - - private static func toDailyUsage(date: String, entries: [UsageEntry]) -> DailyUsage { - DailyUsage( - date: date, - totalCost: totalCost(from: entries), - totalTokens: totalTokens(from: entries), - modelsUsed: Array(Set(entries.map { $0.model })) - ) - } - - // MARK: - Predictions - - public static func predictMonthlyCost(from stats: UsageStats, daysElapsed: Int) -> Double { - guard daysElapsed > 0 else { return 0 } - return (stats.totalCost / Double(daysElapsed)) * 30 - } - - public static func burnRate(from entries: [UsageEntry], hours: Int = 24) -> Double { - let recentEntries = Array(entries.suffix(100)) - guard !recentEntries.isEmpty else { return 0 } - - let hoursElapsed = calculateHoursElapsed(recentEntries) - guard hoursElapsed > 0 else { return 0 } - - return totalCost(from: recentEntries) / hoursElapsed - } - - 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 - } - - // MARK: - Cache Efficiency - - public static func cacheSavings(from stats: UsageStats) -> CacheSavings { - let cacheReadTokens = stats.byModel.reduce(0) { $0 + $1.cacheReadTokens } - let estimatedSaved = estimateCacheSavings(cacheReadTokens) - return CacheSavings( - tokensSaved: cacheReadTokens, - estimatedSaved: estimatedSaved, - description: cacheSavingsDescription(tokens: cacheReadTokens, saved: estimatedSaved) - ) - } - - private static func estimateCacheSavings(_ cacheReadTokens: Int) -> Double { - max(0, Double(cacheReadTokens) * 0.000009) - } - - private static func cacheSavingsDescription(tokens: Int, saved: Double) -> String { - tokens > 0 - ? "Saved ~$\(String(format: "%.2f", saved)) with cache (\(tokens.abbreviated) tokens)" - : "No cache usage yet" - } -} - -// MARK: - Formatting Extensions - -public extension Int { - var abbreviated: String { - abbreviatedNumber(self) - } -} - -private func abbreviatedNumber(_ value: Int) -> String { - switch value { - case 1_000_000_000...: - return String(format: "%.1fB", Double(value) / 1_000_000_000) - case 1_000_000...: - return String(format: "%.1fM", Double(value) / 1_000_000) - case 1_000...: - return String(format: "%.1fK", Double(value) / 1_000) - default: - return "\(value)" - } -} - -public extension Double { - var asCurrency: String { - String(format: "$%.2f", self) - } - - var asPercentage: String { - String(format: "%.1f%%", self) - } -} - -// MARK: - Supporting Types - -public struct CacheSavings { - 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) - } - } -} 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/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 From 580cb492def583e3dfd4e4a4e50d5a6d129e13fa Mon Sep 17 00:00:00 2001 From: webcpu Date: Mon, 29 Dec 2025 15:07:16 +0100 Subject: [PATCH 5/6] refactor: remove ClaudeLiveMonitor package (use SessionMonitorImpl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaudeUsageData's SessionMonitorImpl already provides the same functionality using native ClaudeUsageCore types. Changes: - Update SessionMonitorService to use SessionMonitorImpl - Delete LiveMonitorConversion.swift (no longer needed) - Delete Packages/ClaudeLiveMonitor/ (15 files, ~1000 lines) - Remove ClaudeLiveMonitor from Package.swift dependencies Architecture now has zero conversion layers: Core (types) ← Data (impl) ← App (uses native types) --- Package.swift | 7 +- Packages/ClaudeLiveMonitor/Package.swift | 37 --- Packages/ClaudeLiveMonitor/README.md | 99 ------ .../Sources/ClaudeLiveMonitor/main.swift | 276 ----------------- .../ClaudeLiveMonitorLib/JSONLParser.swift | 203 ------------ .../LiveMonitor+FileOperations.swift | 63 ---- .../LiveMonitor+SessionBlocks.swift | 172 ----------- .../LiveMonitor/LiveMonitor.swift | 93 ------ .../LiveMonitorActor.swift | 292 ------------------ .../LiveRenderer/LiveRenderer+Metrics.swift | 131 -------- .../LiveRenderer/LiveRenderer+Terminal.swift | 95 ------ .../LiveRenderer/LiveRenderer.swift | 125 -------- .../Models/Models+Pricing.swift | 135 -------- .../ClaudeLiveMonitorLib/Models/Models.swift | 143 --------- .../ClaudeLiveMonitorLib/TypeAliases.swift | 33 -- .../Conversion/LiveMonitorConversion.swift | 76 ----- .../Services/SessionMonitorService.swift | 35 +-- 17 files changed, 17 insertions(+), 1998 deletions(-) delete mode 100644 Packages/ClaudeLiveMonitor/Package.swift delete mode 100644 Packages/ClaudeLiveMonitor/README.md delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitor/main.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/JSONLParser.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+FileOperations.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor+SessionBlocks.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitor/LiveMonitor.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveMonitorActor.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Metrics.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer+Terminal.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/LiveRenderer/LiveRenderer.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models+Pricing.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/Models/Models.swift delete mode 100644 Packages/ClaudeLiveMonitor/Sources/ClaudeLiveMonitorLib/TypeAliases.swift delete mode 100644 Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift diff --git a/Package.swift b/Package.swift index 9beb1c7..5bba133 100644 --- a/Package.swift +++ b/Package.swift @@ -25,9 +25,7 @@ let package = Package( name: "claude-usage", targets: ["ClaudeMonitorCLI"]) ], - dependencies: [ - .package(path: "Packages/ClaudeLiveMonitor") // Transitional - will be merged into ClaudeUsageData - ], + dependencies: [], targets: [ // MARK: - Domain Layer (no dependencies) @@ -49,8 +47,7 @@ let package = Package( name: "ClaudeUsage", dependencies: [ "ClaudeUsageCore", - "ClaudeUsageData", - .product(name: "ClaudeLiveMonitorLib", package: "ClaudeLiveMonitor") + "ClaudeUsageData" ], path: "Sources/ClaudeUsage"), 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/Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift b/Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift deleted file mode 100644 index 6de845d..0000000 --- a/Sources/ClaudeUsage/Shared/Conversion/LiveMonitorConversion.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// LiveMonitorConversion.swift -// ClaudeUsage -// -// Converts ClaudeLiveMonitorLib types to ClaudeUsageCore types -// Used at the boundary between LiveMonitor and the app -// - -import Foundation -import ClaudeUsageCore -import ClaudeLiveMonitorLib - -// MARK: - SessionBlock Conversion - -extension ClaudeUsageCore.SessionBlock { - /// Convert from ClaudeLiveMonitorLib.SessionBlock - init(from lm: ClaudeLiveMonitorLib.SessionBlock) { - self.init( - id: lm.id, - startTime: lm.startTime, - endTime: lm.endTime, - actualEndTime: lm.actualEndTime, - isActive: lm.isActive, - entries: lm.entries.map { ClaudeUsageCore.UsageEntry(from: $0) }, - tokens: ClaudeUsageCore.TokenCounts(from: lm.tokenCounts), - costUSD: lm.costUSD, - models: lm.models, - burnRate: ClaudeUsageCore.BurnRate(from: lm.burnRate), - tokenLimit: nil - ) - } -} - -// MARK: - BurnRate Conversion - -extension ClaudeUsageCore.BurnRate { - /// Convert from ClaudeLiveMonitorLib.BurnRate - init(from lm: ClaudeLiveMonitorLib.BurnRate) { - self.init( - tokensPerMinute: lm.tokensPerMinute, - costPerHour: lm.costPerHour - ) - } -} - -// MARK: - TokenCounts Conversion - -extension ClaudeUsageCore.TokenCounts { - /// Convert from ClaudeLiveMonitorLib.TokenCounts - init(from lm: ClaudeLiveMonitorLib.TokenCounts) { - self.init( - input: lm.inputTokens, - output: lm.outputTokens, - cacheCreation: lm.cacheCreationInputTokens, - cacheRead: lm.cacheReadInputTokens - ) - } -} - -// MARK: - UsageEntry Conversion - -extension ClaudeUsageCore.UsageEntry { - /// Convert from ClaudeLiveMonitorLib.UsageEntry - init(from lm: ClaudeLiveMonitorLib.UsageEntry) { - self.init( - timestamp: lm.timestamp, - model: lm.model, - tokens: ClaudeUsageCore.TokenCounts(from: lm.usage), - costUSD: lm.costUSD, - project: "", // LiveMonitor entries don't have project - sourceFile: lm.sourceFile, - messageId: lm.messageId, - requestId: lm.requestId - ) - } -} diff --git a/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift index b24eb3d..840feef 100644 --- a/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift +++ b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift @@ -5,47 +5,42 @@ import Foundation import ClaudeUsageCore -import ClaudeLiveMonitorLib +import ClaudeUsageData // MARK: - Protocol protocol SessionMonitorService: Sendable { - func getActiveSession() async -> ClaudeUsageCore.SessionBlock? - func getBurnRate() async -> ClaudeUsageCore.BurnRate? + func getActiveSession() async -> SessionBlock? + func getBurnRate() async -> BurnRate? func getAutoTokenLimit() async -> Int? } // MARK: - Default Implementation actor DefaultSessionMonitorService: SessionMonitorService { - private let monitor: LiveMonitor + private let monitor: SessionMonitorImpl - private var cachedSession: (session: ClaudeUsageCore.SessionBlock?, timestamp: Date)? + 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 = SessionMonitorImpl( + basePath: configuration.basePath, + sessionDurationHours: configuration.sessionDurationHours ) - self.monitor = LiveMonitor(config: config) } - func getActiveSession() async -> ClaudeUsageCore.SessionBlock? { + func getActiveSession() async -> SessionBlock? { if let cached = cachedSession, isCacheValid(timestamp: cached.timestamp) { return cached.session } - let lmSession = await monitor.getActiveBlock() - let session = lmSession.map { ClaudeUsageCore.SessionBlock(from: $0) } + let session = await monitor.getActiveSession() cachedSession = (session, Date()) return session } - func getBurnRate() async -> ClaudeUsageCore.BurnRate? { + func getBurnRate() async -> BurnRate? { await getActiveSession()?.burnRate } @@ -76,12 +71,12 @@ private func isCacheValid(timestamp: Date, ttl: TimeInterval = CacheConfig.ttl) #if DEBUG final class MockSessionMonitorService: SessionMonitorService, @unchecked Sendable { - var mockSession: ClaudeUsageCore.SessionBlock? - var mockBurnRate: ClaudeUsageCore.BurnRate? + var mockSession: SessionBlock? + var mockBurnRate: BurnRate? var mockTokenLimit: Int? - func getActiveSession() async -> ClaudeUsageCore.SessionBlock? { mockSession } - func getBurnRate() async -> ClaudeUsageCore.BurnRate? { mockBurnRate } + func getActiveSession() async -> SessionBlock? { mockSession } + func getBurnRate() async -> BurnRate? { mockBurnRate } func getAutoTokenLimit() async -> Int? { mockTokenLimit } } #endif From f6502a7bc0fda940428509bab46adc5353998ff0 Mon Sep 17 00:00:00 2001 From: webcpu Date: Mon, 29 Dec 2025 15:15:05 +0100 Subject: [PATCH 6/6] refactor: rename types per Swift API Design Guidelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocols describe capabilities (use -ing/-able suffixes): - UsageRepository → UsageDataSource - SessionMonitor → SessionDataSource Concrete types use clean noun names: - UsageRepositoryImpl → UsageRepository - SessionMonitorImpl → SessionMonitor Per: https://www.swift.org/documentation/api-design-guidelines/ --- Sources/ClaudeMonitorCLI/main.swift | 4 ++-- .../Shared/Services/SessionMonitorService.swift | 4 ++-- .../ClaudeUsage/Shared/Store/UsageDataLoader.swift | 4 ++-- Sources/ClaudeUsage/Shared/Store/UsageStore.swift | 4 ++-- Sources/ClaudeUsageCore/ClaudeUsageCore.swift | 2 +- ...UsageRepository.swift => UsageDataSource.swift} | 14 ++++++++------ Sources/ClaudeUsageData/ClaudeUsageData.swift | 4 ++-- ...ssionMonitorImpl.swift => SessionMonitor.swift} | 10 +++++----- ...eRepositoryImpl.swift => UsageRepository.swift} | 10 +++++----- 9 files changed, 29 insertions(+), 27 deletions(-) rename Sources/ClaudeUsageCore/Protocols/{UsageRepository.swift => UsageDataSource.swift} (67%) rename Sources/ClaudeUsageData/Monitoring/{SessionMonitorImpl.swift => SessionMonitor.swift} (96%) rename Sources/ClaudeUsageData/Repository/{UsageRepositoryImpl.swift => UsageRepository.swift} (92%) diff --git a/Sources/ClaudeMonitorCLI/main.swift b/Sources/ClaudeMonitorCLI/main.swift index b5d43fd..7a89296 100644 --- a/Sources/ClaudeMonitorCLI/main.swift +++ b/Sources/ClaudeMonitorCLI/main.swift @@ -14,8 +14,8 @@ struct ClaudeMonitorCLI { print("Claude Usage Monitor") print("====================") - let repository = UsageRepositoryImpl() - let sessionMonitor = SessionMonitorImpl() + let repository = UsageRepository() + let sessionMonitor = SessionMonitor() do { // Get today's stats diff --git a/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift index 840feef..630b1a5 100644 --- a/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift +++ b/Sources/ClaudeUsage/Shared/Services/SessionMonitorService.swift @@ -18,13 +18,13 @@ protocol SessionMonitorService: Sendable { // MARK: - Default Implementation actor DefaultSessionMonitorService: SessionMonitorService { - private let monitor: SessionMonitorImpl + private let monitor: SessionMonitor private var cachedSession: (session: SessionBlock?, timestamp: Date)? private var cachedTokenLimit: (limit: Int?, timestamp: Date)? init(configuration: AppConfiguration) { - self.monitor = SessionMonitorImpl( + self.monitor = SessionMonitor( basePath: configuration.basePath, sessionDurationHours: configuration.sessionDurationHours ) diff --git a/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift b/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift index 69b22ff..1adeaad 100644 --- a/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift +++ b/Sources/ClaudeUsage/Shared/Store/UsageDataLoader.swift @@ -9,10 +9,10 @@ import ClaudeUsageCore // MARK: - UsageDataLoader actor UsageDataLoader { - private let repository: any UsageRepository + private let repository: any UsageDataSource private let sessionMonitorService: SessionMonitorService - init(repository: any UsageRepository, sessionMonitorService: SessionMonitorService) { + init(repository: any UsageDataSource, sessionMonitorService: SessionMonitorService) { self.repository = repository self.sessionMonitorService = sessionMonitorService } diff --git a/Sources/ClaudeUsage/Shared/Store/UsageStore.swift b/Sources/ClaudeUsage/Shared/Store/UsageStore.swift index c85bddf..2efc099 100644 --- a/Sources/ClaudeUsage/Shared/Store/UsageStore.swift +++ b/Sources/ClaudeUsage/Shared/Store/UsageStore.swift @@ -74,13 +74,13 @@ final class UsageStore { // MARK: - Initialization init( - repository: (any UsageRepository)? = nil, + repository: (any UsageDataSource)? = nil, sessionMonitorService: SessionMonitorService? = nil, configurationService: ConfigurationService? = nil, clock: any ClockProtocol = SystemClock() ) { let config = configurationService ?? DefaultConfigurationService() - let repo = repository ?? UsageRepositoryImpl(basePath: config.configuration.basePath) + let repo = repository ?? UsageRepository(basePath: config.configuration.basePath) let sessionService = sessionMonitorService ?? DefaultSessionMonitorService(configuration: config.configuration) self.dataLoader = UsageDataLoader(repository: repo, sessionMonitorService: sessionService) diff --git a/Sources/ClaudeUsageCore/ClaudeUsageCore.swift b/Sources/ClaudeUsageCore/ClaudeUsageCore.swift index 17219a7..375608c 100644 --- a/Sources/ClaudeUsageCore/ClaudeUsageCore.swift +++ b/Sources/ClaudeUsageCore/ClaudeUsageCore.swift @@ -7,7 +7,7 @@ // // Modules: // - Models: UsageEntry, TokenCounts, SessionBlock, UsageStats, etc. -// - Protocols: UsageRepository, SessionMonitor +// - Protocols: UsageDataSource, SessionDataSource // - Analytics: PricingCalculator, UsageAggregator // diff --git a/Sources/ClaudeUsageCore/Protocols/UsageRepository.swift b/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift similarity index 67% rename from Sources/ClaudeUsageCore/Protocols/UsageRepository.swift rename to Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift index 0c621bf..6beb75b 100644 --- a/Sources/ClaudeUsageCore/Protocols/UsageRepository.swift +++ b/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift @@ -1,15 +1,16 @@ // -// UsageRepository.swift +// UsageDataSource.swift // ClaudeUsageCore // -// Protocol defining usage data access +// Protocols defining usage data access capabilities // import Foundation -// MARK: - UsageRepository Protocol +// MARK: - UsageDataSource -public protocol UsageRepository: Sendable { +/// Provides access to usage data +public protocol UsageDataSource: Sendable { /// Get all usage entries for today func getTodayEntries() async throws -> [UsageEntry] @@ -20,9 +21,10 @@ public protocol UsageRepository: Sendable { func getAllEntries() async throws -> [UsageEntry] } -// MARK: - SessionMonitor Protocol +// MARK: - SessionDataSource -public protocol SessionMonitor: Sendable { +/// Provides access to live session data +public protocol SessionDataSource: Sendable { /// Get the currently active session block, if any func getActiveSession() async -> SessionBlock? diff --git a/Sources/ClaudeUsageData/ClaudeUsageData.swift b/Sources/ClaudeUsageData/ClaudeUsageData.swift index e5e6b96..69b0ab7 100644 --- a/Sources/ClaudeUsageData/ClaudeUsageData.swift +++ b/Sources/ClaudeUsageData/ClaudeUsageData.swift @@ -6,9 +6,9 @@ // Provides repository implementation, file parsing, and session monitoring. // // Components: -// - Repository: UsageRepositoryImpl +// - Repository: UsageRepository // - Parsing: JSONLParser -// - Monitoring: DirectoryMonitor, SessionMonitorImpl +// - Monitoring: DirectoryMonitor, SessionMonitor // import Foundation diff --git a/Sources/ClaudeUsageData/Monitoring/SessionMonitorImpl.swift b/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift similarity index 96% rename from Sources/ClaudeUsageData/Monitoring/SessionMonitorImpl.swift rename to Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift index 9f95cfd..2aa4c00 100644 --- a/Sources/ClaudeUsageData/Monitoring/SessionMonitorImpl.swift +++ b/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift @@ -1,16 +1,16 @@ // -// SessionMonitorImpl.swift +// SessionMonitor.swift // ClaudeUsageData // -// Implementation of SessionMonitor protocol for live session detection +// Monitor for detecting active Claude sessions // import Foundation import ClaudeUsageCore -// MARK: - SessionMonitorImpl +// MARK: - SessionMonitor -public actor SessionMonitorImpl: SessionMonitor { +public actor SessionMonitor: SessionDataSource { private let basePath: String private let sessionDurationHours: Double private let parser = JSONLParser() @@ -25,7 +25,7 @@ public actor SessionMonitorImpl: SessionMonitor { self.sessionDurationHours = sessionDurationHours } - // MARK: - SessionMonitor Protocol + // MARK: - SessionDataSource public func getActiveSession() async -> SessionBlock? { loadModifiedFiles() diff --git a/Sources/ClaudeUsageData/Repository/UsageRepositoryImpl.swift b/Sources/ClaudeUsageData/Repository/UsageRepository.swift similarity index 92% rename from Sources/ClaudeUsageData/Repository/UsageRepositoryImpl.swift rename to Sources/ClaudeUsageData/Repository/UsageRepository.swift index 5dad1a5..eddb5f6 100644 --- a/Sources/ClaudeUsageData/Repository/UsageRepositoryImpl.swift +++ b/Sources/ClaudeUsageData/Repository/UsageRepository.swift @@ -1,8 +1,8 @@ // -// UsageRepositoryImpl.swift +// UsageRepository.swift // ClaudeUsageData // -// Implementation of UsageRepository protocol +// Repository for accessing Claude usage data // import Foundation @@ -11,9 +11,9 @@ import OSLog private let logger = Logger(subsystem: "com.claudeusage", category: "Repository") -// MARK: - UsageRepositoryImpl +// MARK: - UsageRepository -public actor UsageRepositoryImpl: UsageRepository { +public actor UsageRepository: UsageDataSource { public let basePath: String private let parser = JSONLParser() @@ -24,7 +24,7 @@ public actor UsageRepositoryImpl: UsageRepository { self.basePath = basePath } - // MARK: - UsageRepository Protocol + // MARK: - UsageDataSource public func getTodayEntries() async throws -> [UsageEntry] { let allFiles = try FileDiscovery.discoverFiles(in: basePath)