@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.
@@ -0,0 +1,217 @@
1
+ const MESSAGES_URL = 'https://api.anthropic.com/v1/messages';
2
+ export function parsePct(v) {
3
+ if (v == null || v === '') {
4
+ return null;
5
+ }
6
+ const n = Number(v);
7
+ if (!Number.isFinite(n)) {
8
+ return null;
9
+ }
10
+ // Header is a percent (e.g. "37"); guard the 0–1 fraction form too.
11
+ const pct = n > 0 && n <= 1 ? n * 100 : n;
12
+ return Math.min(100, Math.max(0, Math.round(pct)));
13
+ }
14
+ export function parseReset(v) {
15
+ if (!v) {
16
+ return null;
17
+ }
18
+ const num = Number(v);
19
+ if (Number.isFinite(num) && num > 1_000_000_000) {
20
+ // unix seconds (or ms)
21
+ const ms = num > 1e12 ? num : num * 1000;
22
+ return new Date(ms).toISOString();
23
+ }
24
+ const d = new Date(v);
25
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
26
+ }
27
+ /** Per-model utilization header: `anthropic-ratelimit-unified-<window>-<model>-utilization`
28
+ * (the account-wide headers have no `<model>` segment and don't match). */
29
+ const MODEL_UTIL_RE = /^anthropic-ratelimit-unified-(\d+[hdwm])-([a-z0-9][a-z0-9_.-]*)-utilization$/;
30
+ export function parseLimitsHeaders(source, get, names = []) {
31
+ const modelLimits = [];
32
+ for (const raw of names) {
33
+ const m = raw.toLowerCase().match(MODEL_UTIL_RE);
34
+ if (!m) {
35
+ continue;
36
+ }
37
+ const [, window, model] = m;
38
+ modelLimits.push({
39
+ model,
40
+ pct: parsePct(get(raw)),
41
+ resetsAt: parseReset(get(`anthropic-ratelimit-unified-${window}-${model}-reset`)),
42
+ window,
43
+ });
44
+ }
45
+ return {
46
+ fiveHourPct: parsePct(get('anthropic-ratelimit-unified-5h-utilization')),
47
+ fiveHourResetsAt: parseReset(get('anthropic-ratelimit-unified-5h-reset')),
48
+ modelLimits,
49
+ sevenDayPct: parsePct(get('anthropic-ratelimit-unified-7d-utilization')),
50
+ sevenDayResetsAt: parseReset(get('anthropic-ratelimit-unified-7d-reset')),
51
+ source,
52
+ };
53
+ }
54
+ const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
55
+ /**
56
+ * Per-model limits for subscription logins. The Messages ping only returns the
57
+ * account-wide 5h/7d headers; the per-model caps Claude's own UI shows (e.g.
58
+ * "Fable · 24% used") come from the OAuth usage endpoint Claude Code queries
59
+ * for /usage. Undocumented — parse defensively and return [] on any surprise.
60
+ */
61
+ async function fetchOauthModelLimits(token) {
62
+ const res = await fetch(OAUTH_USAGE_URL, {
63
+ headers: {
64
+ 'anthropic-beta': 'oauth-2025-04-20',
65
+ authorization: `Bearer ${token}`,
66
+ 'user-agent': 'claude-code/2.1.5 (usagefleet)',
67
+ },
68
+ signal: AbortSignal.timeout(15_000),
69
+ });
70
+ if (!res.ok) {
71
+ return [];
72
+ }
73
+ const body = await res.json().catch(() => null);
74
+ if (process.env.USAGEFLEET_DEBUG_HEADERS) {
75
+ console.error(`[debug] oauth/usage: ${JSON.stringify(body)}`);
76
+ }
77
+ return parseOauthUsage(body);
78
+ }
79
+ /** oauth/usage values are already 0–100 percentages (unlike the 0–1 header
80
+ * fractions) — clamp without the fraction heuristic so 1% never reads as 100%. */
81
+ function clampPct(n) {
82
+ return Math.min(100, Math.max(0, Math.round(n)));
83
+ }
84
+ /** Normalize a scope's model name to a header-safe key ("Fable" → "fable"). */
85
+ function modelKeyOf(name) {
86
+ return name.toLowerCase().replaceAll(/[^a-z0-9_.-]+/g, '-');
87
+ }
88
+ /**
89
+ * Extract per-model entries from an oauth/usage payload.
90
+ *
91
+ * Preferred source: the `limits[]` array — model-scoped entries (e.g.
92
+ * `{kind: "weekly_scoped", group: "weekly", percent, resets_at,
93
+ * scope: {model: {display_name: "Fable"}}}`) are exactly the per-model bars
94
+ * Claude's own UI renders. Account-wide entries have `scope: null` and are
95
+ * skipped (the header ping covers them).
96
+ *
97
+ * Fallback: legacy top-level `seven_day_<model>` objects with a `utilization`
98
+ * number (all null on current accounts, but cheap to keep).
99
+ */
100
+ export function parseOauthUsage(body) {
101
+ if (typeof body !== 'object' || body === null) {
102
+ return [];
103
+ }
104
+ const root = body;
105
+ const out = [];
106
+ const seen = new Set();
107
+ if (Array.isArray(root.limits)) {
108
+ for (const item of root.limits) {
109
+ if (typeof item !== 'object' || item === null) {
110
+ continue;
111
+ }
112
+ const l = item;
113
+ const scope = (typeof l.scope === 'object' && l.scope !== null ? l.scope : {});
114
+ const model = (typeof scope.model === 'object' && scope.model !== null ? scope.model : null);
115
+ const name = (typeof model?.id === 'string' && model.id) ||
116
+ (typeof model?.display_name === 'string' && model.display_name) ||
117
+ null;
118
+ if (!name || typeof l.percent !== 'number') {
119
+ continue;
120
+ }
121
+ const window = l.group === 'session' ? '5h' : l.group === 'weekly' ? '7d' : '7d';
122
+ const key = modelKeyOf(name);
123
+ out.push({
124
+ model: key,
125
+ pct: clampPct(l.percent),
126
+ resetsAt: typeof l.resets_at === 'string' ? parseReset(l.resets_at) : null,
127
+ window,
128
+ });
129
+ seen.add(`${window}:${key}`);
130
+ }
131
+ }
132
+ for (const [key, val] of Object.entries(root)) {
133
+ const m = key.match(/^(five_hour|seven_day)_([a-z0-9_]+)$/);
134
+ if (!m || typeof val !== 'object' || val === null) {
135
+ continue;
136
+ }
137
+ const entry = val;
138
+ if (typeof entry.utilization !== 'number') {
139
+ continue;
140
+ }
141
+ const window = m[1] === 'five_hour' ? '5h' : '7d';
142
+ if (seen.has(`${window}:${m[2]}`)) {
143
+ continue;
144
+ }
145
+ out.push({
146
+ model: m[2],
147
+ pct: clampPct(entry.utilization),
148
+ resetsAt: typeof entry.resets_at === 'string' ? parseReset(entry.resets_at) : null,
149
+ window,
150
+ });
151
+ }
152
+ return out;
153
+ }
154
+ /**
155
+ * Read the account's real rate-limit utilization. Sends a 1-token ping to the
156
+ * Messages API; Anthropic returns the unified 5h/7d utilization in response
157
+ * headers (same approach as Claude-Usage-Tracker's OAuth path).
158
+ */
159
+ export async function fetchLimits(creds) {
160
+ const headers = {
161
+ 'anthropic-version': '2023-06-01',
162
+ 'content-type': 'application/json',
163
+ };
164
+ if (creds.source === 'sub') {
165
+ headers['authorization'] = `Bearer ${creds.token}`;
166
+ headers['anthropic-beta'] = 'oauth-2025-04-20';
167
+ headers['user-agent'] = 'claude-code/2.1.5 (usagefleet)';
168
+ }
169
+ else {
170
+ headers['x-api-key'] = creds.token;
171
+ }
172
+ const res = await fetch(MESSAGES_URL, {
173
+ body: JSON.stringify({
174
+ max_tokens: 1,
175
+ messages: [{ role: 'user', content: 'hi' }],
176
+ model: 'claude-haiku-4-5-20251001',
177
+ }),
178
+ headers,
179
+ method: 'POST',
180
+ signal: AbortSignal.timeout(15_000),
181
+ });
182
+ // The unified rate-limit headers are (historically) present on success AND
183
+ // error responses — this OAuth/header-scraping path against the public
184
+ // Messages endpoint is undocumented and may break without notice. If a
185
+ // rejected response ALSO lacks the headers, the feature is unavailable; throw
186
+ // so the caller logs it instead of POSTing an all-null report silently.
187
+ // Diagnostic: dump every rate-limit header so unrecognized per-model names
188
+ // can be discovered in the field (USAGEFLEET_DEBUG_HEADERS=1).
189
+ if (process.env.USAGEFLEET_DEBUG_HEADERS) {
190
+ for (const [k, v] of res.headers.entries()) {
191
+ if (k.includes('ratelimit')) {
192
+ console.error(`[debug] ${k}: ${v}`);
193
+ }
194
+ }
195
+ }
196
+ const report = parseLimitsHeaders(creds.source, n => res.headers.get(n), res.headers.keys());
197
+ const gotHeaders = report.fiveHourPct != null ||
198
+ report.sevenDayPct != null ||
199
+ report.fiveHourResetsAt != null ||
200
+ report.sevenDayResetsAt != null ||
201
+ report.modelLimits.length > 0;
202
+ if (!res.ok && !gotHeaders) {
203
+ throw new Error(`limits unavailable: HTTP ${res.status} with no rate-limit headers`);
204
+ }
205
+ // Subscription logins: merge in the per-model caps from the OAuth usage
206
+ // endpoint (the ping headers never include them). Best-effort — keep the
207
+ // header-derived report on any failure.
208
+ if (creds.source === 'sub' && report.modelLimits.length === 0) {
209
+ try {
210
+ report.modelLimits = await fetchOauthModelLimits(creds.token);
211
+ }
212
+ catch {
213
+ /* endpoint unavailable — report account-wide limits only */
214
+ }
215
+ }
216
+ return report;
217
+ }
@@ -0,0 +1,243 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
3
+ import { sep } from 'node:path';
4
+ import { detectClaudeCreds, macKeychainDenied } from './claude-creds.js';
5
+ import { fetchLimits } from './claude-limits.js';
6
+ import { maybeNotify } from './notifier.js';
7
+ import { detectOs } from './os.js';
8
+ import { RELEASE_VERSION } from './release.js';
9
+ import { listJsonlFiles } from './scanner.js';
10
+ import { readStore, updateStore } from './store.js';
11
+ import { tailFile } from './tailer.js';
12
+ import { postLimits, uploadBatch } from './uploader.js';
13
+ /** Only files inside a `.../.claude/projects/...` subtree are real usage logs.
14
+ * Desktop session roots also hold `audit.jsonl` (a full duplicate of the same
15
+ * uuids) and other JSONL — restricting to this subtree mirrors Claude Code and
16
+ * avoids re-uploading every desktop record twice. */
17
+ const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
18
+ /**
19
+ * One full scan: tail every JSONL file from its stored offset, upload new usage
20
+ * records (chunked to batchSize), and commit each file's offset only after all
21
+ * its chunks are acknowledged (at-least-once; the server dedups on uuid).
22
+ */
23
+ export async function runOnce(cfg, log = () => {
24
+ /* empty */
25
+ }) {
26
+ const { state } = readStore(cfg.storePath);
27
+ // Each scan root is tagged with the app that owns it. Claude Code's projects
28
+ // dir is scanned whole; the Claude Desktop sessions root is filtered to its
29
+ // `.claude/projects` subtree (see PROJECTS_SUBPATH).
30
+ const roots = [
31
+ { dir: cfg.projectsDir, onlyProjects: false, source: 'cli' },
32
+ ];
33
+ if (cfg.desktopDir) {
34
+ roots.push({ dir: cfg.desktopDir, onlyProjects: true, source: 'desktop' });
35
+ }
36
+ for (const dir of cfg.piDirs) {
37
+ roots.push({ dir, onlyProjects: false, source: 'pi' });
38
+ }
39
+ const files = [];
40
+ for (const root of roots) {
41
+ for (const fp of listJsonlFiles(root.dir)) {
42
+ if (root.onlyProjects && !fp.includes(PROJECTS_SUBPATH)) {
43
+ continue;
44
+ }
45
+ files.push({ fp, source: root.source });
46
+ }
47
+ }
48
+ const result = {
49
+ accepted: 0,
50
+ dropped: 0,
51
+ duplicates: 0,
52
+ failed: false,
53
+ files: files.length,
54
+ sent: 0,
55
+ };
56
+ // Defensive: a bad batchSize must never stall the chunk loop.
57
+ const step = cfg.batchSize > 0 ? Math.floor(cfg.batchSize) : 100;
58
+ let advanced = false;
59
+ for (const { fp, source } of files) {
60
+ let tail;
61
+ try {
62
+ tail = tailFile(fp, state.files[fp], source);
63
+ }
64
+ catch (error) {
65
+ // One unreadable/oversized file must not abort the whole cycle.
66
+ log(`skip ${fp}: ${error.message}`);
67
+ continue;
68
+ }
69
+ if (!tail || tail.consumedBytes === 0) {
70
+ continue;
71
+ }
72
+ if (tail.records.length === 0) {
73
+ // Consumed only non-usage lines — safe to advance immediately.
74
+ state.files[fp] = tail.nextState;
75
+ advanced = true;
76
+ continue;
77
+ }
78
+ // sendChunk absorbs "invalid" by bisecting, so only auth/transient escape.
79
+ let outcome = 'ok';
80
+ for (let i = 0; i < tail.records.length; i += step) {
81
+ outcome = await sendChunk(tail.records.slice(i, i + step), cfg, result, log);
82
+ if (outcome !== 'ok') {
83
+ break;
84
+ }
85
+ }
86
+ if (outcome === 'ok') {
87
+ state.files[fp] = tail.nextState;
88
+ advanced = true;
89
+ }
90
+ else if (outcome === 'auth') {
91
+ // Token revoked/expired. The data is valid and must NOT be skipped — keep
92
+ // the offset so it uploads once a fresh token is configured. Retrying the
93
+ // remaining files would 401 identically, so stop this cycle and surface.
94
+ log(`auth rejected (401/403) — device token invalid or revoked; re-run \`usagefleet init\` with a fresh token, then restart the service`);
95
+ result.failed = true;
96
+ break;
97
+ }
98
+ else if (outcome === 'invalid') {
99
+ // The whole batch was rejected, not individual records (see sendChunk).
100
+ // Keep the offset: this needs a collector or server fix, not a purge.
101
+ log(`upload for ${fp} rejected as a whole batch — keeping the offset; check that this collector version and OS are supported by the server`);
102
+ result.failed = true;
103
+ }
104
+ else {
105
+ // transient (402 outside plan, 5xx, network, timeout): keep the offset and
106
+ // retry next cycle, but DO NOT break — later files must still get a turn.
107
+ log(`upload failed for ${fp} (transient) — will retry next cycle`);
108
+ result.failed = true;
109
+ }
110
+ }
111
+ // One durable write per cycle rather than one per file: the store is fsynced
112
+ // on every save, and a crash mid-cycle only costs a re-upload the server
113
+ // dedups. Only our own section is replaced, so a token written by a
114
+ // concurrent `usagefleet init` survives.
115
+ if (pruneMissingFiles(state, files) || advanced) {
116
+ updateStore(cfg.storePath, store => {
117
+ store.state.files = state.files;
118
+ store.state.updatedAt = new Date().toISOString();
119
+ });
120
+ }
121
+ return result;
122
+ }
123
+ /**
124
+ * Upload one chunk and tally it. A 400/422 means the server parsed the request
125
+ * and rejected the records themselves, so the chunk is split and the halves are
126
+ * retried: one malformed line then costs one record instead of the whole batch.
127
+ * Bisection adds ~log2(n) requests and only on the rare malformed line.
128
+ * Every other failure is handed back untouched so the caller keeps the offset.
129
+ *
130
+ * A 400 can also mean the server rejected the *envelope* — an `os` or
131
+ * `collectorVersion` outside its schema — in which case every record fails and
132
+ * bisecting would drop the entire batch for a bug that an upgrade fixes. So the
133
+ * split stops after MAX_DROPPED_PER_CHUNK drops and the rest is handed back as
134
+ * `invalid`, which keeps the offset. Real malformed lines are rare and isolated;
135
+ * anything that survives that many splits is not record-shaped.
136
+ */
137
+ const MAX_DROPPED_PER_CHUNK = 2;
138
+ async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped + MAX_DROPPED_PER_CHUNK) {
139
+ const res = await uploadBatch({
140
+ collectorVersion: RELEASE_VERSION,
141
+ hostname: hostname(),
142
+ os: detectOs(),
143
+ records,
144
+ sentAt: new Date().toISOString(),
145
+ }, cfg);
146
+ if (res.ok) {
147
+ result.sent += records.length;
148
+ result.accepted += res.accepted ?? 0;
149
+ result.duplicates += res.duplicates ?? 0;
150
+ return 'ok';
151
+ }
152
+ if (res.fatal !== 'invalid') {
153
+ return res.fatal;
154
+ }
155
+ const single = records[0];
156
+ if (records.length === 1 && single) {
157
+ if (result.dropped >= dropCeiling) {
158
+ // Give up on the record theory. The caller keeps the offset, so the
159
+ // records counted on the way down are retried, not lost — untally them
160
+ // rather than report a loss that did not happen.
161
+ log(`every split of this batch was rejected, so the batch itself is bad, not its records — nothing skipped`);
162
+ result.dropped = dropCeiling - MAX_DROPPED_PER_CHUNK;
163
+ return 'invalid';
164
+ }
165
+ log(`server rejected record ${single.uuid} as malformed — skipping it`);
166
+ result.dropped += 1;
167
+ result.failed = true;
168
+ return 'ok';
169
+ }
170
+ const mid = Math.ceil(records.length / 2);
171
+ const head = await sendChunk(records.slice(0, mid), cfg, result, log, dropCeiling);
172
+ return head === 'ok' ? sendChunk(records.slice(mid), cfg, result, log, dropCeiling) : head;
173
+ }
174
+ /**
175
+ * Drop offsets for logs that no longer exist on disk, so a long-lived install
176
+ * does not grow its state file by one entry per Claude session forever. Only
177
+ * paths absent from this cycle's scan are stat'd, and only a real ENOENT prunes
178
+ * — a root that is merely unconfigured right now keeps its offsets.
179
+ * Returns whether anything was removed.
180
+ */
181
+ function pruneMissingFiles(state, scanned) {
182
+ const seen = new Set(scanned.map(f => f.fp));
183
+ let removed = false;
184
+ for (const fp of Object.keys(state.files)) {
185
+ if (seen.has(fp) || existsSync(fp)) {
186
+ continue;
187
+ }
188
+ // oxlint-disable-next-line typescript/no-dynamic-delete -- state.files is a JSON blob keyed by path
189
+ delete state.files[fp];
190
+ removed = true;
191
+ }
192
+ return removed;
193
+ }
194
+ /**
195
+ * Auto-detect the local Claude login, read the real 5h/weekly utilization from
196
+ * Anthropic's rate-limit headers, and report it to the server. Best-effort —
197
+ * returns null (and logs) when no login is found or the request fails.
198
+ */
199
+ export async function reportLimitsOnce(cfg, log = () => {
200
+ /* empty */
201
+ }) {
202
+ const creds = await detectClaudeCreds();
203
+ if (!creds) {
204
+ if (process.platform === 'darwin' && macKeychainDenied()) {
205
+ // "Works by hand, broken as a service" signature: a launchd agent can be
206
+ // denied the login-Keychain read. Make it diagnosable instead of silent.
207
+ log("limits skipped: login Keychain read for 'Claude Code-credentials' was denied " +
208
+ '(typical under a background launchd agent). Grant /usr/bin/security access to the ' +
209
+ 'item, or set ANTHROPIC_API_KEY for the service.');
210
+ }
211
+ else {
212
+ log('no usable Claude login — missing, or expired with a refresh that failed; ' +
213
+ 'sign in with `claude` or set ANTHROPIC_API_KEY');
214
+ }
215
+ return null;
216
+ }
217
+ let report;
218
+ try {
219
+ report = await fetchLimits(creds);
220
+ }
221
+ catch (error) {
222
+ log(`limits fetch failed: ${error.message}`);
223
+ return null;
224
+ }
225
+ const ok = await postLimits(report, cfg);
226
+ if (!ok) {
227
+ log('limits upload failed');
228
+ }
229
+ // Cache the reading so `status` can show current usage without spending
230
+ // another billable API call.
231
+ updateStore(cfg.storePath, store => {
232
+ store.limits = {
233
+ at: new Date().toISOString(),
234
+ fiveHourPct: report.fiveHourPct,
235
+ sevenDayPct: report.sevenDayPct,
236
+ source: report.source,
237
+ };
238
+ });
239
+ // Local desktop notification on freshly-crossed thresholds. Independent of the
240
+ // server upload (notify even if the POST failed) and never throws.
241
+ maybeNotify(report, undefined, log);
242
+ return report;
243
+ }
package/dist/config.js ADDED
@@ -0,0 +1,74 @@
1
+ import { defaultDesktopSessionsDir, defaultPiSessionsDirs, defaultProjectsDir } from './paths.js';
2
+ import { readStore, storePath } from './store.js';
3
+ /** Matches the server's BatchSchema `.max(1000)`. */
4
+ const MAX_BATCH = 1000;
5
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
6
+ /** Resolve config from env first, then the stored settings (see store.ts). */
7
+ export function loadConfig() {
8
+ const file = readStore();
9
+ // Use `||` (not `??`) so an empty-string env var falls back to the config
10
+ // file — launchd/systemd units may inject empty USAGEFLEET_* values.
11
+ const endpoint = (process.env.USAGEFLEET_ENDPOINT || file.endpoint || '').replace(/\/+$/, '');
12
+ const token = process.env.USAGEFLEET_TOKEN || file.token || '';
13
+ if (!endpoint) {
14
+ throw new Error('USAGEFLEET_ENDPOINT is not set');
15
+ }
16
+ if (!token) {
17
+ throw new Error('USAGEFLEET_TOKEN is not set');
18
+ }
19
+ if (!isSecureEndpoint(endpoint)) {
20
+ throw new Error(`USAGEFLEET_ENDPOINT must be https (got ${endpoint}). It carries the device token on every request and self-update executes a binary fetched from it.`);
21
+ }
22
+ // Guard batch size: "0" (infinite loop), NaN (silent drop), fractional → 100.
23
+ // Clamped to the server's own 1000-record cap, since a larger batch is
24
+ // rejected as malformed and would cost the whole chunk a bisect to discover.
25
+ const parsedBatch = Math.floor(Number(process.env.USAGEFLEET_BATCH));
26
+ const batchSize = Number.isFinite(parsedBatch) && parsedBatch > 0 ? Math.min(parsedBatch, MAX_BATCH) : 100;
27
+ return {
28
+ batchSize,
29
+ desktopDir: resolveOptionalDir(process.env.USAGEFLEET_DESKTOP, file.desktopDir, defaultDesktopSessionsDir()),
30
+ endpoint,
31
+ piDirs: resolvePiDirs(process.env.USAGEFLEET_PI, file.piDir),
32
+ projectsDir: process.env.USAGEFLEET_PROJECTS || file.projectsDir || defaultProjectsDir(),
33
+ storePath: storePath(),
34
+ token,
35
+ };
36
+ }
37
+ /** pi scan roots: env "off"/"0" disables, else a comma-separated env list, else
38
+ * the config file's string-or-array, else every auto-detected default. */
39
+ export function resolvePiDirs(env, fromFile) {
40
+ if (env === '0' || env?.toLowerCase() === 'off') {
41
+ return [];
42
+ }
43
+ const raw = env ? env.split(',') : Array.isArray(fromFile) ? fromFile : fromFile ? [fromFile] : null;
44
+ if (!raw) {
45
+ return defaultPiSessionsDirs();
46
+ }
47
+ return [...new Set(raw.map(d => d.trim()).filter(d => d.length > 0))];
48
+ }
49
+ /** Optional scan root (USAGEFLEET_DESKTOP / USAGEFLEET_PI): env "off"/"0"
50
+ * disables, env or config-file path overrides, else the auto-detected default. */
51
+ function resolveOptionalDir(env, fromFile, fallback) {
52
+ if (env === '0' || env?.toLowerCase() === 'off') {
53
+ return null;
54
+ }
55
+ return env || fromFile || fallback;
56
+ }
57
+ /**
58
+ * The endpoint must be https: it carries the device token on every request, and
59
+ * the payload is a log of what this machine is working on. Loopback is exempt so
60
+ * local development keeps working.
61
+ */
62
+ export function isSecureEndpoint(endpoint) {
63
+ let url;
64
+ try {
65
+ url = new URL(endpoint);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ if (url.protocol === 'https:') {
71
+ return true;
72
+ }
73
+ return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname);
74
+ }
package/dist/guard.js ADDED
@@ -0,0 +1,59 @@
1
+ import { loadConfig } from './config.js';
2
+ /** Hooks run on the interactive path — a slow/hung server must not stall a
3
+ * prompt for long. On timeout we fail open (see {@link runGuard}). */
4
+ const TIMEOUT_MS = 5000;
5
+ /**
6
+ * The message shown to the user when the prompt is refused, or null to let it
7
+ * through. Only an explicit `blocked: true` blocks — anything unexpected
8
+ * (missing fields, old server, junk) falls through to null on purpose, so a
9
+ * tracker problem can never stop someone from working.
10
+ */
11
+ export function blockMessage(view) {
12
+ if (view.blocked !== true) {
13
+ return null;
14
+ }
15
+ const weekly = view.blockedWindow === 'weekly';
16
+ const pct = (weekly ? view.weeklyPct : view.sessionPct) ?? 100;
17
+ const group = view.group ? `"${view.group}"` : 'this group';
18
+ const until = view.blockedUntil ? new Date(view.blockedUntil) : null;
19
+ const resets = until && !Number.isNaN(until.getTime())
20
+ ? ` Resets ${until.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}.`
21
+ : '';
22
+ return (`usagefleet: ${group} has used ${pct}% of its ${weekly ? 'weekly' : '5h'} budget, ` +
23
+ `so new prompts are blocked.${resets}`);
24
+ }
25
+ /**
26
+ * Exit code for `usagefleet guard`, the Claude Code `UserPromptSubmit` hook:
27
+ * 2 refuses the prompt (stderr is shown to the user), 0 lets it through.
28
+ * Prints nothing on stdout — on this hook stdout is injected into the model's
29
+ * context.
30
+ */
31
+ export async function runGuard() {
32
+ let cfg;
33
+ try {
34
+ cfg = loadConfig();
35
+ }
36
+ catch {
37
+ return 0; // not configured on this machine — nothing to enforce
38
+ }
39
+ let view;
40
+ try {
41
+ const res = await fetch(`${cfg.endpoint}/api/v1/limits`, {
42
+ headers: { 'x-api-key': cfg.token },
43
+ signal: AbortSignal.timeout(TIMEOUT_MS),
44
+ });
45
+ if (!res.ok) {
46
+ return 0;
47
+ }
48
+ view = (await res.json());
49
+ }
50
+ catch {
51
+ return 0; // offline / timeout / bad JSON — fail open
52
+ }
53
+ const msg = blockMessage(view);
54
+ if (!msg) {
55
+ return 0;
56
+ }
57
+ process.stderr.write(`${msg}\n`);
58
+ return 2;
59
+ }