usagemax 0.3.9 → 0.3.10

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
@@ -44,7 +44,9 @@ The CLI checks npm's `latest` dist-tag at most twice per day. Run
44
44
  `usagemax update` to update a global installation through the package manager
45
45
  that launched it (Bun or npm). Add `--check` for a read-only check, or
46
46
  `--json` for automation. A command suffix such as `usagemax update sync` hands
47
- that command to the newest release without typing `@latest`. Set
47
+ that command to the newest release without typing `@latest`. When `--check` or
48
+ `--no-install` is present, the suffix is reported in JSON and never handed off;
49
+ in a terminal, install the update and then run the printed command. Set
48
50
  `USAGEMAX_AUTO_UPDATE=1` for an explicit automatic handoff. Use `--no-update-check` or set
49
51
  `USAGEMAX_DISABLE_UPDATE_CHECK=1` in offline environments. Interactive
50
52
  terminals show a small stderr progress line; JSON,
@@ -80,6 +82,7 @@ usagemax status Show local link state
80
82
  usagemax doctor Check discovered sources
81
83
  usagemax report [ccusage args] Run a local ccusage report
82
84
  usagemax token status Diagnose a key piped on stdin
85
+ usagemax telemetry test Send one zero-token write-path smoke event
83
86
  usagemax service install Opt into periodic OS checkpoints
84
87
  usagemax service status|run|uninstall
85
88
  usagemax unlink [--revoke] Remove local credentials
@@ -95,6 +98,7 @@ bunx usagemax sync --full # all retained local history
95
98
  bunx usagemax sync --archives # one-time compressed-history recovery
96
99
  bunx usagemax sync --restart # restart an expired saved upload
97
100
  bunx usagemax status --remote --json # remote check; secret is never printed
101
+ bunx usagemax telemetry test --json # one observability event; no accounting change
98
102
  bunx usagemax doctor --deep --json # parse and audit retained history
99
103
  bunx usagemax link UMX-… --no-sync # link without uploading yet
100
104
  bunx usagemax --version --json # print CLI and ccusage versions
@@ -105,12 +109,18 @@ bunx usagemax unlink --json # safe automation result; no secret o
105
109
 
106
110
  ## Sources and coverage
107
111
 
