usagemax 0.3.8 → 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
@@ -40,10 +40,14 @@ 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`. 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
50
+ `USAGEMAX_AUTO_UPDATE=1` for an explicit automatic handoff. Use `--no-update-check` or set
47
51
  `USAGEMAX_DISABLE_UPDATE_CHECK=1` in offline environments. Interactive
48
52
  terminals show a small stderr progress line; JSON,
49
53
  quiet, CI, and scheduled runs remain machine-readable and quiet.
@@ -78,9 +82,13 @@ usagemax status Show local link state
78
82
  usagemax doctor Check discovered sources
79
83
  usagemax report [ccusage args] Run a local ccusage report
80
84
  usagemax token status Diagnose a key piped on stdin
85
+ usagemax telemetry test Send one zero-token write-path smoke event
81
86
  usagemax service install Opt into periodic OS checkpoints
82
87
  usagemax service status|run|uninstall
83
88
  usagemax unlink [--revoke] Remove local credentials
89
+ usagemax update Update the global CLI through Bun or npm
90
+ usagemax update --check Check without installing
91
+ usagemax update sync Run sync through the newest CLI release
84
92
  ```
85
93
 
86
94
  Useful options:
@@ -90,21 +98,29 @@ bunx usagemax sync --full # all retained local history
90
98
  bunx usagemax sync --archives # one-time compressed-history recovery
91
99
  bunx usagemax sync --restart # restart an expired saved upload
92
100
  bunx usagemax status --remote --json # remote check; secret is never printed
101
+ bunx usagemax telemetry test --json # one observability event; no accounting change
93
102
  bunx usagemax doctor --deep --json # parse and audit retained history
94
103
  bunx usagemax link UMX-… --no-sync # link without uploading yet
95
104
  bunx usagemax --version --json # print CLI and ccusage versions
96
- bunx usagemax update --json # machine-readable release check
105
+ bunx usagemax update --json # update and return machine-readable status
106
+ bunx usagemax update --check --json # check without installing
97
107
  bunx usagemax unlink --json # safe automation result; no secret output
98
108
  ```
99
109
 
100
110
  ## Sources and coverage
101
111
 
102
- 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)
103
113
  and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
104
114
  CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
105
115
  OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
106
116
  well.
107
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
+
108
124
  The collector follows the supported provider environment overrides and bounded
109
125
  home locations. It recognizes Claude Desktop sessions, `.cc-mirror`, renamed
110
126
  Claude/Codex backup folders, and supported Windows homes from WSL. In WSL, use
@@ -129,14 +145,14 @@ stdin; never pass it as an argument or put it in a URL:
129
145
 
130
146
  ```bash
131
147
  set +x
148
+ set -o pipefail
132
149
  printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
133
150
  | bunx usagemax token status \
134
- --device-id "$USAGEMAX_INSTALLATION_ID" \
135
151
  --json
136
152
  ```
137
153
 
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
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
140
156
  `node packages/cli/src/cli.js token status` from this repository until the new
141
157
  package is published.
142
158
 
@@ -148,9 +164,10 @@ For a numeric-only result suitable for a smoke check:
148
164
 
149
165
  ```bash
150
166
  set +x
167
+ set -o pipefail
151
168
  printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
152
169
  | bunx usagemax token status \
153
- --device-id "$USAGEMAX_INSTALLATION_ID" --json \
170
+ --json \
154
171
  | jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
