@usagefleet/cli 1.2.59

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/dist/hook.js ADDED
@@ -0,0 +1,103 @@
1
+ import { mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { writeFileAtomic } from './atomic-write.js';
4
+ import { claudeSettingsPath } from './paths.js';
5
+ /** Outer bound on the hook, in seconds. runGuard's own fetch gives up after 5s
6
+ * and fails open; this only matters if the process itself wedges. */
7
+ const HOOK_TIMEOUT_S = 10;
8
+ /** Recognises a guard hook we installed (at any binary path, from any version)
9
+ * so install is idempotent and uninstall is precise. */
10
+ const GUARD_COMMAND = /usagefleet.*\bguard\b/;
11
+ /** `/path/to/usagefleet guard`, quoted for the shell Claude Code runs it in. */
12
+ export function guardCommand(program) {
13
+ return program.map(p => (p.includes(' ') ? `"${p}"` : p)).join(' ');
14
+ }
15
+ /** Drop every guard hook we ever installed, leaving the rest of the file alone. */
16
+ export function withoutGuardHook(settings) {
17
+ const groups = settings.hooks?.UserPromptSubmit;
18
+ if (!groups) {
19
+ return settings;
20
+ }
21
+ const kept = groups
22
+ .map(g => ({
23
+ ...g,
24
+ hooks: (g.hooks ?? []).filter(h => !GUARD_COMMAND.test(h.command ?? '')),
25
+ }))
26
+ .filter(g => g.hooks.length > 0);
27
+ const hooks = { ...settings.hooks };
28
+ if (kept.length > 0) {
29
+ hooks.UserPromptSubmit = kept;
30
+ }
31
+ else {
32
+ delete hooks.UserPromptSubmit;
33
+ }
34
+ return {
35
+ ...settings,
36
+ hooks: Object.keys(hooks).length > 0 ? hooks : undefined,
37
+ };
38
+ }
39
+ /** Strip-then-append, so re-running install refreshes a stale binary path
40
+ * instead of stacking a second hook. */
41
+ export function withGuardHook(settings, command) {
42
+ const base = withoutGuardHook(settings);
43
+ const groups = base.hooks?.UserPromptSubmit ?? [];
44
+ return {
45
+ ...base,
46
+ hooks: {
47
+ ...base.hooks,
48
+ UserPromptSubmit: [...groups, { hooks: [{ command, timeout: HOOK_TIMEOUT_S, type: 'command' }] }],
49
+ },
50
+ };
51
+ }
52
+ /** Read → transform → write ~/.claude/settings.json, skipping the write when
53
+ * nothing changed. Refuses to touch a file it cannot parse: a hand-edited
54
+ * settings file is worth more than this hook. */
55
+ function editSettings(transform, onWrite) {
56
+ const path = claudeSettingsPath();
57
+ let raw = '';
58
+ try {
59
+ raw = readFileSync(path, 'utf-8');
60
+ }
61
+ catch {
62
+ /* no settings file yet */
63
+ }
64
+ let settings = {};
65
+ if (raw.trim()) {
66
+ try {
67
+ const parsed = JSON.parse(raw);
68
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
69
+ throw new Error('settings JSON is not an object');
70
+ }
71
+ settings = parsed;
72
+ }
73
+ catch {
74
+ console.warn(`Could not parse ${path} — left it untouched. Fix the JSON and re-run.`);
75
+ return;
76
+ }
77
+ }
78
+ const next = transform(settings);
79
+ if (JSON.stringify(next) === JSON.stringify(settings)) {
80
+ return;
81
+ }
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ // Atomic: this file is the user's, not ours, and an interrupted write would
84
+ // truncate their whole Claude Code configuration for the sake of our hook.
85
+ writeFileAtomic(path, `${JSON.stringify(next, null, 2)}\n`);
86
+ onWrite(path);
87
+ }
88
+ /**
89
+ * Register `usagefleet guard` as a Claude Code UserPromptSubmit hook, so a
90
+ * group with blocking enabled actually refuses prompts. Called by
91
+ * `usagefleet install`; set USAGEFLEET_HOOK=0 to keep settings.json
92
+ * untouched.
93
+ */
94
+ export function installPromptHook(program) {
95
+ if (process.env.USAGEFLEET_HOOK === '0') {
96
+ return;
97
+ }
98
+ const command = guardCommand(program);
99
+ editSettings(s => withGuardHook(s, command), path => console.log(`Registered the over-limit prompt guard in ${path}.`));
100
+ }
101
+ export function uninstallPromptHook() {
102
+ editSettings(withoutGuardHook, path => console.log(`Removed the prompt guard from ${path}.`));
103
+ }
package/dist/index.js ADDED
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ import { detectClaudeCreds } from './claude-creds.js';
3
+ import { reportLimitsOnce, runOnce } from './collector.js';
4
+ import { loadConfig } from './config.js';
5
+ import { runGuard } from './guard.js';
6
+ import { loadNotifyConfig } from './notifier.js';
7
+ import { sendNotification } from './notify.js';
8
+ import { detectOs } from './os.js';
9
+ import { RELEASE_VERSION } from './release.js';
10
+ import { serviceStatus } from './service.js';
11
+ import { readStore, storePath, updateStore } from './store.js';
12
+ import { ago, bar, bold, dim, green, pct, row, state as stateLine, step, yellow } from './ui.js';
13
+ import { checkForUpdate } from './update.js';
14
+ function flag(name) {
15
+ const prefix = `--${name}`;
16
+ const args = process.argv;
17
+ for (let i = 0; i < args.length; i++) {
18
+ const a = args[i];
19
+ if (a === prefix) {
20
+ const next = args[i + 1];
21
+ // don't swallow a following option as this flag's value
22
+ return next !== undefined && !next.startsWith('--') ? next : undefined;
23
+ }
24
+ if (a.startsWith(`${prefix}=`)) {
25
+ return a.slice(prefix.length + 1);
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ function ts() {
31
+ return new Date().toISOString().replace('T', ' ').slice(0, 19);
32
+ }
33
+ /** "5h ██░░░░░░░░ 2% · weekly ████░░░░░░ 13%" — the shared limits line.
34
+ * Bars are plain characters, so they survive a service log as well as a TTY. */
35
+ function limitsSummary(limits) {
36
+ const models = limits.modelLimits.map(m => ` · ${m.model}(${m.window}) ${bar(m.pct, 6)} ${pct(m.pct)}`).join('');
37
+ return `5h ${bar(limits.fiveHourPct)} ${pct(limits.fiveHourPct)} · weekly ${bar(limits.sevenDayPct)} ${pct(limits.sevenDayPct)}${models}`;
38
+ }
39
+ async function cmdRun() {
40
+ const cfg = loadConfig();
41
+ const r = await runOnce(cfg, m => console.log(`[${ts()}] ${m}`));
42
+ console.log(`[${ts()}] scanned ${r.files} files · sent ${r.sent} · accepted ${r.accepted} · duplicates ${r.duplicates}${r.dropped > 0 ? ` · DROPPED ${r.dropped}` : ''}${r.failed ? ' · FAILED' : ''}`);
43
+ const limits = await reportLimitsOnce(cfg, m => console.log(`[${ts()}] ${m}`));
44
+ if (limits) {
45
+ console.log(`[${ts()}] limits (${limits.source}): ${limitsSummary(limits)}`);
46
+ }
47
+ if (r.failed) {
48
+ process.exitCode = 1;
49
+ }
50
+ }
51
+ async function cmdLimits() {
52
+ const cfg = loadConfig();
53
+ const limits = await reportLimitsOnce(cfg, m => console.log(`[${ts()}] ${m}`));
54
+ if (!limits) {
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ console.log(`[${ts()}] reported ${limits.source}: ${limitsSummary(limits)}`);
59
+ }
60
+ async function cmdWatch() {
61
+ const cfg = loadConfig();
62
+ const raw = Number(flag('interval') ?? process.env.USAGEFLEET_INTERVAL ?? 15);
63
+ const interval = Math.max(1, Number.isFinite(raw) && raw > 0 ? raw : 15) * 1000;
64
+ // The limits ping hits the real Messages API (1 billable token) — don't run it
65
+ // every usage-scan tick. Report at most once per USAGEFLEET_LIMITS_INTERVAL
66
+ // seconds (default 300), decoupled from the much faster usage poll.
67
+ const rawLimits = Number(process.env.USAGEFLEET_LIMITS_INTERVAL ?? 300);
68
+ const limitsInterval = Math.max(interval / 1000, Number.isFinite(rawLimits) && rawLimits > 0 ? rawLimits : 300) * 1000;
69
+ let lastLimitsAt = 0;
70
+ // Self-update: once at startup, then every USAGEFLEET_UPDATE_INTERVAL seconds
71
+ // (default 6h — a release lands on a device the same day, not the next).
72
+ // USAGEFLEET_UPDATE=0 opts out.
73
+ const rawUpdate = Number(process.env.USAGEFLEET_UPDATE_INTERVAL ?? 6 * 60 * 60);
74
+ const updateInterval = Math.max(60, Number.isFinite(rawUpdate) && rawUpdate > 0 ? rawUpdate : 6 * 60 * 60) * 1000;
75
+ let lastUpdateAt = 0;
76
+ console.log(header());
77
+ console.log(dim(`[${ts()}] watching every ${interval / 1000}s → ${cfg.endpoint}`));
78
+ for (const dir of [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d)) {
79
+ console.log(dim(` ${dir}`));
80
+ }
81
+ let stopping = false;
82
+ let timer = null;
83
+ let running = false;
84
+ const tick = async () => {
85
+ if (stopping) {
86
+ return;
87
+ }
88
+ running = true;
89
+ try {
90
+ const r = await runOnce(cfg, m => console.log(`[${ts()}] ${m}`));
91
+ // Dropped records are real data loss, so they must show up even in a
92
+ // cycle that uploaded nothing.
93
+ if (r.sent > 0 || r.dropped > 0) {
94
+ console.log(`[${ts()}] ${r.dropped > 0 ? yellow('!') : green('↑')} sent ${r.sent} · accepted ${r.accepted} · dup ${r.duplicates}${r.dropped > 0 ? ` · ${yellow(`DROPPED ${r.dropped}`)}` : ''}`);
95
+ }
96
+ const nowMs = Date.now();
97
+ if (nowMs - lastUpdateAt >= updateInterval) {
98
+ lastUpdateAt = nowMs;
99
+ await checkForUpdate(m => console.log(`[${ts()}] ${m}`));
100
+ }
101
+ if (nowMs - lastLimitsAt >= limitsInterval) {
102
+ lastLimitsAt = nowMs;
103
+ const limits = await reportLimitsOnce(cfg, m => console.log(`[${ts()}] ${m}`));
104
+ if (limits) {
105
+ console.log(`[${ts()}] limits (${limits.source}): ${limitsSummary(limits)}`);
106
+ }
107
+ }
108
+ }
109
+ catch (error) {
110
+ console.error(`[${ts()}] cycle error:`, error.message);
111
+ }
112
+ finally {
113
+ running = false;
114
+ }
115
+ if (!stopping) {
116
+ timer = setTimeout(tick, interval);
117
+ }
118
+ };
119
+ function shutdown() {
120
+ stopping = true;
121
+ if (timer) {
122
+ clearTimeout(timer);
123
+ }
124
+ console.log(`\n[${ts()}] stopping…`);
125
+ // Let an in-flight cycle finish committing offsets; hard-exit fallback.
126
+ const bail = setTimeout(() => process.exit(0), 5000);
127
+ bail.unref();
128
+ const wait = setInterval(() => {
129
+ if (!running) {
130
+ clearInterval(wait);
131
+ process.exit(0);
132
+ }
133
+ }, 100);
134
+ wait.unref();
135
+ }
136
+ process.on('SIGINT', shutdown);
137
+ process.on('SIGTERM', shutdown);
138
+ await tick();
139
+ }
140
+ function cmdNotifyTest() {
141
+ const cfg = loadNotifyConfig();
142
+ if (!cfg.enabled) {
143
+ console.log('Notifications are disabled (USAGEFLEET_NOTIFY=0).');
144
+ return;
145
+ }
146
+ sendNotification('usagefleet', 'Test notification — desktop alerts are working.', {
147
+ urgency: 'normal',
148
+ });
149
+ console.log(`[${ts()}] sent a test notification via ${detectOs()} (thresholds: ${cfg.thresholds.join(', ')}%)`);
150
+ }
151
+ async function cmdStatus() {
152
+ const cfg = loadConfig();
153
+ const { limits, state } = readStore(cfg.storePath);
154
+ const tracked = Object.keys(state.files).length;
155
+ const mb = (Object.values(state.files).reduce((a, f) => a + f.offset, 0) / 1_048_576).toFixed(1);
156
+ const svc = serviceStatus();
157
+ const creds = await detectClaudeCreds();
158
+ console.log(header());
159
+ console.log('');
160
+ console.log(svc.state === 'running'
161
+ ? stateLine('ok', 'service', `running${svc.pid ? dim(` · pid ${svc.pid}`) : ''}`)
162
+ : stateLine('bad', 'service', `${svc.state} ${dim(svc.state === 'stopped' ? '· check the log' : '· run `usagefleet install`')}`));
163
+ console.log(creds
164
+ ? stateLine('ok', 'claude', `${creds.source}${dim(creds.subscriptionType ? ` · ${creds.subscriptionType}` : '')}`)
165
+ : stateLine('warn', 'claude', `no login ${dim('· sign in with `claude` or set ANTHROPIC_API_KEY')}`));
166
+ console.log(limits
167
+ ? stateLine(limitHealth(limits.fiveHourPct, limits.sevenDayPct), 'limits', `5h ${bar(limits.fiveHourPct)} ${pct(limits.fiveHourPct)} · weekly ${bar(limits.sevenDayPct)} ${pct(limits.sevenDayPct)} ${dim(ago(limits.at))}`)
168
+ : stateLine('warn', 'limits', `no reading yet ${dim('· run `usagefleet limits`')}`));
169
+ console.log('');
170
+ console.log(row('endpoint', cfg.endpoint));
171
+ console.log(row('device', `${state.deviceId} · token ${cfg.token.slice(0, 8)}…`));
172
+ const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
173
+ for (const [i, dir] of watching.entries()) {
174
+ console.log(row(i === 0 ? 'watching' : '', dir));
175
+ }
176
+ console.log(row('tracked', `${tracked} file${tracked === 1 ? '' : 's'} · ${mb} MB read · synced ${ago(state.updatedAt)}`));
177
+ console.log(row('config', cfg.storePath));
178
+ }
179
+ /** Worst of the two windows decides the dot colour. */
180
+ function limitHealth(fiveHour, sevenDay) {
181
+ const worst = Math.max(fiveHour ?? 0, sevenDay ?? 0);
182
+ return worst >= 95 ? 'bad' : worst >= 80 ? 'warn' : 'ok';
183
+ }
184
+ /** "usagefleet 1.2.55 mac" — the one-line banner every command opens with. */
185
+ function header() {
186
+ const build = RELEASE_VERSION === 'dev' ? dim(' (local build · self-update off)') : '';
187
+ return `${bold('usagefleet')} ${RELEASE_VERSION}${build} ${dim(detectOs())}`;
188
+ }
189
+ function cmdInit() {
190
+ const endpoint = flag('endpoint') ?? process.env.USAGEFLEET_ENDPOINT;
191
+ const token = flag('token') ?? process.env.USAGEFLEET_TOKEN;
192
+ if (!endpoint || !token) {
193
+ console.error('Usage: usagefleet init --endpoint <url> --token <device-token>');
194
+ process.exit(1);
195
+ }
196
+ const path = storePath();
197
+ // Merges over whatever is already there, so tail offsets and projectsDir
198
+ // survive a re-init and the device does not re-upload its whole history.
199
+ updateStore(path, store => {
200
+ store.endpoint = endpoint;
201
+ store.token = token;
202
+ });
203
+ console.log(step('configured', `${endpoint} · ${path}`));
204
+ }
205
+ function help() {
206
+ console.log(`usagefleet ${RELEASE_VERSION} — Claude usage collector
207
+
208
+ Usage:
209
+ usagefleet run Scan once, upload usage + report limits
210
+ usagefleet watch [--interval s] Poll continuously (default 15s)
211
+ usagefleet limits Report only your real 5h/weekly limit usage
212
+ usagefleet guard Exit 2 if this device's group is over a blocking limit
213
+ (use as a Claude Code UserPromptSubmit hook)
214
+ usagefleet update Update to the latest release now (watch does this every 6h)
215
+ usagefleet notify-test Fire a test desktop notification
216
+ usagefleet status Show service health, limits, resolved config
217
+ usagefleet version Print the release version
218
+ usagefleet init --endpoint <url> --token <t> Write ~/.config/usagefleet/config.json
219
+ usagefleet install Install as a background service (launchd/systemd/Task Scheduler)
220
+ and register the guard as a Claude Code hook
221
+ usagefleet uninstall Remove the background service and the hook
222
+
223
+ Config (env overrides ~/.config/usagefleet/config.json, which holds settings,
224
+ tail offsets and notification marks; USAGEFLEET_CONFIG relocates it):
225
+ USAGEFLEET_ENDPOINT server base URL (e.g. https://track.example.com)
226
+ USAGEFLEET_TOKEN device token from the Devices page
227
+ USAGEFLEET_PROJECTS override ~/.claude/projects (Claude Code)
228
+ USAGEFLEET_DESKTOP override Claude Desktop sessions dir ("off" to disable)
229
+ USAGEFLEET_PI override pi sessions dirs, comma-separated ("off" to disable)
230
+ USAGEFLEET_INTERVAL watch interval seconds
231
+ USAGEFLEET_NOTIFY desktop notifications on/off (default on; 0 to disable)
232
+ USAGEFLEET_HOOK register the guard in ~/.claude/settings.json on install (0 to skip)
233
+ USAGEFLEET_UPDATE self-update while watching (0 to disable)
234
+ USAGEFLEET_UPDATE_INTERVAL seconds between update checks (default 21600 = 6h)
235
+ USAGEFLEET_NOTIFY_THRESHOLDS comma list of % alerts (default 80,95)`);
236
+ }
237
+ async function main() {
238
+ // Log-and-continue for the long-running watch daemon: a stray rejection must
239
+ // not silently kill the background service. One-shot commands still set a
240
+ // non-zero exit via their own error paths.
241
+ process.on('unhandledRejection', reason => {
242
+ console.error(`[${ts()}] unhandledRejection:`, reason);
243
+ });
244
+ process.on('uncaughtException', err => {
245
+ console.error(`[${ts()}] uncaughtException:`, err.message);
246
+ });
247
+ const cmd = process.argv[2] ?? 'help';
248
+ switch (cmd) {
249
+ case 'run': {
250
+ return cmdRun();
251
+ }
252
+ case 'watch': {
253
+ return cmdWatch();
254
+ }
255
+ case 'limits': {
256
+ return cmdLimits();
257
+ }
258
+ case 'guard': {
259
+ process.exitCode = await runGuard();
260
+ return;
261
+ }
262
+ case 'update': {
263
+ await checkForUpdate(m => console.log(`[${ts()}] ${m}`), true);
264
+ return;
265
+ }
266
+ case 'notify-test': {
267
+ return cmdNotifyTest();
268
+ }
269
+ case 'status': {
270
+ return cmdStatus();
271
+ }
272
+ // Bare version, so the installer can compare builds without parsing help.
273
+ case 'version':
274
+ case '--version':
275
+ case '-v': {
276
+ console.log(RELEASE_VERSION);
277
+ return;
278
+ }
279
+ case 'init': {
280
+ return cmdInit();
281
+ }
282
+ case 'install': {
283
+ const { install } = await import('./service.js');
284
+ return install();
285
+ }
286
+ case 'uninstall': {
287
+ const { uninstall } = await import('./service.js');
288
+ return uninstall();
289
+ }
290
+ default: {
291
+ return help();
292
+ }
293
+ }
294
+ }
295
+ main().catch(error => {
296
+ console.error(error.message);
297
+ process.exit(1);
298
+ });
@@ -0,0 +1,112 @@
1
+ import { sendNotification } from './notify.js';
2
+ import { freshWindow, readStore, storePath, updateStore } from './store.js';
3
+ const DEFAULT_THRESHOLDS = [80, 95];
4
+ /** Resolve notify config from env. Enabled by default; disable with
5
+ * USAGEFLEET_NOTIFY=0 (also: false/off/no). Thresholds from
6
+ * USAGEFLEET_NOTIFY_THRESHOLDS as a comma list (e.g. "50,80,95"). */
7
+ export function loadNotifyConfig(env = process.env) {
8
+ const flag = env.USAGEFLEET_NOTIFY;
9
+ const enabled = flag == null || !/^(0|false|off|no)$/i.test(flag.trim());
10
+ let thresholds = DEFAULT_THRESHOLDS;
11
+ const raw = env.USAGEFLEET_NOTIFY_THRESHOLDS;
12
+ if (raw && raw.trim()) {
13
+ const parsed = raw
14
+ .split(',')
15
+ .map(s => Math.round(Number(s.trim())))
16
+ .filter(n => Number.isFinite(n) && n > 0 && n <= 100);
17
+ if (parsed.length > 0) {
18
+ thresholds = [...new Set(parsed)].toSorted((a, b) => a - b);
19
+ }
20
+ }
21
+ return { enabled, thresholds };
22
+ }
23
+ /** Per-window high-water mark so each threshold notifies at most once per
24
+ * window. `resetsAt` ties the mark to a specific window — when it changes the
25
+ * window has rolled over and the mark clears. */
26
+ export function emptyNotifyState() {
27
+ return { fiveHour: freshWindow(), sevenDay: freshWindow() };
28
+ }
29
+ /**
30
+ * Decide whether a window crosses a not-yet-notified threshold. Pure — no IO.
31
+ * Returns the threshold to fire (or null) and the next persisted state.
32
+ *
33
+ * A window rollover (resetsAt change) resets the high-water mark first, so the
34
+ * first crossing in a new window always re-notifies. If utilization later drops
35
+ * below the mark within the SAME window (e.g. a server correction), the mark is
36
+ * lowered so a subsequent re-cross notifies again.
37
+ */
38
+ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
39
+ const rolledOver = !prev || prev.resetsAt !== resetsAt;
40
+ const lastBucket = rolledOver ? 0 : prev.lastBucket;
41
+ if (pct == null) {
42
+ // No reading this cycle — keep the mark, just track the (possibly new) window.
43
+ return { fire: null, next: { lastBucket, resetsAt } };
44
+ }
45
+ // Highest threshold the current pct has reached (thresholds are ascending).
46
+ let top = 0;
47
+ for (const t of thresholds) {
48
+ if (pct >= t) {
49
+ top = t;
50
+ }
51
+ }
52
+ if (top > lastBucket) {
53
+ return { fire: top, next: { lastBucket: top, resetsAt } };
54
+ }
55
+ if (top < lastBucket) {
56
+ return { fire: null, next: { lastBucket: top, resetsAt } };
57
+ }
58
+ return { fire: null, next: { lastBucket, resetsAt } };
59
+ }
60
+ /** Relative "resets in 12m" / "resets in 2h" suffix, or "" if unknown/past. */
61
+ function resetSuffix(resetsAt) {
62
+ if (!resetsAt) {
63
+ return '';
64
+ }
65
+ const ms = new Date(resetsAt).getTime() - Date.now();
66
+ if (!Number.isFinite(ms) || ms <= 0) {
67
+ return '';
68
+ }
69
+ const min = Math.round(ms / 60_000);
70
+ if (min < 60) {
71
+ return ` · resets in ${min}m`;
72
+ }
73
+ const h = Math.round(min / 60);
74
+ if (h < 48) {
75
+ return ` · resets in ${h}h`;
76
+ }
77
+ return ` · resets in ${Math.round(h / 24)}d`;
78
+ }
79
+ function urgencyFor(bucket) {
80
+ return bucket >= 95 ? 'critical' : 'normal';
81
+ }
82
+ /**
83
+ * Notify on freshly-crossed 5h/weekly thresholds, deduped across runs via the
84
+ * store's `notify` section. Best-effort and self-contained: it owns its state
85
+ * IO and never throws out to the caller.
86
+ */
87
+ export function maybeNotify(report, cfg = loadNotifyConfig(), log = () => {
88
+ /* empty */
89
+ }, path = storePath()) {
90
+ if (!cfg.enabled || cfg.thresholds.length === 0) {
91
+ return;
92
+ }
93
+ try {
94
+ const state = readStore(path).notify;
95
+ const five = evaluateWindow(state.fiveHour, report.fiveHourPct, report.fiveHourResetsAt, cfg.thresholds);
96
+ const seven = evaluateWindow(state.sevenDay, report.sevenDayPct, report.sevenDayResetsAt, cfg.thresholds);
97
+ if (five.fire != null) {
98
+ sendNotification('Claude usage · 5-hour limit', `${report.fiveHourPct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: urgencyFor(five.fire) });
99
+ log(`notified: 5h at ${report.fiveHourPct}% (crossed ${five.fire}%)`);
100
+ }
101
+ if (seven.fire != null) {
102
+ sendNotification('Claude usage · weekly limit', `${report.sevenDayPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: urgencyFor(seven.fire) });
103
+ log(`notified: weekly at ${report.sevenDayPct}% (crossed ${seven.fire}%)`);
104
+ }
105
+ updateStore(path, store => {
106
+ store.notify = { fiveHour: five.next, sevenDay: seven.next };
107
+ });
108
+ }
109
+ catch (error) {
110
+ log(`notify skipped: ${error.message}`);
111
+ }
112
+ }
package/dist/notify.js ADDED
@@ -0,0 +1,88 @@
1
+ import { execFile } from 'node:child_process';
2
+ /** Collapse every whitespace run (including the raw newlines AppleScript string
3
+ * literals forbid) to a single space, leaving hyphens and other punctuation
4
+ * intact. */
5
+ function oneLine(s) {
6
+ return s.replaceAll(/\s+/g, ' ').trim();
7
+ }
8
+ /** Escape a string for embedding inside an AppleScript double-quoted literal. */
9
+ function osaEscape(s) {
10
+ return oneLine(s).replaceAll('\\', '\\\\').replaceAll('"', '\\"');
11
+ }
12
+ /** Fire-and-forget child process; swallow spawn/runtime errors so a missing
13
+ * binary or denied display never disturbs the collector. */
14
+ function spawnQuiet(cmd, args, onError) {
15
+ try {
16
+ const child = execFile(cmd, args, { timeout: 5000 }, err => {
17
+ if (err && onError) {
18
+ onError(err);
19
+ }
20
+ });
21
+ // Don't keep the event loop (or a one-shot `run`) alive waiting on the UI.
22
+ child.unref?.();
23
+ }
24
+ catch (error) {
25
+ onError?.(error);
26
+ }
27
+ }
28
+ function notifyMac(title, message) {
29
+ // execFile (no shell) — the only interpolation surface is the AppleScript
30
+ // string, which osaEscape neutralizes.
31
+ const script = `display notification "${osaEscape(message)}" with title "${osaEscape(title)}"`;
32
+ spawnQuiet('osascript', ['-e', script]);
33
+ }
34
+ function notifyLinux(title, message, urgency) {
35
+ // notify-send is the freedesktop standard; KDE Plasma's notification daemon
36
+ // implements it. If it's absent (ENOENT) or fails, fall back to kdialog's
37
+ // native passive popup (ships with KDE).
38
+ spawnQuiet('notify-send',
39
+ // `--` ends option parsing so a title/message can never be read as a flag.
40
+ ['-a', 'usagefleet', '-u', urgency, '--', oneLine(title), oneLine(message)], () => {
41
+ spawnQuiet('kdialog', ['--title', oneLine(title), '--passivepopup', oneLine(message), '10']);
42
+ });
43
+ }
44
+ /** Escape a string for a PowerShell single-quoted literal (no interpolation
45
+ * happens inside one, so doubling `'` is the whole job). */
46
+ function psEscape(s) {
47
+ return oneLine(s).replaceAll("'", "''");
48
+ }
49
+ function notifyWindows(title, message) {
50
+ // WinRT toast through Windows PowerShell 5.1 (always present on Win10/11;
51
+ // pwsh 7 can't load WinRT types). Text goes in via the DOM, so there is no
52
+ // XML-injection surface. Borrowing PowerShell's AppUserModelID avoids having
53
+ // to register one of our own — the toast shows up under "Windows PowerShell".
54
+ const script = [
55
+ '[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]|Out-Null',
56
+ '$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
57
+ "$n=$t.GetElementsByTagName('text')",
58
+ `$n.Item(0).AppendChild($t.CreateTextNode('${psEscape(title)}'))|Out-Null`,
59
+ `$n.Item(1).AppendChild($t.CreateTextNode('${psEscape(message)}'))|Out-Null`,
60
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Microsoft.WindowsPowerShell').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
61
+ ].join(';');
62
+ spawnQuiet('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script]);
63
+ }
64
+ /**
65
+ * Show a desktop notification. Best-effort and non-blocking: it never throws and
66
+ * never blocks the caller. Supported platforms:
67
+ * - macOS: `osascript` -> Notification Center.
68
+ * - Linux: `notify-send` (KDE Plasma + other freedesktop daemons), falling
69
+ * back to `kdialog --passivepopup` on KDE.
70
+ * - Windows: WinRT toast via `powershell.exe` -> Action Center.
71
+ * Other platforms are a no-op.
72
+ */
73
+ export function sendNotification(title, message, opts = {}) {
74
+ try {
75
+ if (process.platform === 'darwin') {
76
+ notifyMac(title, message);
77
+ }
78
+ else if (process.platform === 'linux') {
79
+ notifyLinux(title, message, opts.urgency ?? 'normal');
80
+ }
81
+ else if (process.platform === 'win32') {
82
+ notifyWindows(title, message);
83
+ }
84
+ }
85
+ catch {
86
+ /* notifications are non-essential — never let one break a cycle */
87
+ }
88
+ }
package/dist/os.js ADDED
@@ -0,0 +1,16 @@
1
+ export function detectOs() {
2
+ switch (process.platform) {
3
+ case 'darwin': {
4
+ return 'mac';
5
+ }
6
+ case 'win32': {
7
+ return 'windows';
8
+ }
9
+ case 'linux': {
10
+ return 'linux';
11
+ }
12
+ default: {
13
+ return process.platform;
14
+ }
15
+ }
16
+ }