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 CHANGED
@@ -1,43 +1,117 @@
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. 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 # 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
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 # remove the local collector key
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 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.
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
- ## Privacy and load
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
- - 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.
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
- - 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
- - A separate private random installation ID survives collector rotation, relinking, and display-name changes. It is not a hardware fingerprint; the server stores only its SHA-256 hash. Concurrent or repeated syncs remain idempotent.
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
- Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development/self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
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.1.1",
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
  ],
@@ -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
+ }