@usagefleet/cli 1.2.59 → 1.2.70

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
@@ -9,14 +9,13 @@ UsageFleet server. Read-only on the log files. Zero runtime dependencies
9
9
 
10
10
  ## Install
11
11
 
12
- Three commands, identical on macOS, Linux and Windows. You only need a device
12
+ Two commands, identical on macOS, Linux and Windows. You only need a device
13
13
  **token** from the server's Devices page (endpoint defaults to
14
14
  `https://usagefleet.com`):
15
15
 
16
16
  ```bash
17
17
  npm i -g @usagefleet/cli
18
- usagefleet init --token uf_xxx # add --endpoint <url> when self-hosting
19
- usagefleet install # autostart at login
18
+ usagefleet install --token uf_xxx # autostart at login; --endpoint <url> when self-hosting
20
19
  ```
21
20
 
22
21
  In PowerShell chain them with `;` instead of `&&` — Windows PowerShell 5.1 has
@@ -44,7 +43,7 @@ bun run src/index.ts status # or: npm run build && node dist/index.js status
44
43
  Get a device **token** from the server's Devices page, then either:
45
44
 
46
45
  ```bash
47
- usagefleet init --endpoint https://track.example.com --token uf_xxx
46
+ usagefleet install --endpoint https://track.example.com --token uf_xxx
48
47
  # writes ~/.config/usagefleet/config.json
49
48
  ```
50
49
 
@@ -120,9 +119,9 @@ set USAGEFLEET_TOKEN=uf_xxx
120
119
  ```
121
120
 
122
121
  > Prefer not to put a long-lived token in shell history/rc files? Use
123
- > `usagefleet init --endpoint <url> --token <t>` instead — it writes
122
+ > `usagefleet install --endpoint <url> --token <t>` instead — it writes
124
123
  > `~/.config/usagefleet/config.json` (mode `600`), which the collector reads
125
- > automatically. `init` merges, so re-running it rotates the token without
124
+ > automatically. The write merges, so re-running it rotates the token without
126
125
  > resetting your tail offsets.
127
126
  > When run as a service, `usagefleet install` bakes every `USAGEFLEET_*` value
128
127
  > that is currently set (plus `ANTHROPIC_API_KEY`) into the launchd/systemd unit.
@@ -137,6 +136,7 @@ usagefleet limits # report ONLY your real 5h/weekly limit usage
137
136
  usagefleet guard # exit 2 if this device's group is over a blocking limit
138
137
  usagefleet update # upgrade to the latest published version now
139
138
  usagefleet status # service health, last limits reading, resolved config
139
+ usagefleet config # config file location + every env override
140
140
  usagefleet version # bare release version
141
141
  ```
142
142
 
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { homedir, userInfo } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { writeFileAtomic } from './atomic-write.js';
6
+ import { dim, line, yellow } from './ui.js';
6
7
  const KEYCHAIN_SERVICE = 'Claude Code-credentials';
