usagemax 0.3.7 → 0.3.9

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
@@ -40,10 +40,12 @@ bunx usagemax sync --dry-run --explain
40
40
  bunx usagemax sync
41
41
  ```
42
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
43
+ The CLI checks npm's `latest` dist-tag at most twice per day. Run
44
+ `usagemax update` to update a global installation through the package manager
45
+ that launched it (Bun or npm). Add `--check` for a read-only check, or
46
+ `--json` for automation. A command suffix such as `usagemax update sync` hands
47
+ that command to the newest release without typing `@latest`. Set
48
+ `USAGEMAX_AUTO_UPDATE=1` for an explicit automatic handoff. Use `--no-update-check` or set
47
49
  `USAGEMAX_DISABLE_UPDATE_CHECK=1` in offline environments. Interactive
48
50
  terminals show a small stderr progress line; JSON,
49
51
  quiet, CI, and scheduled runs remain machine-readable and quiet.
@@ -53,7 +55,7 @@ logs. This example only inspects local source coverage:
53
55
 
54
56
  ```bash
55
57
  set +x
56
- bunx usagemax doctor --deep --json | jq '{complete, sources: [.sources[] | {name, status}]}'
58
+ bunx usagemax doctor --deep --json | jq '{complete: .inventoryComplete, sources: .detectedSources}'
57
59
  ```
58
60
 
59
61
  The JSON shape is intended for local automation; unsupported or unavailable
@@ -81,6 +83,9 @@ usagemax token status Diagnose a key piped on stdin
81
83
  usagemax service install Opt into periodic OS checkpoints
82
84
  usagemax service status|run|uninstall
83
85
  usagemax unlink [--revoke] Remove local credentials
86
+ usagemax update Update the global CLI through Bun or npm
87
+ usagemax update --check Check without installing
88
+ usagemax update sync Run sync through the newest CLI release
84
89
  ```
85
90
 
86
91
  Useful options:
@@ -92,11 +97,15 @@ bunx usagemax sync --restart # restart an expired saved upload
92
97
  bunx usagemax status --remote --json # remote check; secret is never printed
93
98
  bunx usagemax doctor --deep --json # parse and audit retained history
94
99
  bunx usagemax link UMX-… --no-sync # link without uploading yet
100
+ bunx usagemax --version --json # print CLI and ccusage versions
101
+ bunx usagemax update --json # update and return machine-readable status
102
+ bunx usagemax update --check --json # check without installing
103
+ bunx usagemax unlink --json # safe automation result; no secret output
95
104
  ```
96
105
 
97
106
  ## Sources and coverage
98
107
 
99
- UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
108
+ UsageMax pins [ccusage v20.0.23](https://github.com/ccusage/ccusage/releases/tag/v20.0.23)
100
109
  and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
101
110
  CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
102
111
  OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
@@ -132,7 +141,7 @@ printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
132
141
  --json
133
142
  ```
134
143
 
135
- The `token status` command is included in CLI `0.3.7`. If a fresh environment
144
+ The `token status` command is included in CLI `0.3.9`. If a fresh environment
136
145
  still has an older npm tag, run `usagemax update token status` or
137
146
  `node packages/cli/src/cli.js token status` from this repository until the new
138
147
  package is published.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "keywords": [
6
6
  "usagemax",
@@ -57,6 +57,6 @@
57
57
  "prepublishOnly": "npm test"
58
58
  },
59
59
  "dependencies": {
60
- "ccusage": "20.0.20"
60
+ "ccusage": "20.0.23"
61
61
  }
62
62
  }
package/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
18
18
  import { collectorStatusView, requestCollectorStatus, requestSnapshot } from "./transport.js";
19
19
  import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
20
20
  import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
21
- import { checkForUpdate, runLatest } from "./updates.js";
21
+ import { checkForUpdate, packageManagerFor, runLatest, updateGlobal } from "./updates.js";
22
22
 
23
23
  // Make the short-lived collector recognizable in Activity Monitor and `ps`.
24
24
  // Windows may still display the underlying node.exe image name in Task Manager.
@@ -26,7 +26,7 @@ process.title = "UsageMax";
26
26
 
27
27
  const require = createRequire(import.meta.url);
28
28
  const executeFile = promisify(execFile);
29
- const VERSION = "0.3.7";
29
+ const VERSION = "0.3.9";
30
30
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
31
31
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
32
32
  const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
