clauddy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/auth.js +167 -0
- package/bin/clauddy.js +12 -0
- package/config.json +8 -0
- package/main.js +256 -0
- package/package.json +87 -0
- package/preload.js +18 -0
- package/renderer/index.html +162 -0
- package/renderer/pet.js +614 -0
- package/renderer/style.css +1195 -0
- package/usage.js +219 -0
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# ๐ซ Claude Usage Monitor
|
|
2
|
+
|
|
3
|
+
A cute pixel-art desktop pet for macOS that tracks your Claude Code usage โ mirroring the official **Settings โ Usage** panel (current session + weekly limits, in tokens & %), with animations.
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/overview.gif" width="300" alt="Claude Usage Monitor โ the full widget showing session, weekly, by-model and 30-day usage" /><br />
|
|
7
|
+
<em>A little terracotta creature that lives in the corner of your screen, eats your tokens, and naps when you're idle.</em>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
## What it shows
|
|
11
|
+
|
|
12
|
+
- **Current session** โ real % used + **"resets in Xh Ym"** + session tokens
|
|
13
|
+
- **Weekly ยท all models** โ real % used + tokens over the last 7 days
|
|
14
|
+
- **Status line** under the pet: `โ working ยท 1.6M tok/min` (or today's tokens when idle)
|
|
15
|
+
- **By model ยท 7 days** โ Opus / Sonnet / Haiku / Fable, in tokens
|
|
16
|
+
- **30-day map** โ colored squares by daily tokens (green = light โ red = heavy), with the monthly total
|
|
17
|
+
|
|
18
|
+
The **percentages are real**, pulled from your account (you log in once โ see below). The token counts, by-model breakdown, activity status, and 30-day map come from your local logs (`~/.claude/projects/**/*.jsonl`). Everything is token-based โ no dollars.
|
|
19
|
+
|
|
20
|
+
## Account & live usage
|
|
21
|
+
|
|
22
|
+
The session/weekly **%** comes straight from your Anthropic account, so it matches the official panel exactly. You connect once via a browser login:
|
|
23
|
+
|
|
24
|
+
1. Open **โ Settings โ "Log in with browser"** โ your browser opens an Anthropic auth page.
|
|
25
|
+
2. Log in, copy the **authentication code** shown, and paste it back into the app โ **Connect**.
|
|
26
|
+
|
|
27
|
+
The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed automatically. **Until you connect**, the limits area shows a _"Connect your account"_ prompt instead of percentages.
|
|
28
|
+
|
|
29
|
+
## The pet's states
|
|
30
|
+
|
|
31
|
+
<table>
|
|
32
|
+
<tr>
|
|
33
|
+
<td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/idle.gif" width="280" alt="idle" /><br /><b>idle</b><br /><sub>breathes & blinks</sub></td>
|
|
34
|
+
<td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/working.gif" width="280" alt="working" /><br /><b>working</b><br /><sub>hops & eats token coins</sub></td>
|
|
35
|
+
</tr>
|
|
36
|
+
<tr>
|
|
37
|
+
<td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/on-fire.gif" width="280" alt="on fire" /><br /><b>on fire</b><br /><sub>session โฅ 90% โ red, shivers, flames</sub></td>
|
|
38
|
+
<td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/tired.gif" width="280" alt="maxed out" /><br /><b>maxed out</b><br /><sub>session at 100% โ drained, slumped, sweating</sub></td>
|
|
39
|
+
</tr>
|
|
40
|
+
<tr>
|
|
41
|
+
<td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/sleeping.gif" width="280" alt="sleeping" /><br /><b>sleeping</b><br /><sub>idle 5+ min โ blue zzz & moonlight</sub></td>
|
|
42
|
+
<td align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/poke.gif" width="280" alt="poke" /><br /><b>poke</b><br /><sub>click the pet โ squish & hearts</sub></td>
|
|
43
|
+
</tr>
|
|
44
|
+
<tr>
|
|
45
|
+
<td colspan="2" align="center"><img src="https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main/docs/media/celebrate.gif" width="280" alt="celebrate" /><br /><b>celebrate</b><br /><sub>session resets โ jump & confetti</sub></td>
|
|
46
|
+
</tr>
|
|
47
|
+
</table>
|
|
48
|
+
|
|
49
|
+
Plus a welcome **wave** on launch. You can preview any state from the terminal with [`./pet`](#simulate-states-pet).
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
macOS (Apple Silicon). Two ways, depending on what you want:
|
|
54
|
+
|
|
55
|
+
### 1. Download the app โ opens at login (recommended)
|
|
56
|
+
|
|
57
|
+
For everyday use, grab the prebuilt app โ no terminal, no setup:
|
|
58
|
+
|
|
59
|
+
1. Download the latest **`Claude Usage Monitor-โฆ-mac.zip`** from the [**Releases**](https://github.com/renatoaug/claude-usage-monitor/releases/latest) page.
|
|
60
|
+
2. Unzip it and drag **Claude Usage Monitor.app** into `/Applications`.
|
|
61
|
+
3. First open: right-click the app โ **Open** โ **Open** (it's unsigned).
|
|
62
|
+
|
|
63
|
+
It registers itself in **Login Items**, so it **starts automatically** with your Mac โ set it and forget it.
|
|
64
|
+
|
|
65
|
+
### 2. Run it via `bunx` (no install)
|
|
66
|
+
|
|
67
|
+
Needs [Bun](https://bun.sh) (or use `npx` with Node 24):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bunx clauddy
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The first run downloads Electron, so give it a moment. Handy for a quick run, but it stays up **only while that command is open** and won't start on its own. Quit it with the **ร** button.
|
|
74
|
+
|
|
75
|
+
> The app keeps its data in `~/.claude-usage-monitor`, regardless of how you run it.
|
|
76
|
+
|
|
77
|
+
## Controls
|
|
78
|
+
|
|
79
|
+
- **Drag** the widget anywhere on screen
|
|
80
|
+
- **โ** minimizes to just the pet's face (showing the live session %); the **โคข** button or a double-click on the pet expands it back
|
|
81
|
+
- **โ** opens settings (log in, toggle alerts, set thresholds)
|
|
82
|
+
- **โ** opens the official Usage page
|
|
83
|
+
- **ร** quits
|
|
84
|
+
|
|
85
|
+
## Alerts
|
|
86
|
+
|
|
87
|
+
Optional **macOS notifications** when your session or weekly usage crosses the thresholds you set (default **80%** and **95%**) โ e.g. _"Your session is over 80% โ now at 82%"_. They re-arm automatically once usage drops back below a threshold (after a reset). Toggle them and edit the thresholds in **โ Settings**.
|
|
88
|
+
|
|
89
|
+
## Configure (`config.json`)
|
|
90
|
+
|
|
91
|
+
Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you can tweak them without rebuilding:
|
|
92
|
+
|
|
93
|
+
```jsonc
|
|
94
|
+
{
|
|
95
|
+
"alerts": true, // macOS notifications on/off
|
|
96
|
+
"alertThresholds": [80, 95], // notify when session/week cross these %
|
|
97
|
+
"pollIntervalMs": 4000, // how often local logs are re-read
|
|
98
|
+
"activeThresholdMs": 8000, // "working" if Claude was active within this window
|
|
99
|
+
"sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Simulate states (`./pet`)
|
|
104
|
+
|
|
105
|
+
While developing the animations, force any state from the terminal โ the app watches `~/.claude-usage-monitor/debug.json` and reacts live (no rebuild needed). Run from the repo root:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
./pet fire # ๐ฅ on fire โ flames, shivers, red tint
|
|
109
|
+
./pet sleeping # ๐ด sleeping โ blue zzz, closed eyes, moonlight
|
|
110
|
+
./pet working # ๐ด working โ eats token coins and hops
|
|
111
|
+
./pet idle # ๐ idle โ breathe + blink
|
|
112
|
+
|
|
113
|
+
./pet poke # ๐ one-shot squish + hearts
|
|
114
|
+
./pet celebrate # ๐ one-shot jump + confetti burst
|
|
115
|
+
|
|
116
|
+
./pet auto # โฉ๏ธ release control, back to real usage data
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## How it works
|
|
120
|
+
|
|
121
|
+
- **`main.js`** โ Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
|
|
122
|
+
- **`usage.js`** โ reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/day, detects the rolling 5-hour session window, and the working/sleeping activity status.
|
|
123
|
+
- **`auth.js`** โ OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
|
|
124
|
+
- **`renderer/`** โ the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
|
|
125
|
+
- **`make-icon.js`** โ generates the app icon from the pixel sprite (`build/icon.icns`).
|
|
126
|
+
|
|
127
|
+
## Data & privacy
|
|
128
|
+
|
|
129
|
+
Everything lives on your machine, in `~/.claude-usage-monitor/`:
|
|
130
|
+
|
|
131
|
+
- `auth.json` โ your OAuth token (file mode `600`, never committed)
|
|
132
|
+
- `config.json` โ your alert settings
|
|
133
|
+
- `debug.json` โ scratch file for the `./pet` simulator
|
|
134
|
+
|
|
135
|
+
Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
|
|
136
|
+
|
|
137
|
+
## Dev tooling
|
|
138
|
+
|
|
139
|
+
- **Bun** for install/scripts, **Node 24** pinned in `.nvmrc`
|
|
140
|
+
- **Biome** for format + lint (`bun run check`); a versioned **pre-commit hook** (`.githooks/pre-commit`) auto-formats staged files and blocks on errors. It's wired up automatically on `bun install` (via the `prepare` script).
|
|
141
|
+
|
|
142
|
+
### Releasing
|
|
143
|
+
|
|
144
|
+
Releases are **fully automated**. Every push to `main` runs
|
|
145
|
+
[semantic-release](https://semantic-release.gitbook.io) (`.github/workflows/release.yml`):
|
|
146
|
+
it reads the **Conventional Commits** and, when there's something to ship,
|
|
147
|
+
computes the version, builds the macOS app, publishes `clauddy` to npm, and
|
|
148
|
+
cuts a GitHub Release with the `.app` zip. Nothing to do by hand โ just merge
|
|
149
|
+
your PRs.
|
|
150
|
+
|
|
151
|
+
- `feat:` โ minor, `fix:` โ patch, `feat!:`/`BREAKING CHANGE` โ major.
|
|
152
|
+
- `docs:`/`chore:`/`ci:` etc. don't trigger a release.
|
|
153
|
+
- Needs an **`NPM_TOKEN`** repo secret (an npm automation token); `GITHUB_TOKEN`
|
|
154
|
+
is automatic.
|
package/auth.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Claude OAuth (same flow as Claude Code) + the authoritative usage endpoint.
|
|
2
|
+
const crypto = require('node:crypto')
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const os = require('node:os')
|
|
6
|
+
|
|
7
|
+
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
8
|
+
const REDIRECT = 'https://platform.claude.com/oauth/code/callback'
|
|
9
|
+
const AUTHORIZE = 'https://claude.ai/oauth/authorize'
|
|
10
|
+
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
11
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
12
|
+
const SCOPE = 'org:create_api_key user:profile user:inference'
|
|
13
|
+
const UA = 'claude-cli/2.1.181 (external, cli)'
|
|
14
|
+
const TOKEN_PATH = path.join(os.homedir(), '.claude-usage-monitor', 'auth.json')
|
|
15
|
+
|
|
16
|
+
const b64url = (buf) =>
|
|
17
|
+
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
18
|
+
|
|
19
|
+
let tokens = null // { access_token, refresh_token, expires_at }
|
|
20
|
+
let pending = null // { verifier, state }
|
|
21
|
+
|
|
22
|
+
function load() {
|
|
23
|
+
if (tokens) return tokens
|
|
24
|
+
try {
|
|
25
|
+
tokens = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'))
|
|
26
|
+
} catch {
|
|
27
|
+
tokens = null
|
|
28
|
+
}
|
|
29
|
+
return tokens
|
|
30
|
+
}
|
|
31
|
+
function save(t) {
|
|
32
|
+
tokens = t
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(path.dirname(TOKEN_PATH), { recursive: true })
|
|
35
|
+
fs.writeFileSync(TOKEN_PATH, JSON.stringify(t, null, 2), { mode: 0o600 })
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
function clear() {
|
|
39
|
+
tokens = null
|
|
40
|
+
try {
|
|
41
|
+
fs.unlinkSync(TOKEN_PATH)
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
function isConnected() {
|
|
45
|
+
return !!load()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Step 1: build the authorize URL (opens in the browser)
|
|
49
|
+
function begin() {
|
|
50
|
+
const verifier = b64url(crypto.randomBytes(32))
|
|
51
|
+
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
|
|
52
|
+
const state = b64url(crypto.randomBytes(32))
|
|
53
|
+
pending = { verifier, state }
|
|
54
|
+
const params = {
|
|
55
|
+
code: 'true',
|
|
56
|
+
client_id: CLIENT_ID,
|
|
57
|
+
response_type: 'code',
|
|
58
|
+
redirect_uri: REDIRECT,
|
|
59
|
+
scope: SCOPE,
|
|
60
|
+
code_challenge: challenge,
|
|
61
|
+
code_challenge_method: 'S256',
|
|
62
|
+
state,
|
|
63
|
+
}
|
|
64
|
+
const query = Object.entries(params)
|
|
65
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
66
|
+
.join('&')
|
|
67
|
+
return `${AUTHORIZE}?${query}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 2: exchange the pasted "code#state" for tokens
|
|
71
|
+
async function complete(pasted) {
|
|
72
|
+
const raw = String(pasted).trim()
|
|
73
|
+
// A directly-pasted long-lived token (e.g. from `claude setup-token`) skips
|
|
74
|
+
// the rate-limited code exchange entirely.
|
|
75
|
+
if (!raw.includes('#')) {
|
|
76
|
+
save({ access_token: raw, refresh_token: null, expires_at: Date.now() + 365 * 864e5 })
|
|
77
|
+
pending = null
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (!pending) throw new Error('no pending auth')
|
|
81
|
+
const [code, returnedState] = raw.split('#')
|
|
82
|
+
const res = await fetch(TOKEN_URL, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
grant_type: 'authorization_code',
|
|
87
|
+
code,
|
|
88
|
+
state: returnedState || pending.state,
|
|
89
|
+
code_verifier: pending.verifier,
|
|
90
|
+
client_id: CLIENT_ID,
|
|
91
|
+
redirect_uri: REDIRECT,
|
|
92
|
+
}),
|
|
93
|
+
})
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const body = await res.text().catch(() => '')
|
|
96
|
+
throw new Error(`exchange ${res.status}: ${body.slice(0, 150)}`)
|
|
97
|
+
}
|
|
98
|
+
const j = await res.json()
|
|
99
|
+
save({
|
|
100
|
+
access_token: j.access_token,
|
|
101
|
+
refresh_token: j.refresh_token,
|
|
102
|
+
expires_at: Date.now() + (j.expires_in || 3600) * 1000,
|
|
103
|
+
})
|
|
104
|
+
pending = null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function refresh() {
|
|
108
|
+
const t = load()
|
|
109
|
+
if (!t?.refresh_token) throw Object.assign(new Error('no refresh token'), { status: 401 })
|
|
110
|
+
const res = await fetch(TOKEN_URL, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
grant_type: 'refresh_token',
|
|
115
|
+
refresh_token: t.refresh_token,
|
|
116
|
+
client_id: CLIENT_ID,
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
if (!res.ok) throw Object.assign(new Error('refresh failed'), { status: res.status })
|
|
120
|
+
const j = await res.json()
|
|
121
|
+
save({
|
|
122
|
+
access_token: j.access_token,
|
|
123
|
+
refresh_token: j.refresh_token || t.refresh_token,
|
|
124
|
+
expires_at: Date.now() + (j.expires_in || 3600) * 1000,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function validToken() {
|
|
129
|
+
const t = load()
|
|
130
|
+
if (!t) throw Object.assign(new Error('not connected'), { status: 401 })
|
|
131
|
+
if (!t.expires_at || t.expires_at - Date.now() < 60000) await refresh()
|
|
132
|
+
return load().access_token
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function win(o) {
|
|
136
|
+
return o && typeof o.utilization === 'number'
|
|
137
|
+
? { pct: o.utilization, resetMs: o.resets_at ? Date.parse(o.resets_at) - Date.now() : null }
|
|
138
|
+
: null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Step 3: fetch the authoritative usage
|
|
142
|
+
async function fetchUsage() {
|
|
143
|
+
const token = await validToken()
|
|
144
|
+
const res = await fetch(USAGE_URL, {
|
|
145
|
+
headers: {
|
|
146
|
+
Authorization: `Bearer ${token}`,
|
|
147
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
148
|
+
'anthropic-version': '2023-06-01',
|
|
149
|
+
'User-Agent': UA,
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
const body = await res.text().catch(() => '')
|
|
154
|
+
throw Object.assign(new Error(`usage ${res.status}: ${body.slice(0, 150)}`), {
|
|
155
|
+
status: res.status,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const j = await res.json()
|
|
159
|
+
return {
|
|
160
|
+
session: win(j.five_hour) || { pct: 0, resetMs: null },
|
|
161
|
+
week: win(j.seven_day) || { pct: 0, resetMs: null },
|
|
162
|
+
sonnet: win(j.seven_day_sonnet),
|
|
163
|
+
opus: win(j.seven_day_opus),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { begin, complete, fetchUsage, clear, isConnected }
|
package/bin/clauddy.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Entry point when installed from npm / run via `bunx claude-usage-monitor`:
|
|
4
|
+
// spawn the Electron runtime pointed at the app's main process.
|
|
5
|
+
const { spawn } = require('node:child_process')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const electron = require('electron')
|
|
8
|
+
|
|
9
|
+
const child = spawn(electron, [path.join(__dirname, '..', 'main.js')], {
|
|
10
|
+
stdio: 'inherit',
|
|
11
|
+
})
|
|
12
|
+
child.on('close', (code) => process.exit(code ?? 0))
|
package/config.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_note": "Defaults bundled with the app. User settings are saved to ~/.claude-usage-monitor/config.json. Session/weekly % come from your account (log in via Settings).",
|
|
3
|
+
"alerts": true,
|
|
4
|
+
"alertThresholds": [80, 95],
|
|
5
|
+
"pollIntervalMs": 4000,
|
|
6
|
+
"activeThresholdMs": 8000,
|
|
7
|
+
"sleepThresholdMs": 300000
|
|
8
|
+
}
|
package/main.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
const { app, BrowserWindow, ipcMain, screen, Notification, shell } = require('electron')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const os = require('node:os')
|
|
5
|
+
const { getUsage } = require('./usage')
|
|
6
|
+
const auth = require('./auth')
|
|
7
|
+
|
|
8
|
+
// data dir: kept outside the project folder so moving the repo doesn't break it
|
|
9
|
+
const DATA_DIR = path.join(os.homedir(), '.claude-usage-monitor')
|
|
10
|
+
// external config: edit without rebuilding the .app
|
|
11
|
+
const EXTERNAL_CONFIG = path.join(DATA_DIR, 'config.json')
|
|
12
|
+
// debug channel: `./pet <state>` writes here to force a state (dev only)
|
|
13
|
+
const DEBUG_FILE = path.join(DATA_DIR, 'debug.json')
|
|
14
|
+
|
|
15
|
+
let win
|
|
16
|
+
let pollTimer
|
|
17
|
+
let config
|
|
18
|
+
let doTick = null
|
|
19
|
+
const W = 276
|
|
20
|
+
|
|
21
|
+
function publicConfig(c) {
|
|
22
|
+
return {
|
|
23
|
+
plan: c.plan,
|
|
24
|
+
sessionTokenBudget: c.sessionTokenBudget,
|
|
25
|
+
weeklyTokenBudget: c.weeklyTokenBudget,
|
|
26
|
+
weeklyAnchorIso: c.weeklyAnchorIso,
|
|
27
|
+
alerts: c.alerts,
|
|
28
|
+
alertThresholds: c.alertThresholds,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
const defaults = {
|
|
34
|
+
plan: 'max5x',
|
|
35
|
+
sessionTokenBudget: 630000000,
|
|
36
|
+
weeklyTokenBudget: 3450000000,
|
|
37
|
+
weeklyAnchorIso: null,
|
|
38
|
+
alerts: true,
|
|
39
|
+
alertThresholds: [80, 95],
|
|
40
|
+
pollIntervalMs: 4000,
|
|
41
|
+
activeThresholdMs: 8000,
|
|
42
|
+
sleepThresholdMs: 300000,
|
|
43
|
+
}
|
|
44
|
+
for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
|
|
45
|
+
try {
|
|
46
|
+
return { ...defaults, ...JSON.parse(fs.readFileSync(p, 'utf8')) }
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
return defaults
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// native notification when usage crosses a threshold
|
|
53
|
+
const armed = new Set()
|
|
54
|
+
function checkAlerts(config, d) {
|
|
55
|
+
if (!config.alerts || !Notification.isSupported()) return
|
|
56
|
+
const ths = config.alertThresholds || [80, 95]
|
|
57
|
+
const scopes = [
|
|
58
|
+
['session', d.session.pct],
|
|
59
|
+
['weekly usage', d.week.pct],
|
|
60
|
+
]
|
|
61
|
+
for (const [name, pct] of scopes) {
|
|
62
|
+
for (const t of ths) {
|
|
63
|
+
const key = `${name}:${t}`
|
|
64
|
+
if (pct >= t) {
|
|
65
|
+
if (!armed.has(key)) {
|
|
66
|
+
armed.add(key)
|
|
67
|
+
new Notification({
|
|
68
|
+
title: 'Claude Usage Monitor',
|
|
69
|
+
body: `Your ${name} is over ${t}% โ now at ${Math.round(pct)}%`,
|
|
70
|
+
silent: false,
|
|
71
|
+
}).show()
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
armed.delete(key) // re-arm when it drops below
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createWindow() {
|
|
81
|
+
config = loadConfig()
|
|
82
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
83
|
+
const H = 480
|
|
84
|
+
|
|
85
|
+
win = new BrowserWindow({
|
|
86
|
+
width: W,
|
|
87
|
+
height: H,
|
|
88
|
+
x: workAreaSize.width - W - 24,
|
|
89
|
+
y: workAreaSize.height - H - 24,
|
|
90
|
+
frame: false,
|
|
91
|
+
transparent: true,
|
|
92
|
+
resizable: false,
|
|
93
|
+
alwaysOnTop: true,
|
|
94
|
+
skipTaskbar: true,
|
|
95
|
+
hasShadow: false,
|
|
96
|
+
fullscreenable: false,
|
|
97
|
+
webPreferences: {
|
|
98
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
99
|
+
contextIsolation: true,
|
|
100
|
+
nodeIntegration: false,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
win.setAlwaysOnTop(true, 'floating')
|
|
105
|
+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
|
106
|
+
win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
|
|
107
|
+
|
|
108
|
+
const tick = () => {
|
|
109
|
+
if (!win || win.isDestroyed()) return
|
|
110
|
+
try {
|
|
111
|
+
const data = getUsage(config)
|
|
112
|
+
win.webContents.send('usage', data)
|
|
113
|
+
checkAlerts(config, data)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
win.webContents.send('usage-error', String(err))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
doTick = tick
|
|
119
|
+
|
|
120
|
+
win.webContents.once('did-finish-load', () => {
|
|
121
|
+
tick()
|
|
122
|
+
win.webContents.send('config', publicConfig(config))
|
|
123
|
+
win.webContents.send('auth-state', { connected: auth.isConnected() })
|
|
124
|
+
pollTimer = setInterval(tick, config.pollIntervalMs)
|
|
125
|
+
startUsagePoll()
|
|
126
|
+
watchDebug()
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// resize the window to fit the content
|
|
131
|
+
ipcMain.on('resize', (_e, w, h) => {
|
|
132
|
+
if (!win || win.isDestroyed()) return
|
|
133
|
+
const width = Math.max(100, Math.round(w))
|
|
134
|
+
const height = Math.max(110, Math.round(h))
|
|
135
|
+
win.setContentSize(width, height)
|
|
136
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
137
|
+
win.setPosition(workAreaSize.width - width - 24, workAreaSize.height - height - 24)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
ipcMain.on('open-usage', () => shell.openExternal('https://claude.ai/settings/usage'))
|
|
141
|
+
|
|
142
|
+
// watch the debug file; forward forced states to the renderer
|
|
143
|
+
function watchDebug() {
|
|
144
|
+
fs.watchFile(DEBUG_FILE, { interval: 400 }, () => {
|
|
145
|
+
if (!win || win.isDestroyed()) return
|
|
146
|
+
try {
|
|
147
|
+
const txt = fs.readFileSync(DEBUG_FILE, 'utf8').trim()
|
|
148
|
+
win.webContents.send('debug-state', txt ? JSON.parse(txt) : null)
|
|
149
|
+
} catch {}
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- real usage via OAuth (authoritative %), polled slowly with 429 backoff ----
|
|
154
|
+
let usageTimer = null
|
|
155
|
+
let usageBackoff = 5 * 60 * 1000
|
|
156
|
+
function scheduleUsagePoll() {
|
|
157
|
+
clearTimeout(usageTimer)
|
|
158
|
+
if (auth.isConnected()) usageTimer = setTimeout(pollUsage, usageBackoff)
|
|
159
|
+
}
|
|
160
|
+
async function pollUsage() {
|
|
161
|
+
try {
|
|
162
|
+
const u = await auth.fetchUsage()
|
|
163
|
+
usageBackoff = 5 * 60 * 1000
|
|
164
|
+
if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (e && e.status === 429) {
|
|
167
|
+
usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
|
|
168
|
+
} else if (e && e.status === 401) {
|
|
169
|
+
auth.clear()
|
|
170
|
+
if (win && !win.isDestroyed()) {
|
|
171
|
+
win.webContents.send('auth-state', { connected: false })
|
|
172
|
+
win.webContents.send('real-usage', null)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
scheduleUsagePoll()
|
|
177
|
+
}
|
|
178
|
+
function startUsagePoll() {
|
|
179
|
+
if (auth.isConnected()) pollUsage()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ipcMain.on('auth-start', () => shell.openExternal(auth.begin()))
|
|
183
|
+
ipcMain.on('auth-code', async (_e, code) => {
|
|
184
|
+
const ok = () => {
|
|
185
|
+
if (win && !win.isDestroyed()) {
|
|
186
|
+
win.webContents.send('auth-state', { connected: true })
|
|
187
|
+
win.webContents.send('auth-result', { ok: true })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await auth.complete(code)
|
|
192
|
+
usageBackoff = 5 * 60 * 1000
|
|
193
|
+
try {
|
|
194
|
+
const u = await auth.fetchUsage() // validate the token
|
|
195
|
+
ok()
|
|
196
|
+
if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e && e.status === 429) {
|
|
199
|
+
// token is fine, the usage endpoint is just throttled โ keep it and retry later
|
|
200
|
+
ok()
|
|
201
|
+
} else {
|
|
202
|
+
throw e
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
scheduleUsagePoll()
|
|
206
|
+
} catch (err) {
|
|
207
|
+
auth.clear() // don't keep an invalid token
|
|
208
|
+
if (win && !win.isDestroyed())
|
|
209
|
+
win.webContents.send('auth-result', { ok: false, error: String(err?.message || err) })
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
ipcMain.on('auth-logout', () => {
|
|
213
|
+
auth.clear()
|
|
214
|
+
clearTimeout(usageTimer)
|
|
215
|
+
if (win && !win.isDestroyed()) {
|
|
216
|
+
win.webContents.send('auth-state', { connected: false })
|
|
217
|
+
win.webContents.send('real-usage', null)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
ipcMain.on('save-config', (_e, patch) => {
|
|
222
|
+
let obj = {}
|
|
223
|
+
for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
|
|
224
|
+
try {
|
|
225
|
+
obj = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
226
|
+
break
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
Object.assign(obj, patch)
|
|
230
|
+
try {
|
|
231
|
+
fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
|
|
232
|
+
fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
|
|
233
|
+
} catch {}
|
|
234
|
+
config = loadConfig()
|
|
235
|
+
armed.clear() // re-arm alerts with new thresholds
|
|
236
|
+
if (doTick) doTick()
|
|
237
|
+
if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
ipcMain.on('quit', () => app.quit())
|
|
241
|
+
|
|
242
|
+
app.whenReady().then(() => {
|
|
243
|
+
createWindow()
|
|
244
|
+
// open at login (packaged app only)
|
|
245
|
+
if (app.isPackaged) {
|
|
246
|
+
app.setLoginItemSettings({ openAtLogin: true, openAsHidden: false })
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
app.on('window-all-closed', () => {
|
|
251
|
+
if (pollTimer) clearInterval(pollTimer)
|
|
252
|
+
fs.unwatchFile(DEBUG_FILE)
|
|
253
|
+
app.quit()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
if (process.platform === 'darwin' && app.dock) app.dock.hide()
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clauddy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
5
|
+
"main": "main.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"clauddy": "bin/clauddy.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"main.js",
|
|
12
|
+
"preload.js",
|
|
13
|
+
"usage.js",
|
|
14
|
+
"auth.js",
|
|
15
|
+
"config.json",
|
|
16
|
+
"renderer"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "electron .",
|
|
20
|
+
"pack": "node scripts/build-app.js --dir",
|
|
21
|
+
"dist": "node scripts/build-app.js --mac zip",
|
|
22
|
+
"icon": "bash build-icon.sh",
|
|
23
|
+
"gifs": "electron tools/capture/capture.js",
|
|
24
|
+
"format": "biome format --write .",
|
|
25
|
+
"lint": "biome lint .",
|
|
26
|
+
"check": "biome check --write .",
|
|
27
|
+
"prepare": "git config core.hooksPath .githooks || true"
|
|
28
|
+
},
|
|
29
|
+
"author": "Renato",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/renatoaug/claude-usage-monitor.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/renatoaug/claude-usage-monitor#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/renatoaug/claude-usage-monitor/issues"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"claude",
|
|
41
|
+
"claude-code",
|
|
42
|
+
"usage",
|
|
43
|
+
"monitor",
|
|
44
|
+
"desktop-pet",
|
|
45
|
+
"electron",
|
|
46
|
+
"macos",
|
|
47
|
+
"menubar",
|
|
48
|
+
"widget"
|
|
49
|
+
],
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"packageManager": "bun@1.3.14",
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=24"
|
|
56
|
+
},
|
|
57
|
+
"build": {
|
|
58
|
+
"appId": "com.renato.claude-usage-monitor",
|
|
59
|
+
"productName": "Claude Usage Monitor",
|
|
60
|
+
"directories": {
|
|
61
|
+
"output": "dist"
|
|
62
|
+
},
|
|
63
|
+
"files": [
|
|
64
|
+
"main.js",
|
|
65
|
+
"preload.js",
|
|
66
|
+
"usage.js",
|
|
67
|
+
"auth.js",
|
|
68
|
+
"config.json",
|
|
69
|
+
"renderer/**"
|
|
70
|
+
],
|
|
71
|
+
"mac": {
|
|
72
|
+
"category": "public.app-category.developer-tools",
|
|
73
|
+
"target": "dir",
|
|
74
|
+
"identity": null,
|
|
75
|
+
"icon": "build/icon.icns"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"electron": "^42.4.1"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@biomejs/biome": "^2.5.0",
|
|
83
|
+
"@semantic-release/exec": "^7.1.0",
|
|
84
|
+
"electron-builder": "^26.15.3",
|
|
85
|
+
"semantic-release": "^25.0.5"
|
|
86
|
+
}
|
|
87
|
+
}
|