7
8
  function credentialsFilePath() {
8
9
  return join(homedir(), '.claude', '.credentials.json');
@@ -112,8 +113,7 @@ async function refreshOauth(blob, from) {
112
113
  // The refresh already rotated the token server-side, so the stored one is
113
114
  // now dead: keep using the new one in memory (valid for hours) and make the
114
115
  // failure loud — on restart the user will have to `claude login` again.
115
- console.error(`could not save the refreshed Claude token (${error.message}) ` +
116
- 'run `claude login` if limits stop reporting after a restart');
116
+ line(yellow('!'), `could not save the refreshed claude token ${dim(${error.message} · run \`claude login\` if limits stop reporting after a restart`)}`);
117
117
  }
118
118
  return {
119
119
  source: 'sub',
package/dist/collector.js CHANGED
@@ -9,6 +9,7 @@ import { RELEASE_VERSION } from './release.js';
9
9
  import { listJsonlFiles } from './scanner.js';
10
10
  import { readStore, updateStore } from './store.js';
11
11
  import { tailFile } from './tailer.js';
12
+ import { tilde } from './ui.js';
12
13
  import { postLimits, uploadBatch } from './uploader.js';
13
14
  /** Only files inside a `.../.claude/projects/...` subtree are real usage logs.
14
15
  * Desktop session roots also hold `audit.jsonl` (a full duplicate of the same
@@ -56,6 +57,9 @@ export async function runOnce(cfg, log = () => {
56
57
  // Defensive: a bad batchSize must never stall the chunk loop.
57
58
  const step = cfg.batchSize > 0 ? Math.floor(cfg.batchSize) : 100;
58
59
  let advanced = false;
60
+ // A server that is down or unreachable fails every file for the same reason,
61
+ // so the cycle reports one count instead of a screenful of identical lines.
62
+ let transientFiles = 0;
59
63
  for (const { fp, source } of files) {
60
64
  let tail;
61
65
  try {
@@ -63,7 +67,7 @@ export async function runOnce(cfg, log = () => {
63
67
  }
64
68
  catch (error) {
65
69
  // One unreadable/oversized file must not abort the whole cycle.
66
- log(`skip ${fp}: ${error.message}`);
70
+ log('warn', `skipped ${tilde(fp)} · ${error.message}`);
67
71
  continue;
68
72
  }
69
73
  if (!tail || tail.consumedBytes === 0) {
@@ -75,7 +79,7 @@ export async function runOnce(cfg, log = () => {
75
79
  advanced = true;
76
80
  continue;
77
81
  }
78
- // sendChunk absorbs "invalid" by bisecting, so only auth/transient escape.
82
+ // sendChunk absorbs "invalid" by bisecting, so only auth/plan/transient escape.
79
83
  let outcome = 'ok';
80
84
  for (let i = 0; i < tail.records.length; i += step) {
81
85
  outcome = await sendChunk(tail.records.slice(i, i + step), cfg, result, log);
@@ -91,27 +95,37 @@ export async function runOnce(cfg, log = () => {
91
95
  // Token revoked/expired. The data is valid and must NOT be skipped — keep
92
96
  // the offset so it uploads once a fresh token is configured. Retrying the
93
97
  // 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`);
98
+ log('warn', 'auth rejected · device token invalid or revoked · re-run `usagefleet install --token <t>` with a fresh token');
99
+ result.failed = true;
100
+ break;
101
+ }
102
+ else if (outcome === 'plan') {
103
+ // The device sits outside the account's device limit (402). Every other
104
+ // file gets the same answer, so stop and say what unblocks it once.
105
+ log('warn', planWall(cfg.endpoint));
95
106
  result.failed = true;
96
107
  break;
97
108
  }
98
109
  else if (outcome === 'invalid') {
99
110
  // The whole batch was rejected, not individual records (see sendChunk).
100
111
  // 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`);
112
+ log('warn', `batch rejected for ${tilde(fp)} · offset kept · check this collector version and OS are supported by the server`);
102
113
  result.failed = true;
103
114
  }
104
115
  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`);
116
+ // transient (5xx, network, timeout): keep the offset and retry next cycle,
117
+ // but DO NOT break — later files must still get a turn.
118
+ transientFiles += 1;
108
119
  result.failed = true;
109
120
  }
110
121
  }
122
+ if (transientFiles > 0) {
123
+ log('warn', `upload failed for ${transientFiles} file${transientFiles === 1 ? '' : 's'} · retrying next cycle`);
124
+ }
111
125
  // One durable write per cycle rather than one per file: the store is fsynced
112
126
  // on every save, and a crash mid-cycle only costs a re-upload the server
113
127
  // dedups. Only our own section is replaced, so a token written by a
114
- // concurrent `usagefleet init` survives.
128
+ // concurrent `usagefleet install` survives.
115
129
  if (pruneMissingFiles(state, files) || advanced) {
116
130
  updateStore(cfg.storePath, store => {
117
131
  store.state.files = state.files;
@@ -158,11 +172,11 @@ async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped
158
172
  // Give up on the record theory. The caller keeps the offset, so the
159
173
  // records counted on the way down are retried, not lost — untally them
160
174
  // 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`);
