@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/README.md +284 -0
- package/dist/atomic-write.js +72 -0
- package/dist/claude-creds.js +161 -0
- package/dist/claude-limits.js +217 -0
- package/dist/collector.js +243 -0
- package/dist/config.js +74 -0
- package/dist/guard.js +59 -0
- package/dist/hook.js +103 -0
- package/dist/index.js +298 -0
- package/dist/notifier.js +112 -0
- package/dist/notify.js +88 -0
- package/dist/os.js +16 -0
- package/dist/parser.js +126 -0
- package/dist/paths.js +48 -0
- package/dist/release.js +8 -0
- package/dist/scanner.js +24 -0
- package/dist/service.js +524 -0
- package/dist/store.js +104 -0
- package/dist/tailer.js +65 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +69 -0
- package/dist/update.js +100 -0
- package/dist/uploader.js +93 -0
- package/package.json +39 -0
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
|
+
});
|
package/dist/notifier.js
ADDED
|
@@ -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