Skip to content

Commit 89d0693

Browse files
committed
Initial commit
0 parents  commit 89d0693

40 files changed

Lines changed: 1672 additions & 0 deletions

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: macos-latest
14+
defaults:
15+
run:
16+
shell: bash
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Set up mise
23+
uses: jdx/mise-action@v2
24+
25+
- name: Show toolchain
26+
run: |
27+
xcodebuild -version
28+
swift --version
29+
30+
- name: Run release script
31+
env:
32+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
run: bash scripts/release.sh "$GITHUB_REF_NAME"

.github/workflows/test.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: test
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
check:
12+
runs-on: macos-latest
13+
defaults:
14+
run:
15+
shell: bash
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Set up mise
22+
uses: jdx/mise-action@v2
23+
24+
- name: Show toolchain
25+
run: |
26+
xcodebuild -version
27+
swift --version
28+
29+
- name: Run claudecost checks
30+
run: |
31+
mise trust -y mise.toml
32+
mise install
33+
mise run check

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/node_modules
5+
xcuserdata/
6+
DerivedData/
7+
.swiftpm/
8+
.netrc

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Stephan Behnke
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Package.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// swift-tools-version: 6.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "ClaudeCost",
6+
platforms: [
7+
.macOS(.v13)
8+
],
9+
products: [
10+
.executable(
11+
name: "ClaudeCost",
12+
targets: ["ClaudeCost"]
13+
)
14+
],
15+
targets: [
16+
.executableTarget(
17+
name: "ClaudeCost"
18+
)
19+
]
20+
)

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# ClaudeCost
2+
3+
`ClaudeCost` is a standalone macOS menubar app that shows your Claude Code spend for today and the current month. It refreshes every 60s.
4+
5+
![ClaudeCost menu bar screenshot](docs/menu-bar.png)
6+
7+
## Requirements
8+
9+
- macOS 13 or newer
10+
- `mise`
11+
12+
## Install
13+
14+
From this directory:
15+
16+
```sh
17+
mise trust
18+
mise install
19+
mise run install
20+
```
21+
22+
The install task copies the bundle to `~/Applications/ClaudeCost.app` and launches it.
23+
It also enables "Open at Login" by default the first time the app runs.
24+
25+
For local development:
26+
27+
```sh
28+
mise run dev
29+
```
30+
31+
`mise` manages the Bun toolchain for this project and uses the system Swift toolchain. The build tasks install the local `ccusage` dependency, compile the helper, and stage both binaries into `ClaudeCost.app`.
32+
33+
App bundle version metadata is sourced from `tooling/version.txt`.
34+
35+
## Development Tasks
36+
37+
```sh
38+
mise run fmt
39+
mise run lint
40+
mise run test
41+
mise run check
42+
```
43+
44+
- `fmt` formats the Swift sources and helper-side files
45+
- `lint` runs `swift-format` linting and Prettier checks
46+
- `test` runs the local Swift test harness
47+
- `check` runs linting, tests, and verifies the release app bundle
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import AppKit
2+
import Foundation
3+
4+
@MainActor
5+
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
6+
private let refreshInterval: TimeInterval = 60
7+
8+
private var statusItem: NSStatusItem?
9+
private var timer: Timer?
10+
private var state = AppState()
11+
private let loginItemManager = LoginItemManager()
12+
private var startAtLoginViewState = StartAtLoginViewState.make(status: .notRegistered)
13+
14+
func applicationDidFinishLaunching(_ notification: Notification) {
15+
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
16+
self.statusItem = statusItem
17+
18+
let menu = NSMenu()
19+
menu.delegate = self
20+
statusItem.menu = menu
21+
22+
timer = Timer.scheduledTimer(
23+
timeInterval: refreshInterval,
24+
target: self,
25+
selector: #selector(refreshTimerFired),
26+
userInfo: nil,
27+
repeats: true
28+
)
29+
30+
startAtLoginViewState = loginItemManager.configureOnLaunch()
31+
renderTitle()
32+
refreshUsage()
33+
}
34+
35+
func applicationWillTerminate(_ notification: Notification) {
36+
timer?.invalidate()
37+
timer = nil
38+
}
39+
40+
func menuNeedsUpdate(_ menu: NSMenu) {
41+
rebuildMenu(menu)
42+
}
43+
44+
@objc
45+
private func refreshTimerFired() {
46+
refreshUsage()
47+
}
48+
49+
@objc
50+
private func refreshMenuItemSelected() {
51+
refreshUsage()
52+
}
53+
54+
@objc
55+
private func startAtLoginMenuItemSelected(_ sender: NSMenuItem) {
56+
let shouldEnable = sender.state != .on
57+
startAtLoginViewState = loginItemManager.setEnabled(shouldEnable)
58+
refreshMenuIfNeeded()
59+
}
60+
61+
@objc
62+
private func quitMenuItemSelected() {
63+
NSApplication.shared.terminate(nil)
64+
}
65+
66+
private func refreshUsage() {
67+
guard let nextState = UsageRefreshController.beginRefresh(from: state) else {
68+
return
69+
}
70+
71+
state = nextState
72+
renderTitle()
73+
74+
Task {
75+
do {
76+
let snapshot = try await UsageFetcher.fetchUsage()
77+
applyRefreshSuccess(snapshot)
78+
} catch {
79+
applyRefreshFailure(error)
80+
}
81+
}
82+
}
83+
84+
private func applyRefreshSuccess(_ snapshot: UsageSnapshot) {
85+
state = UsageRefreshController.applySuccess(snapshot: snapshot, to: state)
86+
renderTitle()
87+
refreshMenuIfNeeded()
88+
}
89+
90+
private func applyRefreshFailure(_ error: Error) {
91+
state = UsageRefreshController.applyFailure(error: error, to: state)
92+
renderTitle()
93+
if let lastError = state.lastError, !lastError.isEmpty {
94+
NSLog("claudecost refresh failed: %@", lastError)
95+
}
96+
refreshMenuIfNeeded()
97+
}
98+
99+
private func renderTitle() {
100+
setStatusAppearance(
101+
title: StatusPresenter.title(for: state),
102+
showWarningSymbol: StatusPresenter.shouldShowWarningSymbol(for: state)
103+
)
104+
}
105+
106+
private func setStatusAppearance(title: String, showWarningSymbol: Bool) {
107+
guard let button = statusItem?.button else {
108+
return
109+
}
110+
111+
button.title = title
112+
button.image = showWarningSymbol ? warningSymbolImage() : nil
113+
button.imagePosition = .imageLeading
114+
}
115+
116+
private func warningSymbolImage() -> NSImage? {
117+
let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
118+
let image = NSImage(
119+
systemSymbolName: "exclamationmark.triangle.fill",
120+
accessibilityDescription: "Warning"
121+
)?
122+
.withSymbolConfiguration(configuration)
123+
image?.isTemplate = true
124+
return image
125+
}
126+
127+
private func refreshMenuIfNeeded() {
128+
guard let menu = statusItem?.menu else {
129+
return
130+
}
131+
rebuildMenu(menu)
132+
}
133+
134+
private func rebuildMenu(_ menu: NSMenu) {
135+
let rows = MenuRowsBuilder.rows(for: state, startAtLogin: startAtLoginViewState)
136+
MenuRenderer.render(menu: menu, rows: rows, target: self, selectorProvider: selector)
137+
}
138+
139+
private func selector(for action: MenuActionKind) -> Selector {
140+
switch action {
141+
case .startAtLogin:
142+
return #selector(startAtLoginMenuItemSelected(_:))
143+
case .refresh:
144+
return #selector(refreshMenuItemSelected)
145+
case .quit:
146+
return #selector(quitMenuItemSelected)
147+
}
148+
}
149+
}

Sources/ClaudeCost/AppState.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Foundation
2+
3+
public struct AppState {
4+
public var isRefreshing = false
5+
public var todayCost = 0.0
6+
public var monthCost = 0.0
7+
public var businessDays = 0
8+
public var avgPerDay = 0.0
9+
public var lastRefreshAt: Date?
10+
public var lastError: String?
11+
12+
public init(
13+
isRefreshing: Bool = false,
14+
todayCost: Double = 0.0,
15+
monthCost: Double = 0.0,
16+
businessDays: Int = 0,
17+
avgPerDay: Double = 0.0,
18+
lastRefreshAt: Date? = nil,
19+
lastError: String? = nil
20+
) {
21+
self.isRefreshing = isRefreshing
22+
self.todayCost = todayCost
23+
self.monthCost = monthCost
24+
self.businessDays = businessDays
25+
self.avgPerDay = avgPerDay
26+
self.lastRefreshAt = lastRefreshAt
27+
self.lastError = lastError
28+
}
29+
}

0 commit comments

Comments
 (0)