108
- UsageMax pins [ccusage v20.0.23](https://github.com/ccusage/ccusage/releases/tag/v20.0.23)
112
+ UsageMax pins [ccusage v20.0.24](https://github.com/ccusage/ccusage/releases/tag/v20.0.24)
109
113
  and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
110
114
  CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
111
115
  OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
112
116
  well.
113
117
 
118
+ The collector explicitly requests ccusage's per-model breakdown so models used
119
+ on the same day remain individually attributed instead of collapsing into a
120
+ generic `mixed` row. This collector update triggers one full reconciliation of
121
+ retained history from 2024-01-01 on the next sync; subsequent runs return to the
122
+ normal incremental schedule.
123
+
114
124
  The collector follows the supported provider environment overrides and bounded
115
125
  home locations. It recognizes Claude Desktop sessions, `.cc-mirror`, renamed
116
126
  Claude/Codex backup folders, and supported Windows homes from WSL. In WSL, use
@@ -135,14 +145,14 @@ stdin; never pass it as an argument or put it in a URL:
135
145
 
136
146
  ```bash
137
147
  set +x
148
+ set -o pipefail
138
149
  printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
139
150
  | bunx usagemax token status \
140
- --device-id "$USAGEMAX_INSTALLATION_ID" \
141
151
  --json
142
152
  ```
143
153
 
144
- The `token status` command is included in CLI `0.3.9`. If a fresh environment
145
- still has an older npm tag, run `usagemax update token status` or
154
+ The `token status` and `telemetry test` commands are included in CLI `0.3.10`.
155
+ If a fresh environment still has an older npm tag, run `usagemax update token status` or
146
156
  `node packages/cli/src/cli.js token status` from this repository until the new
147
157
  package is published.
148
158
 
@@ -154,9 +164,10 @@ For a numeric-only result suitable for a smoke check:
154
164
 
155
165
  ```bash
156
166
  set +x
167
+ set -o pipefail
157
168
  printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
158
169
  | bunx usagemax token status \
159
- --device-id "$USAGEMAX_INSTALLATION_ID" --json \
170
+ --json \
160
171
  | jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
161
172
  ```
162
173
 
@@ -172,10 +183,22 @@ installation does not match. A recognized key missing `telemetry:write` has
172
183
  `status: scope_missing`, `scopeStatus: missing_telemetry_write`, and
173
184
  `ingestAuthorized: false`.
174
185
 
175
- For a write-path smoke check, the root README includes a `curl` request that
176
- sends one `agent_state` event with all token counters and `costMicros` set to
177
- zero. It is observability-only: `agent_state` does not update accounting, and
178
- the probe may bind an otherwise unbound advanced key.
186
+ For a write-path smoke check, run `usagemax telemetry test`. A linked install
187
+ uses its stored collector key. For an advanced key, pipe it through stdin:
188
+
189
+ ```bash
190
+ set +x
191
+ printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
192
+ | bunx usagemax telemetry test \
193
+ --token-stdin \
194
+ --json
195
+ ```
196
+
197
+ The command sends one `agent_state` event with all token counters and
198
+ `costMicros` set to zero. It never sends prompts or raw logs. The event is
199
+ observability-only and does not update accounting; the first accepted write
200
+ binds an otherwise unbound advanced key to the CLI installation. Rejected
201
+ credentials return a non-zero process exit code for reliable scripts.
179
202
 
180
203
  ## Privacy and resource use
181
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "keywords": [
6
6
  "usagemax",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "src/archives.js",
36
+ "src/ccusage.js",
36
37
  "src/cli.js",
37
38
  "src/core.js",
38
39
  "src/installation.js",
@@ -57,6 +58,6 @@
57
58
  "prepublishOnly": "npm test"
58
59
  },
59
60
  "dependencies": {
60
- "ccusage": "20.0.23"
61
+ "ccusage": "20.0.24"
61
62
  }
62
63
  }
package/src/ccusage.js ADDED
@@ -0,0 +1,21 @@
1
+ import { reportDateArgs } from "./core.js";
2
+
3
+ /** Build the stable, model-complete ccusage command used by sync and doctor. */
4
+ export function ccusageDailyArgs(config, options = {}) {
5
+ return [
6
+ "daily",
7
+ "--json",
8
+ "--offline",
9
+ "--mode",
10
+ "calculate",
11
+ "--timezone",
12
+ "UTC",
13
+ "--by-agent",
14
+ "--breakdown",
15
+ "--order",
16
+ "asc",
17
+ "--sections",
18
+ "daily,session",
19
+ ...reportDateArgs(config, options),
20
+ ];
21
+ }
package/src/cli.js CHANGED
@@ -11,14 +11,15 @@ import { promisify } from "node:util";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  import { prepareArchiveRecovery } from "./archives.js";
14
- import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
14
+ import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
15
+ import { ccusageDailyArgs } from "./ccusage.js";
15
16
  import { stableInstallationId } from "./installation.js";
16
17
  import { createProgress } from "./progress.js";
17
18
  import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
18
- import { collectorStatusView, requestCollectorStatus, requestSnapshot } from "./transport.js";
19
+ import { collectorCanWrite, collectorStatusView, requestCollectorStatus, requestSnapshot, requestTelemetrySmokeTest } from "./transport.js";
19
20
  import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
20
- import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
21
- import { checkForUpdate, packageManagerFor, runLatest, updateGlobal } from "./updates.js";
21
+ import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, discoverWslWindowsHomeStatus, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
22
+ import { checkForUpdate, packageManagerFor, runLatest, updateCommandWillRun, updateGlobal, updateRequest } from "./updates.js";
22
23
 
23
24
  // Make the short-lived collector recognizable in Activity Monitor and `ps`.
24
25
  // Windows may still display the underlying node.exe image name in Task Manager.
@@ -26,7 +27,7 @@ process.title = "UsageMax";
26
27
 
27
28
  const require = createRequire(import.meta.url);
28
29
  const executeFile = promisify(execFile);
29
- const VERSION = "0.3.9";
30
+ const VERSION = "0.3.10";
30
31
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
31
32
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
32
33
  const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
@@ -111,7 +112,27 @@ function deviceLabel() {
111
112
  return "Computer";
112
113
  }
113
114
 
114
- function help() {
115
+ function help(command) {
116
+ if (command) {
117
+ const usage = {
118
+ link: "Usage: usagemax link <one-use-code> [--no-sync] [--name <name>] [--json]",
119
+ sync: "Usage: usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json] [--quiet|--no-progress]",
120
+ status: "Usage: usagemax status [--remote] [--json] [--quiet|--no-progress]",
121
+ doctor: "Usage: usagemax doctor [--deep] [--json] [--quiet|--no-progress]",
122
+ "token status": "Usage: printf '%s' \"$USAGEMAX_COLLECTOR_TOKEN\" | usagemax token status [--device-id <uuid>] [--json] [--quiet|--no-progress]\nRead-only credential check; the token is read from stdin and never printed.",
123
+ "telemetry test": "Usage: usagemax telemetry test [--token-stdin] [--device-id <uuid>] [--json] [--quiet|--no-progress]\nSend one content-free zero-token observability event to verify the write path.",
124
+ service: "Usage: usagemax service install [--every <minutes>] | status | run | uninstall",
125
+ "service install": "Usage: usagemax service install [--every <minutes>]",
126
+ "service status": "Usage: usagemax service status",
127
+ "service run": "Usage: usagemax service run",
128
+ "service uninstall": "Usage: usagemax service uninstall",
129
+ update: "Usage: usagemax update [--check|--no-install] [--json]\n usagemax update <command args> [--json]",
130
+ report: "Usage: usagemax report [...ccusage args]",
131
+ unlink: "Usage: usagemax unlink [--revoke] [--json]",
132
+ }[command];
133
+ process.stdout.write(`${usage || `No help is available for '${command}'. Run usagemax --help.`}\n`);
134
+ return;
135
+ }
115
136
  process.stdout.write(`UsageMax ${VERSION}\n\n`);
116
137
  process.stdout.write("Link aggregate coding-agent usage to your UsageMax profile.\n\n");
117
138
  process.stdout.write("Commands:\n");
@@ -125,8 +146,10 @@ function help() {
125
146
  process.stdout.write(" usagemax status Show local link status\n");
126
147
  process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
127
148
  process.stdout.write(" [--quiet|--no-progress] Disable interactive progress output\n");
128
- process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
149
+ process.stdout.write(" usagemax token status [--device-id <uuid>] [--json] [--quiet|--no-progress]\n");
129
150
  process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
151
+ process.stdout.write(" usagemax telemetry test [--token-stdin] [--device-id <uuid>] [--json] [--quiet|--no-progress]\n");
152
+ process.stdout.write(" Send one zero-token observability smoke event\n");
130
153
  process.stdout.write(" usagemax service install [--every 15]\n");
131
154
  process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
132
155
  process.stdout.write(" usagemax service status|run|uninstall\n");
@@ -138,6 +161,44 @@ function help() {
138
161
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
139
162
  process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
140
163
  process.stdout.write(" [--json] Emit machine-readable unlink status\n");
164
+ process.stdout.write("\nAdd --help after a command for its usage.\n");
165
+ }
166
+
167
+ function validateCommandArgs(command, args) {
168
+ const specs = {
169
+ link: { flags: ["--no-sync", "--json", "--check-updates", "--no-update-check"], values: ["--name"], positional: 1 },
170
+ sync: { flags: ["--full", "--archives", "--restart", "--dry-run", "--explain", "--json", "--quiet", "--no-progress", "--check-updates", "--no-update-check"], values: [], positional: 0 },
171
+ status: { flags: ["--remote", "--json", "--quiet", "--no-progress"], values: [], positional: 0 },
172
+ doctor: { flags: ["--deep", "--json", "--quiet", "--no-progress", "--check-updates", "--no-update-check"], values: [], positional: 0 },
173
+ unlink: { flags: ["--revoke", "--json"], values: [], positional: 0 },
174
+ "token status": { flags: ["--json", "--quiet", "--no-progress"], values: ["--device-id"], positional: 0 },
175
+ "telemetry test": { flags: ["--token-stdin", "--json", "--quiet", "--no-progress"], values: ["--device-id"], positional: 0 },
176
+ };
177
+ if (command === "service") {
178
+ const action = args[0] || "status";
179
+ if (!["install", "status", "run", "uninstall"].includes(action)) throw new Error(`Unknown service action: ${action}. Run usagemax service --help.`);
180
+ command = `service ${action}`;
181
+ args = args.slice(1);
182
+ specs[command] = { flags: [], values: ["--config-dir", ...(action === "install" ? ["--every"] : [])], positional: 0 };
183
+ }
184
+ const spec = specs[command];
185
+ if (!spec) return;
186
+ let positional = 0;
187
+ for (let index = 0; index < args.length; index += 1) {
188
+ const arg = args[index];
189
+ if (!arg.startsWith("-")) {
190
+ positional += 1;
191
+ continue;
192
+ }
193
+ if (spec.flags.includes(arg)) continue;
194
+ if (spec.values.includes(arg)) {
195
+ if (index + 1 >= args.length || args[index + 1].startsWith("-")) throw new Error(`${arg} requires a value.`);
196
+ index += 1;
197
+ continue;
198
+ }
199
+ throw new Error(`Unknown option for ${command}: ${arg}. Run usagemax ${command} --help.`);
200
+ }
201
+ if (positional > spec.positional) throw new Error(`Unexpected argument for ${command}. Run usagemax ${command} --help.`);
141
202
  }
142
203
 
143
204
  function ccusageCliPath() {
@@ -145,12 +206,10 @@ function ccusageCliPath() {
145
206
  }
146
207
 
147
208
  async function ccusageJson(config, { full = false, env } = {}) {
148
- const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
149
- args.push("--sections", "daily,session");
209
+ const args = [ccusageCliPath(), ...ccusageDailyArgs(config, { full })];
150
210
  // Reconcile yesterday once after the UTC date changes. All other incremental
151
211
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
152
212
  // no supported local source changed at all.
153
- args.push(...reportDateArgs(config, { full }));
154
213
  const { stdout } = await executeFile(process.execPath, args, {
155
214
  encoding: "utf8",
156
215
  maxBuffer: MAX_REPORT_BYTES,
@@ -432,6 +491,7 @@ function printRemoteStatus(view) {
432
491
  if (view.credentialType) process.stdout.write(`Type: ${view.credentialType}${view.writeOnly ? "; write-only" : ""}\n`);
433
492
  if (view.scopes?.length) process.stdout.write(`Scopes: ${view.scopes.join(", ")}\n`);
434
493
  if (view.scopeStatus) process.stdout.write(`Ingest scope: ${view.scopeStatus === "valid" ? "authorized" : "missing telemetry:write"}\n`);
494
+ if (typeof view.ingestAuthorized === "boolean") process.stdout.write(`Ingestion: ${view.ingestAuthorized ? "authorized" : "blocked"}\n`);
435
495
  if (view.deviceBinding) process.stdout.write(`Device binding: ${view.deviceBinding}\n`);
436
496
  if (view.profileHandle) process.stdout.write(`Profile: @${view.profileHandle}\n`);
437
497
  if (view.deviceName) process.stdout.write(`Computer: ${view.deviceName}\n`);
@@ -450,9 +510,11 @@ async function status(args = []) {
450
510
  if (!config) {
451
511
  if (json) {
452
512
  process.stdout.write(`${JSON.stringify({ linked: false, version: VERSION, ccusageVersion: CCUSAGE_VERSION, configPath: configPath(), remote: remote ? { status: "not_linked" } : undefined })}\n`);
513
+ if (remote) process.exitCode = 1;
453
514
  return;
454
515
  }
455
516
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
517
+ if (remote) process.exitCode = 1;
456
518
  return;
457
519
  }
458
520
  const stableId = await stableInstallationId(configDirectory(), config.deviceId);
@@ -489,6 +551,7 @@ async function status(args = []) {
489
551
  }
490
552
  if (json) {
491
553
  process.stdout.write(`${JSON.stringify({ ...local, remote: remoteView })}\n`);
554
+ if (!collectorCanWrite(remoteView)) process.exitCode = 1;
492
555
  return;
493
556
  }
494
557
  }
@@ -505,6 +568,7 @@ async function status(args = []) {
505
568
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
506
569
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
507
570
  if (remote) printRemoteStatus(remoteView);
571
+ if (remote && !collectorCanWrite(remoteView)) process.exitCode = 1;
508
572
  }
509
573
 
510
574
  async function readTokenFromStdin() {
@@ -525,6 +589,8 @@ async function readTokenFromStdin() {
525
589
  async function tokenStatus(args = []) {
526
590
  const requestedDeviceId = option(args, "--device-id");
527
591
  if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
592
+ const localConfig = await readConfig();
593
+ const deviceId = requestedDeviceId || localConfig?.deviceId;
528
594
  const json = args.includes("--json");
529
595
  const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
530
596
  progress.start("Checking the collector credential…");
@@ -539,7 +605,7 @@ async function tokenStatus(args = []) {
539
605
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
540
606
  try {
541
607
  if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
542
- const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
608
+ const result = await requestCollectorStatus(endpoint, { token, deviceId });
543
609
  const view = collectorStatusView(result.httpStatus, result.body, token);
544
610
  progress.succeed("Collector credential checked.");
545
611
  if (json) process.stdout.write(`${JSON.stringify(view)}\n`);
@@ -547,6 +613,39 @@ async function tokenStatus(args = []) {
547
613
  process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
548
614
  printRemoteStatus(view);
549
615
  }
616
+ if (!collectorCanWrite(view)) process.exitCode = 1;
617
+ } catch (error) {
618
+ progress.stop();
619
+ throw error;
620
+ }
621
+ }
622
+
623
+ async function telemetryTest(args = []) {
624
+ const requestedDeviceId = option(args, "--device-id");
625
+ if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
626
+ const json = args.includes("--json");
627
+ const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
628
+ const localConfig = await readConfig();
629
+ let token;
630
+ try {
631
+ if (!localConfig || args.includes("--token-stdin")) {
632
+ token = await readTokenFromStdin();
633
+ } else {
634
+ token = localConfig.token;
635
+ }
636
+ const deviceId = requestedDeviceId || await stableInstallationId(configDirectory(), localConfig?.deviceId);
637
+ const configuredEndpoint = process.env.USAGEMAX_TELEMETRY_ENDPOINT || localConfig?.ingestUrl || `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
638
+ const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
639
+ if (!endpoint) throw new Error("USAGEMAX_TELEMETRY_ENDPOINT must use HTTPS, except for localhost development.");
640
+ progress.start("Sending a zero-token telemetry smoke event…");
641
+ const result = await requestTelemetrySmokeTest(endpoint, { token, deviceId });
642
+ if (result.ok) progress.succeed("Telemetry smoke event accepted.");
643
+ else progress.fail("Telemetry smoke event was rejected.");
644
+ const summary = { ...result, deviceIdConfigured: true };
645
+ if (json) process.stdout.write(`${JSON.stringify(summary)}\n`);
646
+ else if (result.ok) process.stdout.write(`UsageMax accepted the smoke event (HTTP ${result.httpStatus}). No token or cost totals were recorded.\n`);
647
+ else process.stdout.write(`Telemetry check failed (${result.status}${result.httpStatus ? `, HTTP ${result.httpStatus}` : ""}). Run usagemax token status --device-id ${deviceId} to diagnose the credential.\n`);
648
+ if (!result.ok) process.exitCode = 1;
550
649
  } catch (error) {
551
650
  progress.stop();
552
651
  throw error;
@@ -561,6 +660,7 @@ async function doctor(args = []) {
561
660
  const config = await readConfig();
562
661
  const env = await ccusageEnvironment();
563
662
  const inventory = await sourceInventory({ env, home: ccusageHome(env) });
663
+ const windowsHomeDiscovery = await discoverWslWindowsHomeStatus();
564
664
  progress.update(`Found ${inventory.sources.length} source${inventory.sources.length === 1 ? "" : "s"} and ${inventory.files}${inventory.truncated ? "+" : ""} local data file${inventory.files === 1 ? "" : "s"}.`);
565
665
  const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
566
666
  const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
@@ -570,6 +670,7 @@ async function doctor(args = []) {
570
670
  configPath: configPath(),
571
671
  linked: Boolean(config),
572
672
  homes,
673
+ windowsHomeDiscovery,
573
674
  detectedSources: inventory.sources,
574
675
  files: inventory.files,
575
676
  inventoryComplete: inventory.complete,
@@ -596,7 +697,16 @@ async function doctor(args = []) {
596
697
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
597
698
  process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
598
699
  if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
599
- process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; readable Windows provider homes are included automatically\n`);
700
+ process.stdout.write(`Windows home discovery: ${windowsHomeDiscovery.status}`);
701
+ if (windowsHomeDiscovery.selected.length) {
702
+ process.stdout.write(` (${windowsHomeDiscovery.selected.join(", ")})`);
703
+ }
704
+ process.stdout.write("\n");
705
+ if (windowsHomeDiscovery.status === "ambiguous") {
706
+ process.stdout.write("No Windows profile was selected to avoid mixing users. Set USAGEMAX_ADDITIONAL_HOME to the exact profile path you own, for example /mnt/c/Users/<your-profile>.\n");
707
+ } else if (windowsHomeDiscovery.status === "unavailable") {
708
+ process.stdout.write("No supported provider directory was found in readable Windows profiles. If your data is elsewhere, set USAGEMAX_ADDITIONAL_HOME to its exact profile path.\n");
709
+ }
600
710
  }
601
711
  if (archives.length) process.stdout.write(`Recovery: ${archives.length} compressed provider archive(s) detected; run \`bunx usagemax sync --archives\` once to reconcile them\n`);
602
712
  if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
@@ -622,32 +732,64 @@ async function report(args) {
622
732
  }
623
733
 
624
734
  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"] : [])] : [];
735
+ const { json, checkOnly, commandArgs, handoffArgs } = updateRequest(args);
629
736
  const check = await checkForUpdate(configDirectory(), VERSION, { force: true });
737
+ const commandWillRun = updateCommandWillRun({ latest: check.latest, checkOnly, commandArgs });
630
738
  if (!check.latest) {
631
- const result = { version: VERSION, latest: null, newer: false, status: "unavailable" };
739
+ const result = {
740
+ version: VERSION,
741
+ latest: null,
742
+ newer: false,
743
+ status: "unavailable",
744
+ ...(commandArgs.length ? { command: `usagemax ${commandArgs.join(" ")}`, commandWillRun: false } : {}),
745
+ };
632
746
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} · update check unavailable.\n`);
747
+ if (!json && commandArgs.length) process.stdout.write(`The requested command was not run because no release could be verified: \`usagemax ${commandArgs.join(" ")}\`.\n`);
633
748
  return;
634
749
  }
635
750
  if (!check.newer) {
636
- const result = { version: VERSION, latest: check.latest, newer: false, status: "current" };
751
+ const result = {
752
+ version: VERSION,
753
+ latest: check.latest,
754
+ newer: false,
755
+ status: "current",
756
+ ...(commandArgs.length ? { command: `usagemax ${commandArgs.join(" ")}`, commandWillRun } : {}),
757
+ };
637
758
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${VERSION} is current.\n`);
759
+ if (commandWillRun) {
760
+ if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
761
+ return runLatest(handoffArgs);
762
+ }
763
+ if (!json && commandArgs.length) process.stdout.write(`Check-only: the requested command was not run: \`usagemax ${commandArgs.join(" ")}\`.\n`);
638
764
  return;
639
765
  }
640
766
  if (handoffArgs.length) {
641
- const result = { version: VERSION, latest: check.latest, newer: true, status: "available", command: `usagemax update ${commandArgs.join(" ")}` };
767
+ const result = {
768
+ version: VERSION,
769
+ latest: check.latest,
770
+ newer: true,
771
+ status: "available",
772
+ command: `usagemax ${commandArgs.join(" ")}`,
773
+ commandWillRun: true,
774
+ };
642
775
  process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
643
776
  if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
644
777
  return runLatest(handoffArgs);
645
778
  }
646
779
  const manager = packageManagerFor({ cliPath: process.argv[1], runtime: process.versions, env: process.env });
647
780
  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 };
781
+ const available = {
782
+ version: VERSION,
783
+ latest: check.latest,
784
+ newer: true,
785
+ status: "available",
786
+ manager,
787
+ installCommand,
788
+ ...(commandArgs.length ? { command: `usagemax ${commandArgs.join(" ")}`, commandWillRun: false } : {}),
789
+ };
649
790
  if (checkOnly) {
650
791
  process.stdout.write(json ? `${JSON.stringify(available)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}). Run \`${installCommand}\`.\n`);
792
+ if (!json && commandArgs.length) process.stdout.write(`Check-only: the requested command was not run. After installing, run \`usagemax ${commandArgs.join(" ")}\`.\n`);
651
793
  return;
652
794
  }
653
795
  if (!json) process.stdout.write(`Updating UsageMax ${VERSION} → ${check.latest} via ${manager}…\n`);
@@ -714,8 +856,21 @@ async function removeLink(args = []) {
714
856
  async function main() {
715
857
  const args = process.argv.slice(2);
716
858
  const command = args[0] || "sync";
717
- if (["--help", "-h", "help"].includes(command)) return help();
859
+ if (["--help", "-h", "help"].includes(command)) return help(args[1]);
718
860
  if (["--version", "-v"].includes(command)) return process.stdout.write(args.includes("--json") ? `${JSON.stringify({ version: VERSION, ccusageVersion: CCUSAGE_VERSION })}\n` : `${VERSION}\n`);
861
+ if (command === "report") return report(args.slice(1));
862
+ const invocationHelp = command === "update"
863
+ ? ["--help", "-h"].includes(args[1])
864
+ : args.slice(1).some((arg) => arg === "--help" || arg === "-h");
865
+ if (invocationHelp) {
866
+ const helpCommand = command === "token" && args[1] === "status" ? "token status"
867
+ : command === "telemetry" ? "telemetry test"
868
+ : command === "service" ? `service ${args[1] || "status"}` : command;
869
+ return help(helpCommand);
870
+ }
871
+ if (command === "token" && args[1] !== "status") throw new Error("Use `usagemax token status`; run usagemax --help for all commands.");
872
+ if (command === "telemetry" && args[1] !== "test") throw new Error("Use `usagemax telemetry test`; run usagemax --help for all commands.");
873
+ validateCommandArgs(command === "token" ? "token status" : command === "telemetry" ? "telemetry test" : command, command === "service" ? args.slice(1) : command === "token" || command === "telemetry" ? args.slice(2) : args.slice(1));
719
874
  if (command === "update") return update(args.slice(1));
720
875
  if (await maybeUpdate(command, args)) return;
721
876
  if (command === "service") {
@@ -738,8 +893,8 @@ async function main() {
738
893
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
739
894
  return;
740
895
  }
741
- if (command === "report") return report(args.slice(1));
742
896
  if (command === "token" && args[1] === "status") return tokenStatus(args.slice(2));
897
+ if (command === "telemetry" && args[1] === "test") return telemetryTest(args.slice(2));
743
898
  if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
744
899
  return withConfigLock(configDirectory(), async () => {
745
900
  if (command === "link") return link(args.slice(1));
package/src/sources.js CHANGED
@@ -3,8 +3,10 @@ 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.23";
7
- export const SOURCE_INVENTORY_VERSION = 3;
6
+ export const CCUSAGE_VERSION = "20.0.24";
7
+ // Reconcile retained history when the collector output contract changes, not
8
+ // just when a source path is added. v4 enables explicit per-model breakdowns.
9
+ export const SOURCE_INVENTORY_VERSION = 4;
8
10
  export const SUPPORTED_SOURCES = [
9
11
  "amp",
10
12
  "claude",
@@ -69,13 +71,24 @@ async function entries(value) {
69
71
  }
70
72
 
71
73
  async function providerBearingHome(home, pathApi) {
72
- const markers = [".claude", ".codex", ".factory", ".gemini", ".openclaw", ".hermes", ".grok"];
74
+ // Only inspect known provider roots. Avoid treating generic profile folders
75
+ // (for example .local/share itself) as evidence that a Windows profile is
76
+ // relevant; that would make multi-user WSL selection noisy and unsafe.
77
+ const markers = [
78
+ ".claude", ".config/claude", ".codex", ".factory", ".gemini", ".openclaw", ".clawdbot", ".moltbot", ".moldbot", ".hermes", ".grok",
79
+ ".kimi", ".kimi-code", ".qwen", ".copilot", ".pi",
80
+ ".local/share/amp", ".local/share/goose", ".local/share/Block/goose", ".local/share/kilo", ".local/share/opencode",
81
+ "AppData/Roaming/Block/goose",
82
+ ".config/manicode", ".config/manicode-dev", ".config/manicode-staging",
83
+ ];
73
84
  const checks = await Promise.all(markers.map((marker) => existsDirectory(pathApi.join(home, marker))));
74
85
  return checks.some(Boolean);
75
86
  }
76
87
 
77
- async function discoverWslWindowsHomes(env, platform, pathApi) {
78
- if (platform !== "linux" || !String(env.WSL_DISTRO_NAME ?? "").trim()) return [];
88
+ export async function discoverWslWindowsHomeStatus({ env = process.env, platform = process.platform, pathApi = path } = {}) {
89
+ if (platform !== "linux" || !String(env.WSL_DISTRO_NAME ?? "").trim()) {
90
+ return { status: "not-applicable", candidates: [], selected: [] };
91
+ }
79
92
  const usersRoot = String(env.USAGEMAX_WSL_USERS_DIR ?? "/mnt/c/Users").trim();
80
93
  const candidates = [];
81
94
  for (const entry of await entries(usersRoot)) {
@@ -86,7 +99,8 @@ async function discoverWslWindowsHomes(env, platform, pathApi) {
86
99
  // A WSL distro can see every Windows profile. Auto-select only when there is
87
100
  // one unambiguous provider-bearing profile; multi-user systems opt in with
88
101
  // USAGEMAX_ADDITIONAL_HOME so one employee never absorbs another's usage.
89
- return candidates.length === 1 ? candidates : [];
102
+ const status = candidates.length === 1 ? "discovered" : candidates.length > 1 ? "ambiguous" : "unavailable";
103
+ return { status, candidates, selected: status === "discovered" ? candidates : [] };
90
104
  }
91
105
 
92
106
  async function discoverBackupRoots(home, pathApi) {
@@ -229,15 +243,15 @@ export function sourceDefinitions({ env = process.env, home = ccusageHome(env),
229
243
  definitions.push(tree("pi", root, "jsonl"));
230
244
  }
231
245
 
232
- const gooseRoot = String(env.GOOSE_PATH_ROOT ?? "").trim();
233
- const gooseDatabases = gooseRoot
234
- ? [join(gooseRoot, "data", "sessions", "sessions.db")]
246
+ const gooseRoots = envList(env, "GOOSE_PATH_ROOT", []);
247
+ const gooseDatabases = gooseRoots.length
248
+ ? gooseRoots.map((root) => join(root, "data", "sessions", "sessions.db"))
235
249
  : [
236
250
  join(home, ".local", "share", "goose", "sessions", "sessions.db"),
237
251
  join(home, "Library", "Application Support", "goose", "sessions", "sessions.db"),
238
252
  join(home, ".local", "share", "Block", "goose", "sessions", "sessions.db"),
239
253
  ];
240
- if (!gooseRoot && String(env.APPDATA ?? "").trim()) {
254
+ if (!gooseRoots.length && String(env.APPDATA ?? "").trim()) {
241
255
  gooseDatabases.push(join(String(env.APPDATA).trim(), "Block", "goose", "data", "sessions", "sessions.db"));
242
256
  }
243
257
  for (const database of gooseDatabases) definitions.push(...db("goose", database));
@@ -260,7 +274,9 @@ export function sourceDefinitions({ env = process.env, home = ccusageHome(env),
260
274
  definitions.push(tree("qwen", join(root, "projects"), "jsonl"));
261
275
  }
262
276
 
263
- definitions.push(tree("copilot", join(home, ".copilot", "otel"), "jsonl"));
277
+ for (const root of envList(env, "COPILOT_OTEL_DIR", [join(home, ".copilot", "otel")])) {
278
+ definitions.push(tree("copilot", root, "jsonl"));
279
+ }
264
280
  if (String(env.COPILOT_OTEL_FILE_EXPORTER_PATH ?? "").trim()) {
265
281
  definitions.push(file("copilot", String(env.COPILOT_OTEL_FILE_EXPORTER_PATH).trim()));
266
282
  }
@@ -283,8 +299,29 @@ export async function ccusageEnvironment({ env = process.env, platform = process
283
299
  const effective = { ...env };
284
300
  const home = ccusageHome(effective);
285
301
  const configuredHomes = commaList(effective.USAGEMAX_ADDITIONAL_HOME).map((item) => expandTilde(item, home, pathApi));
286
- const wslHomes = await discoverWslWindowsHomes(effective, platform, pathApi);
302
+ const wslDiscovery = await discoverWslWindowsHomeStatus({ env: effective, platform, pathApi });
303
+ const wslHomes = wslDiscovery.selected;
287
304
  const additionalHomes = uniquePaths([...configuredHomes, ...wslHomes], pathApi).filter((item) => pathApi.normalize(item) !== pathApi.normalize(home));
305
+ const discoveredWindowsGooseRoots = [];
306
+ if (platform === "linux" && String(effective.WSL_DISTRO_NAME ?? "").trim()) {
307
+ for (const candidate of additionalHomes) {
308
+ const root = pathApi.join(candidate, "AppData", "Roaming", "Block", "goose");
309
+ if (await existsDirectory(root)) discoveredWindowsGooseRoots.push(root);
310
+ }
311
+ }
312
+ if (discoveredWindowsGooseRoots.length) {
313
+ const explicitRoots = commaList(effective.GOOSE_PATH_ROOT);
314
+ const defaultRoots = [
315
+ pathApi.join(home, ".local", "share", "goose"),
316
+ pathApi.join(home, "Library", "Application Support", "goose"),
317
+ pathApi.join(home, ".local", "share", "Block", "goose"),
318
+ ...(String(effective.APPDATA ?? "").trim() ? [pathApi.join(String(effective.APPDATA).trim(), "Block", "goose")] : []),
319
+ ];
320
+ effective.GOOSE_PATH_ROOT = uniquePaths([
321
+ ...(explicitRoots.length ? explicitRoots : defaultRoots),
322
+ ...discoveredWindowsGooseRoots,
323
+ ], pathApi).join(",");
324
+ }
288
325
  const homes = [home, ...additionalHomes];
289
326
  const backups = await Promise.all(homes.map((candidate) => discoverBackupRoots(candidate, pathApi)));
290
327
  const nestedClaude = (await Promise.all(homes.map((candidate) => discoverNestedClaudeRoots(candidate, pathApi)))).flat();
@@ -318,6 +355,7 @@ export async function ccusageEnvironment({ env = process.env, platform = process
318
355
  setDiscoveredList(effective, "KIMI_DATA_DIR", [pathApi.join(home, ".kimi"), pathApi.join(home, ".kimi-code")], additionalHomes.flatMap((candidate) => [pathApi.join(candidate, ".kimi"), pathApi.join(candidate, ".kimi-code")]), pathApi);
319
356
  setDiscoveredList(effective, "QWEN_DATA_DIR", [pathApi.join(home, ".qwen")], additions([".qwen"]), pathApi);
320
357
  setDiscoveredList(effective, "GEMINI_DATA_DIR", [pathApi.join(home, ".gemini", "tmp")], additions([".gemini", "tmp"]), pathApi);
358
+ setDiscoveredList(effective, "COPILOT_OTEL_DIR", [pathApi.join(home, ".copilot", "otel")], additions([".copilot", "otel"]), pathApi);
321
359
 
322
360
  effective.USAGEMAX_DISCOVERED_HOMES = homes.join(",");
323
361
 
package/src/transport.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { setTimeout as delay } from "node:timers/promises";
2
3
 
3
4
  export function retryAfterMs(value, now = Date.now()) {
@@ -15,6 +16,15 @@ const collectorStates = new Set(["active", "revoked", "workspace_disabled", "mem
15
16
  const deviceBindings = new Set(["unbound", "bound", "matched", "mismatch"]);
16
17
  const collectorTokenPattern = /umx_[a-f0-9]{64}/gi;
17
18
 
19
+ export function collectorCanWrite(view) {
20
+ return view?.httpStatus === 200
21
+ && view?.status === "active"
22
+ && view?.credentialType === "collector"
23
+ && view?.scopeStatus === "valid"
24
+ && view?.ingestAuthorized === true
25
+ && view?.deviceBinding !== "mismatch";
26
+ }
27
+
18
28
  function safeText(value, secret) {
19
29
  let text = value;
20
30
  if (secret) text = text.split(secret).join("[redacted]");
@@ -83,6 +93,72 @@ export async function requestCollectorStatus(endpoint, config, {
83
93
  return { httpStatus: response.status, body };
84
94
  }
85
95
 
96
+ /** Send one zero-token observability event to prove the authenticated write path. */
97
+ export async function requestTelemetrySmokeTest(endpoint, config, {
98
+ timeout = 15_000,
99
+ fetchImpl = fetch,
100
+ now = Date.now,
101
+ eventId = randomUUID(),
102
+ } = {}) {
103
+ const eventKey = `usagemax-cli-test:${eventId}`;
104
+ const event = {
105
+ eventKey,
106
+ eventType: "agent_state",
107
+ provider: "usagemax-cli",
108
+ model: "usagemax-connectivity-check",
109
+ source: "usagemax-cli",
110
+ agentName: "UsageMax CLI",
111
+ status: "ok",
112
+ state: "connected",
113
+ occurredAt: now(),
114
+ inputTokens: 0,
115
+ outputTokens: 0,
116
+ cacheReadTokens: 0,
117
+ cacheWriteTokens: 0,
118
+ reasoningTokens: 0,
119
+ totalTokens: 0,
120
+ costMicros: 0,
121
+ costBasis: "unknown",
122
+ completeness: "unknown",
123
+ accountingMode: "observability",
124
+ schemaVersion: 1,
125
+ };
126
+ const body = JSON.stringify({ events: [event] });
127
+ let response;
128
+ try {
129
+ response = await fetchImpl(endpoint, {
130
+ method: "POST",
131
+ headers: {
132
+ authorization: `Bearer ${config.token}`,
133
+ "content-type": "application/json",
134
+ "x-usagemax-device-id": config.deviceId,
135
+ "idempotency-key": `usagemax-cli:${config.deviceId}:${eventId}`,
136
+ },
137
+ body,
138
+ cache: "no-store",
139
+ signal: AbortSignal.timeout(timeout),
140
+ });
141
+ } catch {
142
+ return { ok: false, status: "unavailable", httpStatus: null };
143
+ }
144
+ const result = record(await response.json().catch(() => null));
145
+ const ok = response.ok && result?.ok === true;
146
+ return {
147
+ ok,
148
+ status: ok ? "accepted" : response.status === 401 ? "unauthorized"
149
+ : response.status === 403 ? "forbidden"
150
+ : response.status === 409 ? "device_or_idempotency_conflict"
151
+ : response.status === 429 ? "rate_limited"
152
+ : response.status >= 500 ? "unavailable" : "rejected",
153
+ httpStatus: response.status,
154
+ ...(Number.isSafeInteger(result?.accepted) ? { accepted: result.accepted } : {}),
155
+ ...(Number.isSafeInteger(result?.duplicates) ? { duplicates: result.duplicates } : {}),
156
+ ...(typeof result?.replay === "boolean" ? { replay: result.replay } : {}),
157
+ observabilityOnly: true,
158
+ accountingUpdated: false,
159
+ };
160
+ }
161
+
86
162
  // Only snapshot operations have server receipts. Never automatically replay a
87
163
  // one-use link request or apply this policy to arbitrary POST operations.
88
164
  export async function requestSnapshot(endpoint, config, operation, payload, {
package/src/updates.js CHANGED
@@ -20,6 +20,24 @@ export function compareVersions(left, right) {
20
20
  return 0;
21
21
  }
22
22
 
23
+ /** Parse the read-only and command-handoff options shared by `usagemax update`. */
24
+ export function updateRequest(args = []) {
25
+ const json = args.includes("--json");
26
+ const checkOnly = args.includes("--check") || args.includes("--no-install");
27
+ const commandArgs = args.filter((arg) => !["--check", "--no-install", "--json"].includes(arg));
28
+ return {
29
+ json,
30
+ checkOnly,
31
+ commandArgs,
32
+ handoffArgs: commandArgs.length && !checkOnly ? [...commandArgs, ...(json ? ["--json"] : [])] : [],
33
+ };
34
+ }
35
+
36
+ /** A command suffix runs only after a usable npm release was confirmed. */
37
+ export function updateCommandWillRun({ latest, checkOnly, commandArgs = [] }) {
38
+ return Boolean(latest && !checkOnly && commandArgs.length);
39
+ }
40
+
23
41
  function cachePath(directory) {
24
42
  return join(directory, "update-check.json");
25
43
  }
@@ -76,11 +94,11 @@ export async function checkForUpdate(directory, currentVersion, { force = false,
76
94
  return { ...result, newer: compareVersions(result.latest, currentVersion) > 0 };
77
95
  }
78
96
 
79
- function runner() {
80
- const override = process.env.USAGEMAX_PACKAGE_RUNNER?.trim();
97
+ function runner({ manager, env = process.env, platform = process.platform }) {
98
+ const override = env.USAGEMAX_PACKAGE_RUNNER?.trim();
81
99
  if (override) return { command: override, prefix: [] };
82
- if (process.env.npm_execpath) return { command: "npm", prefix: ["exec", "--yes"] };
83
- return { command: "bunx", prefix: ["--bun"] };
100
+ if (manager === "bun") return { command: platform === "win32" ? "bunx.exe" : "bunx", prefix: ["--bun"] };
101
+ return { command: platform === "win32" ? "npm.cmd" : "npm", prefix: ["exec", "--yes"] };
84
102
  }
85
103
 
86
104
  /**
@@ -107,7 +125,9 @@ export async function updateGlobal({ latest = "latest", manager = packageManager
107
125
  ? ["add", "--global", `usagemax@${latest}`]
108
126
  : ["install", "--global", `usagemax@${latest}`];
109
127
  const child = spawnImpl(command, args, {
110
- stdio: quiet ? ["ignore", "pipe", "pipe"] : "inherit",
128
+ // Do not leave child output pipes undrained: a JSON-mode package update
129
+ // can otherwise block once Bun/npm fills the operating-system pipe buffer.
130
+ stdio: quiet ? "ignore" : "inherit",
111
131
  env: { ...env, NPM_CONFIG_UPDATE_NOTIFIER: "false" },
112
132
  });
113
133
  return new Promise((resolve, reject) => {
@@ -121,11 +141,18 @@ export async function updateGlobal({ latest = "latest", manager = packageManager
121
141
  }
122
142
 
123
143
  /** Re-run a command through the current npm dist-tag without requiring @latest. */
124
- export async function runLatest(args, { spawnImpl = spawn } = {}) {
125
- const selected = runner();
144
+ export async function runLatest(args, {
145
+ spawnImpl = spawn,
146
+ cliPath = process.argv[1],
147
+ runtime = process.versions,
148
+ env = process.env,
149
+ platform = process.platform,
150
+ } = {}) {
151
+ const manager = packageManagerFor({ cliPath, runtime, env });
152
+ const selected = runner({ manager, env, platform });
126
153
  const child = spawnImpl(selected.command, [...selected.prefix, "usagemax@latest", "--", ...args], {
127
154
  stdio: "inherit",
128
- env: { ...process.env, USAGEMAX_UPDATE_HANDOFF: "1" },
155
+ env: { ...env, USAGEMAX_UPDATE_HANDOFF: "1" },
129
156
  });
130
157
  const code = await new Promise((resolve, reject) => {
131
158
  child.once("error", reject);