175
+ log('warn', 'every split was rejected · the batch is bad, not its records · nothing skipped');
162
176
  result.dropped = dropCeiling - MAX_DROPPED_PER_CHUNK;
163
177
  return 'invalid';
164
178
  }
165
- log(`server rejected record ${single.uuid} as malformed skipping it`);
179
+ log('warn', `record ${single.uuid} rejected as malformed · skipped`);
166
180
  result.dropped += 1;
167
181
  result.failed = true;
168
182
  return 'ok';
@@ -191,6 +205,11 @@ function pruneMissingFiles(state, scanned) {
191
205
  }
192
206
  return removed;
193
207
  }
208
+ /** The one thing that unblocks a device parked outside the account's device limit.
209
+ * Shared by both upload legs so the wording cannot drift between them. */
210
+ function planWall(endpoint) {
211
+ return `device outside your plan's device limit · free a slot or upgrade at ${endpoint}/devices · nothing is lost, uploads resume once it fits`;
212
+ }
194
213
  /**
195
214
  * Auto-detect the local Claude login, read the real 5h/weekly utilization from
196
215
  * Anthropic's rate-limit headers, and report it to the server. Best-effort —
@@ -204,12 +223,11 @@ export async function reportLimitsOnce(cfg, log = () => {
204
223
  if (process.platform === 'darwin' && macKeychainDenied()) {
205
224
  // "Works by hand, broken as a service" signature: a launchd agent can be
206
225
  // 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.');
226
+ log('warn', "limits skipped · keychain read for 'Claude Code-credentials' denied, typical under a launchd agent · " +
227
+ 'grant /usr/bin/security access to the item, or set ANTHROPIC_API_KEY for the service');
210
228
  }
211
229
  else {
212
- log('no usable Claude login missing, or expired with a refresh that failed; ' +
230
+ log('warn', 'no usable claude login · missing, or expired with a failed refresh · ' +
213
231
  'sign in with `claude` or set ANTHROPIC_API_KEY');
214
232
  }
215
233
  return null;
@@ -219,12 +237,24 @@ export async function reportLimitsOnce(cfg, log = () => {
219
237
  report = await fetchLimits(creds);
220
238
  }
221
239
  catch (error) {
222
- log(`limits fetch failed: ${error.message}`);
240
+ log('warn', `limits fetch failed · ${error.message}`);
223
241
  return null;
224
242
  }
225
- const ok = await postLimits(report, cfg);
226
- if (!ok) {
227
- log('limits upload failed');
243
+ // Same vocabulary as the usage leg: a rejection the operator can act on has to
244
+ // say which one it is, since this runs every cycle and an anonymous failure
245
+ // would repeat forever without ever naming the fix.
246
+ const outcome = await postLimits(report, cfg);
247
+ if (outcome === 'plan') {
248
+ log('warn', planWall(cfg.endpoint));
249
+ }
250
+ else if (outcome === 'auth') {
251
+ log('warn', 'limits rejected · device token invalid or revoked · re-run `usagefleet install --token <device-token>`');
252
+ }
253
+ else if (outcome === 'invalid') {
254
+ log('warn', 'limits rejected as malformed · this is a bug, please report it');
255
+ }
256
+ else if (outcome !== 'ok') {
257
+ log('warn', 'limits upload failed · retrying next cycle');
228
258
  }
229
259
  // Cache the reading so `status` can show current usage without spending
230
260
  // another billable API call.
package/dist/config.js CHANGED
@@ -2,22 +2,22 @@ import { defaultDesktopSessionsDir, defaultPiSessionsDirs, defaultProjectsDir }
2
2
  import { readStore, storePath } from './store.js';
3
3
  /** Matches the server's BatchSchema `.max(1000)`. */
4
4
  const MAX_BATCH = 1000;
