usagemax 0.1.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/LICENSE +21 -0
- package/README.md +42 -0
- package/package.json +40 -0
- package/src/cli.js +330 -0
- package/src/core.js +174 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UsageMax
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# UsageMax CLI
|
|
2
|
+
|
|
3
|
+
Connect aggregate coding-agent usage from your computer to a private UsageMax workspace.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
1. Sign in at [usagemax.com/account](https://usagemax.com/account).
|
|
8
|
+
2. Choose **Link a computer** and copy the one-time command.
|
|
9
|
+
3. Run it locally:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
|
|
13
|
+
```
|
|
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.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
```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
|
|
28
|
+
bunx usagemax report session --breakdown
|
|
29
|
+
bunx usagemax unlink # remove the local collector key
|
|
30
|
+
```
|
|
31
|
+
|
|
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.
|
|
33
|
+
|
|
34
|
+
## Privacy and load
|
|
35
|
+
|
|
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.
|
|
38
|
+
- 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.
|
|
41
|
+
|
|
42
|
+
Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development/self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "usagemax",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "UsageMax",
|
|
7
|
+
"homepage": "https://usagemax.com",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/SYMBaiEX/usagemax/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/SYMBaiEX/usagemax.git",
|
|
14
|
+
"directory": "packages/cli"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"usagemax": "./src/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/cli.js",
|
|
22
|
+
"src/core.js",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test src/*.test.js",
|
|
34
|
+
"pack:check": "npm pack --dry-run",
|
|
35
|
+
"prepublishOnly": "npm test"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"ccusage": "20.0.20"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
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";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { homedir, platform } from "node:os";
|
|
8
|
+
import { delimiter, dirname, extname, join } from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const executeFile = promisify(execFile);
|
|
16
|
+
const VERSION = "0.1.0";
|
|
17
|
+
const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
|
|
18
|
+
const CONFIG_FILE = "config.json";
|
|
19
|
+
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
|
+
|
|
23
|
+
function configDirectory() {
|
|
24
|
+
if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
|
|
25
|
+
if (platform() === "win32") return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "UsageMax");
|
|
26
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "usagemax");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function configPath() {
|
|
30
|
+
return join(configDirectory(), CONFIG_FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readConfig() {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(await readFile(configPath(), "utf8"));
|
|
36
|
+
if (!parsed || parsed.version !== 1 || !/^umx_[a-f0-9]{64}$/.test(parsed.token || "")) return null;
|
|
37
|
+
if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
|
|
38
|
+
if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
|
|
39
|
+
parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
|
|
40
|
+
return parsed;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function writeConfig(config) {
|
|
47
|
+
const path = configPath();
|
|
48
|
+
const directory = dirname(path);
|
|
49
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
50
|
+
const temporary = join(directory, `.config.${process.pid}.${randomUUID()}.tmp`);
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
53
|
+
await rename(temporary, path);
|
|
54
|
+
if (platform() !== "win32") await chmod(path, 0o600);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await unlink(temporary).catch(() => undefined);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function option(args, name) {
|
|
62
|
+
const index = args.indexOf(name);
|
|
63
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function deviceLabel() {
|
|
67
|
+
if (platform() === "darwin") return "Mac";
|
|
68
|
+
if (platform() === "win32") return "Windows PC";
|
|
69
|
+
if (platform() === "linux") return "Linux computer";
|
|
70
|
+
return "Computer";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function help() {
|
|
74
|
+
process.stdout.write(`UsageMax ${VERSION}\n\n`);
|
|
75
|
+
process.stdout.write("Link aggregate coding-agent usage to your UsageMax profile.\n\n");
|
|
76
|
+
process.stdout.write("Commands:\n");
|
|
77
|
+
process.stdout.write(" usagemax Sync changed local usage\n");
|
|
78
|
+
process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
|
|
79
|
+
process.stdout.write(" [--no-sync] [--name <name>]\n");
|
|
80
|
+
process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
|
|
81
|
+
process.stdout.write(" usagemax status Show local link status\n");
|
|
82
|
+
process.stdout.write(" usagemax doctor [--deep] Check sources without parsing logs\n");
|
|
83
|
+
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
84
|
+
process.stdout.write(" usagemax unlink Remove the local collector key\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ccusageCliPath() {
|
|
88
|
+
return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
|
|
89
|
+
}
|
|
90
|
+
|
|
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
|
+
async function ccusageJson(config, { full = false } = {}) {
|
|
164
|
+
const args = [ccusageCliPath(), "daily", "--json", "--offline", "--by-agent", "--order", "asc"];
|
|
165
|
+
// Reconcile yesterday once after the UTC date changes. All other incremental
|
|
166
|
+
// scans parse only today; a metadata fingerprint avoids invoking ccusage when
|
|
167
|
+
// no supported local source changed at all.
|
|
168
|
+
if (!full && config?.lastSyncAt) {
|
|
169
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
170
|
+
args.push("--last", config.lastReconciledDay === today ? "1" : "2");
|
|
171
|
+
}
|
|
172
|
+
const { stdout } = await executeFile(process.execPath, args, {
|
|
173
|
+
encoding: "utf8",
|
|
174
|
+
maxBuffer: MAX_REPORT_BYTES,
|
|
175
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
176
|
+
});
|
|
177
|
+
return JSON.parse(stdout);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function link(args) {
|
|
181
|
+
const code = normalizeLinkCode(args[0]);
|
|
182
|
+
if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
|
|
183
|
+
const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
|
|
184
|
+
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
185
|
+
if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
|
|
186
|
+
const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
|
|
187
|
+
const response = await fetch(endpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/json" },
|
|
190
|
+
body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION }),
|
|
191
|
+
signal: AbortSignal.timeout(15_000),
|
|
192
|
+
});
|
|
193
|
+
const body = await response.json().catch(() => ({}));
|
|
194
|
+
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
|
+
const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
|
|
196
|
+
if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
|
|
197
|
+
const config = {
|
|
198
|
+
version: 1,
|
|
199
|
+
token: body.token,
|
|
200
|
+
ingestUrl,
|
|
201
|
+
profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
|
|
202
|
+
profileHandle: typeof body.profileHandle === "string" ? body.profileHandle : undefined,
|
|
203
|
+
deviceId: randomUUID(),
|
|
204
|
+
deviceName: name,
|
|
205
|
+
linkedAt: new Date().toISOString(),
|
|
206
|
+
snapshots: {},
|
|
207
|
+
};
|
|
208
|
+
await writeConfig(config);
|
|
209
|
+
process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
|
|
210
|
+
if (args.includes("--no-sync")) {
|
|
211
|
+
process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
process.stdout.write("Running the first one-shot sync…\n");
|
|
215
|
+
await sync(["--full"], config);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function sync(args, suppliedConfig) {
|
|
219
|
+
const config = suppliedConfig || await readConfig();
|
|
220
|
+
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");
|
|
222
|
+
const inventory = await sourceInventory();
|
|
223
|
+
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 };
|
|
226
|
+
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
227
|
+
else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const report = await ccusageJson(config, { full });
|
|
231
|
+
const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, "ccusage@20.0.20");
|
|
232
|
+
let accepted = 0;
|
|
233
|
+
for (let offset = 0; offset < plan.length; offset += 100) {
|
|
234
|
+
const batch = plan.slice(offset, offset + 100);
|
|
235
|
+
const events = batch.map((item) => item.event);
|
|
236
|
+
const response = await fetch(config.ingestUrl, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: {
|
|
239
|
+
authorization: `Bearer ${config.token}`,
|
|
240
|
+
"content-type": "application/json",
|
|
241
|
+
"idempotency-key": batchId(config.deviceId, events),
|
|
242
|
+
},
|
|
243
|
+
body: JSON.stringify({ events }),
|
|
244
|
+
signal: AbortSignal.timeout(30_000),
|
|
245
|
+
});
|
|
246
|
+
const body = await response.json().catch(() => ({}));
|
|
247
|
+
if (!response.ok) throw new Error(body?.error === "unauthorized" ? "This collector key is no longer valid. Link the computer again." : `UsageMax rejected a sync batch (${response.status}).`);
|
|
248
|
+
accepted += Number(body.accepted || 0);
|
|
249
|
+
for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
|
|
250
|
+
config.lastSyncAt = new Date().toISOString();
|
|
251
|
+
config.lastReconciledDay = today;
|
|
252
|
+
config.sourceFingerprint = inventory.fingerprint;
|
|
253
|
+
await writeConfig(config);
|
|
254
|
+
}
|
|
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 };
|
|
262
|
+
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
263
|
+
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");
|
|
265
|
+
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
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function status() {
|
|
270
|
+
const config = await readConfig();
|
|
271
|
+
if (!config) {
|
|
272
|
+
process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
|
|
276
|
+
process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
|
|
277
|
+
process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function doctor(args = []) {
|
|
281
|
+
const config = await readConfig();
|
|
282
|
+
const inventory = await sourceInventory();
|
|
283
|
+
process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
|
|
284
|
+
process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
|
|
285
|
+
if (args.includes("--deep")) {
|
|
286
|
+
const report = await ccusageJson(config, { full: false });
|
|
287
|
+
process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
|
|
288
|
+
}
|
|
289
|
+
process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function report(args) {
|
|
293
|
+
const forwarded = args.length ? args : ["daily"];
|
|
294
|
+
const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], { stdio: "inherit", env: process.env });
|
|
295
|
+
const code = await new Promise((resolve, reject) => {
|
|
296
|
+
child.once("error", reject);
|
|
297
|
+
child.once("exit", (status) => resolve(status ?? 1));
|
|
298
|
+
});
|
|
299
|
+
if (code !== 0) process.exitCode = code;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function removeLink() {
|
|
303
|
+
const path = configPath();
|
|
304
|
+
const config = await readConfig();
|
|
305
|
+
if (!config) {
|
|
306
|
+
process.stdout.write("This computer is not linked.\n");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
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");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function main() {
|
|
314
|
+
const args = process.argv.slice(2);
|
|
315
|
+
const command = args[0] || "sync";
|
|
316
|
+
if (["--help", "-h", "help"].includes(command)) return help();
|
|
317
|
+
if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
|
|
318
|
+
if (command === "link") return link(args.slice(1));
|
|
319
|
+
if (command === "sync") return sync(args.slice(1));
|
|
320
|
+
if (command === "status") return status();
|
|
321
|
+
if (command === "doctor") return doctor(args.slice(1));
|
|
322
|
+
if (command === "report") return report(args.slice(1));
|
|
323
|
+
if (command === "unlink") return removeLink();
|
|
324
|
+
throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
main().catch((error) => {
|
|
328
|
+
process.stderr.write(`UsageMax: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
});
|
package/src/core.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_SAFE_COUNTER = 1_000_000_000_000;
|
|
4
|
+
const MAX_SAFE_COST_MICROS = 1_000_000_000_000_000;
|
|
5
|
+
|
|
6
|
+
function number(value, maximum = MAX_SAFE_COUNTER) {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
8
|
+
? Math.min(maximum, Math.max(0, Math.round(value)))
|
|
9
|
+
: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function moneyMicros(value) {
|
|
13
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
14
|
+
? Math.min(MAX_SAFE_COST_MICROS, Math.max(0, Math.round(value * 1_000_000)))
|
|
15
|
+
: 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function text(value, fallback, maximum = 120) {
|
|
19
|
+
if (typeof value !== "string") return fallback;
|
|
20
|
+
const clean = value.trim().replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, maximum);
|
|
21
|
+
return clean || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function providerFor(model, source) {
|
|
25
|
+
const name = model.toLowerCase();
|
|
26
|
+
if (name.includes("claude")) return "anthropic";
|
|
27
|
+
if (/^(?:gpt|o[1345]|codex)/.test(name) || name.includes("openai")) return "openai";
|
|
28
|
+
if (name.includes("gemini")) return "google";
|
|
29
|
+
if (name.includes("grok")) return "xai";
|
|
30
|
+
if (name.includes("deepseek")) return "deepseek";
|
|
31
|
+
return source;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sha256(value) {
|
|
35
|
+
return createHash("sha256").update(value).digest("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeLinkCode(value) {
|
|
39
|
+
const compact = text(value, "", 64).toUpperCase().replace(/\s+/g, "-");
|
|
40
|
+
return /^UMX-[A-HJ-NP-Z2-9]{4}(?:-[A-HJ-NP-Z2-9]{4}){3}$/.test(compact) ? compact : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validHttpsUrl(value, { allowLocalhost = false } = {}) {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(value);
|
|
46
|
+
if (url.protocol === "https:") return url.toString();
|
|
47
|
+
if (allowLocalhost && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname)) {
|
|
48
|
+
return url.toString();
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function currentRows(report) {
|
|
55
|
+
if (!report || typeof report !== "object" || !Array.isArray(report.daily)) {
|
|
56
|
+
throw new Error("ccusage returned an unsupported report shape");
|
|
57
|
+
}
|
|
58
|
+
const rows = [];
|
|
59
|
+
for (const rawRow of report.daily) {
|
|
60
|
+
if (!rawRow || typeof rawRow !== "object") continue;
|
|
61
|
+
const period = text(rawRow.period, "", 10);
|
|
62
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(period)) continue;
|
|
63
|
+
const agentRows = Array.isArray(rawRow.agents) && rawRow.agents.length ? rawRow.agents : [rawRow];
|
|
64
|
+
for (const rawAgent of agentRows) {
|
|
65
|
+
if (!rawAgent || typeof rawAgent !== "object") continue;
|
|
66
|
+
const source = text(rawAgent.agent, "unknown", 60).toLowerCase();
|
|
67
|
+
const breakdowns = Array.isArray(rawAgent.modelBreakdowns) && rawAgent.modelBreakdowns.length
|
|
68
|
+
? rawAgent.modelBreakdowns
|
|
69
|
+
: [{
|
|
70
|
+
modelName: Array.isArray(rawAgent.modelsUsed) && rawAgent.modelsUsed.length === 1 ? rawAgent.modelsUsed[0] : "mixed",
|
|
71
|
+
inputTokens: rawAgent.inputTokens,
|
|
72
|
+
outputTokens: rawAgent.outputTokens,
|
|
73
|
+
cacheCreationTokens: rawAgent.cacheCreationTokens,
|
|
74
|
+
cacheReadTokens: rawAgent.cacheReadTokens,
|
|
75
|
+
cost: rawAgent.totalCost,
|
|
76
|
+
}];
|
|
77
|
+
for (const rawBreakdown of breakdowns) {
|
|
78
|
+
if (!rawBreakdown || typeof rawBreakdown !== "object") continue;
|
|
79
|
+
const model = text(rawBreakdown.modelName, "unknown", 120);
|
|
80
|
+
const inputTokens = number(rawBreakdown.inputTokens);
|
|
81
|
+
const outputTokens = number(rawBreakdown.outputTokens);
|
|
82
|
+
const cacheWriteTokens = number(rawBreakdown.cacheCreationTokens);
|
|
83
|
+
const cacheReadTokens = number(rawBreakdown.cacheReadTokens);
|
|
84
|
+
const costMicros = moneyMicros(rawBreakdown.cost);
|
|
85
|
+
rows.push({
|
|
86
|
+
key: `${source}\u001f${period}\u001f${model}`,
|
|
87
|
+
source,
|
|
88
|
+
period,
|
|
89
|
+
model,
|
|
90
|
+
current: {
|
|
91
|
+
inputTokens,
|
|
92
|
+
outputTokens,
|
|
93
|
+
cacheReadTokens,
|
|
94
|
+
cacheWriteTokens,
|
|
95
|
+
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
96
|
+
costMicros,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion = "ccusage") {
|
|
106
|
+
const snapshots = priorSnapshots && typeof priorSnapshots === "object" ? priorSnapshots : {};
|
|
107
|
+
const plan = [];
|
|
108
|
+
const regressions = [];
|
|
109
|
+
for (const row of currentRows(report)) {
|
|
110
|
+
const prior = snapshots[row.key] && typeof snapshots[row.key] === "object" ? snapshots[row.key] : {};
|
|
111
|
+
const delta = {
|
|
112
|
+
inputTokens: Math.max(0, row.current.inputTokens - number(prior.inputTokens)),
|
|
113
|
+
outputTokens: Math.max(0, row.current.outputTokens - number(prior.outputTokens)),
|
|
114
|
+
cacheReadTokens: Math.max(0, row.current.cacheReadTokens - number(prior.cacheReadTokens)),
|
|
115
|
+
cacheWriteTokens: Math.max(0, row.current.cacheWriteTokens - number(prior.cacheWriteTokens)),
|
|
116
|
+
totalTokens: Math.max(0, row.current.totalTokens - number(prior.totalTokens)),
|
|
117
|
+
costMicros: Math.max(0, row.current.costMicros - number(prior.costMicros, MAX_SAFE_COST_MICROS)),
|
|
118
|
+
};
|
|
119
|
+
if (Object.keys(delta).some((key) => row.current[key] < number(prior[key], key === "costMicros" ? MAX_SAFE_COST_MICROS : MAX_SAFE_COUNTER))) {
|
|
120
|
+
regressions.push(row.key);
|
|
121
|
+
}
|
|
122
|
+
if (delta.totalTokens === 0 && delta.costMicros === 0) continue;
|
|
123
|
+
const snapshot = Object.fromEntries(Object.keys(row.current).map((key) => [
|
|
124
|
+
key,
|
|
125
|
+
Math.max(
|
|
126
|
+
row.current[key],
|
|
127
|
+
number(prior[key], key === "costMicros" ? MAX_SAFE_COST_MICROS : MAX_SAFE_COUNTER),
|
|
128
|
+
),
|
|
129
|
+
]));
|
|
130
|
+
const version = sha256(`${row.key}\u001f${JSON.stringify(snapshot)}`).slice(0, 24);
|
|
131
|
+
plan.push({
|
|
132
|
+
snapshotKey: row.key,
|
|
133
|
+
snapshot,
|
|
134
|
+
event: {
|
|
135
|
+
eventKey: `ccusage-v1:${deviceId}:${version}`,
|
|
136
|
+
agentId: `${deviceId}:${row.source}`,
|
|
137
|
+
agentName: row.source,
|
|
138
|
+
eventType: "model_request",
|
|
139
|
+
source: row.source,
|
|
140
|
+
provider: providerFor(row.model, row.source),
|
|
141
|
+
model: row.model,
|
|
142
|
+
inputTokens: delta.inputTokens,
|
|
143
|
+
outputTokens: delta.outputTokens,
|
|
144
|
+
cacheReadTokens: delta.cacheReadTokens,
|
|
145
|
+
cacheWriteTokens: delta.cacheWriteTokens,
|
|
146
|
+
totalTokens: delta.totalTokens,
|
|
147
|
+
costMicros: delta.costMicros,
|
|
148
|
+
costBasis: "estimated",
|
|
149
|
+
pricingSource: "ccusage / LiteLLM",
|
|
150
|
+
pricingVersion,
|
|
151
|
+
status: "ok",
|
|
152
|
+
state: "synced",
|
|
153
|
+
occurredAt: `${row.period}T12:00:00.000Z`,
|
|
154
|
+
completeness: "estimated",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
plan.sort((left, right) => left.event.occurredAt.localeCompare(right.event.occurredAt));
|
|
159
|
+
return { plan, regressions };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function batchId(deviceId, events) {
|
|
163
|
+
const identity = events.map((event) => event.eventKey).join("\u001f");
|
|
164
|
+
return `cli:${deviceId}:${sha256(identity).slice(0, 32)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function sourceSummary(report) {
|
|
168
|
+
if (!report || typeof report !== "object" || !Array.isArray(report.daily)) return [];
|
|
169
|
+
const sources = report.daily.flatMap((row) => {
|
|
170
|
+
const agentRows = Array.isArray(row?.agents) && row.agents.length ? row.agents : [row];
|
|
171
|
+
return agentRows.map((agent) => text(agent?.agent, "", 60).toLowerCase()).filter(Boolean);
|
|
172
|
+
});
|
|
173
|
+
return [...new Set(sources)].sort();
|
|
174
|
+
}
|