@usagefleet/cli 1.2.97 → 1.2.98

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 CHANGED
@@ -126,24 +126,25 @@ the CLI persists: your settings plus two machine-managed sections, `state` (tail
126
126
  offsets) and `notify` (which thresholds already fired). Delete it to start
127
127
  clean. Re-running `login` merges, so rotating a token doesn't reset offsets.
128
128
 
129
- Env vars override the file:
130
-
131
- | Variable | Meaning |
132
- |----------|---------|
133
- | `USAGEFLEET_TOKEN` | device token |
134
- | `USAGEFLEET_PROJECTS` | override `~/.claude/projects` |
135
- | `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
136
- | `USAGEFLEET_PI` | override the pi sessions dirs, comma-separated; `off` to skip |
137
- | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
138
- | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
139
- | `USAGEFLEET_NOTIFY` | desktop notifications, on by default |
140
- | `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert (default `80,95`) |
141
- | `USAGEFLEET_BATCH` | records per upload (default 100, server caps at 1000) |
142
- | `USAGEFLEET_CONFIG` | override the config file path |
143
- | `USAGEFLEET_UPDATE` | `0` turns the self-update check off |
144
- | `USAGEFLEET_UPDATE_INTERVAL` | seconds between update checks (default `21600` = 6h) |
145
- | `USAGEFLEET_HOOK` | `0` keeps the prompt-blocking guard out of Claude Code and pi |
146
- | `CLAUDE_CONFIG_DIR` | Claude Code's own knob: which login to watch (default `~/.claude`) |
129
+ Every knob is a top-level key in that file, and the matching env var overrides
130
+ it (`usagefleet config` prints this same table):
131
+
132
+ | File key | Env override | Meaning |
133
+ |----------|--------------|---------|
134
+ | `token` | `USAGEFLEET_TOKEN` | device token |
135
+ | `projectsDir` | `USAGEFLEET_PROJECTS` | override `~/.claude/projects` |
136
+ | `desktopDir` | `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
137
+ | `piDir` | `USAGEFLEET_PI` | pi sessions dirs — string or array in the file, comma-separated in env; `off` to skip |
138
+ | `interval` | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
139
+ | `limitsInterval` | `USAGEFLEET_LIMITS_INTERVAL` | seconds between limit reports (default 60; 300 on API keys, where each report costs a 1-token ping) |
140
+ | `notifications` | `USAGEFLEET_NOTIFY` | desktop notifications, on by default (`false`/`0` disables) |
141
+ | `notifyThresholds` | `USAGEFLEET_NOTIFY_THRESHOLDS` | utilization % that trigger an alert — array in the file, comma list in env (default `80,95`) |
142
+ | `batch` | `USAGEFLEET_BATCH` | records per upload (default 100, server caps at 1000) |
143
+ | `update` | `USAGEFLEET_UPDATE` | `false`/`0` turns the self-update check off |
144
+ | `updateInterval` | `USAGEFLEET_UPDATE_INTERVAL` | seconds between update checks (default `21600` = 6h) |
145
+ | `hook` | `USAGEFLEET_HOOK` | `false`/`0` keeps the prompt-blocking guard out of Claude Code and pi |
146
+ | — | `USAGEFLEET_CONFIG` | override the config file path (env only it locates the file) |
147
+ | — | `CLAUDE_CONFIG_DIR` | Claude Code's own knob: which login to watch (default `~/.claude`) |
147
148
 
148
149
  When run as a service, `login` bakes every `USAGEFLEET_*` value currently set
149
150
  (plus `ANTHROPIC_API_KEY` and `CLAUDE_CONFIG_DIR`) into the launchd/systemd
package/dist/config.js CHANGED
@@ -18,7 +18,7 @@ export function loadConfig() {
18
18
  // Guard batch size: "0" (infinite loop), NaN (silent drop), fractional → 100.
19
19
  // Clamped to the server's own 1000-record cap, since a larger batch is
20
20
  // rejected as malformed and would cost the whole chunk a bisect to discover.
21
- const parsedBatch = Math.floor(Number(process.env.USAGEFLEET_BATCH));
21
+ const parsedBatch = Math.floor(positiveNumber(process.env.USAGEFLEET_BATCH, file.batch) ?? 100);
22
22
  const batchSize = Number.isFinite(parsedBatch) && parsedBatch > 0 ? Math.min(parsedBatch, MAX_BATCH) : 100;
23
23
  return {
24
24
  batchSize,
@@ -29,6 +29,27 @@ export function loadConfig() {
29
29
  token,
30
30
  };
31
31
  }
32
+ /** env → file → nothing, for the numeric knobs (intervals, batch): an env
33
+ * value wins when present (empty string counts as unset, same `||` rule as
34
+ * loadConfig), and a non-positive or non-numeric candidate is skipped rather
35
+ * than trusted. */
36
+ export function positiveNumber(env, fromFile) {
37
+ for (const candidate of [env ? Number(env) : null, fromFile]) {
38
+ if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
39
+ return candidate;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+ /** The off-switches (hook, update, notifications): an env value wins when
45
+ * present — 0/false/off/no disable, anything else enables — otherwise `false`
46
+ * under the config-file key disables. */
47
+ export function flagOff(env, fromFile) {
48
+ if (env) {
49
+ return /^(0|false|off|no)$/i.test(env.trim());
50
+ }
51
+ return fromFile === false;
52
+ }
32
53
  /** pi scan roots: env "off"/"0" disables, else a comma-separated env list, else
33
54
  * the config file's string-or-array, else every auto-detected default. */
34
55
  export function resolvePiDirs(env, fromFile) {
package/dist/hook.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { writeFileAtomic } from './atomic-write.js';
4
+ import { flagOff } from './config.js';
4
5
  import { claudeSettingsPath } from './paths.js';
6
+ import { readStore } from './store.js';
5
7
  import { step, tilde, warn } from './ui.js';
6
8
  /** Outer bound on the hook, in seconds. runGuard's own fetch gives up after 5s
7
9
  * and fails open; this only matters if the process itself wedges. */
@@ -102,11 +104,11 @@ function editSettings(transform, onWrite) {
102
104
  /**
103
105
  * Register `usagefleet guard` as a Claude Code UserPromptSubmit hook, so a
104
106
  * group with blocking enabled actually refuses prompts. Called by
105
- * `usagefleet login`; set USAGEFLEET_HOOK=0 to keep settings.json
106
- * untouched.
107
+ * `usagefleet login`; USAGEFLEET_HOOK=0 or `"hook": false` in the config file
108
+ * keeps settings.json untouched.
107
109
  */
108
110
  export function installPromptHook(program) {
109
- if (process.env.USAGEFLEET_HOOK === '0') {
111
+ if (flagOff(process.env.USAGEFLEET_HOOK, readStore().hook)) {
110
112
  return;
111
113
  }
112
114
  const command = guardCommand(program, process.env.USAGEFLEET_CONFIG);
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { detectClaudeCreds } from './claude-creds.js';
3
3
  import { reportLimitsOnce, runOnce } from './collector.js';
4
4
  import { commands, completionScript, installCompletions, removeCompletions, shells, suggest } from './completion.js';
5
- import { ENDPOINT, loadConfig } from './config.js';
5
+ import { ENDPOINT, loadConfig, positiveNumber } from './config.js';
6
6
  import { runGuard } from './guard.js';
7
7
  import { loadNotifyConfig } from './notifier.js';
8
8
  import { sendNotification } from './notify.js';
@@ -68,22 +68,25 @@ async function cmdLimits() {
68
68
  }
69
69
  async function cmdWatch() {
70
70
  const cfg = loadConfig();
71
- const raw = Number(flag('interval') ?? process.env.USAGEFLEET_INTERVAL ?? 15);
71
+ const settings = readStore(cfg.storePath);
72
+ const raw = flag('interval') == null
73
+ ? (positiveNumber(process.env.USAGEFLEET_INTERVAL, settings.interval) ?? 15)
74
+ : Number(flag('interval'));
72
75
  const interval = Math.max(1, Number.isFinite(raw) && raw > 0 ? raw : 15) * 1000;
73
76
  // Limits reporting is decoupled from the much faster usage poll. Default
74
77
  // interval depends on how the reading is fetched: subscription logins use the
75
78
  // free oauth/usage endpoint (60s keeps the dashboard split fresh at zero token
76
79
  // cost), API keys pay a 1-token Messages ping per reading (300s). An explicit
77
- // USAGEFLEET_LIMITS_INTERVAL overrides both.
78
- const rawLimits = Number(process.env.USAGEFLEET_LIMITS_INTERVAL);
79
- const explicitLimits = Number.isFinite(rawLimits) && rawLimits > 0 ? rawLimits * 1000 : null;
80
+ // USAGEFLEET_LIMITS_INTERVAL or `limitsInterval` file key overrides both.
81
+ const rawLimits = positiveNumber(process.env.USAGEFLEET_LIMITS_INTERVAL, settings.limitsInterval);
82
+ const explicitLimits = rawLimits ? rawLimits * 1000 : null;
80
83
  let limitsInterval = explicitLimits ?? 60_000;
81
84
  let lastLimitsAt = 0;
82
85
  // Self-update: once at startup, then every USAGEFLEET_UPDATE_INTERVAL seconds
83
86
  // (default 6h — a release lands on a device the same day, not the next).
84
- // USAGEFLEET_UPDATE=0 opts out.
85
- const rawUpdate = Number(process.env.USAGEFLEET_UPDATE_INTERVAL ?? 6 * 60 * 60);
86
- const updateInterval = Math.max(60, Number.isFinite(rawUpdate) && rawUpdate > 0 ? rawUpdate : 6 * 60 * 60) * 1000;
87
+ // USAGEFLEET_UPDATE=0 or `"update": false` opts out.
88
+ const rawUpdate = positiveNumber(process.env.USAGEFLEET_UPDATE_INTERVAL, settings.updateInterval) ?? 6 * 60 * 60;
89
+ const updateInterval = Math.max(60, rawUpdate) * 1000;
87
90
  let lastUpdateAt = 0;
88
91
  const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
89
92
  console.log(header(`watching every ${interval / 1000}s`));
@@ -154,7 +157,7 @@ async function cmdWatch() {
154
157
  function cmdNotifyTest() {
155
158
  const cfg = loadNotifyConfig();
156
159
  if (!cfg.enabled) {
157
- console.log(warn('notify', 'disabled · unset USAGEFLEET_NOTIFY=0 to enable'));
160
+ console.log(warn('notify', 'disabled · unset USAGEFLEET_NOTIFY=0 / `"notifications": false` to enable'));
158
161
  return;
159
162
  }
160
163
  sendNotification('usagefleet', 'Test notification — desktop alerts are working.', {
@@ -245,31 +248,40 @@ function print(rows) {
245
248
  console.log(` ${name.padEnd(width)} ${dim(meaning)}`);
246
249
  }
247
250
  }
248
- /** Where settings live and every env var that overrides them. Reads nothing:
249
- * it must work before a token exists, when the config is what you're fixing. */
251
+ /** Where settings live: every knob is a key in the config file, and the
252
+ * matching env var overrides it. Reads nothing: it must work before a token
253
+ * exists, when the config is what you're fixing. */
250
254
  function cmdConfig() {
251
- const env = [
252
- ['USAGEFLEET_TOKEN', 'device token from the Devices page'],
253
- ['USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
254
- ['USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
255
- ['USAGEFLEET_PI', 'override pi session dirs, comma-separated'],
256
- ['USAGEFLEET_INTERVAL', 'watch interval seconds (default 15)'],
257
- ['USAGEFLEET_LIMITS_INTERVAL', 'seconds between limits reports (default 60; 300 on API keys)'],
258
- ['USAGEFLEET_BATCH', 'records per upload (default 100, max 1000)'],
259
- ['USAGEFLEET_NOTIFY', 'desktop notifications (0 disables)'],
260
- ['USAGEFLEET_NOTIFY_THRESHOLDS', 'comma list of % alerts (default 80,95)'],
261
- ['USAGEFLEET_HOOK', 'register the guard on install (0 skips)'],
262
- ['USAGEFLEET_UPDATE', 'self-update while watching (0 disables)'],
263
- ['USAGEFLEET_UPDATE_INTERVAL', 'seconds between update checks (default 21600)'],
264
- ['USAGEFLEET_CONFIG', 'relocate the config file'],
255
+ const knobs = [
256
+ ['token', 'USAGEFLEET_TOKEN', 'device token from the Devices page'],
257
+ ['projectsDir', 'USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
258
+ ['desktopDir', 'USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
259
+ ['piDir', 'USAGEFLEET_PI', 'pi session dirs · string or array; env comma-separated'],
260
+ ['interval', 'USAGEFLEET_INTERVAL', 'watch interval seconds (default 15)'],
261
+ [
262
+ 'limitsInterval',
263
+ 'USAGEFLEET_LIMITS_INTERVAL',
264
+ 'seconds between limits reports (default 60; 300 on API keys)',
265
+ ],
266
+ ['batch', 'USAGEFLEET_BATCH', 'records per upload (default 100, max 1000)'],
267
+ ['notifications', 'USAGEFLEET_NOTIFY', 'desktop notifications (false/0 disables)'],
268
+ ['notifyThresholds', 'USAGEFLEET_NOTIFY_THRESHOLDS', '% alerts · array; env comma list (default 80,95)'],
269
+ ['hook', 'USAGEFLEET_HOOK', 'register the prompt guard on install (false/0 skips)'],
270
+ ['update', 'USAGEFLEET_UPDATE', 'self-update while watching (false/0 disables)'],
271
+ ['updateInterval', 'USAGEFLEET_UPDATE_INTERVAL', 'seconds between update checks (default 21600)'],
272
+ ['—', 'USAGEFLEET_CONFIG', 'relocate the config file (env only — it locates the file)'],
265
273
  ];
266
274
  console.log(header());
267
275
  console.log(hint('settings, tail offsets and notification marks'));
268
276
  console.log('');
269
277
  console.log(row('file', tilde(storePath())));
270
278
  console.log('');
271
- console.log(hint('env overrides the file'));
272
- print(env);
279
+ console.log(hint('file key · env override wins'));
280
+ const keyWidth = Math.max(...knobs.map(([key]) => key.length));
281
+ const envWidth = Math.max(...knobs.map(([, env]) => env.length));
282
+ for (const [key, env, meaning] of knobs) {
283
+ console.log(` ${key.padEnd(keyWidth)} ${env.padEnd(envWidth)} ${dim(meaning)}`);
284
+ }
273
285
  console.log('');
274
286
  console.log(hint('`usagefleet status` shows the resolved values'));
275
287
  }
package/dist/notifier.js CHANGED
@@ -1,23 +1,17 @@
1
+ import { flagOff } from './config.js';
1
2
  import { sendNotification } from './notify.js';
2
3
  import { readStore, storePath, updateStore } from './store.js';
3
4
  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;
5
+ /** Resolve notify config, env first then the config file. Enabled by default;
6
+ * disable with USAGEFLEET_NOTIFY=0 (also: false/off/no) or `"notifications":
7
+ * false`. Thresholds from USAGEFLEET_NOTIFY_THRESHOLDS as a comma list
8
+ * (e.g. "50,80,95") or a `notifyThresholds` array. */
9
+ export function loadNotifyConfig(env = process.env, file = readStore()) {
10
+ const enabled = !flagOff(env.USAGEFLEET_NOTIFY, file.notifications);
11
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
- }
12
+ const candidates = raw?.trim() ? raw.split(',').map(s => Number(s.trim())) : (file.notifyThresholds ?? []);
13
+ const parsed = candidates.map(Math.round).filter(n => Number.isFinite(n) && n > 0 && n <= 100);
14
+ const thresholds = parsed.length > 0 ? [...new Set(parsed)].toSorted((a, b) => a - b) : DEFAULT_THRESHOLDS;
21
15
  return { enabled, thresholds };
22
16
  }
23
17
  /**
package/dist/pi-hook.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { writeFileAtomic } from './atomic-write.js';
4
+ import { flagOff } from './config.js';
4
5
  import { piAgentDir } from './paths.js';
6
+ import { readStore } from './store.js';
5
7
  import { step, tilde } from './ui.js';
6
8
  /** pi has no settings-file hooks; its extension point is a TypeScript file
7
9
  * auto-discovered from `<agent-dir>/extensions/*.ts`. So the pi guard is a
8
10
  * generated file rather than a settings entry, with the same lifecycle as the
9
11
  * Claude hook: written by `login` (which self-update re-runs, refreshing the
10
- * baked binary path), removed by `uninstall`, skipped by USAGEFLEET_HOOK=0. */
12
+ * baked binary path), removed by `uninstall`, skipped by USAGEFLEET_HOOK=0 or
13
+ * `"hook": false` in the config file. */
11
14
  const FILE = 'usagefleet-guard.ts';