5
+ /** The hosted service. Only self-hosted deployments have to name an endpoint,
6
+ * so setup on the hosted one is a token and nothing else. */
7
+ export const DEFAULT_ENDPOINT = 'https://usagefleet.com';
5
8
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
6
9
  /** Resolve config from env first, then the stored settings (see store.ts). */
7
10
  export function loadConfig() {
8
11
  const file = readStore();
9
12
  // Use `||` (not `??`) so an empty-string env var falls back to the config
10
13
  // file — launchd/systemd units may inject empty USAGEFLEET_* values.
11
- const endpoint = (process.env.USAGEFLEET_ENDPOINT || file.endpoint || '').replace(/\/+$/, '');
14
+ const endpoint = (process.env.USAGEFLEET_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, '');
12
15
  const token = process.env.USAGEFLEET_TOKEN || file.token || '';
13
- if (!endpoint) {
14
- throw new Error('USAGEFLEET_ENDPOINT is not set');
15
- }
16
16
  if (!token) {
17
17
  throw new Error('USAGEFLEET_TOKEN is not set');
18
18
  }
19
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.`);
20
+ throw new Error(`endpoint must be https (got ${endpoint}). It carries the device token on every request. Set --endpoint or USAGEFLEET_ENDPOINT.`);
21
21
  }
22
22
  // Guard batch size: "0" (infinite loop), NaN (silent drop), fractional → 100.
23
23
  // Clamped to the server's own 1000-record cap, since a larger batch is
package/dist/hook.js CHANGED
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { writeFileAtomic } from './atomic-write.js';
4
4
  import { claudeSettingsPath } from './paths.js';
5
+ import { step, tilde, warn } from './ui.js';
5
6
  /** Outer bound on the hook, in seconds. runGuard's own fetch gives up after 5s
6
7
  * and fails open; this only matters if the process itself wedges. */
7
8
  const HOOK_TIMEOUT_S = 10;
@@ -71,7 +72,7 @@ function editSettings(transform, onWrite) {
71
72
  settings = parsed;
72
73
  }
73
74
  catch {
74
- console.warn(`Could not parse ${path} left it untouched. Fix the JSON and re-run.`);
75
+ console.log(warn('hook', `${tilde(path)} is not valid JSON · left untouched`));
75
76
  return;
76
77
  }
77
78
  }
@@ -96,8 +97,8 @@ export function installPromptHook(program) {
96
97
  return;
97
98
  }
98
99
  const command = guardCommand(program);
99
- editSettings(s => withGuardHook(s, command), path => console.log(`Registered the over-limit prompt guard in ${path}.`));
100
+ editSettings(s => withGuardHook(s, command), path => console.log(step('hook', `prompt guard · ${tilde(path)}`)));
100
101
  }
101
102
  export function uninstallPromptHook() {
102
- editSettings(withoutGuardHook, path => console.log(`Removed the prompt guard from ${path}.`));
103
+ editSettings(withoutGuardHook, path => console.log(step('removed', `prompt guard · ${tilde(path)}`)));
103
104
  }
package/dist/index.js CHANGED
@@ -8,8 +8,8 @@ import { sendNotification } from './notify.js';
8
8
  import { detectOs } from './os.js';
9
9
  import { RELEASE_VERSION } from './release.js';
10
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';
11
+ import { readStore, storePath } from './store.js';
12
+ import { ago, bar, blue, dim, fail, green, header, hint, host, line, note, pct, row, state as stateLine, step, tilde, warn, yellow, } from './ui.js';
13
13
  import { checkForUpdate } from './update.js';
14
14
  function flag(name) {
15
15
  const prefix = `--${name}`;
@@ -27,22 +27,30 @@ function flag(name) {
27
27
  }
28
28
  return undefined;
29
29
  }
30
- function ts() {
31
- return new Date().toISOString().replace('T', ' ').slice(0, 19);
32
- }
33
30
  /** "5h ██░░░░░░░░ 2% · weekly ████░░░░░░ 13%" — the shared limits line.
34
31
  * Bars are plain characters, so they survive a service log as well as a TTY. */
35
32
  function limitsSummary(limits) {
36
33
  const models = limits.modelLimits.map(m => ` · ${m.model}(${m.window}) ${bar(m.pct, 6)} ${pct(m.pct)}`).join('');
37
34
  return `5h ${bar(limits.fiveHourPct)} ${pct(limits.fiveHourPct)} · weekly ${bar(limits.sevenDayPct)} ${pct(limits.sevenDayPct)}${models}`;
38
35
  }
36
+ /** Every message the collector, notifier and self-update emit, as a stream
37
+ * line. The level picks the glyph so a problem never reads like a result. */
38
+ const stream = (level, m) => {
39
+ line(level === 'warn' ? yellow('!') : note, m);
40
+ };
41
+ /** Upload result as one stream line — the shape `run` and `watch` share.
42
+ * Dropped records are the only part worth a colour: they are lost data. */
43
+ function cycleLine(r) {
44
+ const dropped = r.dropped > 0 ? ` · ${yellow(`${r.dropped} dropped`)}` : '';
45
+ line(r.dropped > 0 ? yellow('!') : green('↑'), `${r.sent} sent ${dim(`· ${r.accepted} accepted · ${r.duplicates} dup · ${r.files} file${r.files === 1 ? '' : 's'}`)}${dropped}`);
46
+ }
39
47
  async function cmdRun() {
40
48
  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}`));
