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 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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
+ }