claude-usage-alerts 0.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/claude-usage-alerts.js +435 -0
- package/package.json +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Smith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# claude-usage-alerts
|
|
2
|
+
|
|
3
|
+
A desktop toast, webhook, or email when your Claude Code usage crosses a threshold, plus a `5h 12% · wk 50%` segment on your statusline.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx claude-usage-alerts init
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`init` asks for an email and an optional webhook URL, then wraps your existing statusline command so it keeps running as before with the usage segment appended. `claude-usage-alerts test` fires a fake 90% alert so you can check each channel. `claude-usage-alerts uninstall` puts your old statusline back.
|
|
10
|
+
|
|
11
|
+
## Limitations
|
|
12
|
+
|
|
13
|
+
- Pro and Max subscriptions only. The `rate_limits` data this reads is not present on API-key or Bedrock/Vertex sessions.
|
|
14
|
+
- Alerts fire only while a Claude Code session is open. There is no daemon; the check runs each time Claude Code redraws the statusline.
|
|
15
|
+
- The numbers are whatever Claude Code reports. This tool does not measure usage itself and keeps no history beyond a 30 minute sample log for the burn-rate estimate.
|
|
16
|
+
- The 100% threshold is unverified. It is unknown whether Claude Code still emits statusline updates once you are rate limited.
|
|
17
|
+
- Email goes through a small relay at `alerts.usero.io`. The first email to an address is a confirmation link; nothing else is sent until you click it. There is a cap of around 20 emails per address per day.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
Claude Code passes a JSON blob to the statusline command on every redraw, including `rate_limits.five_hour` and `rate_limits.seven_day` with `used_percentage` and `resets_at`. This script reads that, prints the segment, and on a new threshold crossing spawns a detached child to send the alerts so the statusline never waits on the network.
|
|
22
|
+
|
|
23
|
+
Each crossing fires once per window. A marker file in `~/.claude/usage-alerts/` keyed by window, reset time and threshold stops repeats, and markers are pruned once the window resets.
|
|
24
|
+
|
|
25
|
+
## Thresholds
|
|
26
|
+
|
|
27
|
+
| Window | Default thresholds | Extra rule |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| 5 hour | 90 | none |
|
|
30
|
+
| 7 day | 50, 75, 90, 100 | fires only when usage is more than 15 points ahead of the elapsed week, or at 90+ regardless |
|
|
31
|
+
|
|
32
|
+
The weekly pace rule stops the 50% alert from firing when you are at 50% usage halfway through the week, which is exactly on pace. If you open a session already past several thresholds, only the highest one fires.
|
|
33
|
+
|
|
34
|
+
At 75% and above, the email and webhook include a burn-rate estimate: a linear projection from the last 30 minutes of samples of when the window would hit 100%. It is `null` when usage is flat, there is under a minute of data, or the projection lands after the reset.
|
|
35
|
+
|
|
36
|
+
## Statusline segment
|
|
37
|
+
|
|
38
|
+
`5h 12% · wk 50%`. Under 50% is dimmed, 75+ is yellow, 90+ is bold red with the reset time on that window, for example `5h 92% ↻3:40pm`. A weekday is added when the reset is more than a day out. Windows Claude Code does not report are left out; with no `rate_limits` at all the segment is empty.
|
|
39
|
+
|
|
40
|
+
## Config
|
|
41
|
+
|
|
42
|
+
`~/.claude/usage-alerts.json` (or under `$CLAUDE_CONFIG_DIR` when set):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"email": "you@example.com",
|
|
47
|
+
"webhook": "https://hooks.example.com/abc",
|
|
48
|
+
"thresholds": { "five_hour": [90], "seven_day": [50, 75, 90, 100] },
|
|
49
|
+
"channels": { "toast": true, "webhook": true, "email": [75, 90, 100] },
|
|
50
|
+
"tz": "Australia/Sydney"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Key | Meaning |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `email` | Address for email alerts. Leave out to disable email. |
|
|
57
|
+
| `webhook` | URL that receives a JSON POST on each alert. Leave out to disable. |
|
|
58
|
+
| `thresholds.five_hour`, `thresholds.seven_day` | Percentages that trigger an alert. Defaults above. |
|
|
59
|
+
| `channels.toast`, `channels.webhook`, `channels.email` | `true` (every threshold, the default), `false` (off), or a list of thresholds that channel should fire on. |
|
|
60
|
+
| `tz` | IANA timezone for times in alerts and the segment. Defaults to the system timezone. |
|
|
61
|
+
| `relay` | Email relay URL. Defaults to `https://alerts.usero.io/send`. |
|
|
62
|
+
| `previousStatusLine` | Written by `init`, read by `uninstall`. Leave it alone. |
|
|
63
|
+
|
|
64
|
+
## Webhook payload
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"title": "Claude Code: 5h limit at 90%",
|
|
69
|
+
"body": "5h usage is at 91% (crossed 90%). Resets 3:40pm.",
|
|
70
|
+
"window": "five_hour",
|
|
71
|
+
"pct": 91,
|
|
72
|
+
"threshold": 90,
|
|
73
|
+
"resets_at": 1757900000,
|
|
74
|
+
"other_window": { "pct": 48, "resets_at": 1758300000 },
|
|
75
|
+
"burn": { "runs_out_at": 1757897000 },
|
|
76
|
+
"tz": "Australia/Sydney"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`window` is `five_hour` or `seven_day`. Times are Unix epoch seconds. `other_window` is `null` when Claude Code did not report the other window.
|
|
81
|
+
|
|
82
|
+
## Desktop toasts
|
|
83
|
+
|
|
84
|
+
macOS via `osascript`, Linux via `notify-send`, Windows via a PowerShell toast. If the tool is missing the toast is skipped silently.
|
|
85
|
+
|
|
86
|
+
## Try it without installing
|
|
87
|
+
|
|
88
|
+
Pipe a sample statusline payload through the script. Nothing is written to your settings; it only prints the segment (and, on a threshold crossing, fires alerts using whatever config exists).
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
echo '{"rate_limits":{"five_hour":{"used_percentage":12,"resets_at":1757900000},"seven_day":{"used_percentage":50,"resets_at":1758300000}}}' \
|
|
92
|
+
| npx claude-usage-alerts
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
npm test
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Single file, Node 18+, no dependencies.
|
|
102
|
+
|
|
103
|
+
## About
|
|
104
|
+
|
|
105
|
+
Built by Will, who makes Usero (usero.io).
|
|
106
|
+
|
|
107
|
+
MIT.
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Usage threshold alerts for Claude Code. Runs as (or wraps) the statusline command.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { spawn, spawnSync, execFile } from 'node:child_process'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
|
|
9
|
+
const WINDOWS = { five_hour: '5h', seven_day: 'wk' }
|
|
10
|
+
const WINDOW_SECS = { five_hour: 5 * 3600, seven_day: 7 * 86400 }
|
|
11
|
+
const DEFAULT_THRESHOLDS = { five_hour: [90], seven_day: [50, 75, 90, 100] }
|
|
12
|
+
const DEFAULT_RELAY = 'https://alerts.usero.io/send'
|
|
13
|
+
const PACE_MARGIN = 15
|
|
14
|
+
const SAMPLE_KEEP_SECS = 30 * 60
|
|
15
|
+
const SAMPLE_MIN_GAP_SECS = 10
|
|
16
|
+
|
|
17
|
+
const ANSI = { dim: '\x1b[2m', yellow: '\x1b[33m', redBold: '\x1b[1;31m', reset: '\x1b[0m' }
|
|
18
|
+
|
|
19
|
+
// ---------- paths / config ----------
|
|
20
|
+
|
|
21
|
+
export function claudeDir() {
|
|
22
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
23
|
+
}
|
|
24
|
+
export const configPath = () => path.join(claudeDir(), 'usage-alerts.json')
|
|
25
|
+
export const settingsPath = () => path.join(claudeDir(), 'settings.json')
|
|
26
|
+
export const stateDir = () => path.join(claudeDir(), 'usage-alerts')
|
|
27
|
+
|
|
28
|
+
export function readJson(file, fallback = {}) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
return fallback
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeJson(file, data) {
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
38
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfig() {
|
|
42
|
+
return readJson(configPath(), {})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function thresholdsFor(config, window) {
|
|
46
|
+
const list = config.thresholds?.[window]
|
|
47
|
+
return Array.isArray(list) && list.length ? [...list].sort((a, b) => a - b) : DEFAULT_THRESHOLDS[window]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Channel config: true (all thresholds), false (off), or an array of thresholds.
|
|
51
|
+
export function channelWants(config, channel, threshold) {
|
|
52
|
+
const v = config.channels?.[channel]
|
|
53
|
+
if (v === undefined || v === true) return true
|
|
54
|
+
if (Array.isArray(v)) return v.includes(threshold)
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function timezone(config) {
|
|
59
|
+
return config.tz || Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- threshold logic ----------
|
|
63
|
+
|
|
64
|
+
export function elapsedWindowPct(window, resetsAt, now) {
|
|
65
|
+
const len = WINDOW_SECS[window]
|
|
66
|
+
return Math.min(100, Math.max(0, ((now - (resetsAt - len)) / len) * 100))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Weekly alerts fire only when usage is well ahead of the week's elapsed time, or at 90+.
|
|
70
|
+
export function passesPace(window, pct, resetsAt, now) {
|
|
71
|
+
if (window !== 'seven_day') return true
|
|
72
|
+
return pct >= 90 || pct - elapsedWindowPct(window, resetsAt, now) > PACE_MARGIN
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Returns the single highest threshold that pct has crossed and that has not fired yet.
|
|
76
|
+
export function selectThreshold(thresholds, pct, fired = () => false) {
|
|
77
|
+
const crossed = thresholds.filter((t) => pct >= t)
|
|
78
|
+
if (!crossed.length) return null
|
|
79
|
+
const top = Math.max(...crossed)
|
|
80
|
+
return fired(top) ? null : top
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function markerName(window, resetsAt, threshold) {
|
|
84
|
+
return `${window}-${resetsAt}-${threshold}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 'wx' makes the create atomic: exactly one tick wins per marker.
|
|
88
|
+
export function claimMarker(dir, name) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
91
|
+
fs.closeSync(fs.openSync(path.join(dir, name), 'wx'))
|
|
92
|
+
return true
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function pruneMarkers(dir, now) {
|
|
99
|
+
let names
|
|
100
|
+
try {
|
|
101
|
+
names = fs.readdirSync(dir)
|
|
102
|
+
} catch {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
for (const n of names) {
|
|
106
|
+
const m = /^(five_hour|seven_day)-(\d+)-\d+$/.exec(n)
|
|
107
|
+
if (m && Number(m[2]) < now) fs.rmSync(path.join(dir, n), { force: true })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------- burn rate ----------
|
|
112
|
+
|
|
113
|
+
export function parseSamples(text) {
|
|
114
|
+
return text
|
|
115
|
+
.split('\n')
|
|
116
|
+
.map((l) => l.split(','))
|
|
117
|
+
.filter((p) => p.length === 3)
|
|
118
|
+
.map(([ts, window, pct]) => ({ ts: Number(ts), window, pct: Number(pct) }))
|
|
119
|
+
.filter((s) => Number.isFinite(s.ts) && Number.isFinite(s.pct))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function serializeSamples(samples) {
|
|
123
|
+
return samples.map((s) => `${s.ts},${s.window},${s.pct}`).join('\n') + (samples.length ? '\n' : '')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Appends one sample per window, throttled, and drops anything older than 30 minutes.
|
|
127
|
+
export function recordSamples(samples, windows, now) {
|
|
128
|
+
let out = samples.filter((s) => now - s.ts <= SAMPLE_KEEP_SECS)
|
|
129
|
+
for (const [window, pct] of Object.entries(windows)) {
|
|
130
|
+
const last = out.filter((s) => s.window === window).at(-1)
|
|
131
|
+
if (last && now - last.ts < SAMPLE_MIN_GAP_SECS) continue
|
|
132
|
+
out.push({ ts: now, window, pct })
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Linear projection over the recent samples; null when flat, too short, or past the reset.
|
|
138
|
+
export function projectRunsOut(samples, window, pct, resetsAt, now) {
|
|
139
|
+
let recent = samples.filter((s) => s.window === window && now - s.ts <= SAMPLE_KEEP_SECS)
|
|
140
|
+
// A drop means the window reset mid-log; only use samples after it.
|
|
141
|
+
for (let i = recent.length - 1; i > 0; i--) {
|
|
142
|
+
if (recent[i].pct < recent[i - 1].pct) {
|
|
143
|
+
recent = recent.slice(i)
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (recent.length < 2) return null
|
|
148
|
+
const first = recent[0]
|
|
149
|
+
const last = recent.at(-1)
|
|
150
|
+
const span = last.ts - first.ts
|
|
151
|
+
if (span < 60 || last.pct <= first.pct) return null
|
|
152
|
+
const rate = (last.pct - first.pct) / span
|
|
153
|
+
const runsOutAt = Math.round(now + (100 - pct) / rate)
|
|
154
|
+
return runsOutAt < resetsAt ? runsOutAt : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------- rendering ----------
|
|
158
|
+
|
|
159
|
+
export function formatReset(resetsAt, now, tz) {
|
|
160
|
+
const d = new Date(resetsAt * 1000)
|
|
161
|
+
const opts = { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: tz }
|
|
162
|
+
if (resetsAt - now > 86400) opts.weekday = 'short'
|
|
163
|
+
return new Intl.DateTimeFormat('en-US', opts).format(d).replace(/,?\s+(AM|PM)$/i, (_, ap) => ap.toLowerCase())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderSegment(rateLimits, now, tz, color = true) {
|
|
167
|
+
const parts = []
|
|
168
|
+
for (const [window, label] of Object.entries(WINDOWS)) {
|
|
169
|
+
const w = rateLimits?.[window]
|
|
170
|
+
if (typeof w?.used_percentage !== 'number') continue
|
|
171
|
+
const pct = Math.floor(w.used_percentage)
|
|
172
|
+
let text = `${label} ${pct}%`
|
|
173
|
+
if (pct >= 90 && typeof w.resets_at === 'number') text += ` ↻${formatReset(w.resets_at, now, tz)}`
|
|
174
|
+
if (color) {
|
|
175
|
+
const c = pct >= 90 ? ANSI.redBold : pct >= 75 ? ANSI.yellow : pct < 50 ? ANSI.dim : ''
|
|
176
|
+
if (c) text = c + text + ANSI.reset
|
|
177
|
+
}
|
|
178
|
+
parts.push(text)
|
|
179
|
+
}
|
|
180
|
+
return parts.join(' · ')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------- alert pipeline ----------
|
|
184
|
+
|
|
185
|
+
export function buildPayload(rateLimits, window, threshold, samples, tz, now) {
|
|
186
|
+
const w = rateLimits[window]
|
|
187
|
+
const pct = Math.floor(w.used_percentage)
|
|
188
|
+
const otherKey = window === 'five_hour' ? 'seven_day' : 'five_hour'
|
|
189
|
+
const other = rateLimits[otherKey]
|
|
190
|
+
return {
|
|
191
|
+
window,
|
|
192
|
+
pct,
|
|
193
|
+
threshold,
|
|
194
|
+
resets_at: w.resets_at,
|
|
195
|
+
other_window:
|
|
196
|
+
typeof other?.used_percentage === 'number'
|
|
197
|
+
? { pct: Math.floor(other.used_percentage), resets_at: other.resets_at }
|
|
198
|
+
: null,
|
|
199
|
+
burn: { runs_out_at: pct >= 75 ? projectRunsOut(samples, window, pct, w.resets_at, now) : null },
|
|
200
|
+
tz,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Decides which alerts (if any) a tick should fire. Pure apart from marker files.
|
|
205
|
+
export function checkThresholds(rateLimits, config, dir, samples, now, claim = claimMarker) {
|
|
206
|
+
const alerts = []
|
|
207
|
+
for (const window of Object.keys(WINDOWS)) {
|
|
208
|
+
const w = rateLimits?.[window]
|
|
209
|
+
if (typeof w?.used_percentage !== 'number' || typeof w?.resets_at !== 'number') continue
|
|
210
|
+
const pct = Math.floor(w.used_percentage)
|
|
211
|
+
const t = selectThreshold(thresholdsFor(config, window), pct, (th) =>
|
|
212
|
+
fs.existsSync(path.join(dir, markerName(window, w.resets_at, th))),
|
|
213
|
+
)
|
|
214
|
+
if (t === null || !passesPace(window, pct, w.resets_at, now)) continue
|
|
215
|
+
if (!claim(dir, markerName(window, w.resets_at, t))) continue
|
|
216
|
+
alerts.push(buildPayload(rateLimits, window, t, samples, timezone(config), now))
|
|
217
|
+
}
|
|
218
|
+
return alerts
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function tick(input, thenCmd) {
|
|
222
|
+
let data = null
|
|
223
|
+
try {
|
|
224
|
+
data = JSON.parse(input)
|
|
225
|
+
} catch {}
|
|
226
|
+
const config = loadConfig()
|
|
227
|
+
const now = Math.floor(Date.now() / 1000)
|
|
228
|
+
const rl = data?.rate_limits
|
|
229
|
+
let segment = ''
|
|
230
|
+
try {
|
|
231
|
+
if (rl) {
|
|
232
|
+
const dir = stateDir()
|
|
233
|
+
const samplesFile = path.join(dir, 'samples.log')
|
|
234
|
+
const raw = fs.existsSync(samplesFile) ? fs.readFileSync(samplesFile, 'utf8') : ''
|
|
235
|
+
const current = {}
|
|
236
|
+
for (const window of Object.keys(WINDOWS)) {
|
|
237
|
+
if (typeof rl[window]?.used_percentage === 'number') current[window] = rl[window].used_percentage
|
|
238
|
+
}
|
|
239
|
+
const samples = recordSamples(parseSamples(raw), current, now)
|
|
240
|
+
const text = serializeSamples(samples)
|
|
241
|
+
if (text !== raw) {
|
|
242
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
243
|
+
fs.writeFileSync(samplesFile, text)
|
|
244
|
+
}
|
|
245
|
+
const alerts = checkThresholds(rl, config, dir, samples, now)
|
|
246
|
+
if (alerts.length) pruneMarkers(dir, now)
|
|
247
|
+
for (const payload of alerts) fireDetached(payload)
|
|
248
|
+
segment = renderSegment(rl, now, timezone(config))
|
|
249
|
+
}
|
|
250
|
+
} catch {}
|
|
251
|
+
|
|
252
|
+
let prefix = ''
|
|
253
|
+
if (thenCmd) {
|
|
254
|
+
try {
|
|
255
|
+
const r = spawnSync(thenCmd, { shell: true, input, encoding: 'utf8' })
|
|
256
|
+
prefix = (r.stdout || '').replace(/\s+$/, '')
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
process.stdout.write(prefix && segment ? `${prefix} ${segment}` : prefix + segment)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function fireDetached(payload) {
|
|
263
|
+
const self = process.argv[1]
|
|
264
|
+
spawn(process.execPath, [self, '--fire', JSON.stringify(payload)], { detached: true, stdio: 'ignore' }).unref()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------- channels (run in the detached child) ----------
|
|
268
|
+
|
|
269
|
+
export function toastText(payload) {
|
|
270
|
+
const name = payload.window === 'five_hour' ? '5h' : 'Weekly'
|
|
271
|
+
let body = `${name} usage is at ${payload.pct}% (crossed ${payload.threshold}%). Resets ${formatReset(payload.resets_at, Math.floor(Date.now() / 1000), payload.tz)}.`
|
|
272
|
+
if (payload.burn?.runs_out_at) body += ` At this pace it runs out around ${formatReset(payload.burn.runs_out_at, 0, payload.tz)}.`
|
|
273
|
+
return { title: `Claude Code: ${name.toLowerCase()} limit at ${payload.threshold}%`, body }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toast({ title, body }) {
|
|
277
|
+
const run = (cmd, args, env) =>
|
|
278
|
+
new Promise((resolve) => execFile(cmd, args, { env: { ...process.env, ...env }, timeout: 10000 }, () => resolve()))
|
|
279
|
+
if (process.platform === 'darwin') {
|
|
280
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
281
|
+
return run('osascript', ['-e', `display notification "${esc(body)}" with title "${esc(title)}"`])
|
|
282
|
+
}
|
|
283
|
+
if (process.platform === 'linux') return run('notify-send', [title, body])
|
|
284
|
+
if (process.platform === 'win32') {
|
|
285
|
+
const ps = `
|
|
286
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
287
|
+
$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
288
|
+
$t = $x.GetElementsByTagName('text')
|
|
289
|
+
$t.Item(0).AppendChild($x.CreateTextNode($env:CUA_TITLE)) | Out-Null
|
|
290
|
+
$t.Item(1).AppendChild($x.CreateTextNode($env:CUA_BODY)) | Out-Null
|
|
291
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Claude Code').Show([Windows.UI.Notifications.ToastNotification]::new($x))`
|
|
292
|
+
return run('powershell', ['-NoProfile', '-Command', ps], { CUA_TITLE: title, CUA_BODY: body })
|
|
293
|
+
}
|
|
294
|
+
return Promise.resolve()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function post(url, body) {
|
|
298
|
+
try {
|
|
299
|
+
await fetch(url, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: { 'content-type': 'application/json' },
|
|
302
|
+
body: JSON.stringify(body),
|
|
303
|
+
signal: AbortSignal.timeout(10000),
|
|
304
|
+
})
|
|
305
|
+
} catch {}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function fire(payload, config, log = () => {}) {
|
|
309
|
+
const t = payload.threshold
|
|
310
|
+
const jobs = []
|
|
311
|
+
if (channelWants(config, 'toast', t)) {
|
|
312
|
+
log('toast')
|
|
313
|
+
jobs.push(toast(toastText(payload)))
|
|
314
|
+
}
|
|
315
|
+
if (config.webhook && channelWants(config, 'webhook', t)) {
|
|
316
|
+
log(`webhook ${config.webhook}`)
|
|
317
|
+
jobs.push(post(config.webhook, { ...toastText(payload), ...payload }))
|
|
318
|
+
}
|
|
319
|
+
if (config.email && channelWants(config, 'email', t)) {
|
|
320
|
+
const relay = config.relay || DEFAULT_RELAY
|
|
321
|
+
log(`email ${config.email} via ${relay}`)
|
|
322
|
+
jobs.push(post(relay, { to: config.email, ...payload }))
|
|
323
|
+
}
|
|
324
|
+
await Promise.all(jobs)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------- init / uninstall / test ----------
|
|
328
|
+
|
|
329
|
+
function shellQuote(s) {
|
|
330
|
+
return process.platform === 'win32' ? `"${s.replace(/"/g, '\\"')}"` : `'${s.replace(/'/g, `'\\''`)}'`
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Prefer the bare command; fall back to an absolute path when it is not on PATH (npx installs).
|
|
334
|
+
function selfCommand() {
|
|
335
|
+
const probe = process.platform === 'win32' ? 'where' : 'which'
|
|
336
|
+
const r = spawnSync(probe, ['claude-usage-alerts'], { encoding: 'utf8' })
|
|
337
|
+
if (r.status === 0) return 'claude-usage-alerts'
|
|
338
|
+
return `${shellQuote(process.execPath)} ${shellQuote(path.resolve(process.argv[1]))}`
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Interactive prompt, or one answer per stdin line when piped (readline drops pending questions on EOF).
|
|
342
|
+
async function prompter() {
|
|
343
|
+
if (process.stdin.isTTY) {
|
|
344
|
+
const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
|
|
345
|
+
return { ask: (q) => rl.question(q), close: () => rl.close() }
|
|
346
|
+
}
|
|
347
|
+
const lines = fs.readFileSync(0, 'utf8').split('\n')
|
|
348
|
+
return { ask: (q) => (process.stdout.write(q + '\n'), lines.shift() ?? ''), close: () => {} }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function init() {
|
|
352
|
+
const p = await prompter()
|
|
353
|
+
const config = loadConfig()
|
|
354
|
+
const email = (await p.ask(`Email for alerts${config.email ? ` [${config.email}]` : ' (blank to skip)'}: `)).trim()
|
|
355
|
+
const webhook = (await p.ask(`Webhook URL${config.webhook ? ` [${config.webhook}]` : ' (blank to skip)'}: `)).trim()
|
|
356
|
+
p.close()
|
|
357
|
+
if (email) config.email = email
|
|
358
|
+
if (webhook) config.webhook = webhook
|
|
359
|
+
|
|
360
|
+
const settings = readJson(settingsPath(), {})
|
|
361
|
+
const prev = settings.statusLine
|
|
362
|
+
const alreadyOurs = typeof prev?.command === 'string' && /claude-usage-alerts/.test(prev.command)
|
|
363
|
+
if (prev && !alreadyOurs) config.previousStatusLine = prev
|
|
364
|
+
let command = selfCommand()
|
|
365
|
+
const wrapped = alreadyOurs ? config.previousStatusLine?.command : prev?.command
|
|
366
|
+
if (wrapped) command += ` --then ${shellQuote(wrapped)}`
|
|
367
|
+
settings.statusLine = { type: 'command', command }
|
|
368
|
+
writeJson(settingsPath(), settings)
|
|
369
|
+
writeJson(configPath(), config)
|
|
370
|
+
|
|
371
|
+
console.log(`\nConfig written to ${configPath()}`)
|
|
372
|
+
console.log(`statusLine.command is now: ${command}`)
|
|
373
|
+
if (wrapped) console.log(`Your previous statusline (${wrapped}) still runs first; uninstall restores it.`)
|
|
374
|
+
if (email) console.log(`\nFirst email to ${email} is a confirmation link. Run "claude-usage-alerts test" to send it now.`)
|
|
375
|
+
console.log('Restart Claude Code (or open a new session) to see the segment.')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function uninstall() {
|
|
379
|
+
const config = loadConfig()
|
|
380
|
+
const settings = readJson(settingsPath(), {})
|
|
381
|
+
if (config.previousStatusLine) settings.statusLine = config.previousStatusLine
|
|
382
|
+
else delete settings.statusLine
|
|
383
|
+
writeJson(settingsPath(), settings)
|
|
384
|
+
delete config.previousStatusLine
|
|
385
|
+
writeJson(configPath(), config)
|
|
386
|
+
console.log(`Restored statusLine in ${settingsPath()}. Config kept at ${configPath()}; delete it if you like.`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function test() {
|
|
390
|
+
const config = loadConfig()
|
|
391
|
+
const now = Math.floor(Date.now() / 1000)
|
|
392
|
+
const payload = {
|
|
393
|
+
window: 'five_hour',
|
|
394
|
+
pct: 91,
|
|
395
|
+
threshold: 90,
|
|
396
|
+
resets_at: now + 2 * 3600,
|
|
397
|
+
other_window: { pct: 48, resets_at: now + 3 * 86400 },
|
|
398
|
+
burn: { runs_out_at: now + 50 * 60 },
|
|
399
|
+
tz: timezone(config),
|
|
400
|
+
}
|
|
401
|
+
console.log('Firing a fake 5h 91% alert to:')
|
|
402
|
+
await fire(payload, config, (line) => console.log(` ${line}`))
|
|
403
|
+
if (!config.email && !config.webhook) console.log(' (no email or webhook configured; run init to add them)')
|
|
404
|
+
console.log('Done.')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------- entry ----------
|
|
408
|
+
|
|
409
|
+
async function main(argv) {
|
|
410
|
+
const [cmd, ...rest] = argv
|
|
411
|
+
if (cmd === 'init') return init()
|
|
412
|
+
if (cmd === 'uninstall') return uninstall()
|
|
413
|
+
if (cmd === 'test') return test()
|
|
414
|
+
if (cmd === '--fire') {
|
|
415
|
+
try {
|
|
416
|
+
await fire(JSON.parse(rest[0]), loadConfig())
|
|
417
|
+
} catch {}
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
if (cmd === '--help' || cmd === '-h') {
|
|
421
|
+
console.log('usage: claude-usage-alerts [init|uninstall|test] | [--then "<statusline cmd>"] < statusline.json')
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
const thenIdx = argv.indexOf('--then')
|
|
425
|
+
const thenCmd = thenIdx >= 0 ? argv[thenIdx + 1] : null
|
|
426
|
+
let input = ''
|
|
427
|
+
try {
|
|
428
|
+
input = fs.readFileSync(0, 'utf8')
|
|
429
|
+
} catch {}
|
|
430
|
+
tick(input, thenCmd)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) {
|
|
434
|
+
main(process.argv.slice(2)).catch(() => {})
|
|
435
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-usage-alerts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Desktop, webhook and email alerts when your Claude Code usage crosses a threshold. Wraps your existing statusline.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-usage-alerts": "bin/claude-usage-alerts.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["claude", "claude-code", "statusline", "rate-limit", "alerts"],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/willsmithte/claude-usage-alerts"
|
|
23
|
+
}
|
|
24
|
+
}
|