49
+ const r = await runOnce(cfg, stream);
50
+ cycleLine(r);
51
+ const limits = await reportLimitsOnce(cfg, stream);
44
52
  if (limits) {
45
- console.log(`[${ts()}] limits (${limits.source}): ${limitsSummary(limits)}`);
53
+ line(note, `${limitsSummary(limits)} ${dim(`· ${limits.source}`)}`);
46
54
  }
47
55
  if (r.failed) {
48
56
  process.exitCode = 1;
@@ -50,12 +58,12 @@ async function cmdRun() {
50
58
  }
51
59
  async function cmdLimits() {
52
60
  const cfg = loadConfig();
53
- const limits = await reportLimitsOnce(cfg, m => console.log(`[${ts()}] ${m}`));
61
+ const limits = await reportLimitsOnce(cfg, stream);
54
62
  if (!limits) {
55
63
  process.exitCode = 1;
56
64
  return;
57
65
  }
58
- console.log(`[${ts()}] reported ${limits.source}: ${limitsSummary(limits)}`);
66
+ line(note, `${limitsSummary(limits)} ${dim(`· ${limits.source}`)}`);
59
67
  }
60
68
  async function cmdWatch() {
61
69
  const cfg = loadConfig();
@@ -73,11 +81,10 @@ async function cmdWatch() {
73
81
  const rawUpdate = Number(process.env.USAGEFLEET_UPDATE_INTERVAL ?? 6 * 60 * 60);
74
82
  const updateInterval = Math.max(60, Number.isFinite(rawUpdate) && rawUpdate > 0 ? rawUpdate : 6 * 60 * 60) * 1000;
75
83
  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
- }
84
+ const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
85
+ console.log(header(`watching every ${interval / 1000}s`));
86
+ console.log(hint(`${watching.map(tilde).join(' · ')} ${host(cfg.endpoint)}`));
87
+ console.log('');
81
88
  let stopping = false;
82
89
  let timer = null;
83
90
  let running = false;
@@ -87,27 +94,27 @@ async function cmdWatch() {
87
94
  }
88
95
  running = true;
89
96
  try {
90
- const r = await runOnce(cfg, m => console.log(`[${ts()}] ${m}`));
97
+ const r = await runOnce(cfg, stream);
91
98
  // Dropped records are real data loss, so they must show up even in a
92
99
  // cycle that uploaded nothing.
93
100
  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}`)}` : ''}`);
101
+ cycleLine(r);
95
102
  }
96
103
  const nowMs = Date.now();
97
104
  if (nowMs - lastUpdateAt >= updateInterval) {
98
105
  lastUpdateAt = nowMs;
99
- await checkForUpdate(m => console.log(`[${ts()}] ${m}`));
106
+ await checkForUpdate((level, m) => line(level === 'ok' ? blue('↻') : yellow('!'), m));
100
107
  }
101
108
  if (nowMs - lastLimitsAt >= limitsInterval) {
102
109
  lastLimitsAt = nowMs;
103
- const limits = await reportLimitsOnce(cfg, m => console.log(`[${ts()}] ${m}`));
110
+ const limits = await reportLimitsOnce(cfg, stream);
104
111
  if (limits) {
105
- console.log(`[${ts()}] limits (${limits.source}): ${limitsSummary(limits)}`);
112
+ line(note, limitsSummary(limits));
106
113
  }
107
114
  }
108
115
  }
109
116
  catch (error) {
110
- console.error(`[${ts()}] cycle error:`, error.message);
117
+ line(yellow('!'), `cycle error ${dim(error.message)}`);
111
118
  }
112
119
  finally {
113
120
  running = false;
@@ -121,7 +128,7 @@ async function cmdWatch() {
121
128
  if (timer) {
122
129
  clearTimeout(timer);
123
130
  }
124
- console.log(`\n[${ts()}] stopping…`);
131
+ line(note, dim('stopping…'));
125
132
  // Let an in-flight cycle finish committing offsets; hard-exit fallback.
126
133
  const bail = setTimeout(() => process.exit(0), 5000);
127
134
  bail.unref();
@@ -140,13 +147,13 @@ async function cmdWatch() {
140
147
  function cmdNotifyTest() {
141
148
  const cfg = loadNotifyConfig();
142
149
  if (!cfg.enabled) {
143
- console.log('Notifications are disabled (USAGEFLEET_NOTIFY=0).');
150
+ console.log(warn('notify', 'disabled · unset USAGEFLEET_NOTIFY=0 to enable'));
144
151
  return;
145
152
  }
146
153
  sendNotification('usagefleet', 'Test notification — desktop alerts are working.', {
147
154
  urgency: 'normal',
148
155
  });
149
- console.log(`[${ts()}] sent a test notification via ${detectOs()} (thresholds: ${cfg.thresholds.join(', ')}%)`);
156
+ console.log(step('notified', `${detectOs()} · thresholds ${cfg.thresholds.join(', ')}%`));
150
157
  }
151
158
  async function cmdStatus() {
152
159
  const cfg = loadConfig();
@@ -167,82 +174,110 @@ async function cmdStatus() {
167
174
  ? 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
175
  : stateLine('warn', 'limits', `no reading yet ${dim('· run `usagefleet limits`')}`));
169
176
  console.log('');
170
- console.log(row('endpoint', cfg.endpoint));
177
+ console.log(row('endpoint', host(cfg.endpoint)));
171
178
  console.log(row('device', `${state.deviceId} · token ${cfg.token.slice(0, 8)}…`));
172
179
  const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
173
180
  for (const [i, dir] of watching.entries()) {
174
- console.log(row(i === 0 ? 'watching' : '', dir));
181
+ console.log(row(i === 0 ? 'watching' : '', tilde(dir)));
175
182
  }
176
183
  console.log(row('tracked', `${tracked} file${tracked === 1 ? '' : 's'} · ${mb} MB read · synced ${ago(state.updatedAt)}`));
177
- console.log(row('config', cfg.storePath));
184
+ console.log(row('config', tilde(cfg.storePath)));
178
185
  }
179
186
  /** Worst of the two windows decides the dot colour. */
180
187
  function limitHealth(fiveHour, sevenDay) {
181
188
  const worst = Math.max(fiveHour ?? 0, sevenDay ?? 0);
182
189
  return worst >= 95 ? 'bad' : worst >= 80 ? 'warn' : 'ok';
183
190
  }
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())}`;
191
+ /** Setup in one command: persist the flags (when given), then install the
192
+ * background service, which refuses to install without a resolvable token.
193
+ * The write merges over the existing store, so re-running install rotates the
194
+ * token without resetting tail offsets. Endpoint only matters when
195
+ * self-hosting; unset keeps whatever is configured. */
196
+ async function cmdInstall() {
197
+ // Apply the flags to the env loadConfig() reads rather than writing them to the
198
+ // store, so there is exactly one precedence chain and install() persists its
199
+ // single winner. Writing to the store first inverted the precedence: loadConfig
200
+ // prefers the env, so a stale USAGEFLEET_TOKEN in the install shell beat the
201
+ // flag and got written back over it, silently voiding token rotation. It also
202
+ // means a rejected value never reaches disk.
203
+ const endpoint = flag('endpoint');
204
+ const token = flag('token');
205
+ if (endpoint) {
206
+ process.env.USAGEFLEET_ENDPOINT = endpoint;
207
+ }
208
+ if (token) {
209
+ process.env.USAGEFLEET_TOKEN = token;
210
+ }
211
+ const { install } = await import('./service.js');
212
+ install();
188
213
  }
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);
214
+ /** Padded two-column list — name in white, meaning in gray, like the result
215
+ * lines. Shared by `help` and `config`. */
216
+ function print(rows) {
217
+ const width = Math.max(...rows.map(([name]) => name.length));
218
+ for (const [name, meaning] of rows) {
219
+ console.log(` ${name.padEnd(width)} ${dim(meaning)}`);
195
220
  }
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
221
  }
