claudeusage-sync 0.0.1 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adilbek Bazarkulov
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 CHANGED
@@ -1,5 +1,260 @@
1
1
  # claudeusage-sync
2
2
 
3
- Placeholder package. The full CLI for syncing Claude Code usage stats to [claudeusage.com](https://claudeusage.com) is in development.
3
+ Turn your local Claude Code usage into a public profile and leaderboard at [claudeusage.com](https://claudeusage.com) without uploading a single prompt.
4
4
 
5
- Check back soon.
5
+ [![version](https://img.shields.io/github/package-json/v/bazarkua/claudeusage-sync?label=version)](https://www.npmjs.com/package/claudeusage-sync)
6
+ [![license: MIT](https://img.shields.io/github/license/bazarkua/claudeusage-sync)](./LICENSE)
7
+ [![node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org)
8
+
9
+ ```sh
10
+ npx claudeusage-sync
11
+ ```
12
+
13
+ The first run opens your browser for a one-click device approval, reads your local
14
+ Claude Code session files, asks for your consent, and uploads **aggregate numeric
15
+ stats only**. Every later run reuses the cached token and uploads just what is new.
16
+
17
+ ---
18
+
19
+ ## What it does
20
+
21
+ Claude Code records each session locally as JSONL files under `~/.claude/projects/`.
22
+ Those files contain your full prompts and responses, but they also contain per-message
23
+ token counts. `claudeusage-sync`:
24
+
25
+ 1. Streams every `~/.claude/projects/*/*.jsonl` file line by line.
26
+ 2. Extracts **only** the usage numbers from each message (token counts, model id,
27
+ timestamp, and the ids needed to de-duplicate). Message text is never read.
28
+ 3. De-duplicates records by `messageId:requestId`, keeping the highest `output_tokens`
29
+ (Claude Code writes cumulative streaming values, so the max is the final count).
30
+ 4. Groups records by your **local day** and computes WakaTime-style active hours
31
+ (the sum of gaps shorter than 10 minutes between consecutive messages).
32
+ 5. Builds an aggregate payload and `POST`s it to `https://claudeusage.com/api/ingest`.
33
+
34
+ The result powers your profile (`/u/<username>`), the global leaderboard, your activity
35
+ grid, and an API-equivalent cost estimate. You can sign in with a display name or stay
36
+ anonymous.
37
+
38
+ ---
39
+
40
+ ## Privacy
41
+
42
+ This is the whole point of the project, so it is worth being precise. The CLI is
43
+ MIT-licensed and source-available — you can read every line in [`src/`](./src) and
44
+ confirm the claims below for yourself.
45
+
46
+ ### What is uploaded
47
+
48
+ Only aggregate, numeric usage data. The exact shape posted to `/api/ingest` is defined
49
+ in [`src/aggregate/payload.ts`](./src/aggregate/payload.ts):
50
+
51
+ ```ts
52
+ type IngestPayload = {
53
+ cliVersion: string;
54
+ schema: 1;
55
+ os: "darwin" | "linux" | "win32";
56
+ machineId: string; // one-way SHA-256 hash, see below
57
+ windowStart: string; // ISO timestamp of the earliest record in this sync
58
+ windowEnd: string; // ISO timestamp of the latest record in this sync
59
+ sessionCount: number; // distinct sessions in the window
60
+ dailyBuckets: Array<{
61
+ date: string; // YYYY-MM-DD, your local day
62
+ hoursActive: number;
63
+ sessionCount: number;
64
+ perModel: Record<string, {
65
+ inputTokens: number;
66
+ outputTokens: number;
67
+ cacheCreateTokens: number;
68
+ cacheReadTokens: number;
69
+ messageCount: number;
70
+ sessionCount: number;
71
+ hoursActive: number;
72
+ firstMessageAt: string; // ISO timestamp
73
+ lastMessageAt: string; // ISO timestamp
74
+ }>;
75
+ }>;
76
+ };
77
+ ```
78
+
79
+ That is it: token totals, message and session counts, active hours, the per-model mix,
80
+ and day-level activity. No content of any kind is in this object.
81
+
82
+ ### What is never uploaded
83
+
84
+ Verifiable from [`src/parse/jsonl.ts`](./src/parse/jsonl.ts) (the only code that reads
85
+ your Claude files) and [`src/aggregate/payload.ts`](./src/aggregate/payload.ts):
86
+
87
+ - **Prompts, responses, or any message content** — the parser reads only token-count
88
+ fields from each line and discards the rest.
89
+ - **Your code, file contents, or raw JSONL lines** — nothing from the files is forwarded.
90
+ - **File paths, project directory names, or repo names** — project folders are only used
91
+ to locate files; their names never enter the payload.
92
+ - **Individual message, request, or session ids** — these are used locally for
93
+ de-duplication and the incremental-sync watermark; the payload carries only *counts*.
94
+ - **Your email address or which provider you signed in with** — the CLI never reads or
95
+ sends these. Your identity is established entirely in the browser during device approval.
96
+ - **Your raw hostname or OS username** — see `machineId` below.
97
+
98
+ ### Two fields that deserve a plain explanation
99
+
100
+ - **`machineId`** is `SHA-256(hostname + ":" + osUsername)` — a one-way hash computed on
101
+ your machine (see [`src/auth/machine.ts`](./src/auth/machine.ts)). Your real hostname and
102
+ username never leave the device; the server only ever sees the hash, and uses it to
103
+ group and de-duplicate syncs across the machines you sync from. During the initial
104
+ device-auth request the CLI also sends `SHA-256(hostname)` for the same purpose.
105
+ - **Timestamps.** The payload contains coarse time bounds only: the `windowStart` /
106
+ `windowEnd` of the sync, and the first/last message time per model per day. There is no
107
+ per-message timestamp and no minute-by-minute activity log. Public profile pages round
108
+ these to the day (and at most the hour for day-detail views).
109
+
110
+ Your sync token (`cu_live_...`) is your credential. It is stored locally at
111
+ `~/.claudeusage/config.json` with `0600` permissions and sent only as an
112
+ `Authorization: Bearer` header to authenticate uploads.
113
+
114
+ See the full policy at [claudeusage.com/privacy](https://claudeusage.com/privacy).
115
+
116
+ ---
117
+
118
+ ## Quickstart
119
+
120
+ ```sh
121
+ # first run: opens your browser for a one-click device approval, then asks consent
122
+ npx claudeusage-sync
123
+
124
+ # later runs: reuses the cached token, uploads only records newer than the last sync
125
+ npx claudeusage-sync
126
+ ```
127
+
128
+ On the first run the CLI prints exactly what will be uploaded and waits for a `y/N`
129
+ confirmation before sending anything. After you approve once, the consent is remembered.
130
+
131
+ Want to see what would be sent without uploading? Use `--dry-run`:
132
+
133
+ ```sh
134
+ npx claudeusage-sync --dry-run
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Commands
140
+
141
+ ```sh
142
+ claudeusage-sync # (default) authenticate if needed, then sync new usage
143
+ claudeusage-sync doctor # show local Claude Code source coverage
144
+ claudeusage-sync status # show last sync time and masked token
145
+ claudeusage-sync unlink # remove local config so the next sync starts fresh
146
+ claudeusage-sync logout # alias of unlink
147
+ ```
148
+
149
+ | Command | Description |
150
+ | -------- | ----------- |
151
+ | *(none)* | Read new usage since the last sync, then upload aggregates. Authenticates first if needed. |
152
+ | `doctor` | Run the same parser as `sync` and print source coverage: JSONL files scanned, usable record count, first/last detailed-usage dates, the local sync watermark, and whether Claude's legacy `stats-cache.json` exists. |
153
+ | `status` | Print the last sync time, masked token, API base, and config path. |
154
+ | `unlink` | Delete `~/.claudeusage/config.json` so the next run re-authenticates from scratch. |
155
+ | `logout` | Alias of `unlink`. |
156
+
157
+ ### Flags
158
+
159
+ These apply to the default sync command:
160
+
161
+ | Flag | Description |
162
+ | --------------------- | ----------- |
163
+ | `--dry-run` | Parse and build the payload, print a summary, but do **not** upload. |
164
+ | `--since <YYYY-MM-DD>` | Only read records on or after this date (overrides the saved watermark). |
165
+ | `--token <token>` | Use this sync token instead of the browser device flow (headless / CI). |
166
+ | `--version` | Print the installed CLI version. |
167
+ | `--help` | Show usage. |
168
+
169
+ ### Environment variables
170
+
171
+ Mostly for CI and local testing:
172
+
173
+ | Variable | Effect |
174
+ | ------------------------ | ------ |
175
+ | `CLAUDEUSAGE_API` | Override the API base URL (default `https://claudeusage.com`). |
176
+ | `CLAUDE_CONFIG_DIR` | Override the Claude config directory; the CLI reads `<dir>/projects`. Default `~/.claude`. |
177
+ | `CLAUDEUSAGE_CONFIG_DIR` | Override where the CLI stores its own config (default `~`, i.e. `~/.claudeusage`). |
178
+ | `CLAUDEUSAGE_ASSUME_YES` | Set to `1` to skip the interactive first-run consent prompt (use in CI). |
179
+
180
+ ---
181
+
182
+ ## Requirements
183
+
184
+ - **Node.js 22 or newer** (Node 20 reaches end-of-life in April 2026).
185
+ - Claude Code installed and used at least once, so that `~/.claude/projects/` exists.
186
+
187
+ ---
188
+
189
+ ## How device authentication works
190
+
191
+ The first sign-in uses an [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)-style
192
+ OAuth device authorization flow, so the CLI never handles your password:
193
+
194
+ 1. The CLI calls `POST /api/cli/auth/start` and receives a short user code plus a
195
+ verification URL.
196
+ 2. It opens that URL in your browser (and prints the code in case the browser does not
197
+ open). You sign in with Google or GitHub and approve the device.
198
+ 3. The CLI polls `POST /api/cli/auth/poll` until the request is approved, denied, or
199
+ expires, honoring `slow_down` back-off.
200
+ 4. On approval it receives a long-lived sync token (`cu_live_...`) and writes it to
201
+ `~/.claudeusage/config.json`.
202
+
203
+ If a stored token is ever rejected (for example, you revoked it from the dashboard), the
204
+ CLI automatically re-runs the device flow and retries the upload.
205
+
206
+ ---
207
+
208
+ ## Headless and CI usage
209
+
210
+ Browsers are not available in CI. Create a sync token from your dashboard settings and
211
+ pass it with `--token`:
212
+
213
+ ```sh
214
+ CLAUDEUSAGE_ASSUME_YES=1 npx claudeusage-sync --token="cu_live_xxxxxxxx..."
215
+ ```
216
+
217
+ `CLAUDEUSAGE_ASSUME_YES=1` skips the interactive consent prompt so the run is fully
218
+ non-interactive. The token authenticates uploads exactly like a browser-approved session.
219
+
220
+ ---
221
+
222
+ ## Links
223
+
224
+ - Website and leaderboard: [claudeusage.com](https://claudeusage.com)
225
+ - Privacy policy: [claudeusage.com/privacy](https://claudeusage.com/privacy)
226
+ - Source and issues: [github.com/bazarkua/claudeusage-sync](https://github.com/bazarkua/claudeusage-sync)
227
+
228
+ ---
229
+
230
+ ## Contributing
231
+
232
+ Issues and pull requests are welcome. The project is small and dependency-light:
233
+
234
+ ```sh
235
+ git clone https://github.com/bazarkua/claudeusage-sync.git
236
+ cd claudeusage-sync
237
+ npm install
238
+ npm run build # compile TypeScript to dist/
239
+ node bin/cli.js --dry-run
240
+ ```
241
+
242
+ To test against a local backend or fixture data:
243
+
244
+ ```sh
245
+ CLAUDEUSAGE_API=http://localhost:3000 \
246
+ CLAUDE_CONFIG_DIR=/tmp/fake-claude \
247
+ node bin/cli.js --dry-run
248
+ ```
249
+
250
+ If you find a privacy or security issue, please open an issue (or contact the maintainer
251
+ privately for sensitive reports) rather than sending a PR with details.
252
+
253
+ ---
254
+
255
+ ## License
256
+
257
+ [MIT](./LICENSE) © Adilbek Bazarkulov
258
+
259
+ *Not affiliated with Anthropic. "Claude" and "Claude Code" are used descriptively to
260
+ refer to the tools whose local usage this CLI reads.*
package/bin/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/cli.js").catch((error) => {
3
+ console.error(error);
4
+ process.exit(1);
5
+ });
@@ -0,0 +1,122 @@
1
+ import { computeMachineId, detectOs } from "../auth/machine.js";
2
+ import { recordWatermarkKey } from "../parse/dedupe.js";
3
+ import { computeHoursActive, groupByLocalDay } from "../parse/hours.js";
4
+ function emptyPayload(cliVersion) {
5
+ const now = new Date().toISOString();
6
+ return {
7
+ cliVersion,
8
+ dailyBuckets: [],
9
+ machineId: computeMachineId(),
10
+ os: detectOs(),
11
+ schema: 1,
12
+ sessionCount: 0,
13
+ windowEnd: now,
14
+ windowStart: now,
15
+ };
16
+ }
17
+ export function buildPayload(records, cliVersion) {
18
+ if (records.length === 0) {
19
+ return emptyPayload(cliVersion);
20
+ }
21
+ const byDay = groupByLocalDay(records);
22
+ const sessions = new Set();
23
+ let minTimestamp = Number.POSITIVE_INFINITY;
24
+ let maxTimestamp = Number.NEGATIVE_INFINITY;
25
+ const dailyBuckets = Array.from(byDay.entries())
26
+ .map(([date, dayRecords]) => {
27
+ const perModel = {};
28
+ const daySessions = new Set();
29
+ for (const record of dayRecords) {
30
+ const timestampMs = new Date(record.timestamp).getTime();
31
+ minTimestamp = Math.min(minTimestamp, timestampMs);
32
+ maxTimestamp = Math.max(maxTimestamp, timestampMs);
33
+ if (record.sessionId) {
34
+ sessions.add(record.sessionId);
35
+ daySessions.add(record.sessionId);
36
+ }
37
+ const model = perModel[record.model] ?? {
38
+ cacheCreateTokens: 0,
39
+ cacheReadTokens: 0,
40
+ firstMessageAt: record.timestamp,
41
+ hoursActive: 0,
42
+ inputTokens: 0,
43
+ lastMessageAt: record.timestamp,
44
+ messageCount: 0,
45
+ outputTokens: 0,
46
+ records: [],
47
+ sessionCount: 0,
48
+ sessions: new Set(),
49
+ };
50
+ model.cacheCreateTokens += record.cacheCreateTokens;
51
+ model.cacheReadTokens += record.cacheReadTokens;
52
+ model.inputTokens += record.inputTokens;
53
+ model.outputTokens += record.outputTokens;
54
+ model.messageCount += 1;
55
+ model.records.push(record);
56
+ if (record.sessionId) {
57
+ model.sessions.add(record.sessionId);
58
+ }
59
+ if (record.timestamp < model.firstMessageAt) {
60
+ model.firstMessageAt = record.timestamp;
61
+ }
62
+ if (record.timestamp > model.lastMessageAt) {
63
+ model.lastMessageAt = record.timestamp;
64
+ }
65
+ perModel[record.model] = model;
66
+ }
67
+ const finalPerModel = {};
68
+ for (const [modelId, model] of Object.entries(perModel)) {
69
+ finalPerModel[modelId] = {
70
+ cacheCreateTokens: model.cacheCreateTokens,
71
+ cacheReadTokens: model.cacheReadTokens,
72
+ firstMessageAt: model.firstMessageAt,
73
+ hoursActive: computeHoursActive(model.records),
74
+ inputTokens: model.inputTokens,
75
+ lastMessageAt: model.lastMessageAt,
76
+ messageCount: model.messageCount,
77
+ outputTokens: model.outputTokens,
78
+ sessionCount: model.sessions.size,
79
+ };
80
+ }
81
+ return {
82
+ date,
83
+ hoursActive: computeHoursActive(dayRecords),
84
+ perModel: finalPerModel,
85
+ sessionCount: daySessions.size,
86
+ };
87
+ })
88
+ .sort((a, b) => a.date.localeCompare(b.date));
89
+ return {
90
+ cliVersion,
91
+ dailyBuckets,
92
+ machineId: computeMachineId(),
93
+ os: detectOs(),
94
+ schema: 1,
95
+ sessionCount: sessions.size,
96
+ windowEnd: new Date(maxTimestamp).toISOString(),
97
+ windowStart: new Date(minTimestamp).toISOString(),
98
+ };
99
+ }
100
+ export function latestWatermark(records) {
101
+ let latest = null;
102
+ for (const record of records) {
103
+ if (!latest) {
104
+ latest = record;
105
+ continue;
106
+ }
107
+ if (record.timestamp > latest.timestamp) {
108
+ latest = record;
109
+ continue;
110
+ }
111
+ if (record.timestamp === latest.timestamp &&
112
+ recordWatermarkKey(record) > recordWatermarkKey(latest)) {
113
+ latest = record;
114
+ }
115
+ }
116
+ return latest
117
+ ? {
118
+ lastSyncAt: latest.timestamp,
119
+ lastSyncMessageId: recordWatermarkKey(latest),
120
+ }
121
+ : {};
122
+ }
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ const ingestResponseSchema = z.object({
3
+ accepted: z.boolean(),
4
+ batchId: z.string().optional(),
5
+ buckets: z.number().optional(),
6
+ duplicate: z.boolean().optional(),
7
+ messageCount: z.number(),
8
+ newRecords: z.number(),
9
+ updatedAt: z.string(),
10
+ userId: z.string(),
11
+ });
12
+ export class NeedsReauth extends Error {
13
+ constructor() {
14
+ super("needs_reauth");
15
+ this.name = "NeedsReauth";
16
+ }
17
+ }
18
+ export class RateLimited extends Error {
19
+ retryAfterSec;
20
+ constructor(retryAfterSec) {
21
+ super(`rate_limited:${retryAfterSec}s`);
22
+ this.retryAfterSec = retryAfterSec;
23
+ this.name = "RateLimited";
24
+ }
25
+ }
26
+ export class IngestConflict extends Error {
27
+ code;
28
+ retryAfterSec;
29
+ constructor(code, retryAfterSec) {
30
+ super(code);
31
+ this.code = code;
32
+ this.retryAfterSec = retryAfterSec;
33
+ this.name = "IngestConflict";
34
+ }
35
+ }
36
+ export async function postIngest(apiBase, token, payload) {
37
+ const response = await fetch(`${apiBase}/api/ingest`, {
38
+ body: JSON.stringify(payload),
39
+ headers: {
40
+ authorization: `Bearer ${token}`,
41
+ "content-type": "application/json",
42
+ },
43
+ method: "POST",
44
+ });
45
+ if (response.status === 401) {
46
+ throw new NeedsReauth();
47
+ }
48
+ if (response.status === 429) {
49
+ const retryAfterSec = Number(response.headers.get("retry-after") ?? "60");
50
+ throw new RateLimited(Number.isFinite(retryAfterSec) ? retryAfterSec : 60);
51
+ }
52
+ if (response.status === 409) {
53
+ const retryAfterSec = Number(response.headers.get("retry-after") ?? "");
54
+ const body = await response.json().catch(() => null);
55
+ const code = body && typeof body.error === "string" ? body.error : "ingest_conflict";
56
+ throw new IngestConflict(code, Number.isFinite(retryAfterSec) ? retryAfterSec : undefined);
57
+ }
58
+ if (!response.ok) {
59
+ throw new Error(`ingest failed: ${response.status} ${await response.text().catch(() => "")}`);
60
+ }
61
+ return ingestResponseSchema.parse(await response.json());
62
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { z } from "zod";
5
+ const configSchema = z.object({
6
+ apiBase: z.string().url(),
7
+ consentAcceptedAt: z.string().optional(),
8
+ lastSyncAt: z.string().optional(),
9
+ lastSyncMessageId: z.string().optional(),
10
+ token: z.string().regex(/^cu_live_[a-f0-9]{48}$/),
11
+ });
12
+ export function configDir() {
13
+ return resolve(process.env.CLAUDEUSAGE_CONFIG_DIR ?? homedir(), ".claudeusage");
14
+ }
15
+ export function configFile() {
16
+ return resolve(configDir(), "config.json");
17
+ }
18
+ export async function readConfig() {
19
+ try {
20
+ const raw = await readFile(configFile(), "utf8");
21
+ return configSchema.parse(JSON.parse(raw));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export async function writeConfig(config) {
28
+ await mkdir(configDir(), { recursive: true, mode: 0o700 });
29
+ await writeFile(configFile(), `${JSON.stringify(config, null, 2)}\n`, {
30
+ mode: 0o600,
31
+ });
32
+ }
33
+ export async function deleteConfig() {
34
+ await rm(configFile(), { force: true });
35
+ }
@@ -0,0 +1,90 @@
1
+ import chalk from "chalk";
2
+ import open from "open";
3
+ import ora from "ora";
4
+ import { z } from "zod";
5
+ import { readPackageInfo } from "../package.js";
6
+ import { writeConfig } from "./config.js";
7
+ import { computeHostnameHash, computeMachineId, detectOs, } from "./machine.js";
8
+ const startResponseSchema = z.object({
9
+ deviceCode: z.string(),
10
+ expiresIn: z.number().int().positive(),
11
+ interval: z.number().positive(),
12
+ userCode: z.string(),
13
+ verificationUri: z.string().url(),
14
+ verificationUriComplete: z.string().url(),
15
+ });
16
+ const pollResponseSchema = z.discriminatedUnion("status", [
17
+ z.object({ status: z.literal("pending") }),
18
+ z.object({ status: z.literal("slow_down") }),
19
+ z.object({ status: z.literal("denied") }),
20
+ z.object({ status: z.literal("expired") }),
21
+ z.object({
22
+ status: z.literal("approved"),
23
+ token: z.string().regex(/^cu_live_[a-f0-9]{48}$/),
24
+ userId: z.string(),
25
+ username: z.string().nullable(),
26
+ }),
27
+ ]);
28
+ const packageInfo = readPackageInfo(import.meta.url);
29
+ function sleep(ms) {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+ export async function runDeviceFlow(apiBase) {
33
+ const startResponse = await fetch(`${apiBase}/api/cli/auth/start`, {
34
+ body: JSON.stringify({
35
+ cliVersion: packageInfo.version,
36
+ hostnameHash: computeHostnameHash(),
37
+ machineId: computeMachineId(),
38
+ os: detectOs(),
39
+ }),
40
+ headers: { "content-type": "application/json" },
41
+ method: "POST",
42
+ });
43
+ if (!startResponse.ok) {
44
+ throw new Error(`device auth start failed: ${startResponse.status}`);
45
+ }
46
+ const start = startResponseSchema.parse(await startResponse.json());
47
+ console.log("");
48
+ console.log(chalk.bold("authorize this device:"));
49
+ console.log(` ${chalk.hex("#d97757")(start.verificationUriComplete)}`);
50
+ console.log("");
51
+ console.log(`${chalk.gray("if the browser does not open, enter code")} ${chalk.bold(start.userCode)} ${chalk.gray("at")} ${start.verificationUri}`);
52
+ console.log("");
53
+ await open(start.verificationUriComplete).catch(() => undefined);
54
+ const spinner = ora("waiting for browser approval...").start();
55
+ let intervalMs = Math.max(1000, start.interval * 1000);
56
+ const expiresAt = Date.now() + start.expiresIn * 1000;
57
+ while (Date.now() < expiresAt) {
58
+ await sleep(intervalMs);
59
+ const pollResponse = await fetch(`${apiBase}/api/cli/auth/poll`, {
60
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
61
+ headers: { "content-type": "application/json" },
62
+ method: "POST",
63
+ });
64
+ const parsed = pollResponseSchema.safeParse(await pollResponse.json().catch(() => ({})));
65
+ if (!parsed.success) {
66
+ spinner.fail("unexpected server response");
67
+ throw new Error("bad poll response");
68
+ }
69
+ if (parsed.data.status === "pending") {
70
+ continue;
71
+ }
72
+ if (parsed.data.status === "slow_down") {
73
+ intervalMs = Math.min(intervalMs * 2, 30_000);
74
+ continue;
75
+ }
76
+ if (parsed.data.status === "denied") {
77
+ spinner.fail("denied in the browser");
78
+ throw new Error("device authorization denied");
79
+ }
80
+ if (parsed.data.status === "expired") {
81
+ spinner.fail("approval code expired");
82
+ throw new Error("device authorization expired");
83
+ }
84
+ spinner.succeed(`authorized as ${chalk.hex("#d97757")(parsed.data.username ?? parsed.data.userId)}`);
85
+ await writeConfig({ apiBase, token: parsed.data.token });
86
+ return parsed.data.token;
87
+ }
88
+ spinner.fail("approval timed out");
89
+ throw new Error("device authorization timed out");
90
+ }
@@ -0,0 +1,20 @@
1
+ import { createHash } from "node:crypto";
2
+ import { hostname, userInfo } from "node:os";
3
+ function sha256(value) {
4
+ return createHash("sha256").update(value).digest("hex");
5
+ }
6
+ export function computeMachineId() {
7
+ const username = userInfo().username || "unknown";
8
+ return sha256(`${hostname()}:${username}`);
9
+ }
10
+ export function computeHostnameHash() {
11
+ return sha256(hostname());
12
+ }
13
+ export function detectOs() {
14
+ if (process.platform === "darwin" ||
15
+ process.platform === "linux" ||
16
+ process.platform === "win32") {
17
+ return process.platform;
18
+ }
19
+ return "linux";
20
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { runDoctor } from "./commands/doctor.js";
4
+ import { runStatus } from "./commands/status.js";
5
+ import { runSync } from "./commands/sync.js";
6
+ import { runUnlink } from "./commands/unlink.js";
7
+ import { readPackageInfo } from "./package.js";
8
+ const packageInfo = readPackageInfo(import.meta.url);
9
+ function handleError(error) {
10
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
11
+ process.exit(1);
12
+ }
13
+ const program = new Command();
14
+ program
15
+ .name("claudeusage-sync")
16
+ .description("sync your Claude Code usage stats to claudeusage.com")
17
+ .version(packageInfo.version)
18
+ .option("--token <token>", "use this sync token instead of the browser device flow (headless/CI)")
19
+ .option("--dry-run", "parse and build the payload but do not upload")
20
+ .option("--since <date>", "only read records newer than this YYYY-MM-DD")
21
+ .action((options) => {
22
+ runSync(options).catch(handleError);
23
+ });
24
+ program
25
+ .command("doctor")
26
+ .description("show local Claude Code source coverage")
27
+ .action(() => {
28
+ runDoctor().catch(handleError);
29
+ });
30
+ program
31
+ .command("status")
32
+ .description("show last sync time and masked token")
33
+ .action(() => {
34
+ runStatus().catch(handleError);
35
+ });
36
+ program
37
+ .command("unlink")
38
+ .description("remove local config so the next sync starts fresh")
39
+ .action(() => {
40
+ runUnlink().catch(handleError);
41
+ });
42
+ program
43
+ .command("logout")
44
+ .description("alias of unlink")
45
+ .action(() => {
46
+ runUnlink().catch(handleError);
47
+ });
48
+ program.parseAsync().catch(handleError);
@@ -0,0 +1,111 @@
1
+ import chalk from "chalk";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { readConfig } from "../auth/config.js";
5
+ import { dedupe } from "../parse/dedupe.js";
6
+ import { streamAllRecords } from "../parse/jsonl.js";
7
+ import { resolveClaudeDir } from "../parse/paths.js";
8
+ function formatIso(value) {
9
+ if (!value) {
10
+ return "none";
11
+ }
12
+ const date = new Date(value);
13
+ if (Number.isNaN(date.getTime())) {
14
+ return value;
15
+ }
16
+ return `${date.toLocaleString()} (${date.toISOString()})`;
17
+ }
18
+ function stringField(value) {
19
+ return typeof value === "string" && value.trim() ? value : null;
20
+ }
21
+ function numberField(value) {
22
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
23
+ }
24
+ async function countDirectJsonlFiles(rootDir) {
25
+ const projects = await readdir(rootDir).catch(() => []);
26
+ let count = 0;
27
+ for (const project of projects) {
28
+ const projectPath = join(rootDir, project);
29
+ const projectStat = await stat(projectPath).catch(() => null);
30
+ if (!projectStat?.isDirectory()) {
31
+ continue;
32
+ }
33
+ const files = await readdir(projectPath).catch(() => []);
34
+ count += files.filter((file) => file.endsWith(".jsonl")).length;
35
+ }
36
+ return count;
37
+ }
38
+ async function readRecords(rootDir) {
39
+ const records = [];
40
+ for await (const record of streamAllRecords(rootDir)) {
41
+ records.push(record);
42
+ }
43
+ return records;
44
+ }
45
+ async function readLegacyStatsCache(rootDir) {
46
+ const statsPath = resolve(dirname(rootDir), "stats-cache.json");
47
+ const raw = await readFile(statsPath, "utf8").catch(() => null);
48
+ if (!raw) {
49
+ return null;
50
+ }
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ return parsed && typeof parsed === "object"
54
+ ? parsed
55
+ : null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export async function runDoctor() {
62
+ const rootDir = resolveClaudeDir();
63
+ const [fileCount, records, config, legacy] = await Promise.all([
64
+ countDirectJsonlFiles(rootDir),
65
+ readRecords(rootDir),
66
+ readConfig(),
67
+ readLegacyStatsCache(rootDir),
68
+ ]);
69
+ const deduped = dedupe(records);
70
+ const firstRecord = deduped.at(0);
71
+ const lastRecord = deduped.at(-1);
72
+ console.log(chalk.bold("claudeusage source doctor"));
73
+ console.log("");
74
+ console.log("Claude projects path:", chalk.gray(rootDir));
75
+ console.log("JSONL files scanned: ", fileCount);
76
+ console.log("usage records: ", records.length);
77
+ console.log("after dedupe: ", deduped.length);
78
+ console.log("first detailed usage:", formatIso(firstRecord?.timestamp));
79
+ console.log("last detailed usage: ", formatIso(lastRecord?.timestamp));
80
+ console.log("");
81
+ if (config) {
82
+ console.log(chalk.bold("local sync config"));
83
+ console.log("api base: ", config.apiBase);
84
+ console.log("last synced at: ", config.lastSyncAt ?? "never");
85
+ console.log("last message id: ", config.lastSyncMessageId ?? "none");
86
+ }
87
+ else {
88
+ console.log(chalk.bold("local sync config"));
89
+ console.log(chalk.yellow("not linked yet"));
90
+ }
91
+ console.log("");
92
+ console.log(chalk.bold("legacy stats cache"));
93
+ if (!legacy) {
94
+ console.log("not found");
95
+ return;
96
+ }
97
+ const firstSessionDate = stringField(legacy.firstSessionDate);
98
+ const lastComputedDate = stringField(legacy.lastComputedDate);
99
+ const totalMessages = numberField(legacy.totalMessages);
100
+ const totalSessions = numberField(legacy.totalSessions);
101
+ console.log("first session: ", formatIso(firstSessionDate ?? undefined));
102
+ console.log("last computed: ", lastComputedDate ?? "unknown");
103
+ console.log("messages: ", totalMessages ?? "unknown");
104
+ console.log("sessions: ", totalSessions ?? "unknown");
105
+ if (firstSessionDate &&
106
+ firstRecord?.timestamp &&
107
+ firstSessionDate < firstRecord.timestamp) {
108
+ console.log("");
109
+ console.log(chalk.yellow("legacy cache starts earlier than detailed JSONL; sync keeps it separate because it is aggregate/incomplete."));
110
+ }
111
+ }
@@ -0,0 +1,18 @@
1
+ import chalk from "chalk";
2
+ import { configFile, readConfig } from "../auth/config.js";
3
+ function maskToken(token) {
4
+ return `${token.slice(0, 12)}...${token.slice(-4)}`;
5
+ }
6
+ export async function runStatus() {
7
+ const config = await readConfig();
8
+ if (!config) {
9
+ console.log(chalk.yellow("not authorized. run `claudeusage-sync` to link this device."));
10
+ console.log(chalk.gray(`config: ${configFile()}`));
11
+ return;
12
+ }
13
+ console.log("token: ", maskToken(config.token));
14
+ console.log("apiBase: ", config.apiBase);
15
+ console.log("last synced at: ", config.lastSyncAt ?? "never");
16
+ console.log("last message id:", config.lastSyncMessageId ?? "none");
17
+ console.log("config: ", configFile());
18
+ }
@@ -0,0 +1,175 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { buildPayload, latestWatermark } from "../aggregate/payload.js";
4
+ import { IngestConflict, NeedsReauth, postIngest, RateLimited, } from "../api/ingest-client.js";
5
+ import { deleteConfig, readConfig, writeConfig, } from "../auth/config.js";
6
+ import { runDeviceFlow } from "../auth/device-flow.js";
7
+ import { readPackageInfo } from "../package.js";
8
+ import { dedupe, detectOutputTokenUndercount, recordWatermarkKey, } from "../parse/dedupe.js";
9
+ import { streamAllRecords } from "../parse/jsonl.js";
10
+ import { resolveClaudeDir } from "../parse/paths.js";
11
+ const DEFAULT_API = "https://claudeusage.com";
12
+ const packageInfo = readPackageInfo(import.meta.url);
13
+ function apiBase() {
14
+ return (process.env.CLAUDEUSAGE_API ?? DEFAULT_API).replace(/\/$/, "");
15
+ }
16
+ function parseSince(value) {
17
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
18
+ throw new Error(`bad --since value: ${value}`);
19
+ }
20
+ const ms = Date.parse(`${value}T00:00:00`);
21
+ if (!Number.isFinite(ms)) {
22
+ throw new Error(`bad --since value: ${value}`);
23
+ }
24
+ return ms;
25
+ }
26
+ function afterWatermark(record, config) {
27
+ if (!config.lastSyncAt) {
28
+ return true;
29
+ }
30
+ if (record.timestamp > config.lastSyncAt) {
31
+ return true;
32
+ }
33
+ return (record.timestamp === config.lastSyncAt &&
34
+ Boolean(config.lastSyncMessageId) &&
35
+ recordWatermarkKey(record) > config.lastSyncMessageId);
36
+ }
37
+ async function readRecords() {
38
+ const rootDir = resolveClaudeDir();
39
+ const records = [];
40
+ for await (const record of streamAllRecords(rootDir)) {
41
+ records.push(record);
42
+ }
43
+ return records;
44
+ }
45
+ // Shown once, on the first sync — transparency, not a question. Authorization
46
+ // already happened in the browser device-approval step, so there's nothing to
47
+ // answer here; the CLI just states exactly what it uploads and proceeds.
48
+ function printUploadNotice(dayCount, sessionCount) {
49
+ console.log("");
50
+ console.log(chalk.bold("uploading aggregate usage only:"));
51
+ console.log(` ${dayCount} day-buckets across ${sessionCount} sessions`);
52
+ console.log(" numeric totals only: tokens, hours, and message counts");
53
+ console.log(chalk.gray(" never uploaded: prompts, responses, file paths, project names, or raw files"));
54
+ console.log(chalk.gray(" privacy: https://claudeusage.com/privacy"));
55
+ console.log("");
56
+ }
57
+ function printDryRun(payload, records) {
58
+ const totals = payload.dailyBuckets.reduce((acc, bucket) => {
59
+ for (const model of Object.values(bucket.perModel)) {
60
+ acc.input += model.inputTokens;
61
+ acc.output += model.outputTokens;
62
+ acc.cacheCreate += model.cacheCreateTokens;
63
+ acc.cacheRead += model.cacheReadTokens;
64
+ acc.messages += model.messageCount;
65
+ }
66
+ return acc;
67
+ }, { cacheCreate: 0, cacheRead: 0, input: 0, messages: 0, output: 0 });
68
+ console.log(chalk.bold("\ndry run - payload summary:"));
69
+ console.log(" records: ", records);
70
+ console.log(" windowStart: ", payload.windowStart);
71
+ console.log(" windowEnd: ", payload.windowEnd);
72
+ console.log(" sessions: ", payload.sessionCount);
73
+ console.log(" dailyBuckets:", payload.dailyBuckets.length);
74
+ console.log(" messages: ", totals.messages);
75
+ console.log(" inputTokens: ", totals.input);
76
+ console.log(" outputTokens:", totals.output);
77
+ console.log(" cacheCreate: ", totals.cacheCreate);
78
+ console.log(" cacheRead: ", totals.cacheRead);
79
+ }
80
+ async function uploadWithReauth(base, token, payload) {
81
+ const upload = ora(`uploading ${payload.dailyBuckets.length} day-buckets...`).start();
82
+ try {
83
+ const result = await postIngest(base, token, payload);
84
+ upload.succeed(result.duplicate
85
+ ? "already synced; duplicate upload ignored"
86
+ : `synced ${result.newRecords} day-buckets (${result.messageCount} messages)`);
87
+ return token;
88
+ }
89
+ catch (error) {
90
+ if (error instanceof NeedsReauth) {
91
+ upload.warn("token rejected; re-running device authorization");
92
+ await deleteConfig();
93
+ const freshToken = await runDeviceFlow(base);
94
+ const result = await postIngest(base, freshToken, payload);
95
+ console.log(chalk.green(result.duplicate
96
+ ? "already synced after re-auth; duplicate upload ignored"
97
+ : `synced after re-auth (${result.newRecords} day-buckets, ${result.messageCount} messages)`));
98
+ return freshToken;
99
+ }
100
+ if (error instanceof RateLimited) {
101
+ upload.fail(`rate-limited. retry in ${error.retryAfterSec}s.`);
102
+ throw error;
103
+ }
104
+ if (error instanceof IngestConflict) {
105
+ if (error.code === "ingest_overlap") {
106
+ upload.fail("upload overlaps data already accepted for this machine; refusing to double-count");
107
+ throw new Error("overlapping sync rejected. Run `claudeusage-sync` normally, or contact support if you need a history reset/backfill.");
108
+ }
109
+ if (error.code === "ingest_in_progress") {
110
+ upload.fail(`another upload is still being applied. retry in ${error.retryAfterSec ?? 30}s.`);
111
+ throw error;
112
+ }
113
+ }
114
+ upload.fail(error instanceof Error ? error.message : String(error));
115
+ throw error;
116
+ }
117
+ }
118
+ export async function runSync(options) {
119
+ const base = apiBase();
120
+ const existingConfig = await readConfig();
121
+ let token = options.token ?? existingConfig?.token;
122
+ const config = existingConfig
123
+ ? { ...existingConfig, apiBase: base }
124
+ : token
125
+ ? { apiBase: base, token }
126
+ : null;
127
+ const spinner = ora("reading Claude Code session files...").start();
128
+ const allRecords = await readRecords();
129
+ spinner.succeed(`read ${allRecords.length} records from ${resolveClaudeDir()}`);
130
+ let filtered = allRecords;
131
+ if (options.since) {
132
+ const cutoff = parseSince(options.since);
133
+ filtered = allRecords.filter((record) => new Date(record.timestamp).getTime() >= cutoff);
134
+ }
135
+ else if (config) {
136
+ filtered = allRecords.filter((record) => afterWatermark(record, config));
137
+ }
138
+ const deduped = dedupe(filtered);
139
+ if (detectOutputTokenUndercount(deduped)) {
140
+ console.log(chalk.yellow("warning: output token undercount suspected in this data; output totals may be low."));
141
+ }
142
+ console.log(chalk.gray(` ${deduped.length} records after dedupe`));
143
+ const payload = buildPayload(deduped, packageInfo.version);
144
+ if (options.dryRun) {
145
+ printDryRun(payload, deduped.length);
146
+ return;
147
+ }
148
+ if (payload.dailyBuckets.length === 0) {
149
+ console.log(chalk.yellow("no new Claude Code usage records to sync."));
150
+ if (config) {
151
+ await writeConfig(config);
152
+ }
153
+ return;
154
+ }
155
+ if (!token) {
156
+ token = await runDeviceFlow(base);
157
+ }
158
+ const writableConfig = config ?? { apiBase: base, token };
159
+ writableConfig.apiBase = base;
160
+ writableConfig.token = token;
161
+ if (!writableConfig.consentAcceptedAt) {
162
+ printUploadNotice(payload.dailyBuckets.length, payload.sessionCount);
163
+ writableConfig.consentAcceptedAt = new Date().toISOString();
164
+ }
165
+ await writeConfig(writableConfig);
166
+ const finalToken = await uploadWithReauth(base, token, payload);
167
+ const watermark = latestWatermark(deduped);
168
+ await writeConfig({
169
+ ...writableConfig,
170
+ ...watermark,
171
+ apiBase: base,
172
+ token: finalToken,
173
+ });
174
+ console.log(chalk.bold("\nprofile:"), chalk.hex("#d97757")(`${base}/dashboard`));
175
+ }
@@ -0,0 +1,6 @@
1
+ import chalk from "chalk";
2
+ import { deleteConfig } from "../auth/config.js";
3
+ export async function runUnlink() {
4
+ await deleteConfig();
5
+ console.log(chalk.gray("removed local config. run `claudeusage-sync` to re-link."));
6
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function readPackageInfo(metaUrl) {
5
+ let dir = dirname(fileURLToPath(metaUrl));
6
+ let packagePath = resolve(dir, "package.json");
7
+ for (let depth = 0; depth < 6 && !existsSync(packagePath); depth += 1) {
8
+ dir = resolve(dir, "..");
9
+ packagePath = resolve(dir, "package.json");
10
+ }
11
+ const raw = readFileSync(packagePath, "utf8");
12
+ const parsed = JSON.parse(raw);
13
+ return {
14
+ version: typeof parsed.version === "string" ? parsed.version : "0.0.0",
15
+ };
16
+ }
@@ -0,0 +1,26 @@
1
+ export function recordWatermarkKey(record) {
2
+ return `${record.messageId}:${record.requestId}`;
3
+ }
4
+ export function dedupe(records) {
5
+ const best = new Map();
6
+ for (const record of records) {
7
+ const key = recordWatermarkKey(record);
8
+ const previous = best.get(key);
9
+ if (!previous || record.outputTokens > previous.outputTokens) {
10
+ best.set(key, record);
11
+ }
12
+ }
13
+ return Array.from(best.values()).sort((a, b) => {
14
+ const time = a.timestamp.localeCompare(b.timestamp);
15
+ return time === 0
16
+ ? recordWatermarkKey(a).localeCompare(recordWatermarkKey(b))
17
+ : time;
18
+ });
19
+ }
20
+ export function detectOutputTokenUndercount(records) {
21
+ if (records.length === 0) {
22
+ return false;
23
+ }
24
+ const suspicious = records.filter((record) => record.outputTokens === 1 && record.stopReason === null).length;
25
+ return suspicious / records.length > 0.5;
26
+ }
@@ -0,0 +1,42 @@
1
+ const IDLE_CUTOFF_MS = 10 * 60 * 1000;
2
+ export function toLocalDateKey(date) {
3
+ const year = date.getFullYear();
4
+ const month = String(date.getMonth() + 1).padStart(2, "0");
5
+ const day = String(date.getDate()).padStart(2, "0");
6
+ return `${year}-${month}-${day}`;
7
+ }
8
+ export function groupByLocalDay(records) {
9
+ const byDay = new Map();
10
+ for (const record of records) {
11
+ const date = new Date(record.timestamp);
12
+ if (Number.isNaN(date.getTime())) {
13
+ continue;
14
+ }
15
+ const dateKey = toLocalDateKey(date);
16
+ const dayRecords = byDay.get(dateKey) ?? [];
17
+ dayRecords.push(record);
18
+ byDay.set(dateKey, dayRecords);
19
+ }
20
+ for (const dayRecords of byDay.values()) {
21
+ dayRecords.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
22
+ }
23
+ return byDay;
24
+ }
25
+ export function computeHoursActive(records) {
26
+ if (records.length < 2) {
27
+ return 0;
28
+ }
29
+ const sorted = records
30
+ .slice()
31
+ .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
32
+ let totalMs = 0;
33
+ for (let index = 1; index < sorted.length; index += 1) {
34
+ const current = new Date(sorted[index]?.timestamp ?? "").getTime();
35
+ const previous = new Date(sorted[index - 1]?.timestamp ?? "").getTime();
36
+ const gap = current - previous;
37
+ if (gap > 0 && gap < IDLE_CUTOFF_MS) {
38
+ totalMs += gap;
39
+ }
40
+ }
41
+ return Math.round((totalMs / 3_600_000) * 100) / 100;
42
+ }
@@ -0,0 +1,95 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ function isObject(value) {
6
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
7
+ }
8
+ function stringField(...values) {
9
+ for (const value of values) {
10
+ if (typeof value === "string" && value.trim()) {
11
+ return value;
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ function nonNegativeInt(value) {
17
+ const n = Number(value ?? 0);
18
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
19
+ }
20
+ function validIso(value) {
21
+ if (!value) {
22
+ return null;
23
+ }
24
+ const ms = Date.parse(value);
25
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
26
+ }
27
+ function extract(parsed) {
28
+ if (!isObject(parsed)) {
29
+ return null;
30
+ }
31
+ const message = isObject(parsed.message) ? parsed.message : {};
32
+ const usage = isObject(message.usage)
33
+ ? message.usage
34
+ : isObject(parsed.usage)
35
+ ? parsed.usage
36
+ : null;
37
+ if (!usage) {
38
+ return null;
39
+ }
40
+ const timestamp = validIso(stringField(parsed.timestamp, message.timestamp, parsed.created_at));
41
+ const model = stringField(message.model, parsed.model);
42
+ const messageId = stringField(message.id, parsed.messageId, parsed.message_id);
43
+ const requestId = stringField(parsed.requestId, parsed.request_id, message.requestId, message.request_id) ?? "";
44
+ const sessionId = stringField(parsed.sessionId, parsed.session_id, message.sessionId) ?? "";
45
+ if (!timestamp || !model || !messageId) {
46
+ return null;
47
+ }
48
+ return {
49
+ cacheCreateTokens: nonNegativeInt(usage.cache_creation_input_tokens),
50
+ cacheReadTokens: nonNegativeInt(usage.cache_read_input_tokens),
51
+ inputTokens: nonNegativeInt(usage.input_tokens),
52
+ messageId,
53
+ model,
54
+ outputTokens: nonNegativeInt(usage.output_tokens),
55
+ requestId,
56
+ sessionId,
57
+ stopReason: stringField(message.stop_reason, parsed.stop_reason),
58
+ timestamp,
59
+ };
60
+ }
61
+ async function* streamFile(filePath) {
62
+ const stream = createReadStream(filePath, { encoding: "utf8" });
63
+ const rl = createInterface({ crlfDelay: Infinity, input: stream });
64
+ for await (const line of rl) {
65
+ if (!line.trim()) {
66
+ continue;
67
+ }
68
+ try {
69
+ const record = extract(JSON.parse(line));
70
+ if (record) {
71
+ yield record;
72
+ }
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ }
78
+ }
79
+ export async function* streamAllRecords(rootDir) {
80
+ const projects = await readdir(rootDir).catch(() => []);
81
+ for (const project of projects) {
82
+ const projectPath = join(rootDir, project);
83
+ const projectStat = await stat(projectPath).catch(() => null);
84
+ if (!projectStat?.isDirectory()) {
85
+ continue;
86
+ }
87
+ const files = await readdir(projectPath).catch(() => []);
88
+ for (const file of files) {
89
+ if (!file.endsWith(".jsonl")) {
90
+ continue;
91
+ }
92
+ yield* streamFile(resolve(projectPath, file));
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,9 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+ export function resolveClaudeDir() {
4
+ const override = process.env.CLAUDE_CONFIG_DIR;
5
+ if (override) {
6
+ return resolve(override, "projects");
7
+ }
8
+ return resolve(homedir(), ".claude", "projects");
9
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claudeusage-sync",
3
- "version": "0.0.1",
4
- "description": "Placeholder CLI for syncing Claude Code usage stats to claudeusage.com. Full release coming soon.",
3
+ "version": "0.2.0",
4
+ "description": "Sync your Claude Code usage stats to claudeusage.com",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -10,7 +10,44 @@
10
10
  "cli"
11
11
  ],
12
12
  "homepage": "https://claudeusage.com",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/bazarkua/claudeusage-sync.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/bazarkua/claudeusage-sync/issues"
19
+ },
13
20
  "license": "MIT",
14
21
  "author": "bazarkua",
15
- "private": false
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "main": "./dist/cli.js",
27
+ "bin": {
28
+ "claudeusage-sync": "bin/cli.js"
29
+ },
30
+ "files": [
31
+ "dist/",
32
+ "bin/",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "start": "node bin/cli.js",
39
+ "prepack": "tsc",
40
+ "prepublishOnly": "tsc"
41
+ },
42
+ "dependencies": {
43
+ "chalk": "5.6.2",
44
+ "commander": "14.0.3",
45
+ "open": "11.0.0",
46
+ "ora": "9.3.0",
47
+ "zod": "4.3.6"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "22.0.0",
51
+ "typescript": "5.9.3"
52
+ }
16
53
  }