@@ -121,6 +121,7 @@ function help() {
121
121
  process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
122
122
  process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
123
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");
124
125
  process.stdout.write(" usagemax status Show local link status\n");
125
126
  process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
126
127
  process.stdout.write(" [--quiet|--no-progress] Disable interactive progress output\n");
@@ -131,9 +132,12 @@ function help() {
131
132
  process.stdout.write(" usagemax service status|run|uninstall\n");
132
133
  process.stdout.write(" usagemax doctor [--deep] [--json] [--quiet|--no-progress]\n");
133
134
  process.stdout.write(" Check source coverage; --deep parses full history\n");
134
- process.stdout.write(" usagemax update [command args] Check npm and optionally run the latest CLI\n");
135
+ process.stdout.write(" usagemax update Update the global CLI through Bun or npm\n");
136
+ process.stdout.write(" [--check] [--json] Check only or emit machine-readable status\n");
137
+ process.stdout.write(" usagemax update <command args> Run a command through the latest CLI\n");
135
138
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
136
139
  process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
140
+ process.stdout.write(" [--json] Emit machine-readable unlink status\n");
137
141
  }
138
142
 
139
143
  function ccusageCliPath() {
@@ -182,6 +186,7 @@ function warnVersion(body) {
182
186
  async function link(args) {
183
187
  const code = normalizeLinkCode(args[0]);
184
188
  if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
189
+ const json = args.includes("--json");
185
190
  const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
186
191
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
187
192
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
@@ -223,13 +228,17 @@ async function link(args) {
223
228
  };
224
229
  await writeConfig(config);
225
230
  warnVersion(body);
226
- process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
231
+ if (json) {
232
+ 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`);
233
+ } else {
234
+ process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
235
+ }
227
236
  if (args.includes("--no-sync")) {
228
- process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
237
+ if (!json) process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
229
238
  return;
230
239
  }
231
- process.stdout.write("Running the first one-shot sync…\n");
232
- await sync(["--full"], config);
240
+ if (!json) process.stdout.write("Running the first one-shot sync…\n");
241
+ await sync(["--full", ...(json ? ["--json"] : [])], config);
233
242
  }
234
243
 
235
244
  async function sync(args, suppliedConfig) {
@@ -254,6 +263,7 @@ async function sync(args, suppliedConfig) {
254
263
  }
255
264
 
256
265
  async function syncPrepared(args, suppliedConfig, recovery, progress = createProgress({ noProgress: true })) {
266
+ const startedAt = Date.now();
257
267
  const config = suppliedConfig || await readConfig();
258
268
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
259
269
  config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
@@ -269,7 +279,7 @@ async function syncPrepared(args, suppliedConfig, recovery, progress = createPro
269
279
  if (config.pendingSync) {
270
280
  progress.update("Resuming the saved upload checkpoint…");
271
281
  if (dryRun) {
272
- const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId };
282
+ const result = { ...config.pendingSync.result, dryRun: true, pendingRunId: config.pendingSync.runId, cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
273
283
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : `Dry run: saved run ${config.pendingSync.runId} awaits resume; no upload.\n`);
274
284
  progress.succeed("Dry run complete; saved upload remains untouched.");
275
285
  return result;
@@ -280,9 +290,10 @@ async function syncPrepared(args, suppliedConfig, recovery, progress = createPro
280
290
  warn: warnVersion,
281
291
  onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
282
292
  });
293
+ const summary = { ...result, cliVersion: VERSION, ccusageVersion: CCUSAGE_VERSION, durationMs: Date.now() - startedAt };
283
294
  progress.succeed("Resumed and completed the saved sync.");
284
- process.stdout.write(json ? `${JSON.stringify(result)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
285
- return result;
295
+ process.stdout.write(json ? `${JSON.stringify(summary)}\n` : "Resumed and completed the saved sync. Run sync again to scan newer local changes.\n");
296
+ return summary;
286
297
  }
287
298
  const inventory = await sourceInventory({ env: recovery.env, home: ccusageHome(recovery.env) });
288
299
  progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
@@ -292,9 +303,12 @@ async function syncPrepared(args, suppliedConfig, recovery, progress = createPro
292
303
  today, now: Date.now(), inventoryVersion: SOURCE_INVENTORY_VERSION, requestedFull, requestedArchives,
293
304
  });
294
305
  if (skip) {
295
- const result = { accepted: 0, changedRows: 0, sessions: 0, sources: inventory.sources, corrections: 0, scanned: false, full: false, coverage: config.lastCoverage || "partial" };
306
+ 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 };
296
307
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
297
- else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
308
+ else {
309
+ process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
310
+ if (explain) process.stdout.write(`Skipped because the source inventory fingerprint is unchanged; coverage remains ${result.coverage}.\n`);
311
+ }
298
312
  progress.succeed("No local changes; upload skipped.");
299
313
  return;
300
314
  }
@@ -334,6 +348,8 @@ async function syncPrepared(args, suppliedConfig, recovery, progress = createPro
334
348
  coverage: authoritative ? "complete" : "partial",
335
349
  coverageReason: "Parser does not certify complete source/day coverage; decreases and deletions are protected.",
336
350
  range: { from: days[0], to: days.at(-1) },
351
+ cliVersion: VERSION,
352
+ ccusageVersion: CCUSAGE_VERSION,
337
353
  };
338
354
  progress.update(`Prepared ${partitions.length} usage chunk${partitions.length === 1 ? "" : "s"} and ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
339
355
  if (requestedArchives) {
@@ -341,6 +357,7 @@ async function syncPrepared(args, suppliedConfig, recovery, progress = createPro
341
357
  result.unsupportedArchives = recovery.unsupported;
342
358
  }
343
359
  if (dryRun) {
360
+ result.durationMs = Date.now() - startedAt;
344
361
  if (json) process.stdout.write(`${JSON.stringify({ ...result, dryRun: true })}\n`);
345
362
  else {
346
363
  process.stdout.write(`Dry run: ${partitions.length} partition(s), ${result.changedRows} changed row(s), ${sessions.length} private session identifiers, no upload.\n`);
@@ -389,6 +406,7 @@ async function syncPrepared(args, suppliedConfig, recovery, progress = createPro
389
406
  warn: warnVersion,
390
407
  onProgress: ({ index, total, operation, acknowledged }) => progress.update(`${acknowledged ? "Uploaded" : "Uploading"} ${index}/${total} · ${operation}`),
391
408
  });
409
+ result.durationMs = Date.now() - startedAt;
392
410
  progress.succeed(`Sync complete · ${partitions.length} chunk${partitions.length === 1 ? "" : "s"}, ${sessions.length} session identifier${sessions.length === 1 ? "" : "s"}.`);
393
411
  if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
394
412
  else {
@@ -431,7 +449,7 @@ async function status(args = []) {
431
449
  const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
432
450
  if (!config) {
433
451
  if (json) {
434
- process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
452
+ process.stdout.write(`${JSON.stringify({ linked: false, version: VERSION, ccusageVersion: CCUSAGE_VERSION, configPath: configPath(), remote: remote ? { status: "not_linked" } : undefined })}\n`);
435
453
  return;
436
454
  }
437
455
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
@@ -444,12 +462,18 @@ async function status(args = []) {
444
462
  }
445
463
  const local = {
446
464
  linked: true,
465
+ version: VERSION,
466
+ ccusageVersion: CCUSAGE_VERSION,
467
+ configPath: configPath(),
447
468
  deviceName: config.deviceName || deviceLabel(),
448
469
  profileHandle: config.profileHandle || null,
449
470
  deviceIdConfigured: Boolean(config.deviceId),
450
471
  lastSyncAt: config.lastSyncAt || null,
451
472
  lastFullSyncAt: config.lastFullSyncAt || null,
452
473
  pendingSync: config.pendingSync?.runId || null,
474
+ coverage: config.lastCoverage || null,
475
+ knownSources: Array.isArray(config.knownSources) ? config.knownSources : [],
476
+ snapshotRows: Object.keys(config.snapshots || {}).length,
453
477
  profileUrl: config.profileUrl || "https://usagemax.com/account",
454
478
  };
455
479
  let remoteView;
@@ -473,8 +497,11 @@ async function status(args = []) {
473
497
  return;
474
498
  }
475
499
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
500
+ process.stdout.write(`CLI: ${VERSION} · ccusage ${CCUSAGE_VERSION}\n`);
476
501
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
477
502
  process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
503
+ process.stdout.write(`Coverage: ${config.lastCoverage || "unknown"} · ${Object.keys(config.snapshots || {}).length} checkpoint rows\n`);
504
+ if (Array.isArray(config.knownSources) && config.knownSources.length) process.stdout.write(`Known sources: ${config.knownSources.join(", ")}\n`);
478
505
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
479
506
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
480
507
  if (remote) printRemoteStatus(remoteView);
@@ -538,6 +565,9 @@ async function doctor(args = []) {
538
565
  const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
539
566
  const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
540
567
  const result = {
568
+ cliVersion: VERSION,
569
+ ccusageVersion: CCUSAGE_VERSION,
570
+ configPath: configPath(),
541
571
  linked: Boolean(config),
542
572
  homes,
543
573
  detectedSources: inventory.sources,
@@ -592,23 +622,46 @@ async function report(args) {
592
622
  }
593
623
 
594
624
  async function update(args = []) {
625
+ const json = args.includes("--json");
626
+ const checkOnly = args.includes("--check") || args.includes("--no-install");
627
+ const commandArgs = args.filter((arg) => !["--check", "--no-install", "--json"].includes(arg));
628
+ const handoffArgs = commandArgs.length ? [...commandArgs, ...(json ? ["--json"] : [])] : [];
595
629
  const check = await checkForUpdate(configDirectory(), VERSION, { force: true });
596
630
  if (!check.latest) {
597
- process.stdout.write(`UsageMax CLI ${VERSION} · update check unavailable.\n`);
631
+ const result = { version: VERSION, latest: null, newer: false, status: "unavailable" };
632
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} · update check unavailable.\n`);
598
633
  return;
599
634
  }
600
635
  if (!check.newer) {
601
- process.stdout.write(`UsageMax CLI ${VERSION} is current.\n`);
636
+ const result = { version: VERSION, latest: check.latest, newer: false, status: "current" };
637
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} is current.\n`);
638
+ return;
639
+ }
640
+ if (handoffArgs.length) {
641
+ const result = { version: VERSION, latest: check.latest, newer: true, status: "available", command: `usagemax update ${commandArgs.join(" ")}` };
642
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
643
+ if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
644
+ return runLatest(handoffArgs);
645
+ }
646
+ const manager = packageManagerFor({ cliPath: process.argv[1], runtime: process.versions, env: process.env });
647
+ const installCommand = manager === "bun" ? "bun add --global usagemax@latest" : "npm install --global usagemax@latest";
648
+ const available = { version: VERSION, latest: check.latest, newer: true, status: "available", manager, installCommand };
649
+ if (checkOnly) {
650
+ process.stdout.write(json ? `${JSON.stringify(available)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}). Run \`${installCommand}\`.\n`);
602
651
  return;
