usagemax 0.1.0 → 0.2.0

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
@@ -1,42 +1,88 @@
1
1
  # UsageMax CLI
2
2
 
3
- Connect aggregate coding-agent usage from your computer to a private UsageMax workspace.
3
+ Connect aggregate coding-agent usage from every computer to one private UsageMax workspace.
4
4
 
5
5
  ## Quick start
6
6
 
7
7
  1. Sign in at [usagemax.com/account](https://usagemax.com/account).
8
8
  2. Choose **Link a computer** and copy the one-time command.
9
- 3. Run it locally:
9
+ 3. Run it in each operating-system environment that contains usage history:
10
10
 
11
11
  ```bash
12
- bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
12
+ bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
13
13
  ```
14
14
 
15
- The link code expires after ten minutes and can be used once. The CLI stores the resulting collector key in a user-only config file, then runs a one-shot sync.
15
+ The link code expires after ten minutes and can be used once. Create a new code
16
+ for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
17
+ collectors roll up into the same profile. Windows and WSL have separate home
18
+ directories, so run UsageMax once in Windows and once inside WSL when both have
19
+ agent history.
20
+
21
+ The CLI stores the resulting collector key in a user-only config file, then runs
22
+ a one-shot full-history sync. Running it again, changing the display name, or
23
+ relinking an installation does not create a second device: the private random
24
+ installation identity remains stable.
16
25
 
17
26
  ## Commands
18
27
 
19
28
  ```bash
20
- bunx usagemax # sync changed usage
21
- bunx usagemax sync # same as above
22
- bunx usagemax sync --full # inspect all available local history
23
- bunx usagemax link UMX-… --no-sync # link without uploading yet
24
- bunx usagemax status # show link and last-sync state
25
- bunx usagemax doctor # metadata-only source check; does not parse logs
26
- bunx usagemax doctor --deep # opt into a full local parser check
27
- bunx usagemax report # open ccusage's local daily report
29
+ bunx usagemax # sync changed usage
30
+ bunx usagemax sync # same as above
31
+ bunx usagemax sync --full # reconcile all retained local history
32
+ bunx usagemax link UMX-… --no-sync # link without uploading yet
33
+ bunx usagemax status # show link and last-sync state
34
+ bunx usagemax doctor # metadata-only source check
35
+ bunx usagemax doctor --deep # parse and verify all retained history
36
+ bunx usagemax report # open ccusage's local daily report
28
37
  bunx usagemax report session --breakdown
29
- bunx usagemax unlink # remove the local collector key
38
+ bunx usagemax unlink # remove the local collector key
30
39
  ```
31
40
 
32
- UsageMax uses [ccusage](https://github.com/ccusage/ccusage) for local source detection, responsive reports, cached pricing, model breakdowns, date handling, and support for popular coding-agent CLIs. `report` passes its remaining arguments to ccusage.
41
+ UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
42
+ and supports all 16 adapters shipped in that release: Amp, Claude Code, Codebuff,
43
+ Codex, GitHub Copilot CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes,
44
+ Kilo Code, Kimi CLI, OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format
45
+ stores configured through ccusage are also discovered.
46
+
47
+ The source inventory follows ccusage's environment overrides, including
48
+ `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DATA_DIR`, `AMP_DATA_DIR`,
49
+ `DROID_SESSIONS_DIR`, `CODEBUFF_DATA_DIR`, `HERMES_HOME`, `PI_AGENT_DIR`,
50
+ `GOOSE_PATH_ROOT`, `OPENCLAW_DIR`, `KILO_DATA_DIR`, `KIMI_DATA_DIR`,
51
+ `QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
52
+ `GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
33
53
 
34
- ## Privacy and load
54
+ Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
55
+ Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
56
+ billing systems do not all expose a stable local token ledger. Capture those
57
+ through UsageMax's native or OTLP endpoint, or through a future provider billing
58
+ connector. UsageMax never invents usage that the source did not retain.
35
59
 
36
- - The sync payload contains aggregate token counts, model/provider names, costs, source names, and dates.
37
- - It does not upload prompts, completions, source code, file contents, project paths, or provider credentials.
60
+ ## Privacy, correctness, and load
61
+
62
+ - The sync payload contains aggregate token counts, model/provider names, costs,
63
+ source names, and dates.
64
+ - It does not upload prompts, completions, source code, file contents, project
65
+ paths, or provider credentials.
38
66
  - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
39
- - After the first import, normal syncs inspect only the current and previous local day; use `sync --full` to reconcile older history.
40
- - The collector key is written with user-only permissions where the operating system supports them.
67
+ - A metadata inventory exits without parsing logs or using the network when
68
+ nothing changed.
69
+ - Normal changed syncs parse today or today plus yesterday. A bounded weekly
70
+ full reconciliation catches restored files, parser changes, and older logs.
71
+ - Full history means all retained local history from 2024 onward. Deleted or
72
+ never-persisted usage requires a provider export; no local tool can reconstruct it.
73
+ - Source totals that cannot be assigned to a model are retained as
74
+ `unattributed` rather than silently discarded.
75
+ - The collector key is written with user-only permissions where the operating
76
+ system supports them.
77
+ - A private random installation ID survives collector rotation, relinking, and
78
+ display-name changes. It is not a hardware fingerprint; the server stores only
79
+ its SHA-256 hash. Concurrent and repeated syncs are idempotent.
80
+ - Do not point two different installations at the same copied or network-mounted
81
+ log tree. Cross-installation copied-history deduplication is inherently
82
+ ambiguous and intentionally not guessed.
83
+
84
+ Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
85
+ self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
41
86
 
42
- Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development/self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
87
+ See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
88
+ the full support matrix and known boundaries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "license": "MIT",
6
6
  "author": "UsageMax",
@@ -15,11 +15,13 @@
15
15
  },
16
16
  "type": "module",
17
17
  "bin": {
18
- "usagemax": "./src/cli.js"
18
+ "usagemax": "src/cli.js"
19
19
  },
20
20
  "files": [
21
21
  "src/cli.js",
22
22
  "src/core.js",
23
+ "src/installation.js",
24
+ "src/sources.js",
23
25
  "README.md",
24
26
  "LICENSE"
25
27
  ],
package/src/cli.js CHANGED
@@ -1,24 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFile, spawn } from "node:child_process";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
6
6
  import { createRequire } from "node:module";
