clauddy 1.29.0 → 2.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/package.json CHANGED
@@ -1,38 +1,18 @@
1
1
  {
2
2
  "name": "clauddy",
3
- "desktopName": "clauddy.desktop",
4
- "version": "1.29.0",
5
- "description": "A cute desktop pet that tracks your Claude Code usage",
6
- "main": "main.js",
3
+ "version": "2.0.0",
4
+ "description": "A desktop pet that tracks your Claude Code usage",
7
5
  "bin": {
8
6
  "clauddy": "bin/clauddy.js"
9
7
  },
10
8
  "files": [
11
- "bin",
12
- "main.js",
13
- "preload.js",
14
- "usage.js",
15
- "auth.js",
16
- "accounts.js",
17
- "codex.js",
18
- "cursor.js",
19
- "reminders.js",
20
- "config.json",
21
- "renderer"
9
+ "bin"
22
10
  ],
23
11
  "scripts": {
24
- "start": "electron .",
25
- "pack": "node scripts/build-app.js --dir",
26
- "dist": "node scripts/build-app.js --mac zip --publish never",
27
- "dist:win": "node scripts/build-app.js --win zip --publish never",
28
- "dist:linux": "node scripts/build-app.js --linux --publish never",
29
- "icon": "bash build-icon.sh",
30
- "gifs": "electron tools/capture/capture.js",
31
- "test": "bun test test/unit/ && bun test test/main/ && bun test test/dom/",
32
- "test:coverage": "node scripts/check-coverage.js",
33
- "format": "biome format --write .",
34
- "lint": "biome lint .",
12
+ "test": "bun test test/",
35
13
  "check": "biome check --write .",
14
+ "lint": "biome lint .",
15
+ "format": "biome format --write .",
36
16
  "prepare": "git config core.hooksPath .githooks || true"
37
17
  },
38
18
  "author": "Renato",
@@ -51,7 +31,6 @@
51
31
  "usage",
52
32
  "monitor",
53
33
  "desktop-pet",
54
- "electron",
55
34
  "macos",
56
35
  "menubar",
57
36
  "widget"
@@ -61,62 +40,11 @@
61
40
  },
62
41
  "packageManager": "bun@1.3.14",
63
42
  "engines": {
64
- "node": ">=24"
65
- },
66
- "build": {
67
- "appId": "app.clauddy",
68
- "afterPack": "scripts/adhoc-sign.js",
69
- "productName": "Clauddy",
70
- "directories": {
71
- "output": "dist"
72
- },
73
- "files": [
74
- "main.js",
75
- "preload.js",
76
- "usage.js",
77
- "auth.js",
78
- "accounts.js",
79
- "codex.js",
80
- "cursor.js",
81
- "reminders.js",
82
- "config.json",
83
- "renderer/**",
84
- "build/trayTemplate.png",
85
- "build/trayTemplate@2x.png",
86
- "build/trayColor.png",
87
- "build/trayColor@2x.png",
88
- "build/icon.png"
89
- ],
90
- "mac": {
91
- "category": "public.app-category.developer-tools",
92
- "target": "dir",
93
- "identity": null,
94
- "icon": "build/icon.icns",
95
- "artifactName": "${productName}-${version}-mac-${arch}.${ext}"
96
- },
97
- "win": {
98
- "target": "zip",
99
- "icon": "build/icon.ico",
100
- "artifactName": "${productName}-${version}-win-${arch}.${ext}"
101
- },
102
- "linux": {
103
- "target": [
104
- "tar.gz",
105
- "AppImage"
106
- ],
107
- "artifactName": "${productName}-${version}-linux-${arch}.${ext}",
108
- "category": "Utility",
109
- "icon": "build/icon.png",
110
- "syncDesktopName": true
111
- }
112
- },
113
- "dependencies": {
114
- "electron": "^42.4.1"
43
+ "node": ">=18"
115
44
  },
116
45
  "devDependencies": {
117
- "@biomejs/biome": "^2.5.0",
118
- "@happy-dom/global-registrator": "^20.11.2",
119
- "electron-builder": "^26.15.3",
120
- "semantic-release": "^25.0.5"
46
+ "@biomejs/biome": "^2.6.6",
47
+ "conventional-changelog-conventionalcommits": "^9.3.1",
48
+ "semantic-release": "^25.0.9"
121
49
  }
122
50
  }