603
652
  }
604
- process.stdout.write(`UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
605
- const commandArgs = args.filter((arg) => arg !== "--check");
606
- if (!commandArgs.length || args.includes("--check")) {
607
- process.stdout.write("Run `usagemax update sync` to hand off the next command to the latest npm release.\n");
653
+ if (!json) process.stdout.write(`Updating UsageMax ${VERSION} ${check.latest} via ${manager}…\n`);
654
+ try {
655
+ await updateGlobal({ latest: check.latest, manager, quiet: json });
656
+ } catch (error) {
657
+ const result = { ...available, status: "failed", error: error instanceof Error ? error.message : String(error) };
658
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
659
+ else process.stderr.write(`UsageMax update failed: ${result.error}\n`);
660
+ process.exitCode = 1;
608
661
  return;
609
662
  }
610
- process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
611
- return runLatest(commandArgs);
663
+ const result = { ...available, status: "updated", installedVersion: check.latest };
664
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI is now ${check.latest}.\n`);
612
665
  }
613
666
 
614
667
  async function maybeUpdate(command, args) {
@@ -629,11 +682,13 @@ async function maybeUpdate(command, args) {
629
682
  async function removeLink(args = []) {
630
683
  const path = configPath();
631
684
  const config = await readConfig();
685
+ const json = args.includes("--json");
632
686
  if (!config) {
633
- process.stdout.write("This computer is not linked.\n");
687
+ process.stdout.write(json ? `${JSON.stringify({ unlinked: false, reason: "not_linked" })}\n` : "This computer is not linked.\n");
634
688
  return;
635
689
  }
636
- if (args.includes("--revoke")) {
690
+ const revoked = args.includes("--revoke");
691
+ if (revoked) {
637
692
  const endpoint = validHttpsUrl(config.revokeUrl, { allowLocalhost: true });
638
693
  if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
639
694
  const response = await fetch(endpoint, {
@@ -647,16 +702,20 @@ async function removeLink(args = []) {
647
702
  if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
648
703
  }
649
704
  await unlink(path);
650
- process.stdout.write(args.includes("--revoke")
651
- ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
652
- : "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");
705
+ if (json) {
706
+ process.stdout.write(`${JSON.stringify({ unlinked: true, revoked, retainedInstallationIdentity: true })}\n`);
707
+ } else {
708
+ process.stdout.write(revoked
709
+ ? "Revoked this collector and removed its local key. Existing usage totals were retained.\n"
710
+ : "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");
711
+ }
653
712
  }
654
713
 
655
714
  async function main() {
656
715
  const args = process.argv.slice(2);
657
716
  const command = args[0] || "sync";
658
717
  if (["--help", "-h", "help"].includes(command)) return help();
659
- if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
718
+ if (["--version", "-v"].includes(command)) return process.stdout.write(args.includes("--json") ? `${JSON.stringify({ version: VERSION, ccusageVersion: CCUSAGE_VERSION })}\n` : `${VERSION}\n`);
660
719
  if (command === "update") return update(args.slice(1));
661
720
  if (await maybeUpdate(command, args)) return;
662
721
  if (command === "service") {
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 CHANGED
@@ -83,6 +83,43 @@ function runner() {
83
83
  return { command: "bunx", prefix: ["--bun"] };
84
84
  }
85
85
 
86
+ /**
87
+ * Pick the package manager that owns the current invocation. Bun's global and
88
+ * ephemeral package paths are explicit; npm remains the safe default for a
89
+ * normal Node installation. The override is useful for managed environments
90
+ * where the executable is wrapped by a fleet-specific package manager.
91
+ */
92
+ export function packageManagerFor({ cliPath = process.argv[1], runtime = process.versions, env = process.env } = {}) {
93
+ const override = String(env.USAGEMAX_PACKAGE_MANAGER || "").trim().toLowerCase();
94
+ if (override === "bun" || override === "npm") return override;
95
+ const normalizedPath = String(cliPath || "").replaceAll("\\", "/");
96
+ if (normalizedPath.includes("/.bun/install/") || runtime?.bun) return "bun";
97
+ if (env.npm_execpath || String(env.npm_config_user_agent || "").startsWith("npm/")) return "npm";
98
+ return "npm";
99
+ }
100
+
101
+ /** Install the current public release into the user's global package scope. */
102
+ export async function updateGlobal({ latest = "latest", manager = packageManagerFor(), spawnImpl = spawn, env = process.env, quiet = false, platform = process.platform } = {}) {
103
+ if (manager !== "bun" && manager !== "npm") throw new Error(`Unsupported package manager: ${manager}`);
104
+ if (!/^\d+\.\d+\.\d+$/.test(String(latest)) && latest !== "latest") throw new Error("Invalid UsageMax release version.");
105
+ const command = manager === "bun" ? (platform === "win32" ? "bun.exe" : "bun") : (platform === "win32" ? "npm.cmd" : "npm");
106
+ const args = manager === "bun"
107
+ ? ["add", "--global", `usagemax@${latest}`]
108
+ : ["install", "--global", `usagemax@${latest}`];
109
+ const child = spawnImpl(command, args, {
110
+ stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit",
111
+ env: { ...env, NPM_CONFIG_UPDATE_NOTIFIER: "false" },
112
+ });
113
+ return new Promise((resolve, reject) => {
114
+ child.once("error", reject);
115
+ child.once("exit", (status) => {
116
+ const code = status ?? 1;
117
+ if (code === 0) resolve({ manager, command, args });
118
+ else reject(Object.assign(new Error(`${command} ${args.join(" ")} exited with code ${code}.`), { code: "USAGEMAX_UPDATE_FAILED", exitCode: code }));
119
+ });
120
+ });
121
+ }
122
+
86
123
  /** Re-run a command through the current npm dist-tag without requiring @latest. */
87
124
  export async function runLatest(args, { spawnImpl = spawn } = {}) {
88
125
  const selected = runner();