usagemax 0.1.1 → 0.3.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 +95 -21
- package/package.json +3 -1
- package/src/archives.js +86 -0
- package/src/cli.js +261 -132
- package/src/core.js +226 -2
- package/src/sources.js +495 -0
package/README.md
CHANGED
|
@@ -1,43 +1,117 @@
|
|
|
1
1
|
# UsageMax CLI
|
|
2
2
|
|
|
3
|
-
Connect aggregate coding-agent usage from
|
|
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
|
|
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.
|
|
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. When run inside WSL, UsageMax includes
|
|
18
|
+
readable supported-provider homes under `/mnt/c/Users` automatically. Use the
|
|
19
|
+
WSL collector as the single collector for that Windows PC instead of linking the
|
|
20
|
+
same host history again from Windows.
|
|
21
|
+
|
|
22
|
+
The CLI stores the resulting collector key in a user-only config file, then runs
|
|
23
|
+
a one-shot full-history sync. Running it again, changing the display name, or
|
|
24
|
+
relinking an installation does not create a second device: the private random
|
|
25
|
+
installation identity remains stable.
|
|
16
26
|
|
|
17
27
|
## Commands
|
|
18
28
|
|
|
19
29
|
```bash
|
|
20
|
-
bunx usagemax
|
|
21
|
-
bunx usagemax sync
|
|
22
|
-
bunx usagemax sync --full
|
|
23
|
-
bunx usagemax
|
|
24
|
-
bunx usagemax
|
|
25
|
-
|
|
26
|
-
bunx usagemax
|
|
27
|
-
bunx usagemax
|
|
30
|
+
bunx usagemax # sync changed usage
|
|
31
|
+
bunx usagemax sync # same as above
|
|
32
|
+
bunx usagemax sync --full # reconcile all retained local history
|
|
33
|
+
bunx usagemax sync --archives # one-time compressed-history recovery
|
|
34
|
+
bunx usagemax sync --dry-run --explain
|
|
35
|
+
# inspect the exact bounded plan; upload nothing
|
|
36
|
+
bunx usagemax link UMX-… --no-sync # link without uploading yet
|
|
37
|
+
bunx usagemax status # show link and last-sync state
|
|
38
|
+
bunx usagemax doctor # metadata-only source check
|
|
39
|
+
bunx usagemax doctor --deep --json # machine-readable retained-history audit
|
|
40
|
+
bunx usagemax report # open ccusage's local daily report
|
|
28
41
|
bunx usagemax report session --breakdown
|
|
29
|
-
bunx usagemax unlink
|
|
42
|
+
bunx usagemax unlink # remove the local collector key
|
|
43
|
+
bunx usagemax unlink --revoke # disable future uploads, then remove locally
|
|
30
44
|
```
|
|
31
45
|
|
|
32
|
-
UsageMax
|
|
46
|
+
UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
|
|
47
|
+
and supports all 16 adapters shipped in that release: Amp, Claude Code, Codebuff,
|
|
48
|
+
Codex, GitHub Copilot CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes,
|
|
49
|
+
Kilo Code, Kimi CLI, OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format
|
|
50
|
+
stores configured through ccusage are also discovered.
|
|
51
|
+
|
|
52
|
+
The source inventory follows ccusage's environment overrides, including
|
|
53
|
+
`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DATA_DIR`, `AMP_DATA_DIR`,
|
|
54
|
+
`DROID_SESSIONS_DIR`, `CODEBUFF_DATA_DIR`, `HERMES_HOME`, `PI_AGENT_DIR`,
|
|
55
|
+
`GOOSE_PATH_ROOT`, `OPENCLAW_DIR`, `KILO_DATA_DIR`, `KIMI_DATA_DIR`,
|
|
56
|
+
`QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
|
|
57
|
+
`GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
|
|
58
|
+
|
|
59
|
+
UsageMax also discovers Claude Desktop local-agent sessions, `.cc-mirror`,
|
|
60
|
+
recognizable renamed Claude/Codex backup folders, and supported Windows homes
|
|
61
|
+
from WSL. Discovery is bounded to known locations and immediate home entries;
|
|
62
|
+
normal syncs do not crawl the whole disk.
|
|
63
|
+
On a multi-user WSL host, automatic Windows-home discovery stays off unless
|
|
64
|
+
there is exactly one provider-bearing profile; set `USAGEMAX_ADDITIONAL_HOME`
|
|
65
|
+
to the intended mounted home explicitly.
|
|
33
66
|
|
|
34
|
-
|
|
67
|
+
If `doctor` reports compressed provider archives, run `sync --archives` once.
|
|
68
|
+
Recovery extracts only safe Claude `projects/*.jsonl` members into a private
|
|
69
|
+
temporary directory, performs one full deduplicated reconciliation, and removes
|
|
70
|
+
the temporary files before exit. Weekly and incremental syncs never unpack
|
|
71
|
+
archives.
|
|
35
72
|
|
|
36
|
-
|
|
37
|
-
|
|
73
|
+
Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
|
|
74
|
+
Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
|
|
75
|
+
billing systems do not all expose a stable local token ledger. Capture those
|
|
76
|
+
through UsageMax's native or OTLP endpoint, or through a future provider billing
|
|
77
|
+
connector. UsageMax never invents usage that the source did not retain.
|
|
78
|
+
|
|
79
|
+
## Privacy, correctness, and load
|
|
80
|
+
|
|
81
|
+
- The sync payload contains authoritative aggregate token counts,
|
|
82
|
+
model/provider names, costs, source names, dates, coverage state, and opaque
|
|
83
|
+
SHA-256 session identities.
|
|
84
|
+
- It does not upload prompts, completions, source code, file contents, project
|
|
85
|
+
paths, or provider credentials.
|
|
38
86
|
- Sync is one-shot. There is no resident scanner or high-frequency polling loop.
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
-
|
|
87
|
+
- A metadata inventory exits without parsing logs or using the network when
|
|
88
|
+
nothing changed.
|
|
89
|
+
- Normal changed syncs parse today or today plus yesterday. A bounded weekly
|
|
90
|
+
full reconciliation catches restored files, parser changes, and older logs.
|
|
91
|
+
- Full history means all retained local history from 2024 onward. UsageMax
|
|
92
|
+
publishes complete source/day partitions, so decreased or removed local rows
|
|
93
|
+
correct the server-owned contribution instead of being silently retained.
|
|
94
|
+
Deleted or
|
|
95
|
+
never-persisted usage requires a provider export; no local tool can reconstruct it.
|
|
96
|
+
- Source totals that cannot be assigned to a model are retained as
|
|
97
|
+
`unattributed` rather than silently discarded.
|
|
98
|
+
- The collector key is written with user-only permissions where the operating
|
|
99
|
+
system supports them.
|
|
100
|
+
- The package contains no shared service credential or private deployment URL.
|
|
101
|
+
It talks to the versioned `https://usagemax.com/api` contract. The random
|
|
102
|
+
per-installation write token is the only local secret; the service stores only
|
|
103
|
+
its hash and applies device binding, replay checks, payload caps, and quotas.
|
|
104
|
+
- A private random installation ID survives collector rotation, relinking, and
|
|
105
|
+
display-name changes. It is not a hardware fingerprint; the server stores only
|
|
106
|
+
its SHA-256 hash. Concurrent and repeated syncs are idempotent.
|
|
107
|
+
- The server, not the local checkpoint, owns the accounting baseline. A lost
|
|
108
|
+
response or interrupted run can be retried without adding the same partition twice.
|
|
109
|
+
- Do not point two different installations at the same copied or network-mounted
|
|
110
|
+
log tree. Cross-installation copied-history deduplication is inherently
|
|
111
|
+
ambiguous and intentionally not guessed.
|
|
112
|
+
|
|
113
|
+
Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
|
|
114
|
+
self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
|
|
42
115
|
|
|
43
|
-
|
|
116
|
+
See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
|
|
117
|
+
the full support matrix and known boundaries.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "UsageMax",
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
"usagemax": "src/cli.js"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
+
"src/archives.js",
|
|
21
22
|
"src/cli.js",
|
|
22
23
|
"src/core.js",
|
|
23
24
|
"src/installation.js",
|
|
25
|
+
"src/sources.js",
|
|
24
26
|
"README.md",
|
|
25
27
|
"LICENSE"
|
|
26
28
|
],
|
package/src/archives.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { ccusageHome, discoverProviderArchives } from "./sources.js";
|
|
8
|
+
|
|
9
|
+
const executeFile = promisify(execFile);
|
|
10
|
+
const MAX_ARCHIVE_LIST_BYTES = 100 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function archiveFlags(filePath, mode) {
|
|
13
|
+
const compressed = /(?:\.tar\.gz|\.tgz)$/i.test(filePath);
|
|
14
|
+
return mode === "list" ? (compressed ? "-tzf" : "-tf") : (compressed ? "-xzf" : "-xf");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function safeArchiveMember(value, source = "claude") {
|
|
18
|
+
const normalized = String(value || "").replaceAll("\\", "/");
|
|
19
|
+
const parts = normalized.split("/").filter((item) => item && item !== ".");
|
|
20
|
+
const providerDirectory = source === "codex"
|
|
21
|
+
? parts.includes("sessions") || parts.includes("archived_sessions")
|
|
22
|
+
: parts.includes("projects");
|
|
23
|
+
return !normalized.startsWith("/") && !parts.includes("..") && providerDirectory && normalized.endsWith(".jsonl");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findProviderRoots(root, maxDirectories = 10_000) {
|
|
27
|
+
const claude = [];
|
|
28
|
+
const codex = [];
|
|
29
|
+
const stack = [root];
|
|
30
|
+
let visited = 0;
|
|
31
|
+
while (stack.length && visited < maxDirectories) {
|
|
32
|
+
const current = stack.pop();
|
|
33
|
+
visited += 1;
|
|
34
|
+
let children;
|
|
35
|
+
try {
|
|
36
|
+
children = await readdir(current, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const child of children) {
|
|
41
|
+
if (!child.isDirectory()) continue;
|
|
42
|
+
const childPath = join(current, child.name);
|
|
43
|
+
if (child.name === "projects") claude.push(childPath);
|
|
44
|
+
else if (child.name === "sessions" || child.name === "archived_sessions") codex.push(current);
|
|
45
|
+
else stack.push(childPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { claude, codex };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function prepareArchiveRecovery(baseEnv) {
|
|
52
|
+
const detected = await discoverProviderArchives({ env: baseEnv, home: ccusageHome(baseEnv) });
|
|
53
|
+
const archives = detected.filter((item) => /(?:\.tar(?:\.gz)?|\.tgz)$/i.test(item.path));
|
|
54
|
+
const unsupported = detected.length - archives.length;
|
|
55
|
+
if (!archives.length) return { archives: 0, cleanup: async () => undefined, env: baseEnv, unsupported };
|
|
56
|
+
|
|
57
|
+
const temporary = await mkdtemp(join(tmpdir(), "usagemax-recover-"));
|
|
58
|
+
try {
|
|
59
|
+
for (let index = 0; index < archives.length; index += 1) {
|
|
60
|
+
const archive = archives[index];
|
|
61
|
+
const destination = join(temporary, `archive-${index + 1}`);
|
|
62
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
63
|
+
const { stdout } = await executeFile("tar", [archiveFlags(archive.path, "list"), archive.path], { encoding: "utf8", maxBuffer: MAX_ARCHIVE_LIST_BYTES });
|
|
64
|
+
const members = stdout.split(/\r?\n/).filter((member) => safeArchiveMember(member, archive.source));
|
|
65
|
+
for (let offset = 0; offset < members.length; offset += 80) {
|
|
66
|
+
await executeFile("tar", [archiveFlags(archive.path, "extract"), archive.path, "-C", destination, "--", ...members.slice(offset, offset + 80)], { maxBuffer: 4 * 1024 * 1024 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const roots = await findProviderRoots(temporary);
|
|
70
|
+
const existingClaude = String(baseEnv.CLAUDE_CONFIG_DIR || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
71
|
+
const existingCodex = String(baseEnv.CODEX_HOME || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
72
|
+
return {
|
|
73
|
+
archives: archives.length,
|
|
74
|
+
cleanup: async () => rm(temporary, { force: true, recursive: true }),
|
|
75
|
+
env: {
|
|
76
|
+
...baseEnv,
|
|
77
|
+
CLAUDE_CONFIG_DIR: [...new Set([...existingClaude, ...roots.claude])].join(","),
|
|
78
|
+
CODEX_HOME: [...new Set([...existingCodex, ...roots.codex])].join(","),
|
|
79
|
+
},
|
|
80
|
+
unsupported,
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await rm(temporary, { force: true, recursive: true });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|