package/accounts.js DELETED
@@ -1,181 +0,0 @@
1
- // Multiple Claude subscriptions in one widget.
2
- //
3
- // An account is a token plus, optionally, the Claude config dir whose logs it
4
- // reads. The first one ("default") keeps the paths the app has always used, so
5
- // an existing install carries over untouched; every extra account gets its own
6
- // folder under `accounts/` for auth.json and alerts.json.
7
- //
8
- // Only the active account is polled — and therefore only it can notify.
9
- const fs = require('node:fs')
10
- const path = require('node:path')
11
- const os = require('node:os')
12
-
13
- const BASE_DIR = process.env.CLAUDE_CONFIG_DIR
14
- ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
15
- : path.join(os.homedir(), '.claude-usage-monitor')
16
- const FILE = path.join(BASE_DIR, 'accounts.json')
17
- const DEFAULT_ID = 'default'
18
-
19
- function defaults() {
20
- return { active: DEFAULT_ID, accounts: [{ id: DEFAULT_ID, label: null, claudeDir: null }] }
21
- }
22
-
23
- function sane(j) {
24
- const list = (Array.isArray(j?.accounts) ? j.accounts : [])
25
- .filter((a) => a && typeof a.id === 'string' && a.id)
26
- .map((a) => ({
27
- id: a.id,
28
- label: typeof a.label === 'string' && a.label ? a.label : null,
29
- claudeDir: typeof a.claudeDir === 'string' && a.claudeDir ? a.claudeDir : null,
30
- }))
31
- // the default account can be removed like any other, so it is only recreated
32
- // when nothing is left — an empty list would leave the widget with no token
33
- if (!list.length) list.push(defaults().accounts[0])
34
- const active = list.some((a) => a.id === j?.active) ? j.active : list[0].id
35
- return { active, accounts: list }
36
- }
37
-
38
- // re-read on every call instead of caching: the file is a handful of rows, it
39
- // is read only when the account list is shown or changed, and a stale cache
40
- // here would mean the widget polling a token it thinks it still has
41
- function load() {
42
- try {
43
- return sane(JSON.parse(fs.readFileSync(FILE, 'utf8')))
44
- } catch {
45
- return defaults() // first run, or a corrupted file
46
- }
47
- }
48
-
49
- function save(state) {
50
- try {
51
- fs.mkdirSync(BASE_DIR, { recursive: true })
52
- fs.writeFileSync(FILE, JSON.stringify(state, null, 2))
53
- } catch {}
54
- }
55
-
56
- // where an account's own files (auth.json, alerts.json) live
57
- function dataDirOf(id) {
58
- return id === DEFAULT_ID ? BASE_DIR : path.join(BASE_DIR, 'accounts', id)
59
- }
60
-
61
- function list() {
62
- return load().accounts.map((a) => ({ ...a }))
63
- }
64
-
65
- function activeId() {
66
- return load().active
67
- }
68
-
69
- function active() {
70
- const s = load()
71
- return s.accounts.find((a) => a.id === s.active) || s.accounts[0]
72
- }
73
-
74
- function find(s, id) {
75
- return s.accounts.find((a) => a.id === id)
76
- }
77
-
78
- function setActive(id) {
79
- const s = load()
80
- const hit = find(s, id)
81
- if (!hit) return null
82
- s.active = id
83
- save(s)
84
- return hit
85
- }
86
-
87
- // a fresh, logged-out account — the caller switches to it and runs the usual
88
- // browser login, which is what gives it an identity
89
- function add(claudeDir) {
90
- const s = load()
91
- // two clicks inside the same millisecond used to mint the same id, and the
92
- // duplicate then looked like the account we were already on
93
- let id = `acct-${Date.now().toString(36)}`
94
- while (s.accounts.some((a) => a.id === id)) {
95
- id = `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
96
- }
97
- s.accounts.push({ id, label: null, claudeDir: claudeDir || null })
98
- save(s)
99
- return id
100
- }
101
-
102
- // the login flow knows the email; that's a better name than "Account 2"
103
- function label(id, text) {
104
- const s = load()
105
- const a = find(s, id)
106
- if (!a || a.label === (text || null)) return
107
- a.label = text || null
108
- save(s)
109
- }
110
-
111
- function setClaudeDir(id, dir) {
112
- const s = load()
113
- const a = find(s, id)
114
- if (!a) return
115
- a.claudeDir = dir || null
116
- save(s)
117
- }
118
-
119
- // removing an account takes its token with it — leaving a stray auth.json
120
- // behind would be a credential nobody can see or revoke from the UI
121
- function remove(id) {
122
- const s = load()
123
- if (id === s.active) return false // switch away first: the widget is showing it
124
- const i = s.accounts.findIndex((a) => a.id === id)
125
- if (i < 0) return false
126
- if (s.accounts.length < 2) return false // never leave the widget with no account
127
- s.accounts.splice(i, 1)
128
- save(s)
129
- try {
130
- if (id === DEFAULT_ID) {
131
- // its folder is the app's own data dir: drop the credentials, not the
132
- // settings, the account list or the simulator file that live beside them
133
- for (const f of ['auth.json', 'alerts.json'])
134
- fs.rmSync(path.join(dataDirOf(id), f), { force: true })
135
- } else {
136
- fs.rmSync(dataDirOf(id), { recursive: true, force: true })
137
- }
138
- } catch {}
139
- return true
140
- }
141
-
142
- // Folders left behind under `accounts/` by slots that are no longer listed —
143
- // an add abandoned mid-login, mostly, since saving the alert state recreates
144
- // the folder on its way out. Only ever drops a folder with no auth.json in it,
145
- // so a token is never destroyed by a bad read of accounts.json.
146
- function pruneOrphans() {
147
- const root = path.join(BASE_DIR, 'accounts')
148
- let dirs = []
149
- try {
150
- dirs = fs.readdirSync(root, { withFileTypes: true })
151
- } catch {
152
- return 0 // no extra accounts were ever added
153
- }
154
- const known = new Set(list().map((a) => a.id))
155
- let n = 0
156
- for (const d of dirs) {
157
- if (!d.isDirectory() || known.has(d.name)) continue
158
- const dir = path.join(root, d.name)
159
- if (fs.existsSync(path.join(dir, 'auth.json'))) continue
160
- try {
161
- fs.rmSync(dir, { recursive: true, force: true })
162
- n++
163
- } catch {}
164
- }
165
- return n
166
- }
167
-
168
- module.exports = {
169
- BASE_DIR,
170
- DEFAULT_ID,
171
- dataDirOf,
172
- list,
173
- active,
174
- activeId,
175
- setActive,
176
- add,
177
- label,
178
- setClaudeDir,
179
- remove,
180
- pruneOrphans,
181
- }
package/auth.js DELETED
@@ -1,300 +0,0 @@
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 PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
13
- const SCOPE = 'org:create_api_key user:profile user:inference'
14
- const UA = 'claude-cli/2.1.181 (external, cli)'
15
- // when CLAUDE_CONFIG_DIR is set (e.g. via direnv for multi-account setups),
16
- // keep the widget's data alongside that account's Claude config
17
- const DATA_DIR = process.env.CLAUDE_CONFIG_DIR
18
- ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
19
- : path.join(os.homedir(), '.claude-usage-monitor')
20
- // mutable: with several accounts configured, each one keeps its token in its
21
- // own dir and the widget switches between them at runtime
22
- let tokenPath = path.join(DATA_DIR, 'auth.json')
23
-
24
- const b64url = (buf) =>
25
- buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
26
-
27
- let tokens = null // { access_token, refresh_token, expires_at }
28
- let pending = null // { verifier, state }
29
- let profile = null // { email, name, plan } — cached account identity
30
-
31
- // point the module at another account's dir — the cached token and profile
32
- // belong to the previous one, so both are dropped
33
- function setDataDir(dir) {
34
- tokenPath = path.join(dir || DATA_DIR, 'auth.json')
35
- tokens = null
36
- profile = null
37
- pending = null
38
- unreadable = false
39
- }
40
-
41
- // Set when the file itself could not be read for a reason other than "it is not
42
- // there" — a disk still waking up, say. That must not read as a logout, or the
43
- // widget asks for a login it doesn't need and every account looks gone. A file
44
- // we did read but cannot parse is a different matter: that one really is dead.
45
- let unreadable = false
46
- function load() {
47
- if (tokens) return tokens
48
- let raw = null
49
- try {
50
- raw = fs.readFileSync(tokenPath, 'utf8')
51
- unreadable = false
52
- } catch (e) {
53
- unreadable = e?.code !== 'ENOENT'
54
- }
55
- try {
56
- tokens = raw == null ? null : JSON.parse(raw)
57
- } catch {
58
- tokens = null
59
- }
60
- return tokens
61
- }
62
- function save(t) {
63
- tokens = t
64
- try {
65
- fs.mkdirSync(path.dirname(tokenPath), { recursive: true })
66
- fs.writeFileSync(tokenPath, JSON.stringify(t, null, 2), { mode: 0o600 })
67
- } catch {}
68
- }
69
- function clear() {
70
- tokens = null
71
- profile = null
72
- unreadable = false
73
- try {
74
- fs.unlinkSync(tokenPath)
75
- } catch {}
76
- }
77
- function isConnected() {
78
- // a token we merely failed to read still counts: the account is connected,
79
- // we just couldn't see it this instant
80
- return !!load() || unreadable
81
- }
82
-
83
- // Step 1: build the authorize URL (opens in the browser)
84
- function begin() {
85
- const verifier = b64url(crypto.randomBytes(32))
86
- const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
87
- const state = b64url(crypto.randomBytes(32))
88
- pending = { verifier, state }
89
- const params = {
90
- code: 'true',
91
- client_id: CLIENT_ID,
92
- response_type: 'code',
93
- redirect_uri: REDIRECT,
94
- scope: SCOPE,
95
- code_challenge: challenge,
96
- code_challenge_method: 'S256',
97
- state,
98
- }
99
- const query = Object.entries(params)
100
- .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
101
- .join('&')
102
- return `${AUTHORIZE}?${query}`
103
- }
104
-
105
- // Step 2: exchange the pasted "code#state" for tokens
106
- async function complete(pasted) {
107
- const raw = String(pasted).trim()
108
- // A directly-pasted long-lived token (e.g. from `claude setup-token`) skips
109
- // the rate-limited code exchange entirely.
110
- if (!raw.includes('#')) {
111
- save({ access_token: raw, refresh_token: null, expires_at: Date.now() + 365 * 864e5 })
112
- pending = null
113
- return
114
- }
115
- if (!pending) throw new Error('no pending auth')
116
- const [code, returnedState] = raw.split('#')
117
- const res = await fetch(TOKEN_URL, {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
120
- body: JSON.stringify({
121
- grant_type: 'authorization_code',
122
- code,
123
- state: returnedState || pending.state,
124
- code_verifier: pending.verifier,
125
- client_id: CLIENT_ID,
126
- redirect_uri: REDIRECT,
127
- }),
128
- })
129
- if (!res.ok) {
130
- const body = await res.text().catch(() => '')
131
- throw new Error(`exchange ${res.status}: ${body.slice(0, 150)}`)
132
- }
133
- const j = await res.json()
134
- save({
135
- access_token: j.access_token,
136
- refresh_token: j.refresh_token,
137
- expires_at: Date.now() + (j.expires_in || 3600) * 1000,
138
- })
139
- pending = null
140
- }
141
-
142
- // Usage and profile both ask for a valid token at once. Two refreshes would
143
- // rotate the refresh token twice, and the loser's grant comes back rejected —
144
- // so callers share the one in flight.
145
- let refreshing = null
146
- function refresh() {
147
- if (!refreshing) refreshing = doRefresh().finally(() => (refreshing = null))
148
- return refreshing
149
- }
150
- async function doRefresh() {
151
- const t = load()
152
- if (!t?.refresh_token) throw Object.assign(new Error('no refresh token'), { status: 401 })
153
- const res = await fetch(TOKEN_URL, {
154
- method: 'POST',
155
- headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
156
- body: JSON.stringify({
157
- grant_type: 'refresh_token',
158
- refresh_token: t.refresh_token,
159
- client_id: CLIENT_ID,
160
- }),
161
- })
162
- if (!res.ok) {
163
- // a rejected grant (expired/revoked refresh token) means the session is
164
- // dead — surface it as 401 so callers clear the token and prompt login.
165
- // transient failures (429, 5xx) keep their status so we retry, not logout.
166
- const dead = res.status === 400 || res.status === 403
167
- throw Object.assign(new Error('refresh failed'), { status: dead ? 401 : res.status })
168
- }
169
- const j = await res.json()
170
- save({
171
- access_token: j.access_token,
172
- refresh_token: j.refresh_token || t.refresh_token,
173
- expires_at: Date.now() + (j.expires_in || 3600) * 1000,
174
- })
175
- }
176
-
177
- async function validToken() {
178
- const t = load()
179
- // status 0 is "try again later", not "log in again": only a token that is
180
- // genuinely absent should send the user back through the browser
181
- if (!t) {
182
- throw Object.assign(new Error(unreadable ? 'token unreadable' : 'not connected'), {
183
- status: unreadable ? 0 : 401,
184
- })
185
- }
186
- if (!t.expires_at || t.expires_at - Date.now() < 60000) await refresh()
187
- return load().access_token
188
- }
189
-
190
- function win(o) {
191
- return o && typeof o.utilization === 'number'
192
- ? { pct: o.utilization, resetMs: o.resets_at ? Date.parse(o.resets_at) - Date.now() : null }
193
- : null
194
- }
195
-
196
- // Weekly limits scoped to one model (e.g. "Fable" on Max) live in the `limits`
197
- // array. The older seven_day_<model> windows are kept as a fallback for plans
198
- // that still report them that way.
199
- function scopedWeeks(j) {
200
- const out = []
201
- for (const l of Array.isArray(j.limits) ? j.limits : []) {
202
- if (l?.kind !== 'weekly_scoped' || typeof l.percent !== 'number') continue
203
- const label = l.scope?.model?.display_name
204
- if (!label) continue
205
- out.push({
206
- label,
207
- pct: l.percent,
208
- resetMs: l.resets_at ? Date.parse(l.resets_at) - Date.now() : null,
209
- })
210
- }
211
- if (out.length) return out
212
- for (const [key, label] of [
213
- ['seven_day_opus', 'Opus'],
214
- ['seven_day_sonnet', 'Sonnet'],
215
- ]) {
216
- const w = win(j[key])
217
- if (w) out.push({ label, ...w })
218
- }
219
- return out
220
- }
221
-
222
- // What the endpoint says its own budget is. Undocumented, so we only ever read
223
- // it — nothing depends on it yet. Knowing the real numbers is what tells us how
224
- // close the activity poll's floor can get.
225
- let rateLimit = null
226
- function readRateLimit(res) {
227
- const out = {}
228
- try {
229
- for (const [k, v] of res.headers) {
230
- if (k.startsWith('anthropic-ratelimit-')) out[k.slice(20)] = v
231
- }
232
- } catch {
233
- return // no headers to read — nothing here is worth failing a fetch over
234
- }
235
- if (!Object.keys(out).length) return
236
- if (JSON.stringify(out) !== JSON.stringify(rateLimit)) console.log('usage rate limit', out)
237
- rateLimit = out
238
- }
239
-
240
- // Step 3: fetch the authoritative usage
241
- async function fetchUsage() {
242
- const token = await validToken()
243
- const res = await fetch(USAGE_URL, {
244
- headers: {
245
- Authorization: `Bearer ${token}`,
246
- 'anthropic-beta': 'oauth-2025-04-20',
247
- 'anthropic-version': '2023-06-01',
248
- 'User-Agent': UA,
249
- },
250
- })
251
- if (!res.ok) {
252
- const body = await res.text().catch(() => '')
253
- throw Object.assign(new Error(`usage ${res.status}: ${body.slice(0, 150)}`), {
254
- status: res.status,
255
- })
256
- }
257
- readRateLimit(res)
258
- const j = await res.json()
259
- return {
260
- session: win(j.five_hour) || { pct: 0, resetMs: null },
261
- week: win(j.seven_day) || { pct: 0, resetMs: null },
262
- scoped: scopedWeeks(j),
263
- }
264
- }
265
-
266
- // The logged-in account's identity (email + plan tier). Cached for the session;
267
- // only changes on logout/login, so we don't re-fetch on every usage poll.
268
- async function fetchProfile() {
269
- if (profile) return profile
270
- const token = await validToken()
271
- const res = await fetch(PROFILE_URL, {
272
- headers: {
273
- Authorization: `Bearer ${token}`,
274
- 'anthropic-beta': 'oauth-2025-04-20',
275
- 'anthropic-version': '2023-06-01',
276
- 'User-Agent': UA,
277
- },
278
- })
279
- if (!res.ok) {
280
- throw Object.assign(new Error(`profile ${res.status}`), { status: res.status })
281
- }
282
- const a = (await res.json()).account || {}
283
- profile = {
284
- email: a.email || null,
285
- name: a.display_name || a.full_name || null,
286
- plan: a.has_claude_max ? 'Max' : a.has_claude_pro ? 'Pro' : null,
287
- }
288
- return profile
289
- }
290
-
291
- module.exports = {
292
- rateLimit: () => rateLimit,
293
- begin,
294
- complete,
295
- fetchUsage,
296
- fetchProfile,
297
- clear,
298
- isConnected,
299
- setDataDir,
300
- }