usagemax 0.3.6 → 0.3.8

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
@@ -16,6 +16,10 @@ integrations, use the documented [OpenAPI contract](https://usagemax.com/openapi
16
16
  or the public [agent surfaces](https://usagemax.com/?mode=agent); the package
17
17
  itself is intended to be invoked as a short-lived local process.
18
18
 
19
+ See the repository's [SDK and integration surface](../../docs/sdk-ecosystem.md)
20
+ for the supported client matrix. UsageMax does not advertise Python or Go
21
+ packages until they are separately reviewed and published.
22
+
19
23
  ## Requirements
20
24
 
21
25
  - Node.js 20 or newer
@@ -26,22 +30,30 @@ itself is intended to be invoked as a short-lived local process.
26
30
 
27
31
  ```bash
28
32
  # Create a one-use code at https://usagemax.com/account.
29
- bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
33
+ bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
30
34
 
31
35
  # npm users can run the same one-shot command with npx.
32
- npx --yes usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
36
+ npx --yes usagemax link UMX-XXXX-XXXX-XXXX-XXXX
33
37
 
34
38
  # Preview, then upload changed local usage.
35
39
  bunx usagemax sync --dry-run --explain
36
40
  bunx usagemax sync
37
41
  ```
38
42
 
43
+ The CLI checks npm's `latest` dist-tag at most twice per day and never replaces
44
+ itself silently. Run `usagemax update sync` to hand a command to the current
45
+ release without typing `@latest`, or set `USAGEMAX_AUTO_UPDATE=1` for an
46
+ explicit automatic handoff. Use `--no-update-check` or set
47
+ `USAGEMAX_DISABLE_UPDATE_CHECK=1` in offline environments. Interactive
48
+ terminals show a small stderr progress line; JSON,
49
+ quiet, CI, and scheduled runs remain machine-readable and quiet.
50
+
39
51
  Agent-friendly checks can request JSON and keep the secret out of arguments and
40
52
  logs. This example only inspects local source coverage:
41
53
 
42
54
  ```bash
43
55
  set +x
44
- bunx usagemax doctor --deep --json | jq '{complete, sources: [.sources[] | {name, status}]}'
56
+ bunx usagemax doctor --deep --json | jq '{complete: .inventoryComplete, sources: .detectedSources}'
45
57
  ```
46
58
 
47
59
  The JSON shape is intended for local automation; unsupported or unavailable
@@ -80,11 +92,14 @@ bunx usagemax sync --restart # restart an expired saved upload
80
92
  bunx usagemax status --remote --json # remote check; secret is never printed
81
93
  bunx usagemax doctor --deep --json # parse and audit retained history
82
94
  bunx usagemax link UMX-… --no-sync # link without uploading yet
95
+ bunx usagemax --version --json # print CLI and ccusage versions
96
+ bunx usagemax update --json # machine-readable release check
97
+ bunx usagemax unlink --json # safe automation result; no secret output
83
98
  ```
84
99
 
85
100
  ## Sources and coverage
86
101
 
87
- UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
102
+ UsageMax pins [ccusage v20.0.23](https://github.com/ccusage/ccusage/releases/tag/v20.0.23)
88
103
  and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
89
104
  CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
90
105
  OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
@@ -115,14 +130,15 @@ stdin; never pass it as an argument or put it in a URL:
115
130
  ```bash
116
131
  set +x
117
132
  printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
118
- | bunx usagemax@latest token status \
133
+ | bunx usagemax token status \
119
134
  --device-id "$USAGEMAX_INSTALLATION_ID" \
120
135
  --json
121
136
  ```
122
137
 
123
- The `token status` command is included in CLI `0.3.6`. If the public npm tag
124
- does not yet contain `0.3.6`, run `node packages/cli/src/cli.js token status`
125
- from the UsageMax repository until that release is published.
138
+ The `token status` command is included in CLI `0.3.8`. If a fresh environment
139
+ still has an older npm tag, run `usagemax update token status` or
140
+ `node packages/cli/src/cli.js token status` from this repository until the new
141
+ package is published.
126
142
 
127
143
  The response is read-only and contains only status, type, scopes, profile/name,
128
144
  activation state, and a binding result of `unbound`, `bound`, `matched`, or
@@ -133,7 +149,7 @@ For a numeric-only result suitable for a smoke check:
133
149
  ```bash
134
150
  set +x
135
151
  printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
136
- | bunx usagemax@latest token status \
152
+ | bunx usagemax token status \
137
153
  --device-id "$USAGEMAX_INSTALLATION_ID" --json \
138
154
  | jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
139
155
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "keywords": [
6
6
  "usagemax",
@@ -38,6 +38,8 @@
38
38
  "src/installation.js",
39
39
  "src/sources.js",
40
40
  "src/transport.js",
41
+ "src/progress.js",
42
+ "src/updates.js",
41
43
  "src/resume.js",
42
44
  "src/service.js",
43
45
  "README.md",
@@ -55,6 +57,6 @@
55
57
  "prepublishOnly": "npm test"
56
58
  },
57
59
  "dependencies": {
58
- "ccusage": "20.0.20"
60
+ "ccusage": "20.0.23"
59
61
  }
60
62
  }
package/src/cli.js CHANGED
@@ -13,10 +13,12 @@ import { fileURLToPath } from "node:url";
13
13
  import { prepareArchiveRecovery } from "./archives.js";
14
14
  import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
15
15
  import { stableInstallationId } from "./installation.js";
16
+ import { createProgress } from "./progress.js";
16
17
  import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
17
18
  import { collectorStatusView, requestCollectorStatus, requestSnapshot } from "./transport.js";
18
19
  import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
19
20
  import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
21
+ import { checkForUpdate, runLatest } from "./updates.js";
20
22
 
21
23
  // Make the short-lived collector recognizable in Activity Monitor and `ps`.
22
24
  // Windows may still display the underlying node.exe image name in Task Manager.
@@ -24,7 +26,7 @@ process.title = "UsageMax";
24
26
 
25
27
  const require = createRequire(import.meta.url);
26
28
  const executeFile = promisify(execFile);
27
- const VERSION = "0.3.6";
29
+ const VERSION = "0.3.8";
28
30
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
29
31
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
30
32
  const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
@@ -118,17 +120,23 @@ function help() {
118
120
  process.stdout.write(" [--no-sync] [--name <name>]\n");
119
121
  process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
120
122
  process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
123
+ process.stdout.write(" [--quiet|--no-progress] [--check-updates|--no-update-check] Control progress and the cached release check\n");
124
+ process.stdout.write(" [--json] Emit one JSON object per link/sync phase\n");
121
125
  process.stdout.write(" usagemax status Show local link status\n");
122
126
  process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
127
+ process.stdout.write(" [--quiet|--no-progress] Disable interactive progress output\n");
123
128
  process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
124
129
  process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
125
130
  process.stdout.write(" usagemax service install [--every 15]\n");
126
131
  process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
127
132
  process.stdout.write(" usagemax service status|run|uninstall\n");
128
- process.stdout.write(" usagemax doctor [--deep] [--json]\n");
133
+ process.stdout.write(" usagemax doctor [--deep] [--json] [--quiet|--no-progress]\n");
129
134
  process.stdout.write(" Check source coverage; --deep parses full history\n");
135
+ process.stdout.write(" usagemax update [command args] Check npm and optionally run the latest CLI\n");
136
+ process.stdout.write(" [--json] Emit machine-readable update status\n");
130
137
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
131
138
  process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
139
+ process.stdout.write(" [--json] Emit machine-readable unlink status\n");
132
140
  }
133
141
 
134
142
  function ccusageCliPath() {
@@ -170,13 +178,14 @@ function newerVersion(recommended) {
170
178
 
171
179
  function warnVersion(body) {
172
180
  if (newerVersion(body?.recommendedCliVersion)) {
173
- process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`bunx usagemax@latest\` for current coverage fixes.\n`);
181
+ process.stderr.write(`UsageMax ${body.recommendedCliVersion} is available. Run \`usagemax update sync\` or set USAGEMAX_AUTO_UPDATE=1.\n`);
174
182
  }
175
183
  }
176
184
 
177
185
  async function link(args) {
178
186
  const code = normalizeLinkCode(args[0]);
179
187
  if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
188
+ const json = args.includes("--json");
180
189
  const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
181
190
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
182
191
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
@@ -218,28 +227,42 @@ async function link(args) {
218
227
  };
219
228
  await writeConfig(config);
220
229
  warnVersion(body);
221
- process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
230
+ if (json) {
231
+ process.stdout.write(`${JSON.stringify({ linked: true, version: VERSION, deviceName: savedName, profileHandle: config.profileHandle || null, profileUrl: config.profileUrl, deviceIdConfigured: true, sync: args.includes("--no-sync") ? "skipped" : "started" })}\n`);
232
+ } else {
233
+ process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
234
+ }
222
235
  if (args.includes("--no-sync")) {
223
- process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
236
+ if (!json) process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
224
237
  return;
225
238
  }
226
- process.stdout.write("Running the first one-shot sync…\n");
227
- await sync(["--full"], config);
239
+ if (!json) process.stdout.write("Running the first one-shot sync…\n");
240
+ await sync(["--full", ...(json ? ["--json"] : [])], config);
228
241
  }
229
242
 
230
243
  async function sync(args, suppliedConfig) {
231
- const baseEnv = await ccusageEnvironment();
232
- const recovery = args.includes("--archives")
233
- ? await prepareArchiveRecovery(baseEnv)
234
- : { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
244
+ const progress = createProgress({ json: args.includes("--json"), quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
245
+ progress.start("Preparing local usage sync…");
246
+ let recovery;
235
247
  try {
236
- return await syncPrepared(args, suppliedConfig, recovery);
248
+ const baseEnv = await ccusageEnvironment();
249
+ recovery = args.includes("--archives")
250
+ ? await prepareArchiveRecovery(baseEnv)
251
+ : { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported: 0 };
252
+ return await syncPrepared(args, suppliedConfig, recovery, progress);
253
+ } catch (error) {
254
+ // Clear the transient line; the shared top-level handler prints one
255
+ // stable error message so failures are not duplicated.
256
+ progress.stop();
257
+ throw error;
237
258
  } finally {
238
- await recovery.cleanup();
259
+ await recovery?.cleanup();
260
+ progress.stop();
239
261
  }
240
262
  }
241
263
 
242
- async function syncPrepared(args, suppliedConfig, recovery) {
264
+ async function syncPrepared(args, suppliedConfig, recovery, progress = createProgress({ noProgress: true })) {
265
+ const startedAt = Date.now();
243
266
  const config = suppliedConfig || await readConfig();
244
267
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
245
268
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
@@ -253,27 +276,42 @@ async function syncPrepared(args, suppliedConfig, recovery) {
253
276
  await writeConfig(config);
254
277
  }
255
278
  if (config.pendingSync) {
279
+ progress.update("Resuming the saved upload checkpoint…");
256
280
  if (dryRun) {
257
- const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
281
+ const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId, cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
258
282
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : `Dry run: saved run ${config.pendingSync.runId} awaits resume; no upload.\n`);
283
+ progress.succeed("Dry run complete; saved upload remains untouched.");
259
284
  return result;
260
285
  }
261
- const result = await resumeUpload(config, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
262
- process.stdout.write(json ? `${JSON.stringify(result)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
263
- return result;
286
+ const result = await resumeUpload(config, {
287
+ save: writeConfig,
288
+ request: snapshotRequest,
289
+ warn: warnVersion,
290
+ onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
291
+ });
292
+ const summary = { ...result, cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
293
+ progress.succeed("Resumed and completed the saved sync.");
294
+ process.stdout.write(json ? `${JSON.stringify(summary)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
295
+ return summary;
264
296
  }
265
297
  const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
298
+ progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
266
299
  const today = new Date().toISOString().slice(0, 10);
267
300
  const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
268
301
  const { bootstrap, full, skip, inventoryStable } = scanPolicy(config, inventory, {
269
302
  today, now: Date.now(), inventoryVersion: SOURCE_INVENTORY_VERSION, requestedFull, requestedArchives,
270
303
  });
271
304
  if (skip) {
272
- const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
305
+ const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial", skipReason: "inventory_unchanged", cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
273
306
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
274
- else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
307
+ else {
308
+ process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
309
+ if (explain) process.stdout.write(`Skipped because the source inventory fingerprint is unchanged; coverage remains ${result.coverage}.\n`);
310
+ }
311
+ progress.succeed("No local changes; upload skipped.");
275
312
  return;
276
313
  }
314
+ progress.update(`Parsing ${full ? "retained history" : "changed history"} with ccusage…`);
277
315
  const report = await ccusageJson(config, { env: recovery.env, full });
278
316
  // ccusage v20 exposes aggregates, not proof that every discovered file was
279
317
  // parsed. Inventory success alone cannot authorize destructive corrections.
@@ -309,17 +347,22 @@ async function syncPrepared(args, suppliedConfig, recovery) {
309
347
  coverage: authoritative ? "complete" : "partial",
310
348
  coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
311
349
  range: { from: days[0], to: days.at(-1) },
350
+ cliVersion: VERSION,
351
+ ccusageVersion: CCUSAGE_VERSION,
312
352
  };
353
+ progress.update(`Prepared ${partitions.length} usage chunk${partitions.length === 1 ? "" : "s"} and ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
313
354
  if (requestedArchives) {
314
355
  result.archives = recovery.archives;
315
356
  result.unsupportedArchives = recovery.unsupported;
316
357
  }
317
358
  if (dryRun) {
359
+ result.durationMs = Date.now() - startedAt;
318
360
  if (json) process.stdout.write(`${JSON.stringify({ ...result, dryRun: true })}\n`);
319
361
  else {
320
362
  process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
321
363
  if (explain) process.stdout.write(`Coverage ${result.coverage}; ${sources.length} source(s); ${days[0] || "unknown"} to ${days.at(-1) || "unknown"}; ${regressions.length} protected regression(s). ${result.coverageReason}\n`);
322
364
  }
365
+ progress.succeed("Dry run complete; nothing uploaded.");
323
366
  return result;
324
367
  }
325
368
  const requests = [{ operation: "begin", payload: {
@@ -356,7 +399,14 @@ async function syncPrepared(args, suppliedConfig, recovery) {
356
399
  };
357
400
  config.lastSyncComplete = false;
358
401
  await writeConfig(config);
359
- await resumeUpload(config, { save: writeConfig, request: snapshotRequest, warn: warnVersion });
402
+ await resumeUpload(config, {
403
+ save: writeConfig,
404
+ request: snapshotRequest,
405
+ warn: warnVersion,
406
+ onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
407
+ });
408
+ result.durationMs = Date.now() - startedAt;
409
+ progress.succeed(`Sync complete · ${partitions.length} chunk${partitions.length === 1 ? "" : "s"}, ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
360
410
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
361
411
  else {
362
412
  process.stdout.write(partitions.length || sessions.length
@@ -395,9 +445,10 @@ async function status(args = []) {
395
445
  const config = await readConfig();
396
446
  const remote = args.includes("--remote");
397
447
  const json = args.includes("--json");
448
+ const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
398
449
  if (!config) {
399
450
  if (json) {
400
- process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
451
+ process.stdout.write(`${JSON.stringify({ linked: false, version: VERSION, ccusageVersion: CCUSAGE_VERSION, configPath: configPath(), remote: remote ? { status: "not_linked" } : undefined })}\n`);
401
452
  return;
402
453
  }
403
454
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
@@ -410,20 +461,29 @@ async function status(args = []) {
410
461
  }
411
462
  const local = {
412
463
  linked: true,
464
+ version: VERSION,
465
+ ccusageVersion: CCUSAGE_VERSION,
466
+ configPath: configPath(),
413
467
  deviceName: config.deviceName || deviceLabel(),
414
468
  profileHandle: config.profileHandle || null,
415
469
  deviceIdConfigured: Boolean(config.deviceId),
416
470
  lastSyncAt: config.lastSyncAt || null,
417
471
  lastFullSyncAt: config.lastFullSyncAt || null,
418
472
  pendingSync: config.pendingSync?.runId || null,
473
+ coverage: config.lastCoverage || null,
474
+ knownSources: Array.isArray(config.knownSources) ? config.knownSources : [],
475
+ snapshotRows: Object.keys(config.snapshots || {}).length,
419
476
  profileUrl: config.profileUrl || "https://usagemax.com/account",
420
477
  };
421
478
  let remoteView;
422
479
  if (remote) {
480
+ progress.start("Checking the stored collector credential…");
423
481
  try {
424
482
  const result = await requestCollectorStatus(collectorStatusEndpoint(config), config);
425
483
  remoteView = collectorStatusView(result.httpStatus, result.body, config.token);
484
+ progress.succeed("Remote credential checked.");
426
485
  } catch (error) {
486
+ progress.stop();
427
487
  remoteView = { tokenFormat: "valid", status: "unavailable", reason: error instanceof Error ? error.message : "Collector status unavailable." };
428
488
  }
429
489
  if (json) {
@@ -436,8 +496,11 @@ async function status(args = []) {
436
496
  return;
437
497
  }
438
498
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
499
+ process.stdout.write(`CLI: ${VERSION} · ccusage ${CCUSAGE_VERSION}\n`);
439
500
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
440
501
  process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
502
+ process.stdout.write(`Coverage: ${config.lastCoverage || "unknown"} · ${Object.keys(config.snapshots || {}).length} checkpoint rows\n`);
503
+ if (Array.isArray(config.knownSources) && config.knownSources.length) process.stdout.write(`Known sources: ${config.knownSources.join(", ")}\n`);
441
504
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
442
505
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
443
506
  if (remote) printRemoteStatus(remoteView);
@@ -461,58 +524,87 @@ async function readTokenFromStdin() {
461
524
  async function tokenStatus(args = []) {
462
525
  const requestedDeviceId = option(args, "--device-id");
463
526
  if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
464
- const token = await readTokenFromStdin();
527
+ const json = args.includes("--json");
528
+ const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
529
+ progress.start("Checking the collector credential…");
530
+ let token;
531
+ try {
532
+ token = await readTokenFromStdin();
533
+ } catch (error) {
534
+ progress.stop();
535
+ throw error;
536
+ }
465
537
  const configuredEndpoint = process.env.USAGEMAX_STATUS_ENDPOINT || DEFAULT_STATUS_ENDPOINT;
466
538
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
467
- if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
468
- const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
469
- const view = collectorStatusView(result.httpStatus, result.body, token);
470
- if (args.includes("--json")) process.stdout.write(`${JSON.stringify(view)}\n`);
471
- else {
472
- process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
473
- printRemoteStatus(view);
539
+ try {
540
+ if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
541
+ const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
542
+ const view = collectorStatusView(result.httpStatus, result.body, token);
543
+ progress.succeed("Collector credential checked.");
544
+ if (json) process.stdout.write(`${JSON.stringify(view)}\n`);
545
+ else {
546
+ process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
547
+ printRemoteStatus(view);
548
+ }
549
+ } catch (error) {
550
+ progress.stop();
551
+ throw error;
474
552
  }
475
553
  }
476
554
 
477
555
  async function doctor(args = []) {
478
- const config = await readConfig();
479
- const env = await ccusageEnvironment();
480
- const inventory = await sourceInventory({ env, home: ccusageHome(env) });
481
- const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
482
- const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
483
- const result = {
484
- linked: Boolean(config),
485
- homes,
486
- detectedSources: inventory.sources,
487
- files: inventory.files,
488
- inventoryComplete: inventory.complete,
489
- inventoryErrors: inventory.errors,
490
- inventoryTruncated: inventory.truncated,
491
- supportedSources: SUPPORTED_SOURCES,
492
- archives: archives.length,
493
- environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
494
- mode: args.includes("--deep") ? "deep" : "metadata-only",
495
- };
496
- if (args.includes("--deep")) {
497
- const report = await ccusageJson(config, { env, full: true });
498
- result.parsedSources = sourceSummary(report);
499
- result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
500
- }
501
- if (args.includes("--json")) {
502
- process.stdout.write(`${JSON.stringify(result)}\n`);
503
- return;
556
+ const json = args.includes("--json");
557
+ const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
558
+ progress.start(args.includes("--deep") ? "Auditing retained source history…" : "Inspecting local source coverage…");
559
+ try {
560
+ const config = await readConfig();
561
+ const env = await ccusageEnvironment();
562
+ const inventory = await sourceInventory({ env, home: ccusageHome(env) });
563
+ progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
564
+ const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
565
+ const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
566
+ const result = {
567
+ cliVersion: VERSION,
568
+ ccusageVersion: CCUSAGE_VERSION,
569
+ configPath: configPath(),
570
+ linked: Boolean(config),
571
+ homes,
572
+ detectedSources: inventory.sources,
573
+ files: inventory.files,
574
+ inventoryComplete: inventory.complete,
575
+ inventoryErrors: inventory.errors,
576
+ inventoryTruncated: inventory.truncated,
577
+ supportedSources: SUPPORTED_SOURCES,
578
+ archives: archives.length,
579
+ environment: platform() === "linux" && process.env.WSL_DISTRO_NAME ? `WSL ${process.env.WSL_DISTRO_NAME}` : platform(),
580
+ mode: args.includes("--deep") ? "deep" : "metadata-only",
581
+ };
582
+ if (args.includes("--deep")) {
583
+ progress.update("Parsing retained history with ccusage…");
584
+ const report = await ccusageJson(config, { env, full: true });
585
+ result.parsedSources = sourceSummary(report);
586
+ result.sessions = buildSessionPlan(report, config?.deviceId || "unlinked").length;
587
+ }
588
+ progress.succeed("Coverage check complete.");
589
+ if (json) {
590
+ process.stdout.write(`${JSON.stringify(result)}\n`);
591
+ return;
592
+ }
593
+ process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
594
+ process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
595
+ process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
596
+ process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
597
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
598
+ process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
599
+ }
600
+ if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
601
+ if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
602
+ if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
603
+ process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
604
+ } catch (error) {
605
+ progress.stop();
606
+ throw error;
504
607
  }
505
- process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
506
- process.stdout.write(`Discovered homes: ${homes.length} (${homes.join(", ")})\n`);
507
- process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
508
- process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
509
- if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
510
- process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
511
- }
512
- if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
513
- if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
514
- if (result.parsedSources) process.stdout.write(`Parsed sources: ${result.parsedSources.join(", ") || "none"}; ${result.sessions} private session identifiers\n`);
515
- process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
516
608
  }
517
609
 
518
610
  async function report(args) {
@@ -528,14 +620,55 @@ async function report(args) {
528
620
  if (code !== 0) process.exitCode = code;
529
621
  }
530
622
 
623
+ async function update(args = []) {
624
+ const json = args.includes("--json");
625
+ const check = await checkForUpdate(configDirectory(), VERSION, { force: true });
626
+ if (!check.latest) {
627
+ const result = { version: VERSION, latest: null, newer: false, status: "unavailable" };
628
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} · update check unavailable.\n`);
629
+ return;
630
+ }
631
+ if (!check.newer) {
632
+ const result = { version: VERSION, latest: check.latest, newer: false, status: "current" };
633
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} is current.\n`);
634
+ return;
635
+ }
636
+ const result = { version: VERSION, latest: check.latest, newer: true, status: "available", command: "usagemax update sync" };
637
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
638
+ const commandArgs = args.filter((arg) => arg !== "--check");
639
+ if (!commandArgs.length || args.includes("--check")) {
640
+ if (!json) process.stdout.write("Run `usagemax update sync` to hand off the next command to the latest npm release.\n");
641
+ return;
642
+ }
643
+ if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
644
+ return runLatest(commandArgs);
645
+ }
646
+
647
+ async function maybeUpdate(command, args) {
648
+ if (process.env.USAGEMAX_UPDATE_HANDOFF === "1" || process.env.USAGEMAX_DISABLE_UPDATE_CHECK === "1" || args.includes("--no-update-check")) return false;
649
+ if (!["sync", "link", "doctor"].includes(command)) return false;
650
+ const explicitCheck = args.includes("--check-updates");
651
+ if (!explicitCheck && args.includes("--json")) return false;
652
+ const check = await checkForUpdate(configDirectory(), VERSION, { force: explicitCheck });
653
+ if (!check.newer) return false;
654
+ if (process.env.USAGEMAX_AUTO_UPDATE === "1") {
655
+ await runLatest([command, ...args.filter((arg) => !["--check-updates", "--no-update-check"].includes(arg))]);
656
+ return true;
657
+ }
658
+ process.stderr.write(`UsageMax ${check.latest} is available. Run \`usagemax update ${command}\` or set USAGEMAX_AUTO_UPDATE=1.\n`);
659
+ return false;
660
+ }
661
+
531
662
  async function removeLink(args = []) {
532
663
  const path = configPath();
533
664
  const config = await readConfig();
665
+ const json = args.includes("--json");
534
666
  if (!config) {
535
- process.stdout.write("This computer is not linked.\n");
667
+ process.stdout.write(json ? `${JSON.stringify({ unlinked: false, reason: "not_linked" })}\n` : "This computer is not linked.\n");
536
668
  return;
537
669
  }
538
- if (args.includes("--revoke")) {
670
+ const revoked = args.includes("--revoke");
671
+ if (revoked) {
539
672
  const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
540
673
  if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
541
674
  const response = await fetch(endpoint, {
@@ -549,16 +682,22 @@ async function removeLink(args = []) {
549
682
  if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
550
683
  }
551
684
  await unlink(path);
552
- process.stdout.write(args.includes("--revoke")
553
- ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
554
- : "Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
685
+ if (json) {
686
+ process.stdout.write(`${JSON.stringify({ unlinked: true, revoked, retainedInstallationIdentity: true })}\n`);
687
+ } else {
688
+ process.stdout.write(revoked
689
+ ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
690
+ : "Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
691
+ }
555
692
  }
556
693
 
557
694
  async function main() {
558
695
  const args = process.argv.slice(2);
559
696
  const command = args[0] || "sync";
560
697
  if (["--help", "-h", "help"].includes(command)) return help();
561
- if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
698
+ if (["--version", "-v"].includes(command)) return process.stdout.write(args.includes("--json") ? `${JSON.stringify({ version: VERSION, ccusageVersion: CCUSAGE_VERSION })}\n` : `${VERSION}\n`);
699
+ if (command === "update") return update(args.slice(1));
700
+ if (await maybeUpdate(command, args)) return;
562
701
  if (command === "service") {
563
702
  const action = args[1] || "status";
564
703
  const directory = option(args, "--config-dir") || configDirectory();
@@ -0,0 +1,73 @@
1
+ const FRAMES = ["·", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+
3
+ function canAnimate({ json = false, quiet = false, noProgress = false } = {}) {
4
+ return Boolean(process.stderr.isTTY)
5
+ && !json
6
+ && !quiet
7
+ && !noProgress
8
+ && process.env.USAGEMAX_NO_PROGRESS !== "1"
9
+ && process.env.CI !== "true";
10
+ }
11
+
12
+ /**
13
+ * A tiny stderr-only progress reporter. JSON and scheduled runs stay silent so
14
+ * stdout remains machine-readable and background jobs do not write a stream.
15
+ */
16
+ export function createProgress(options = {}) {
17
+ const enabled = canAnimate(options);
18
+ let timer;
19
+ let frame = 0;
20
+ let active = false;
21
+ let last = "";
22
+ let startedAt = 0;
23
+
24
+ const elapsed = () => {
25
+ const seconds = Math.max(0, (Date.now() - startedAt) / 1000);
26
+ return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
27
+ };
28
+
29
+ const render = (text) => {
30
+ if (!enabled || !active) return;
31
+ const line = `${FRAMES[frame % FRAMES.length]} ${text} · ${elapsed()}`;
32
+ frame += 1;
33
+ last = line;
34
+ process.stderr.write(`\r\x1b[2K${line}`);
35
+ };
36
+
37
+ return {
38
+ enabled,
39
+ start(text) {
40
+ if (!enabled) return;
41
+ active = true;
42
+ startedAt = Date.now();
43
+ render(text);
44
+ timer = setInterval(() => render(last.replace(/^[^ ]+ /, "").replace(/ · \d+(?:\.\d+)?s$/, "")), 120);
45
+ timer.unref?.();
46
+ },
47
+ update(text) {
48
+ if (!enabled) return;
49
+ if (!active) this.start(text);
50
+ else render(text);
51
+ },
52
+ succeed(text) {
53
+ if (!enabled) return;
54
+ const duration = elapsed();
55
+ this.stop();
56
+ process.stderr.write(`\r\x1b[2K✓ ${text} (${duration})\n`);
57
+ },
58
+ fail(text) {
59
+ if (!enabled) return;
60
+ const duration = elapsed();
61
+ this.stop();
62
+ process.stderr.write(`\r\x1b[2K✗ ${text} (${duration})\n`);
63
+ },
64
+ stop() {
65
+ if (!enabled) return;
66
+ if (timer) clearInterval(timer);
67
+ timer = undefined;
68
+ if (active) process.stderr.write("\r\x1b[2K");
69
+ active = false;
70
+ startedAt = 0;
71
+ },
72
+ };
73
+ }
package/src/resume.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // A pending run is saved before network I/O. Persisting the acknowledged cursor
2
2
  // after each request permits replays when the response or local write is lost.
3
3
  // The server must receipt begin, chunks, sessions and complete idempotently.
4
- export async function resumeUpload(config, { save, request, warn = () => {} }) {
4
+ export async function resumeUpload(config, { save, request, warn = () => {}, onProgress = () => {} }) {
5
5
  const pending = config.pendingSync;
6
6
  if (!pending || pending.version !== 1 || !Array.isArray(pending.requests)) {
7
7
  throw new Error("Invalid saved sync; preserve the config for recovery.");
@@ -9,10 +9,12 @@ export async function resumeUpload(config, { save, request, warn = () => {} }) {
9
9
  try {
10
10
  for (let index = pending.cursor; index < pending.requests.length; index += 1) {
11
11
  const { operation, payload } = pending.requests[index];
12
+ onProgress({ index: index + 1, total: pending.requests.length, operation });
12
13
  const response = await request(config, operation, payload, operation === "partitions" ? 60_000 : 30_000);
13
14
  warn(response);
14
15
  pending.cursor = index + 1;
15
16
  await save(config);
17
+ onProgress({ index: index + 1, total: pending.requests.length, operation, acknowledged: true });
16
18
  }
17
19
  // Commit local baseline and remove the journal in the same atomic write.
18
20
  const next = { ...config, ...pending.checkpoint };
package/src/sources.js CHANGED
@@ -3,7 +3,7 @@ import { readFile, readdir, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
 
6
- export const CCUSAGE_VERSION = "20.0.20";
6
+ export const CCUSAGE_VERSION = "20.0.23";
7
7
  export const SOURCE_INVENTORY_VERSION = 3;
8
8
  export const SUPPORTED_SOURCES = [
9
9
  "amp",
package/src/updates.js ADDED
@@ -0,0 +1,99 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+
6
+ export const REGISTRY_URL = "https://registry.npmjs.org/usagemax/latest";
7
+ export const UPDATE_CHECK_TTL_MS = 12 * 60 * 60 * 1000;
8
+
9
+ function record(value) {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
11
+ }
12
+
13
+ export function compareVersions(left, right) {
14
+ const a = String(left || "").split(".").map(Number);
15
+ const b = String(right || "").split(".").map(Number);
16
+ if (a.length !== 3 || b.length !== 3 || a.some((part) => !Number.isInteger(part)) || b.some((part) => !Number.isInteger(part))) return 0;
17
+ for (let index = 0; index < 3; index += 1) {
18
+ if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function cachePath(directory) {
24
+ return join(directory, "update-check.json");
25
+ }
26
+
27
+ async function readCache(directory) {
28
+ try {
29
+ const value = record(JSON.parse(await readFile(cachePath(directory), "utf8")));
30
+ if (!value || !Number.isFinite(value.checkedAt)) return null;
31
+ return value;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function writeCache(directory, value) {
38
+ await mkdir(dirname(cachePath(directory)), { recursive: true, mode: 0o700 });
39
+ const path = cachePath(directory);
40
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
41
+ try {
42
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
43
+ await rename(temporary, path);
44
+ } finally {
45
+ await unlink(temporary).catch(() => undefined);
46
+ }
47
+ }
48
+
49
+ export async function latestVersion({ fetchImpl = fetch, timeout = 1_500 } = {}) {
50
+ try {
51
+ const response = await fetchImpl(REGISTRY_URL, {
52
+ headers: { accept: "application/json" },
53
+ cache: "no-store",
54
+ signal: AbortSignal.timeout(timeout),
55
+ });
56
+ if (!response.ok) return null;
57
+ const body = record(await response.json());
58
+ return /^\d+\.\d+\.\d+$/.test(body?.version || "") ? body.version : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check npm at most twice a day. A failed check is deliberately non-fatal.
66
+ * The result contains no account data and is safe to cache locally.
67
+ */
68
+ export async function checkForUpdate(directory, currentVersion, { force = false, now = Date.now(), fetchImpl = fetch } = {}) {
69
+ const cached = await readCache(directory);
70
+ if (!force && cached && now - cached.checkedAt < UPDATE_CHECK_TTL_MS) {
71
+ return { ...cached, newer: compareVersions(cached.latest, currentVersion) > 0 };
72
+ }
73
+ const latest = await latestVersion({ fetchImpl });
74
+ const result = { checkedAt: now, latest: latest || cached?.latest || null };
75
+ await writeCache(directory, result).catch(() => undefined);
76
+ return { ...result, newer: compareVersions(result.latest, currentVersion) > 0 };
77
+ }
78
+
79
+ function runner() {
80
+ const override = process.env.USAGEMAX_PACKAGE_RUNNER?.trim();
81
+ if (override) return { command: override, prefix: [] };
82
+ if (process.env.npm_execpath) return { command: "npm", prefix: ["exec", "--yes"] };
83
+ return { command: "bunx", prefix: ["--bun"] };
84
+ }
85
+
86
+ /** Re-run a command through the current npm dist-tag without requiring @latest. */
87
+ export async function runLatest(args, { spawnImpl = spawn } = {}) {
88
+ const selected = runner();
89
+ const child = spawnImpl(selected.command, [...selected.prefix, "usagemax@latest", "--", ...args], {
90
+ stdio: "inherit",
91
+ env: { ...process.env, USAGEMAX_UPDATE_HANDOFF: "1" },
92
+ });
93
+ const code = await new Promise((resolve, reject) => {
94
+ child.once("error", reject);
95
+ child.once("exit", (status) => resolve(status ?? 1));
96
+ });
97
+ process.exitCode = code;
98
+ return code;
99
+ }