155
172
  ```
156
173
 
@@ -166,10 +183,22 @@ installation does not match. A recognized key missing `telemetry:write` has
166
183
  `status: scope_missing`, `scopeStatus: missing_telemetry_write`, and
167
184
  `ingestAuthorized: false`.
168
185
 
169
- For a write-path smoke check, the root README includes a `curl` request that
170
- sends one `agent_state` event with all token counters and `costMicros` set to
171
- zero. It is observability-only: `agent_state` does not update accounting, and
172
- 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.
173
202
 
174
203
  ## Privacy and resource use
175
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.3.8",
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, runLatest } 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.8";
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,18 +146,59 @@ 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");
133
156
  process.stdout.write(" usagemax doctor [--deep] [--json] [--quiet|--no-progress]\n");
134
157
  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");
158
+ process.stdout.write(" usagemax update Update the global CLI through Bun or npm\n");
159
+ process.stdout.write(" [--check] [--json] Check only or emit machine-readable status\n");
160
+ process.stdout.write(" usagemax update <command args> Run a command through the latest CLI\n");
137
161
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
138
162
  process.stdout.write(" usagemax unlink [--revoke] Remove locally; --revoke also disables uploads\n");
139
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.`);
140
202
  }
141
203
 
142
204
  function ccusageCliPath() {
@@ -144,12 +206,10 @@ function ccusageCliPath() {
144
206
  }
145
207
 
146
208
  async function ccusageJson(config, { full = false, env } = {}) {
147
- const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
148
- args.push("--sections", "daily,session");
209
+ const args = [ccusageCliPath(), ...ccusageDailyArgs(config, { full })];
149
210
  // Reconcile yesterday once after the UTC date changes. All other incremental
150
211
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
151
212
  // no supported local source changed at all.
152
- args.push(...reportDateArgs(config, { full }));
153
213
  const { stdout } = await executeFile(process.execPath, args, {
154
214
  encoding: "utf8",
155
215
  maxBuffer: MAX_REPORT_BYTES,
@@ -431,6 +491,7 @@ function printRemoteStatus(view) {
431
491
  if (view.credentialType) process.stdout.write(`Type: ${view.credentialType}${view.writeOnly ? "; write-only" : ""}\n`);
432
492
  if (view.scopes?.length) process.stdout.write(`Scopes: ${view.scopes.join(", ")}\n`);
433
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`);
434
495
  if (view.deviceBinding) process.stdout.write(`Device binding: ${view.deviceBinding}\n`);
435
496
  if (view.profileHandle) process.stdout.write(`Profile: @${view.profileHandle}\n`);
436
497
  if (view.deviceName) process.stdout.write(`Computer: ${view.deviceName}\n`);
@@ -449,9 +510,11 @@ async function status(args = []) {
449
510
  if (!config) {
450
511
  if (json) {
451
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;
452
514
  return;
453
515
  }
454
516
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
517
+ if (remote) process.exitCode = 1;
455
518
  return;
456
519
  }
457
520
  const stableId = await stableInstallationId(configDirectory(), config.deviceId);
@@ -488,6 +551,7 @@ async function status(args = []) {
488
551
  }
489
552
  if (json) {
490
553
  process.stdout.write(`${JSON.stringify({ ...local, remote: remoteView })}\n`);
554
+ if (!collectorCanWrite(remoteView)) process.exitCode = 1;
491
555
  return;
492
556
  }
493
557
  }
@@ -504,6 +568,7 @@ async function status(args = []) {
504
568
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
505
569
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
506
570
  if (remote) printRemoteStatus(remoteView);
571
+ if (remote && !collectorCanWrite(remoteView)) process.exitCode = 1;
507
572
  }
508
573
 
509
574
  async function readTokenFromStdin() {
@@ -524,6 +589,8 @@ async function readTokenFromStdin() {
524
589
  async function tokenStatus(args = []) {
525
590
  const requestedDeviceId = option(args, "--device-id");
526
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;
527
594
  const json = args.includes("--json");
528
595
  const progress = createProgress({ json, quiet: args.includes("--quiet"), noProgress: args.includes("--no-progress") });
529
596
  progress.start("Checking the collector credential…");
@@ -538,7 +605,7 @@ async function tokenStatus(args = []) {
538
605
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
539
606
  try {
540
607
  if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
541
- const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
608
+ const result = await requestCollectorStatus(endpoint, { token, deviceId });
542
609
  const view = collectorStatusView(result.httpStatus, result.body, token);
543
610
  progress.succeed("Collector credential checked.");
544
611
  if (json) process.stdout.write(`${JSON.stringify(view)}\n`);
@@ -546,6 +613,39 @@ async function tokenStatus(args = []) {
546
613
  process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
547
614
  printRemoteStatus(view);
548
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;
549
649
  } catch (error) {
550
650
  progress.stop();
551
651
  throw error;
@@ -560,6 +660,7 @@ async function doctor(args = []) {
560
660
  const config = await readConfig();
561
661
  const env = await ccusageEnvironment();
562
662
  const inventory = await sourceInventory({ env, home: ccusageHome(env) });
663
+ const windowsHomeDiscovery = await discoverWslWindowsHomeStatus();
563
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"}.`);
564
665
  const archives = await discoverProviderArchives({ env, home: ccusageHome(env) });
565
666
  const homes = String(env.USAGEMAX_DISCOVERED_HOMES || ccusageHome(env)).split(",").filter(Boolean);
@@ -569,6 +670,7 @@ async function doctor(args = []) {
569
670
  configPath: configPath(),
570
671
  linked: Boolean(config),
571
672
  homes,
673
+ windowsHomeDiscovery,
572
674
  detectedSources: inventory.sources,
573
675
  files: inventory.files,
574
676
  inventoryComplete: inventory.complete,
@@ -595,7 +697,16 @@ async function doctor(args = []) {
595
697
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
596
698
  process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
597
699
  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`);
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
+ }
599
710
  }
600
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`);
601
712
  if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
@@ -621,27 +732,78 @@ async function report(args) {
621
732
  }
622
733
 
623
734
  async function update(args = []) {
624
- const json = args.includes("--json");
735
+ const { json, checkOnly, commandArgs, handoffArgs } = updateRequest(args);
625
736
  const check = await checkForUpdate(configDirectory(), VERSION, { force: true });
737
+ const commandWillRun = updateCommandWillRun({ latest: check.latest, checkOnly, commandArgs });
626
738
  if (!check.latest) {
627
- 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
+ };
628
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`);
629
748
  return;
630
749
  }
631
750
  if (!check.newer) {
632
- 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
+ };
633
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`);
764
+ return;
765
+ }
766
+ if (handoffArgs.length) {
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
+ };
775
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI ${check.latest} is available (current ${VERSION}).\n`);
776
+ if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
777
+ return runLatest(handoffArgs);
778
+ }
779
+ const manager = packageManagerFor({ cliPath: process.argv[1], runtime: process.versions, env: process.env });
780
+ const installCommand = manager === "bun" ? "bun add --global usagemax@latest" : "npm install --global usagemax@latest";
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
+ };
790
+ if (checkOnly) {
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`);
634
793
  return;
635
794
  }
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");
795
+ if (!json) process.stdout.write(`Updating UsageMax ${VERSION} → ${check.latest} via ${manager}…\n`);
796
+ try {
797
+ await updateGlobal({ latest: check.latest, manager, quiet: json });
798
+ } catch (error) {
799
+ const result = { ...available, status: "failed", error: error instanceof Error ? error.message : String(error) };
800
+ if (json) process.stdout.write(`${JSON.stringify(result)}\n`);
801
+ else process.stderr.write(`UsageMax update failed: ${result.error}\n`);
802
+ process.exitCode = 1;
641
803
  return;
642
804
  }
643
- if (!json) process.stdout.write(`Launching UsageMax ${check.latest}…\n`);
644
- return runLatest(commandArgs);
805
+ const result = { ...available, status: "updated", installedVersion: check.latest };
806
+ process.stdout.write(json ? `${JSON.stringify(result)}\n` : `UsageMax CLI is now ${check.latest}.\n`);
645
807
  }
646
808
 
647
809
  async function maybeUpdate(command, args) {
@@ -694,8 +856,21 @@ async function removeLink(args = []) {
694
856
  async function main() {
695
857
  const args = process.argv.slice(2);
696
858
  const command = args[0] || "sync";
697
- if (["--help", "-h", "help"].includes(command)) return help();
859
+ if (["--help", "-h", "help"].includes(command)) return help(args[1]);
698
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));
699
874
  if (command === "update") return update(args.slice(1));
700
875
  if (await maybeUpdate(command, args)) return;
701
876
  if (command === "service") {
@@ -718,8 +893,8 @@ async function main() {
718
893
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
719
894
  return;
720
895
  }
721
- if (command === "report") return report(args.slice(1));
722
896
  if (command === "token" && args[1] === "status") return tokenStatus(args.slice(2));
897
+ if (command === "telemetry" && args[1] === "test") return telemetryTest(args.slice(2));
723
898
  if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
724
899
  return withConfigLock(configDirectory(), async () => {
725
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,19 +94,65 @@ 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"] };
102
+ }
103
+
104
+ /**
105
+ * Pick the package manager that owns the current invocation. Bun's global and
106
+ * ephemeral package paths are explicit; npm remains the safe default for a
107
+ * normal Node installation. The override is useful for managed environments
108
+ * where the executable is wrapped by a fleet-specific package manager.
109
+ */
110
+ export function packageManagerFor({ cliPath = process.argv[1], runtime = process.versions, env = process.env } = {}) {
111
+ const override = String(env.USAGEMAX_PACKAGE_MANAGER || "").trim().toLowerCase();
112
+ if (override === "bun" || override === "npm") return override;
113
+ const normalizedPath = String(cliPath || "").replaceAll("\\", "/");
114
+ if (normalizedPath.includes("/.bun/install/") || runtime?.bun) return "bun";
115
+ if (env.npm_execpath || String(env.npm_config_user_agent || "").startsWith("npm/")) return "npm";
116
+ return "npm";
117
+ }
118
+
119
+ /** Install the current public release into the user's global package scope. */
120
+ export async function updateGlobal({ latest = "latest", manager = packageManagerFor(), spawnImpl = spawn, env = process.env, quiet = false, platform = process.platform } = {}) {
121
+ if (manager !== "bun" && manager !== "npm") throw new Error(`Unsupported package manager: ${manager}`);
122
+ if (!/^\d+\.\d+\.\d+$/.test(String(latest)) && latest !== "latest") throw new Error("Invalid UsageMax release version.");
123
+ const command = manager === "bun" ? (platform === "win32" ? "bun.exe" : "bun") : (platform === "win32" ? "npm.cmd" : "npm");
124
+ const args = manager === "bun"
125
+ ? ["add", "--global", `usagemax@${latest}`]
126
+ : ["install", "--global", `usagemax@${latest}`];
127
+ const child = spawnImpl(command, args, {
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",
131
+ env: { ...env, NPM_CONFIG_UPDATE_NOTIFIER: "false" },
132
+ });
133
+ return new Promise((resolve, reject) => {
134
+ child.once("error", reject);
135
+ child.once("exit", (status) => {
136
+ const code = status ?? 1;
137
+ if (code === 0) resolve({ manager, command, args });
138
+ else reject(Object.assign(new Error(`${command} ${args.join(" ")} exited with code ${code}.`), { code: "USAGEMAX_UPDATE_FAILED", exitCode: code }));
139
+ });
140
+ });
84
141
  }
85
142
 
86
143
  /** 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();
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 });
89
153
  const child = spawnImpl(selected.command, [...selected.prefix, "usagemax@latest", "--", ...args], {
90
154
  stdio: "inherit",
91
- env: { ...process.env, USAGEMAX_UPDATE_HANDOFF: "1" },
155
+ env: { ...env, USAGEMAX_UPDATE_HANDOFF: "1" },
92
156
  });
93
157
  const code = await new Promise((resolve, reject) => {
94
158
  child.once("error", reject);