7
7
  import { homedir, platform } from "node:os";
8
- import { delimiter, dirname, extname, join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import process from "node:process";
10
10
  import { promisify } from "node:util";
11
11
 
12
12
  import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
13
+ import { stableInstallationId } from "./installation.js";
14
+ import { CCUSAGE_VERSION, ccusageEnvironment, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
13
15
 
14
16
  const require = createRequire(import.meta.url);
15
17
  const executeFile = promisify(execFile);
16
- const VERSION = "0.1.0";
18
+ const VERSION = "0.2.0";
17
19
  const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
18
20
  const CONFIG_FILE = "config.json";
19
21
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
20
- const MAX_FINGERPRINT_FILES = 50_000;
21
- const USAGE_EXTENSIONS = new Set([".db", ".json", ".jsonl", ".sqlite", ".sqlite3"]);
22
+ const FULL_RECONCILE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
22
23
 
23
24
  function configDirectory() {
24
25
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -66,6 +67,7 @@ function option(args, name) {
66
67
  function deviceLabel() {
67
68
  if (platform() === "darwin") return "Mac";
68
69
  if (platform() === "win32") return "Windows PC";
70
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) return `WSL · ${process.env.WSL_DISTRO_NAME}`;
69
71
  if (platform() === "linux") return "Linux computer";
70
72
  return "Computer";
71
73
  }
@@ -79,7 +81,7 @@ function help() {
79
81
  process.stdout.write(" [--no-sync] [--name <name>]\n");
80
82
  process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
81
83
  process.stdout.write(" usagemax status Show local link status\n");
82
- process.stdout.write(" usagemax doctor [--deep] Check sources without parsing logs\n");
84
+ process.stdout.write(" usagemax doctor [--deep] Check source coverage; --deep parses full history\n");
83
85
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
84
86
  process.stdout.write(" usagemax unlink Remove the local collector key\n");
85
87
  }
@@ -88,91 +90,21 @@ function ccusageCliPath() {
88
90
  return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
89
91
  }
90
92
 
91
- function configuredPaths(variable, fallbacks) {
92
- const configured = process.env[variable];
93
- return configured
94
- ? configured.split(",").flatMap((group) => group.split(delimiter)).map((value) => value.trim()).filter(Boolean)
95
- : fallbacks;
96
- }
97
-
98
- function usageRoots() {
99
- const home = homedir();
100
- return [
101
- ["claude", configuredPaths("CLAUDE_CONFIG_DIR", [join(home, ".config", "claude", "projects"), join(home, ".claude", "projects")])],
102
- ["codex", configuredPaths("CODEX_HOME", [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")])],
103
- ["opencode", configuredPaths("OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])],
104
- ["hermes", configuredPaths("HERMES_HOME", [join(home, ".hermes", "sessions")])],
105
- ["pi", configuredPaths("PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")])],
106
- ["copilot", configuredPaths("COPILOT_HOME", [join(home, ".copilot")])],
107
- ["gemini", configuredPaths("GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])],
108
- ].flatMap(([source, paths]) => paths.map((path) => ({ source, path })));
109
- }
110
-
111
- async function sourceInventory() {
112
- const hash = createHash("sha256");
113
- const sources = new Set();
114
- const roots = usageRoots();
115
- let files = 0;
116
- let truncated = false;
117
- for (const root of roots) {
118
- try {
119
- const rootStat = await stat(root.path);
120
- if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
121
- sources.add(root.source);
122
- } catch {
123
- continue;
124
- }
125
- const stack = [root.path];
126
- while (stack.length && files < MAX_FINGERPRINT_FILES) {
127
- const current = stack.pop();
128
- let entries;
129
- try {
130
- entries = await readdir(current, { withFileTypes: true });
131
- } catch {
132
- try {
133
- const metadata = await stat(current);
134
- if (metadata.isFile() && USAGE_EXTENSIONS.has(extname(current).toLowerCase())) {
135
- hash.update(`${root.source}\u0000${current}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
136
- files += 1;
137
- }
138
- } catch {}
139
- continue;
140
- }
141
- for (const entry of entries) {
142
- const path = join(current, entry.name);
143
- if (entry.isDirectory()) {
144
- if (!["node_modules", ".git", "cache", "tmp"].includes(entry.name)) stack.push(path);
145
- continue;
146
- }
147
- if (!entry.isFile() || !USAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
148
- try {
149
- const metadata = await stat(path);
150
- hash.update(`${root.source}\u0000${path}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
151
- files += 1;
152
- } catch {}
153
- if (files >= MAX_FINGERPRINT_FILES) {
154
- truncated = true;
155
- break;
156
- }
157
- }
158
- }
159
- }
160
- return { fingerprint: hash.digest("hex"), sources: [...sources].sort(), files, truncated };
161
- }
162
-
163
93
  async function ccusageJson(config, { full = false } = {}) {
164
- const args = [ccusageCliPath(), "daily", "--json", "--offline", "--by-agent", "--order", "asc"];
94
+ const args = [ccusageCliPath(), "daily", "--json", "--offline", "--mode", "calculate", "--timezone", "UTC", "--by-agent", "--order", "asc"];
165
95
  // Reconcile yesterday once after the UTC date changes. All other incremental
166
96
  // scans parse only today; a metadata fingerprint avoids invoking ccusage when
167
97
  // no supported local source changed at all.
168
- if (!full && config?.lastSyncAt) {
98
+ if (full) {
99
+ args.push("--since", "2024-01-01", "--until", new Date().toISOString().slice(0, 10));
100
+ } else if (config?.lastSyncAt) {
169
101
  const today = new Date().toISOString().slice(0, 10);
170
102
  args.push("--last", config.lastReconciledDay === today ? "1" : "2");
171
103
  }
172
104
  const { stdout } = await executeFile(process.execPath, args, {
173
105
  encoding: "utf8",
174
106
  maxBuffer: MAX_REPORT_BYTES,
175
- env: { ...process.env, NO_COLOR: "1" },
107
+ env: { ...await ccusageEnvironment(), NO_COLOR: "1" },
176
108
  });
177
109
  return JSON.parse(stdout);
178
110
  }
@@ -184,26 +116,32 @@ async function link(args) {
184
116
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
185
117
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
186
118
  const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
119
+ const previous = await readConfig();
120
+ const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
121
+ const headers = { "content-type": "application/json" };
122
+ if (previous?.token) headers.authorization = `Bearer ${previous.token}`;
187
123
  const response = await fetch(endpoint, {
188
124
  method: "POST",
189
- headers: { "content-type": "application/json" },
190
- body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION }),
125
+ headers,
126
+ body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
191
127
  signal: AbortSignal.timeout(15_000),
192
128
  });
193
129
  const body = await response.json().catch(() => ({}));
194
130
  if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
195
131
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
196
132
  if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
133
+ const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
134
+ const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
197
135
  const config = {
198
136
  version: 1,
199
137
  token: body.token,
200
138
  ingestUrl,
201
139
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
202
- profileHandle: typeof body.profileHandle === "string" ? body.profileHandle : undefined,
203
- deviceId: randomUUID(),
140
+ profileHandle,
141
+ deviceId,
204
142
  deviceName: name,
205
143
  linkedAt: new Date().toISOString(),
206
- snapshots: {},
144
+ snapshots: sameAccount ? previous.snapshots : {},
207
145
  };
208
146
  await writeConfig(config);
209
147
  process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
@@ -218,17 +156,28 @@ async function link(args) {
218
156
  async function sync(args, suppliedConfig) {
219
157
  const config = suppliedConfig || await readConfig();
220
158
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
221
- const full = args.includes("--full");
159
+ config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
160
+ const requestedFull = args.includes("--full");
222
161
  const inventory = await sourceInventory();
223
162
  const today = new Date().toISOString().slice(0, 10);
224
- if (!full && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
225
- const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false };
163
+ const knownSources = Array.isArray(config.knownSources) ? config.knownSources : [];
164
+ const foundNewSource = inventory.sources.some((source) => !knownSources.includes(source));
165
+ const lastFullSync = Date.parse(config.lastFullSyncAt || "");
166
+ const fullDue = config.sourceInventoryVersion !== SOURCE_INVENTORY_VERSION
167
+ || !Number.isFinite(lastFullSync)
168
+ || Date.now() - lastFullSync >= FULL_RECONCILE_INTERVAL_MS
169
+ || foundNewSource;
170
+ const full = requestedFull || fullDue;
171
+ if (!full && inventory.complete && config.lastSyncComplete && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
172
+ const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false, full: false };
226
173
  if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
227
174
  else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
228
175
  return;
229
176
  }
230
177
  const report = await ccusageJson(config, { full });
231
- const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, "ccusage@20.0.20");
178
+ const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, `ccusage@${CCUSAGE_VERSION}`);
179
+ config.lastSyncComplete = false;
180
+ await writeConfig(config);
232
181
  let accepted = 0;
233
182
  for (let offset = 0; offset < plan.length; offset += 100) {
234
183
  const batch = plan.slice(offset, offset + 100);
@@ -239,6 +188,7 @@ async function sync(args, suppliedConfig) {
239
188
  authorization: `Bearer ${config.token}`,
240
189
  "content-type": "application/json",
241
190
  "idempotency-key": batchId(config.deviceId, events),
191
+ "x-usagemax-device-id": config.deviceId,
242
192
  },
243
193
  body: JSON.stringify({ events }),
244
194
  signal: AbortSignal.timeout(30_000),
@@ -248,20 +198,21 @@ async function sync(args, suppliedConfig) {
248
198
  accepted += Number(body.accepted || 0);
249
199
  for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
250
200
  config.lastSyncAt = new Date().toISOString();
251
- config.lastReconciledDay = today;
252
- config.sourceFingerprint = inventory.fingerprint;
253
201
  await writeConfig(config);
254
202
  }
255
- if (!plan.length) {
256
- config.lastSyncAt = new Date().toISOString();
257
- config.lastReconciledDay = today;
258
- config.sourceFingerprint = inventory.fingerprint;
259
- await writeConfig(config);
260
- }
261
- const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true };
203
+ config.lastSyncAt = new Date().toISOString();
204
+ config.lastReconciledDay = today;
205
+ config.lastSyncComplete = true;
206
+ config.sourceInventoryVersion = SOURCE_INVENTORY_VERSION;
207
+ config.knownSources = [...new Set([...knownSources, ...inventory.sources, ...sourceSummary(report)])].sort();
208
+ if (inventory.complete) config.sourceFingerprint = inventory.fingerprint;
209
+ else delete config.sourceFingerprint;
210
+ if (full) config.lastFullSyncAt = config.lastSyncAt;
211
+ await writeConfig(config);
212
+ const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true, full };
262
213
  if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
263
214
  else {
264
- process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}.\n` : "Already up to date. No usage rows were uploaded.\n");
215
+ process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}${full ? " (full history)" : ""}.\n` : `Already up to date. No usage rows were uploaded${full ? " after a full-history reconciliation" : ""}.\n`);
265
216
  if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; UsageMax kept the prior high-water mark to prevent double counting.\n`);
266
217
  }
267
218
  }
@@ -272,8 +223,14 @@ async function status() {
272
223
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
273
224
  return;
274
225
  }
226
+ const stableId = await stableInstallationId(configDirectory(), config.deviceId);
227
+ if (config.deviceId !== stableId) {
228
+ config.deviceId = stableId;
229
+ await writeConfig(config);
230
+ }
275
231
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
276
232
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
233
+ process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
277
234
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
278
235
  }
279
236
 
@@ -282,8 +239,13 @@ async function doctor(args = []) {
282
239
  const inventory = await sourceInventory();
283
240
  process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
284
241
  process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
242
+ process.stdout.write(`Supported sources: ${SUPPORTED_SOURCES.join(", ")} (+ named pi-format stores)\n`);
243
+ if (platform() === "linux" && process.env.WSL_DISTRO_NAME) {
244
+ process.stdout.write(`Environment: WSL ${process.env.WSL_DISTRO_NAME}; its Linux home is collected separately from Windows\n`);
245
+ }
246
+ if (!inventory.complete) process.stdout.write(`Inventory: incomplete (${inventory.errors} read error(s)${inventory.truncated ? ", file limit reached" : ""}); no-change shortcut disabled\n`);
285
247
  if (args.includes("--deep")) {
286
- const report = await ccusageJson(config, { full: false });
248
+ const report = await ccusageJson(config, { full: true });
287
249
  process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
288
250
  }
289
251
  process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
@@ -291,7 +253,10 @@ async function doctor(args = []) {
291
253
 
292
254
  async function report(args) {
293
255
  const forwarded = args.length ? args : ["daily"];
294
- const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], { stdio: "inherit", env: process.env });
256
+ const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], {
257
+ stdio: "inherit",
258
+ env: await ccusageEnvironment(),
259
+ });
295
260
  const code = await new Promise((resolve, reject) => {
296
261
  child.once("error", reject);
297
262
  child.once("exit", (status) => resolve(status ?? 1));
@@ -307,7 +272,7 @@ async function removeLink() {
307
272
  return;
308
273
  }
309
274
  await unlink(path);
310
- process.stdout.write("Removed the local UsageMax collector key. Revoke the collector in your account if this computer is no longer trusted.\n");
275
+ process.stdout.write("Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
311
276
  }
312
277
 
313
278
  async function main() {
package/src/core.js CHANGED
@@ -22,12 +22,24 @@ function text(value, fallback, maximum = 120) {
22
22
  }
23
23
 
24
24
  function providerFor(model, source) {
25
- const name = model.toLowerCase();
25
+ const displayName = model.toLowerCase().replace(/^\[[^\]]+\]\s*/, "");
26
+ const name = displayName.replace(/^api[-_:]/, "");
27
+ if (name.startsWith("openrouter/")) return "openrouter";
28
+ if (name.startsWith("azure/") || name.startsWith("azure-openai/")) return "azure-openai";
29
+ if (name.startsWith("bedrock/") || name.startsWith("aws/")) return "aws-bedrock";
30
+ if (name.startsWith("vertex/") || name.startsWith("vertex_ai/") || name.startsWith("google/")) return "google";
26
31
  if (name.includes("claude")) return "anthropic";
27
32
  if (/^(?:gpt|o[1345]|codex)/.test(name) || name.includes("openai")) return "openai";
28
33
  if (name.includes("gemini")) return "google";
29
34
  if (name.includes("grok")) return "xai";
30
35
  if (name.includes("deepseek")) return "deepseek";
36
+ if (name.includes("kimi") || name.includes("moonshot")) return "moonshot";
37
+ if (name.includes("qwen")) return "alibaba";
38
+ if (name.includes("glm") || name.includes("zai") || name.includes("z.ai")) return "zai";
39
+ if (name.includes("mistral") || name.includes("codestral")) return "mistral";
40
+ if (name.includes("llama") || name.includes("meta/")) return "meta";
41
+ if (name.includes("minimax")) return "minimax";
42
+ if (name.includes("command-r") || name.includes("cohere")) return "cohere";
31
43
  return source;
32
44
  }
33
45
 
@@ -74,6 +86,8 @@ function currentRows(report) {
74
86
  cacheReadTokens: rawAgent.cacheReadTokens,
75
87
  cost: rawAgent.totalCost,
76
88
  }];
89
+ let allocatedTokens = 0;
90
+ let allocatedCostMicros = 0;
77
91
  for (const rawBreakdown of breakdowns) {
78
92
  if (!rawBreakdown || typeof rawBreakdown !== "object") continue;
79
93
  const model = text(rawBreakdown.modelName, "unknown", 120);
@@ -82,6 +96,10 @@ function currentRows(report) {
82
96
  const cacheWriteTokens = number(rawBreakdown.cacheCreationTokens);
83
97
  const cacheReadTokens = number(rawBreakdown.cacheReadTokens);
84
98
  const costMicros = moneyMicros(rawBreakdown.cost);
99
+ const classifiedTokens = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
100
+ const totalTokens = Math.max(classifiedTokens, number(rawBreakdown.totalTokens));
101
+ allocatedTokens += totalTokens;
102
+ allocatedCostMicros += costMicros;
85
103
  rows.push({
86
104
  key: `${source}\u001f${period}\u001f${model}`,
87
105
  source,
@@ -92,11 +110,29 @@ function currentRows(report) {
92
110
  outputTokens,
93
111
  cacheReadTokens,
94
112
  cacheWriteTokens,
95
- totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
113
+ totalTokens,
96
114
  costMicros,
97
115
  },
98
116
  });
99
117
  }
118
+ const residualTokens = Math.max(0, number(rawAgent.totalTokens) - allocatedTokens);
119
+ const residualCostMicros = Math.max(0, moneyMicros(rawAgent.totalCost) - allocatedCostMicros);
120
+ if (residualTokens > 0 || residualCostMicros > 0) {
121
+ rows.push({
122
+ key: `${source}\u001f${period}\u001funattributed`,
123
+ source,
124
+ period,
125
+ model: "unattributed",
126
+ current: {
127
+ inputTokens: 0,
128
+ outputTokens: 0,
129
+ cacheReadTokens: 0,
130
+ cacheWriteTokens: 0,
131
+ totalTokens: residualTokens,
132
+ costMicros: residualCostMicros,
133
+ },
134
+ });
135
+ }
100
136
  }
101
137
  }
102
138
  return rows;
@@ -0,0 +1,38 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { platform } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const INSTALLATION_FILE = "installation.json";
7
+
8
+ export function validInstallationId(value) {
9
+ return typeof value === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
10
+ }
11
+
12
+ export async function stableInstallationId(directory, preferred) {
13
+ const path = join(directory, INSTALLATION_FILE);
14
+ try {
15
+ const parsed = JSON.parse(await readFile(path, "utf8"));
16
+ if (validInstallationId(parsed?.id)) return parsed.id;
17
+ throw new Error(`UsageMax installation identity is invalid: ${path}`);
18
+ } catch (error) {
19
+ if (error?.code !== "ENOENT") throw error;
20
+ }
21
+
22
+ const id = validInstallationId(preferred) ? preferred : randomUUID();
23
+ await mkdir(directory, { recursive: true, mode: 0o700 });
24
+ try {
25
+ await writeFile(path, `${JSON.stringify({ version: 1, id }, null, 2)}\n`, {
26
+ encoding: "utf8",
27
+ mode: 0o600,
28
+ flag: "wx",
29
+ });
30
+ if (platform() !== "win32") await chmod(path, 0o600);
31
+ return id;
32
+ } catch (error) {
33
+ if (error?.code !== "EEXIST") throw error;
34
+ const parsed = JSON.parse(await readFile(path, "utf8"));
35
+ if (validInstallationId(parsed?.id)) return parsed.id;
36
+ throw new Error(`UsageMax installation identity is invalid: ${path}`);
37
+ }
38
+ }
package/src/sources.js ADDED
@@ -0,0 +1,331 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const CCUSAGE_VERSION = "20.0.20";
7
+ export const SOURCE_INVENTORY_VERSION = 2;
8
+ export const SUPPORTED_SOURCES = [
9
+ "amp",
10
+ "claude",
11
+ "codebuff",
12
+ "codex",
13
+ "copilot",
14
+ "droid",
15
+ "gemini",
16
+ "goose",
17
+ "grok",
18
+ "hermes",
19
+ "kilo",
20
+ "kimi",
21
+ "openclaw",
22
+ "opencode",
23
+ "pi",
24
+ "qwen",
25
+ ];
26
+
27
+ const MAX_FINGERPRINT_FILES = 50_000;
28
+
29
+ function has(env, name) {
30
+ return Object.prototype.hasOwnProperty.call(env, name);
31
+ }
32
+
33
+ function commaList(value) {
34
+ return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
35
+ }
36
+
37
+ function envList(env, name, fallback, { blankUsesFallback = false } = {}) {
38
+ if (!has(env, name) || (blankUsesFallback && !String(env[name] ?? "").trim())) return fallback;
39
+ return commaList(env[name]);
40
+ }
41
+
42
+ function expandTilde(value, home, pathApi) {
43
+ if (value === "~") return home;
44
+ if (value.startsWith("~/") || value.startsWith("~\\")) return pathApi.join(home, value.slice(2));
45
+ return value;
46
+ }
47
+
48
+ export function ccusageHome(env = process.env, fallback = homedir()) {
49
+ for (const value of [env.HOME, env.USERPROFILE]) {
50
+ if (typeof value === "string" && value.trim()) return value;
51
+ }
52
+ const windowsHome = `${env.HOMEDRIVE ?? ""}${env.HOMEPATH ?? ""}`;
53
+ return windowsHome.trim() || fallback;
54
+ }
55
+
56
+ function tree(source, root, matcher) {
57
+ return { source, path: root, kind: "tree", matcher };
58
+ }
59
+
60
+ function file(source, filePath) {
61
+ return { source, path: filePath, kind: "file", matcher: "any" };
62
+ }
63
+
64
+ function db(source, filePath) {
65
+ return [file(source, filePath), file(source, `${filePath}-wal`), file(source, `${filePath}-shm`)];
66
+ }
67
+
68
+ export function sourceDefinitions({ env = process.env, home = ccusageHome(env), cwd = process.cwd(), pathApi = path } = {}) {
69
+ const join = (...parts) => pathApi.join(...parts);
70
+ const definitions = [];
71
+
72
+ const claudeRoots = has(env, "CLAUDE_CONFIG_DIR")
73
+ ? commaList(env.CLAUDE_CONFIG_DIR).map((item) => expandTilde(item, home, pathApi))
74
+ : [join(has(env, "XDG_CONFIG_HOME") ? String(env.XDG_CONFIG_HOME) : join(home, ".config"), "claude"), join(home, ".claude")];
75
+ for (const root of claudeRoots) {
76
+ const projects = pathApi.basename(root) === "projects" ? root : join(root, "projects");
77
+ definitions.push(tree("claude", projects, "jsonl"));
78
+ }
79
+
80
+ for (const root of envList(env, "CODEX_HOME", [join(home, ".codex")])) {
81
+ definitions.push({ source: "codex", path: root, kind: "codex-home", matcher: "jsonl" });
82
+ }
83
+ for (const root of envList(env, "OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])) {
84
+ definitions.push(tree("opencode", root, "opencode"));
85
+ }
86
+ for (const root of envList(env, "AMP_DATA_DIR", [join(home, ".local", "share", "amp")])) {
87
+ definitions.push(tree("amp", join(root, "threads"), "json"));
88
+ }
89
+ for (const root of envList(env, "DROID_SESSIONS_DIR", [join(home, ".factory", "sessions")])) {
90
+ definitions.push(tree("droid", root, "droid"));
91
+ }
92
+
93
+ const codebuffRoots = has(env, "CODEBUFF_DATA_DIR")
94
+ ? commaList(env.CODEBUFF_DATA_DIR)
95
+ : ["manicode", "manicode-dev", "manicode-staging"].map((channel) => join(home, ".config", channel));
96
+ for (const root of codebuffRoots) {
97
+ definitions.push(tree("codebuff", pathApi.basename(root) === "projects" ? root : join(root, "projects"), "codebuff"));
98
+ }
99
+
100
+ for (const root of envList(env, "HERMES_HOME", [join(home, ".hermes")])) {
101
+ definitions.push(...db("hermes", join(root, "state.db")));
102
+ }
103
+ for (const root of envList(env, "PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")], { blankUsesFallback: true })) {
104
+ definitions.push(tree("pi", root, "jsonl"));
105
+ }
106
+
107
+ const gooseRoot = String(env.GOOSE_PATH_ROOT ?? "").trim();
108
+ const gooseDatabases = gooseRoot
109
+ ? [join(gooseRoot, "data", "sessions", "sessions.db")]
110
+ : [
111
+ join(home, ".local", "share", "goose", "sessions", "sessions.db"),
112
+ join(home, "Library", "Application Support", "goose", "sessions", "sessions.db"),
113
+ join(home, ".local", "share", "Block", "goose", "sessions", "sessions.db"),
114
+ ];
115
+ if (!gooseRoot && String(env.APPDATA ?? "").trim()) {
116
+ gooseDatabases.push(join(String(env.APPDATA).trim(), "Block", "goose", "data", "sessions", "sessions.db"));
117
+ }
118
+ for (const database of gooseDatabases) definitions.push(...db("goose", database));
119
+
120
+ const openClawRoots = envList(env, "OPENCLAW_DIR", [
121
+ join(home, ".openclaw"),
122
+ join(home, ".clawdbot"),
123
+ join(home, ".moltbot"),
124
+ join(home, ".moldbot"),
125
+ ], { blankUsesFallback: true });
126
+ for (const root of openClawRoots) definitions.push(tree("openclaw", root, "openclaw"));
127
+
128
+ for (const root of envList(env, "KILO_DATA_DIR", [join(home, ".local", "share", "kilo")])) {
129
+ definitions.push(...db("kilo", join(root, "kilo.db")));
130
+ }
131
+ for (const root of envList(env, "KIMI_DATA_DIR", [join(home, ".kimi"), join(home, ".kimi-code")])) {
132
+ definitions.push(tree("kimi", join(root, "sessions"), "jsonl"), file("kimi", join(root, "config.json")));
133
+ }
134
+ for (const root of envList(env, "QWEN_DATA_DIR", [join(home, ".qwen")])) {
135
+ definitions.push(tree("qwen", join(root, "projects"), "jsonl"));
136
+ }
137
+
138
+ definitions.push(tree("copilot", join(home, ".copilot", "otel"), "jsonl"));
139
+ if (String(env.COPILOT_OTEL_FILE_EXPORTER_PATH ?? "").trim()) {
140
+ definitions.push(file("copilot", String(env.COPILOT_OTEL_FILE_EXPORTER_PATH).trim()));
141
+ }
142
+ for (const root of envList(env, "GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])) {
143
+ definitions.push(tree("gemini", root, "gemini"));
144
+ }
145
+ const grokRoot = String(env.GROK_HOME ?? "").trim() || join(home, ".grok");
146
+ definitions.push(tree("grok", join(grokRoot, "sessions"), "grok"));
147
+
148
+ const configRoots = has(env, "CLAUDE_CONFIG_DIR")
149
+ ? commaList(env.CLAUDE_CONFIG_DIR)
150
+ : [join(home, ".config", "claude"), join(home, ".claude")];
151
+ const configPaths = [join(cwd, ".ccusage", "ccusage.json"), ...configRoots.map((root) => join(root, "ccusage.json"))];
152
+ for (const configPath of configPaths) definitions.push(file("ccusage-config", configPath));
153
+
154
+ return definitions;
155
+ }
156
+
157
+ export async function ccusageEnvironment({ env = process.env, platform = process.platform, pathApi = path } = {}) {
158
+ const effective = { ...env };
159
+ if (platform !== "win32" || String(effective.GOOSE_PATH_ROOT ?? "").trim() || !String(effective.APPDATA ?? "").trim()) {
160
+ return effective;
161
+ }
162
+ const gooseRoot = pathApi.join(String(effective.APPDATA).trim(), "Block", "goose");
163
+ try {
164
+ if ((await stat(pathApi.join(gooseRoot, "data", "sessions", "sessions.db"))).isFile()) effective.GOOSE_PATH_ROOT = gooseRoot;
165
+ } catch {}
166
+ return effective;
167
+ }
168
+
169
+ function accepts(matcher, filePath, pathApi) {
170
+ const name = pathApi.basename(filePath);
171
+ if (matcher === "any") return true;
172
+ if (matcher === "jsonl") return name.endsWith(".jsonl");
173
+ if (matcher === "json") return name.endsWith(".json");
174
+ if (matcher === "gemini") return name.endsWith(".json") || name.endsWith(".jsonl");
175
+ if (matcher === "codebuff") return name === "chat-messages.json";
176
+ if (matcher === "droid") return name.endsWith(".settings.json") || name.endsWith(".jsonl");
177
+ if (matcher === "openclaw") return name.includes(".jsonl");
178
+ if (matcher === "grok") return name === "updates.jsonl" || name === "summary.json";
179
+ if (matcher === "opencode") return name.endsWith(".db") || name.includes(".db-") || name.endsWith(".json");
180
+ return false;
181
+ }
182
+
183
+ async function existsDirectory(value) {
184
+ try {
185
+ return (await stat(value)).isDirectory();
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ async function expandCodexRoots(definitions, pathApi) {
192
+ const expanded = [];
193
+ for (const definition of definitions) {
194
+ if (definition.kind !== "codex-home") {
195
+ expanded.push(definition);
196
+ continue;
197
+ }
198
+ const sessions = pathApi.join(definition.path, "sessions");
199
+ const archived = pathApi.join(definition.path, "archived_sessions");
200
+ const [hasSessions, hasArchived] = await Promise.all([existsDirectory(sessions), existsDirectory(archived)]);
201
+ if (hasSessions) expanded.push(tree("codex", sessions, "jsonl"));
202
+ if (hasArchived) expanded.push(tree("codex", archived, "jsonl"));
203
+ if (!hasSessions && !hasArchived) expanded.push(tree("codex", definition.path, "jsonl"));
204
+ }
205
+ return expanded;
206
+ }
207
+
208
+ function configCandidates(definitions) {
209
+ return definitions.filter((item) => item.source === "ccusage-config").map((item) => item.path);
210
+ }
211
+
212
+ async function namedPiStores(definitions, home, pathApi) {
213
+ for (const configPath of configCandidates(definitions)) {
214
+ try {
215
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
216
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
217
+ const stores = Array.isArray(parsed.pi?.stores) ? parsed.pi.stores : [];
218
+ return stores.flatMap((store) => {
219
+ if (!store || typeof store.name !== "string" || typeof store.path !== "string" || !store.path.trim()) return [];
220
+ return [tree(store.name.trim().toLowerCase(), expandTilde(store.path.trim(), home, pathApi), "jsonl")];
221
+ });
222
+ } catch {}
223
+ }
224
+ return [];
225
+ }
226
+
227
+ function fileIdentity(source, filePath, metadata) {
228
+ return `${source}\u0000${filePath}\u0000${metadata.size}\u0000${metadata.mtimeMs}\u0000${metadata.ctimeMs}\n`;
229
+ }
230
+
231
+ export async function sourceInventory({ env = process.env, cwd = process.cwd(), home = ccusageHome(env), pathApi = path, maxFiles = MAX_FINGERPRINT_FILES } = {}) {
232
+ const hash = createHash("sha256");
233
+ hash.update(`inventory:${SOURCE_INVENTORY_VERSION}\nccusage:${CCUSAGE_VERSION}\naliases:${env.CCUSAGE_MODEL_ALIASES ?? ""}\n`);
234
+ let definitions = sourceDefinitions({ env, cwd, home, pathApi });
235
+ definitions.push(...await namedPiStores(definitions, home, pathApi));
236
+ definitions = await expandCodexRoots(definitions, pathApi);
237
+ definitions.sort((left, right) => `${left.source}\u0000${left.path}`.localeCompare(`${right.source}\u0000${right.path}`));
238
+
239
+ const sources = new Set();
240
+ const seenRoots = new Set();
241
+ const seenFiles = new Set();
242
+ let files = 0;
243
+ let truncated = false;
244
+ let complete = true;
245
+ let errors = 0;
246
+
247
+ async function addFile(definition, filePath) {
248
+ const identity = `${definition.source}\u0000${filePath}`;
249
+ if (seenFiles.has(identity)) return;
250
+ if (files >= maxFiles) {
251
+ truncated = true;
252
+ complete = false;
253
+ return;
254
+ }
255
+ try {
256
+ const metadata = await stat(filePath);
257
+ if (!metadata.isFile()) return;
258
+ seenFiles.add(identity);
259
+ hash.update(fileIdentity(definition.source, filePath, metadata));
260
+ if (definition.source === "ccusage-config") {
261
+ try {
262
+ hash.update(await readFile(filePath));
263
+ } catch {
264
+ complete = false;
265
+ errors += 1;
266
+ }
267
+ } else {
268
+ sources.add(definition.source);
269
+ }
270
+ files += 1;
271
+ } catch (error) {
272
+ if (error?.code !== "ENOENT") {
273
+ complete = false;
274
+ errors += 1;
275
+ }
276
+ }
277
+ }
278
+
279
+ for (const definition of definitions) {
280
+ const rootIdentity = `${definition.source}\u0000${definition.kind}\u0000${definition.path}`;
281
+ if (seenRoots.has(rootIdentity)) continue;
282
+ seenRoots.add(rootIdentity);
283
+ hash.update(`root:${rootIdentity}\n`);
284
+ if (definition.kind === "file") {
285
+ await addFile(definition, definition.path);
286
+ continue;
287
+ }
288
+ let rootMetadata;
289
+ try {
290
+ rootMetadata = await stat(definition.path);
291
+ } catch (error) {
292
+ if (error?.code !== "ENOENT") {
293
+ complete = false;
294
+ errors += 1;
295
+ }
296
+ continue;
297
+ }
298
+ if (!rootMetadata.isDirectory()) continue;
299
+ const stack = [definition.path];
300
+ while (stack.length && !truncated) {
301
+ const current = stack.pop();
302
+ let entries;
303
+ try {
304
+ entries = await readdir(current, { withFileTypes: true });
305
+ } catch {
306
+ complete = false;
307
+ errors += 1;
308
+ continue;
309
+ }
310
+ entries.sort((left, right) => left.name.localeCompare(right.name));
311
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
312
+ const entry = entries[index];
313
+ const filePath = pathApi.join(current, entry.name);
314
+ if (entry.isDirectory()) stack.push(filePath);
315
+ else if (entry.isFile() && accepts(definition.matcher, filePath, pathApi)) await addFile(definition, filePath);
316
+ if (truncated) break;
317
+ }
318
+ }
319
+ }
320
+
321
+ return {
322
+ complete,
323
+ errors,
324
+ files,
325
+ fingerprint: hash.digest("hex"),
326
+ sources: [...sources].sort(),
327
+ supportedSources: SUPPORTED_SOURCES,
328
+ truncated,
329
+ version: SOURCE_INVENTORY_VERSION,
330
+ };
331
+ }