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/README.md +40 -339
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -236
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
package/package.json
CHANGED
|
@@ -1,38 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauddy",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
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
|
-
"
|
|
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": ">=
|
|
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.
|
|
118
|
-
"
|
|
119
|
-
"
|
|
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
|
-
}
|