12
15
  export function piGuardPath(agentDir = piAgentDir()) {
13
16
  return join(agentDir, 'extensions', FILE);
@@ -70,7 +73,7 @@ export default function (pi: { on(event: 'input', handler: InputHandler): void }
70
73
  /** Skips machines without pi (agent dir absent) rather than conjuring a `.pi`
71
74
  * tree that pi itself never made. */
72
75
  export function installPiGuard(program, agentDir = piAgentDir()) {
73
- if (process.env.USAGEFLEET_HOOK === '0' || !existsSync(agentDir)) {
76
+ if (flagOff(process.env.USAGEFLEET_HOOK, readStore().hook) || !existsSync(agentDir)) {
74
77
  return;
75
78
  }
76
79
  const path = piGuardPath(agentDir);
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.97";
2
+ export const RELEASE_VERSION = "1.2.98";
package/dist/store.js CHANGED
@@ -41,15 +41,24 @@ function readJson(path) {
41
41
  export function freshWindow() {
42
42
  return { lastBucket: 0, resetsAt: null };
43
43
  }
44
- /** Fill in every field so callers get a total value, whatever the file held. */
44
+ /** Fill in every field so callers get a total value, whatever the file held.
45
+ * Listing fields explicitly (rather than spreading `raw`) is what drops junk
46
+ * keys — so every new settings key must be carried through here or the next
47
+ * `updateStore` silently erases it. */
45
48
  function normalize(raw) {
46
49
  return {
50
+ batch: raw.batch,
47
51
  desktopDir: raw.desktopDir,
52
+ hook: raw.hook,
53
+ interval: raw.interval,
48
54
  limits: raw.limits,
55
+ limitsInterval: raw.limitsInterval,
56
+ notifications: raw.notifications,
49
57
  notify: {
50
58
  fiveHour: { ...freshWindow(), ...raw.notify?.fiveHour },
51
59
  sevenDay: { ...freshWindow(), ...raw.notify?.sevenDay },
52
60
  },
61
+ notifyThresholds: raw.notifyThresholds,
53
62
  piDir: raw.piDir,
54
63
  projectsDir: raw.projectsDir,
55
64
  state: {
@@ -58,6 +67,8 @@ function normalize(raw) {
58
67
  updatedAt: raw.state?.updatedAt ?? new Date().toISOString(),
59
68
  },
60
69
  token: raw.token,
70
+ update: raw.update,
71
+ updateInterval: raw.updateInterval,
61
72
  version: 1,
62
73
  };
63
74
  }
package/dist/update.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { basename, delimiter, dirname, join } from 'node:path';
4
+ import { flagOff } from './config.js';
4
5
  import { RELEASE_VERSION } from './release.js';
6
+ import { readStore } from './store.js';
5
7
  import { tilde } from './ui.js';
6
8
  /** The published package: one artifact for every OS, installed with
7
9
  * `npm i -g @usagefleet/cli`. */
@@ -157,7 +159,7 @@ export async function checkForUpdate(log, force = false) {
157
159
  if (!self) {
158
160
  return null;
159
161
  }
160
- if (!force && process.env.USAGEFLEET_UPDATE === '0') {
162
+ if (!force && flagOff(process.env.USAGEFLEET_UPDATE, readStore().update)) {
161
163
  return null;
162
164
  }
163
165
  // A rejected fetch (offline, DNS, timeout) must stay inside this function:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.97",
3
+ "version": "1.2.98",
4
4
  "description": "Tails Claude Code, Claude Desktop, and pi agent JSONL logs and reports token usage to a UsageFleet server.",
5
5
  "keywords": [
6
6
  "claude",