222
+ /** Where settings live and every env var that overrides them. Reads nothing:
223
+ * it must work before a token exists, when the config is what you're fixing. */
224
+ function cmdConfig() {
225
+ const env = [
226
+ ['USAGEFLEET_ENDPOINT', 'server base URL (self-hosting only)'],
227
+ ['USAGEFLEET_TOKEN', 'device token from the Devices page'],
228
+ ['USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
229
+ ['USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
230
+ ['USAGEFLEET_PI', 'override pi session dirs, comma-separated'],
231
+ ['USAGEFLEET_INTERVAL', 'watch interval seconds (default 15)'],
232
+ ['USAGEFLEET_LIMITS_INTERVAL', 'seconds between limits pings (default 300)'],
233
+ ['USAGEFLEET_BATCH', 'records per upload (default 100, max 1000)'],
234
+ ['USAGEFLEET_NOTIFY', 'desktop notifications (0 disables)'],
235
+ ['USAGEFLEET_NOTIFY_THRESHOLDS', 'comma list of % alerts (default 80,95)'],
236
+ ['USAGEFLEET_HOOK', 'register the guard on install (0 skips)'],
237
+ ['USAGEFLEET_UPDATE', 'self-update while watching (0 disables)'],
238
+ ['USAGEFLEET_UPDATE_INTERVAL', 'seconds between update checks (default 21600)'],
239
+ ['USAGEFLEET_CONFIG', 'relocate the config file'],
240
+ ];
241
+ console.log(header());
242
+ console.log(hint('settings, tail offsets and notification marks'));
243
+ console.log('');
244
+ console.log(row('file', tilde(storePath())));
245
+ console.log('');
246
+ console.log(hint('env — overrides the file'));
247
+ print(env);
248
+ console.log('');
249
+ console.log(hint('`usagefleet status` shows the resolved values'));
250
+ }
251
+ /** Command list, in the same padded-column style as the result lines. */
205
252
  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)`);
253
+ const commands = [
254
+ ['run', 'scan once, upload usage + report limits'],
255
+ ['watch [--interval s]', 'poll continuously (default 15s)'],
256
+ ['limits', 'report only your real 5h/weekly usage'],
257
+ ['guard', 'exit 2 when the group is over a blocking limit'],
258
+ ['update', 'update to the latest release now'],
259
+ ['notify-test', 'fire a test desktop notification'],
260
+ ['status', 'service health, limits, resolved config'],
261
+ ['config', 'config file location and env overrides'],
262
+ ['version', 'print the release version'],
263
+ ['install --token <t>', 'configure + install the service and prompt guard'],
264
+ ['uninstall', 'remove the service and the guard'],
265
+ ];
266
+ console.log(header());
267
+ console.log('');
268
+ print(commands);
269
+ console.log('');
270
+ console.log(hint('`usagefleet config` lists the config file and its env overrides'));
236
271
  }
237
272
  async function main() {
238
273
  // Log-and-continue for the long-running watch daemon: a stray rejection must
239
274
  // not silently kill the background service. One-shot commands still set a
240
275
  // non-zero exit via their own error paths.
241
276
  process.on('unhandledRejection', reason => {
242
- console.error(`[${ts()}] unhandledRejection:`, reason);
277
+ line(yellow('!'), `unhandled rejection ${dim(String(reason))}`);
243
278
  });
244
279
  process.on('uncaughtException', err => {
245
- console.error(`[${ts()}] uncaughtException:`, err.message);
280
+ line(yellow('!'), `uncaught exception ${dim(err.message)}`);
246
281
  });
247
282
  const cmd = process.argv[2] ?? 'help';
248
283
  switch (cmd) {
@@ -260,7 +295,9 @@ async function main() {
260
295
  return;
261
296
  }
262
297
  case 'update': {
263
- await checkForUpdate(m => console.log(`[${ts()}] ${m}`), true);
298
+ console.log(header());
299
+ console.log('');
300
+ await checkForUpdate((level, m) => console.log(level === 'ok' ? step('update', m) : warn('update', m)), true);
264
301
  return;
265
302
  }
266
303
  case 'notify-test': {
@@ -269,6 +306,9 @@ async function main() {
269
306
  case 'status': {
270
307
  return cmdStatus();
271
308
  }
309
+ case 'config': {
310
+ return cmdConfig();
311
+ }
272
312
  // Bare version, so the installer can compare builds without parsing help.
273
313
  case 'version':
274
314
  case '--version':
@@ -276,12 +316,10 @@ async function main() {
276
316
  console.log(RELEASE_VERSION);
277
317
  return;
278
318
  }
279
- case 'init': {
280
- return cmdInit();
281
- }
319
+ // `init` was the separate config step; it now just does the whole setup.
320
+ case 'init':
282
321
  case 'install': {
283
- const { install } = await import('./service.js');
284
- return install();
322
+ return cmdInstall();
285
323
  }
286
324
  case 'uninstall': {
287
325
  const { uninstall } = await import('./service.js');
@@ -293,6 +331,6 @@ async function main() {
293
331
  }
294
332
  }
295
333
  main().catch(error => {
296
- console.error(error.message);
334
+ console.error(fail('error', error.message));
297
335
  process.exit(1);
298
336
  });
package/dist/notifier.js CHANGED
@@ -96,17 +96,17 @@ export function maybeNotify(report, cfg = loadNotifyConfig(), log = () => {
96
96
  const seven = evaluateWindow(state.sevenDay, report.sevenDayPct, report.sevenDayResetsAt, cfg.thresholds);
97
97
  if (five.fire != null) {
98
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}%)`);
99
+ log('ok', `notified · 5h at ${report.fiveHourPct}% · crossed ${five.fire}%`);
100
100
  }
101
101
  if (seven.fire != null) {
102
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}%)`);
103
+ log('ok', `notified · weekly at ${report.sevenDayPct}% · crossed ${seven.fire}%`);
104
104
  }
105
105
  updateStore(path, store => {
106
106
  store.notify = { fiveHour: five.next, sevenDay: seven.next };
107
107
  });
108
108
  }
109
109
  catch (error) {
110
- log(`notify skipped: ${error.message}`);
110
+ log('warn', `notify skipped · ${error.message}`);
111
111
  }
112
112
  }