tokaholics 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 Mathias Karlsson
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,90 @@
1
+ # tokaholics CLI
2
+
3
+ Measures your **Claude Code** token burn and pushes it to the Tokaholics
4
+ leaderboard. It reads **only usage numbers** (token counts, model, timestamp)
5
+ from `~/.claude/projects/**/*.jsonl` — never your prompts, code, file names, or
6
+ project names.
7
+
8
+ The CLI is MIT-licensed and open source — read it before you run it.
9
+
10
+ ## What it touches on your machine
11
+
12
+ `setup` is explicit about everything it does and asks before changing anything:
13
+
14
+ | Action | Default | Notes |
15
+ |--------|:-------:|-------|
16
+ | Read `~/.claude/projects/**/*.jsonl` | ✅ | usage fields only — see [Privacy](#privacy) |
17
+ | Upload daily `(day, model)` token totals | ✅ | via the `ingest` endpoint |
18
+ | Store a device token | ✅ | macOS **Keychain**, never plaintext on disk |
19
+ | Background sync agent (launchd, every 5 min) | ✅ | opt out with `--no-agent` |
20
+ | Claude Code **Stop hook** in `~/.claude/settings.json` | ❌ | opt in with `--hook` |
21
+
22
+ Nothing is installed silently, and everything is reversible (see
23
+ [Uninstall](#uninstall)).
24
+
25
+ ## Get started
26
+
27
+ ```bash
28
+ npx tokaholics setup <code> # code from the iOS app: You → Connect a computer
29
+ npx tokaholics setup <code> --hook # …and also sync instantly after each session
30
+ ```
31
+
32
+ `setup` pairs this Mac, backfills your history, and turns on background sync.
33
+ It prints exactly what it will do and waits for your confirmation. That's it —
34
+ open the app.
35
+
36
+ ## Commands
37
+
38
+ | Command | What it does |
39
+ |---------|--------------|
40
+ | `tokaholics setup <code> [--hook] [--no-agent] [-y]` | Pair + backfill + background sync (the one-liner above) |
41
+ | `tokaholics login <code>` | Pair only (no sync/agent/hook) |
42
+ | `tokaholics stats` | Show your local 7-day burn (**no upload**) |
43
+ | `tokaholics projects` | Burn by project, last 30 days (**local only**) |
44
+ | `tokaholics sync [--full]` | Parse new log lines and push changed days |
45
+ | `tokaholics start [--interval 300]` | Install background sync (launchd, macOS) |
46
+ | `tokaholics stop` | Remove background sync |
47
+ | `tokaholics install-hook` / `uninstall-hook` | Add / remove the instant Stop hook |
48
+ | `tokaholics status` | Show pairing/config |
49
+ | `tokaholics logout` | Unpair this machine + remove the background agent |
50
+
51
+ Try it with **no account** first — `npx tokaholics stats` runs fully offline.
52
+
53
+ ## Uninstall
54
+
55
+ ```bash
56
+ tokaholics uninstall-hook # if you added --hook
57
+ tokaholics logout # unpairs + removes the launchd agent
58
+ ```
59
+
60
+ That removes the launchd agent, the Stop hook, and the Keychain token. The
61
+ config dir `~/.tokaholics` can then be deleted.
62
+
63
+ ## How it works
64
+
65
+ 1. Streams every `.jsonl` session log line-by-line (memory-safe).
66
+ 2. Keeps only assistant `usage` rows, **deduped by `message.id`** (the same id
67
+ repeats across streaming rows).
68
+ 3. Aggregates **absolute** totals per `(day, model)` → idempotent upserts, so
69
+ re-running `sync` never double-counts.
70
+ 4. Pushes via a per-device token (only its SHA-256 hash is stored server-side).
71
+ Row-Level Security limits you to your own rows; others' numbers are only ever
72
+ visible as aggregates. Cost (USD) is computed server-side from a pricing table.
73
+
74
+ The client holds no secret keys — only the public `publishable` key, which is
75
+ safe to embed.
76
+
77
+ ## Privacy
78
+
79
+ The only fields ever read from your logs are:
80
+ `message.id`, `message.model`, `timestamp`, and the four `usage` token counts.
81
+ No prompt text, file contents, file names, or project names leave your machine.
82
+
83
+ ## Dev (from the repo)
84
+
85
+ ```bash
86
+ cd cli
87
+ npm install
88
+ node bin/tokaholics.js stats # works offline, no account needed
89
+ npm test # RPC smoke test (needs Supabase env vars)
90
+ ```
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // tokaholics — CLI that measures your Claude Code token burn and pushes it to
3
+ // the leaderboard. Reads ONLY usage numbers from your logs, never your code.
4
+
5
+ import { Command } from 'commander';
6
+ import { hostname, platform } from 'node:os';
7
+ import { createInterface } from 'node:readline';
8
+ import { readFileSync } from 'node:fs';
9
+
10
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
+ import { aggregateUsage, aggregateByProject, daysAgo, today } from '../src/parse.js';
12
+ import { readConfig, writeConfig, setSecret, clearSecret } from '../src/store.js';
13
+ import { redeemPairing, pushUsage } from '../src/api.js';
14
+ import { installAgent, uninstallAgent } from '../src/daemon.js';
15
+ import { installHook, uninstallHook } from '../src/hook.js';
16
+ import { aggregateIncremental, resetIncremental } from '../src/incremental.js';
17
+
18
+ const program = new Command();
19
+ program
20
+ .name('tokaholics')
21
+ .description('Track your Claude Code burn. Climb the leaderboard with your friends.')
22
+ .version(pkg.version);
23
+
24
+ const fmt = (n) => (n / 1e6).toFixed(1) + 'M';
25
+ const REPO = 'https://github.com/dragon6sic6/tokaholics-cli';
26
+
27
+ // Yes/no prompt. Non-interactive (no TTY) resolves to the default so piped/CI
28
+ // runs never hang. Enter accepts the default.
29
+ function confirm(question, def = true) {
30
+ if (!process.stdin.isTTY) return Promise.resolve(def);
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ const hint = def ? '[Y/n]' : '[y/N]';
33
+ return new Promise((resolve) => {
34
+ rl.question(`${question} ${hint} `, (a) => {
35
+ rl.close();
36
+ const s = a.trim().toLowerCase();
37
+ resolve(s === '' ? def : (s === 'y' || s === 'yes'));
38
+ });
39
+ });
40
+ }
41
+
42
+ // ── login / pairing ──────────────────────────────────────────────────────────
43
+ program
44
+ .command('login')
45
+ .description('Pair this machine with your Tokaholics account')
46
+ .argument('<code>', '6-digit pairing code shown in the iOS app')
47
+ .option('--url <url>', 'Supabase project URL (first-time setup)')
48
+ .option('--anon <key>', 'Supabase anon key (first-time setup)')
49
+ .action(async (code, opts) => {
50
+ if (opts.url || opts.anon) {
51
+ await writeConfig({
52
+ ...(opts.url ? { supabaseUrl: opts.url } : {}),
53
+ ...(opts.anon ? { supabaseAnon: opts.anon } : {}),
54
+ });
55
+ }
56
+ const name = hostname();
57
+ const { device_id, device_token, user_id, username } = await redeemPairing(code, name, platform());
58
+ await setSecret(device_token);
59
+ await writeConfig({ deviceId: device_id, userId: user_id, username });
60
+ console.log(`✓ Paired as @${username} (device: ${name}).`);
61
+ console.log(' Run `tokaholics start` to sync in the background, or `tokaholics sync` once.');
62
+ });
63
+
64
+ program
65
+ .command('setup')
66
+ .description('Pair + backfill + background sync. Add --hook for instant post-session sync.')
67
+ .argument('<code>', 'pairing code from the iOS app (You → Connect a computer)')
68
+ .option('--url <url>', 'Supabase project URL (first-time setup only)')
69
+ .option('--anon <key>', 'Supabase anon key (first-time setup only)')
70
+ .option('--hook', 'Also add a Claude Code Stop hook for instant sync (edits ~/.claude/settings.json)')
71
+ .option('--no-agent', 'Skip the background sync agent')
72
+ .option('-y, --yes', 'Skip the confirmation prompt (for non-interactive use)')
73
+ .action(async (code, opts) => {
74
+ if (opts.url || opts.anon) {
75
+ await writeConfig({
76
+ ...(opts.url ? { supabaseUrl: opts.url } : {}),
77
+ ...(opts.anon ? { supabaseAnon: opts.anon } : {}),
78
+ });
79
+ }
80
+
81
+ const wantAgent = opts.agent !== false; // commander sets agent=false on --no-agent
82
+ const wantHook = !!opts.hook;
83
+
84
+ // Transparency: say exactly what we read and what we'll change, then consent.
85
+ console.log('');
86
+ console.log('Tokaholics measures token COUNTS only. It reads ~/.claude logs for usage');
87
+ console.log('numbers (tokens, model, timestamp) and never your prompts, code, file');
88
+ console.log(`names, or project names. Source: ${REPO}`);
89
+ console.log('');
90
+ console.log('This will:');
91
+ console.log(' • pair this Mac with your account (device token → macOS Keychain)');
92
+ console.log(' • read ~/.claude/projects/**/*.jsonl and upload daily token totals');
93
+ if (wantAgent) console.log(' • install a background sync agent (launchd, every 5 min)');
94
+ if (wantHook) console.log(' • add a Stop hook to ~/.claude/settings.json (instant sync)');
95
+ if (!wantHook) console.log(' (no Claude-hook — add later with --hook or `tokaholics install-hook`)');
96
+ console.log('');
97
+ console.log(`Undo anytime: tokaholics logout${wantHook ? ' + tokaholics uninstall-hook' : ''}`);
98
+ console.log('');
99
+
100
+ if (!opts.yes && !(await confirm('Continue?', true))) {
101
+ console.log('Aborted — nothing was changed.');
102
+ return;
103
+ }
104
+
105
+ const name = hostname();
106
+ const { device_id, device_token, user_id, username } = await redeemPairing(code, name, platform());
107
+ await setSecret(device_token);
108
+ await writeConfig({ deviceId: device_id, userId: user_id, username });
109
+ console.log(`✓ Paired as @${username} (${name}).`);
110
+
111
+ await resetIncremental();
112
+ const { rows, changedDays } = await aggregateIncremental();
113
+ if (rows.length) {
114
+ const n = await pushUsage(rows);
115
+ const tot = rows.reduce((s, r) =>
116
+ s + r.input_tokens + r.output_tokens + r.cache_write_tokens + r.cache_read_tokens, 0);
117
+ console.log(`✓ Backfilled ${n} day/model rows (${fmt(tot)} tokens) across ${changedDays} days.`);
118
+ }
119
+
120
+ if (wantAgent) {
121
+ await installAgent({ intervalSec: 300 });
122
+ console.log('✓ Background sync running (every 5 min).');
123
+ } else {
124
+ console.log('• Background agent skipped — run `tokaholics start` later, or `tokaholics sync` by hand.');
125
+ }
126
+
127
+ if (wantHook) {
128
+ await installHook();
129
+ console.log('✓ Instant sync after every Claude Code session.');
130
+ } else {
131
+ console.log('• Instant Claude-hook not installed — run `tokaholics install-hook` if you want it.');
132
+ }
133
+
134
+ console.log('\n🔥 You are live on Tokaholics. Open the app and watch your burn.');
135
+ });
136
+
137
+ program
138
+ .command('logout')
139
+ .description('Unpair this machine and remove everything (agent + hook)')
140
+ .action(async () => {
141
+ await clearSecret();
142
+ await writeConfig({ deviceId: null, userId: null });
143
+ await uninstallAgent();
144
+ await uninstallHook();
145
+ console.log('✓ Logged out. Background sync, the Claude hook, and the device token are all removed.');
146
+ });
147
+
148
+ // ── sync ──────────────────────────────────────────────────────────────────────
149
+ program
150
+ .command('sync')
151
+ .description('Incrementally parse new log lines and push changed days')
152
+ .option('--full', 'Rebuild from a full scan of all history')
153
+ .action(async (opts) => {
154
+ const cfg = await readConfig();
155
+ if (!cfg.deviceId) {
156
+ console.error('Not paired. Run `tokaholics login <code>` first.');
157
+ process.exit(1);
158
+ }
159
+ if (opts.full) await resetIncremental();
160
+ const { rows, changedDays, seeded } = await aggregateIncremental();
161
+ if (rows.length === 0) {
162
+ console.log('Up to date — nothing new to push.');
163
+ return;
164
+ }
165
+ const n = await pushUsage(rows);
166
+ console.log(`✓ Pushed ${n} day/model rows (${changedDays} day(s) changed${seeded ? ', full backfill' : ''}).`);
167
+ });
168
+
169
+ // ── local stats (no network) ───────────────────────────────────────────────────
170
+ program
171
+ .command('stats')
172
+ .description('Show your local burn for the last 7 days (no upload)')
173
+ .action(async () => {
174
+ const { rows } = await aggregateUsage({ sinceDay: daysAgo(7) });
175
+ const byDay = {};
176
+ for (const r of rows) {
177
+ const t = r.input_tokens + r.output_tokens + r.cache_write_tokens + r.cache_read_tokens;
178
+ byDay[r.day] = (byDay[r.day] || 0) + t;
179
+ }
180
+ console.log('Your burn (last 7 days):');
181
+ for (const [d, t] of Object.entries(byDay).sort()) {
182
+ const bar = '█'.repeat(Math.min(40, Math.round(t / 1e7)));
183
+ const tag = d === today() ? ' ← today' : '';
184
+ console.log(` ${d} ${fmt(t).padStart(7)} ${bar}${tag}`);
185
+ }
186
+ });
187
+
188
+ program
189
+ .command('projects')
190
+ .description('Show your burn broken down by project (last 30 days, local)')
191
+ .action(async () => {
192
+ const rows = await aggregateByProject({ sinceDay: daysAgo(30) });
193
+ if (rows.length === 0) { console.log('No usage found.'); return; }
194
+ const max = rows[0].tokens;
195
+ console.log('Your burn by project (last 30 days):');
196
+ for (const r of rows.slice(0, 20)) {
197
+ const bar = '█'.repeat(Math.max(1, Math.round((r.tokens / max) * 30)));
198
+ console.log(` ${fmt(r.tokens).padStart(7)} ${bar} ${r.project}`);
199
+ }
200
+ });
201
+
202
+ // ── background agent ────────────────────────────────────────────────────────────
203
+ program
204
+ .command('start')
205
+ .description('Install the background sync agent (launchd, every 5 min)')
206
+ .option('--interval <sec>', 'Sync interval in seconds', '300')
207
+ .action(async (opts) => {
208
+ const plist = await installAgent({ intervalSec: parseInt(opts.interval, 10) });
209
+ console.log(`✓ Background sync running. (${plist})`);
210
+ });
211
+
212
+ program
213
+ .command('stop')
214
+ .description('Remove the background sync agent')
215
+ .action(async () => {
216
+ await uninstallAgent();
217
+ console.log('✓ Background sync stopped.');
218
+ });
219
+
220
+ program
221
+ .command('install-hook')
222
+ .description('Push usage instantly after every Claude Code session (Stop hook)')
223
+ .action(async () => {
224
+ const path = await installHook();
225
+ console.log(`✓ Realtime hook installed in ${path}.`);
226
+ console.log(' Your burn now syncs the moment each Claude Code session ends.');
227
+ });
228
+
229
+ program
230
+ .command('uninstall-hook')
231
+ .description('Remove the Claude Code Stop hook')
232
+ .action(async () => {
233
+ await uninstallHook();
234
+ console.log('✓ Realtime hook removed.');
235
+ });
236
+
237
+ program
238
+ .command('status')
239
+ .description('Show pairing + config status')
240
+ .action(async () => {
241
+ const cfg = await readConfig();
242
+ console.log('Account: ', cfg.username ? `@${cfg.username}` : '(not paired)');
243
+ console.log('Device id: ', cfg.deviceId || '—');
244
+ console.log('Supabase: ', cfg.supabaseUrl || '(default/env)');
245
+ });
246
+
247
+ program.parseAsync().catch((e) => {
248
+ console.error(`✗ ${e?.message || e}`);
249
+ process.exit(1);
250
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "tokaholics",
3
+ "version": "0.2.0",
4
+ "description": "Track your Claude Code token burn and climb the leaderboard with your friends. Reads usage counts only — never your prompts or code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokaholics": "bin/tokaholics.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "tokens",
19
+ "usage",
20
+ "leaderboard",
21
+ "cli"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "commander": "^12.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@supabase/supabase-js": "^2.45.0"
31
+ },
32
+ "scripts": {
33
+ "start": "node bin/tokaholics.js",
34
+ "test": "node tests/rpc-smoke.mjs"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/dragon6sic6/tokaholics-cli.git"
39
+ },
40
+ "homepage": "https://tokaholics.pro",
41
+ "bugs": {
42
+ "url": "https://github.com/dragon6sic6/tokaholics-cli/issues"
43
+ },
44
+ "author": "Mathias Karlsson",
45
+ "license": "MIT"
46
+ }
package/src/api.js ADDED
@@ -0,0 +1,65 @@
1
+ // Supabase access for the CLI. The CLI authenticates with a per-device token
2
+ // (issued by `redeem-pairing-code`) and writes ONLY through the `ingest` edge
3
+ // function. It never touches the database directly and holds no service key.
4
+
5
+ import { readConfig, getSecret } from './store.js';
6
+
7
+ // Built-in defaults so a freshly-installed CLI knows where to talk.
8
+ // The publishable key is safe to embed in clients. Overridable via config/env.
9
+ const DEFAULT_URL =
10
+ process.env.TOKAHOLICS_SUPABASE_URL || 'https://pdfuopfqhubsumcpfqdb.supabase.co';
11
+ const DEFAULT_ANON =
12
+ process.env.TOKAHOLICS_SUPABASE_ANON || 'sb_publishable__Z3ntgU8fQGyA7s8VJlo5g_JcAjItQe';
13
+
14
+ async function endpoint() {
15
+ const cfg = await readConfig();
16
+ const url = cfg.supabaseUrl || DEFAULT_URL;
17
+ const anon = cfg.supabaseAnon || DEFAULT_ANON;
18
+ if (!url || !anon) throw new Error('Missing Supabase config (url/anon).');
19
+ return { url, anon };
20
+ }
21
+
22
+ async function callFn(name, body) {
23
+ const { url, anon } = await endpoint();
24
+ const res = await fetch(`${url}/functions/v1/${name}`, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ apikey: anon,
29
+ Authorization: `Bearer ${anon}`,
30
+ },
31
+ body: JSON.stringify(body),
32
+ });
33
+ const text = await res.text();
34
+ let data;
35
+ try { data = JSON.parse(text); } catch { data = { raw: text }; }
36
+ if (!res.ok) throw new Error(`${name} failed (${res.status}): ${data.error || text}`);
37
+ return data;
38
+ }
39
+
40
+ // Redeem a 6-digit pairing code from the iOS app.
41
+ // Returns { device_id, user_id, username, device_token }.
42
+ export async function redeemPairing(code, deviceName, plat) {
43
+ return callFn('redeem-pairing-code', {
44
+ code,
45
+ device_name: deviceName,
46
+ platform: plat,
47
+ });
48
+ }
49
+
50
+ // Idempotent upsert of absolute daily aggregates via the ingest function.
51
+ export async function pushUsage(rows) {
52
+ const token = await getSecret();
53
+ if (!token) throw new Error('No device token. Run `tokaholics login <code>` first.');
54
+ const payload = rows.map((r) => ({
55
+ day: r.day,
56
+ model: r.model,
57
+ input_tokens: r.input_tokens,
58
+ output_tokens: r.output_tokens,
59
+ cache_write_tokens: r.cache_write_tokens,
60
+ cache_read_tokens: r.cache_read_tokens,
61
+ message_count: r.message_count,
62
+ }));
63
+ const out = await callFn('ingest', { device_token: token, rows: payload });
64
+ return out.rows;
65
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,55 @@
1
+ // Installs the CLI as a macOS launchd agent so usage syncs in the background
2
+ // even after reboot. Runs `tokaholics sync` on an interval.
3
+
4
+ import { writeFile, mkdir, rm } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { homedir, platform } from 'node:os';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const pexec = promisify(execFile);
12
+ const LABEL = 'ai.tokaholics.sync';
13
+ const PLIST = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
14
+
15
+ function binPath() {
16
+ // Absolute path to bin/tokaholics.js inside this package.
17
+ return fileURLToPath(new URL('../bin/tokaholics.js', import.meta.url));
18
+ }
19
+
20
+ export async function installAgent({ intervalSec = 300 } = {}) {
21
+ if (platform() !== 'darwin') {
22
+ throw new Error('Background agent is macOS-only for now. Use `tokaholics sync` via cron on Linux.');
23
+ }
24
+ await mkdir(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
25
+ const node = process.execPath;
26
+ const logDir = join(homedir(), '.tokaholics');
27
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
28
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
29
+ <plist version="1.0">
30
+ <dict>
31
+ <key>Label</key><string>${LABEL}</string>
32
+ <key>ProgramArguments</key>
33
+ <array>
34
+ <string>${node}</string>
35
+ <string>${binPath()}</string>
36
+ <string>sync</string>
37
+ </array>
38
+ <key>StartInterval</key><integer>${intervalSec}</integer>
39
+ <key>RunAtLoad</key><true/>
40
+ <key>StandardOutPath</key><string>${join(logDir, 'sync.log')}</string>
41
+ <key>StandardErrorPath</key><string>${join(logDir, 'sync.err.log')}</string>
42
+ </dict>
43
+ </plist>`;
44
+ await writeFile(PLIST, plist);
45
+ // Reload if already loaded.
46
+ try { await pexec('launchctl', ['unload', PLIST]); } catch { /* not loaded */ }
47
+ await pexec('launchctl', ['load', PLIST]);
48
+ return PLIST;
49
+ }
50
+
51
+ export async function uninstallAgent() {
52
+ if (platform() !== 'darwin') return;
53
+ try { await pexec('launchctl', ['unload', PLIST]); } catch { /* */ }
54
+ try { await rm(PLIST); } catch { /* */ }
55
+ }
package/src/hook.js ADDED
@@ -0,0 +1,63 @@
1
+ // Installs a Claude Code "Stop" hook so usage is pushed immediately after every
2
+ // session ends — making friends' leaderboards update in near real-time.
3
+ //
4
+ // We edit ~/.claude/settings.json idempotently and tag our entry so it can be
5
+ // cleanly removed again. Existing hooks/settings are preserved.
6
+
7
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const SETTINGS = join(homedir(), '.claude', 'settings.json');
13
+ const TAG = 'tokaholics'; // identifies our hook entry
14
+
15
+ function syncCommand() {
16
+ const bin = fileURLToPath(new URL('../bin/tokaholics.js', import.meta.url));
17
+ // Quote the path in case it contains spaces.
18
+ return `"${process.execPath}" "${bin}" sync`;
19
+ }
20
+
21
+ async function readSettings() {
22
+ try {
23
+ return JSON.parse(await readFile(SETTINGS, 'utf8'));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ async function writeSettings(obj) {
30
+ await mkdir(join(homedir(), '.claude'), { recursive: true });
31
+ await writeFile(SETTINGS, JSON.stringify(obj, null, 2));
32
+ }
33
+
34
+ function withoutOurHook(stopArray) {
35
+ // Remove any Stop entries whose commands reference tokaholics.
36
+ return (stopArray || []).filter((entry) => {
37
+ const cmds = (entry.hooks || []).map((h) => h.command || '');
38
+ return !cmds.some((c) => c.includes(TAG));
39
+ });
40
+ }
41
+
42
+ export async function installHook() {
43
+ const s = await readSettings();
44
+ s.hooks = s.hooks || {};
45
+ s.hooks.Stop = withoutOurHook(s.hooks.Stop); // de-dupe first
46
+ s.hooks.Stop.push({
47
+ matcher: '',
48
+ hooks: [{ type: 'command', command: syncCommand() }],
49
+ });
50
+ await writeSettings(s);
51
+ return SETTINGS;
52
+ }
53
+
54
+ export async function uninstallHook() {
55
+ const s = await readSettings();
56
+ if (s.hooks?.Stop) {
57
+ s.hooks.Stop = withoutOurHook(s.hooks.Stop);
58
+ if (s.hooks.Stop.length === 0) delete s.hooks.Stop;
59
+ if (Object.keys(s.hooks).length === 0) delete s.hooks;
60
+ }
61
+ await writeSettings(s);
62
+ return SETTINGS;
63
+ }
@@ -0,0 +1,146 @@
1
+ // Incremental sync: instead of re-scanning every .jsonl on each run, we remember
2
+ // a byte offset per file and only read the bytes appended since last time, keeping
3
+ // running absolute per-(day,model) totals AND a persisted set of every seen
4
+ // message.id in a local state file. The ingest API is idempotent (absolute
5
+ // upsert), so we push only the days that actually changed.
6
+ //
7
+ // The seen-id set MUST be global + persisted: the same message.id can appear in
8
+ // multiple files (resumed/branched sessions) and across a sync boundary (streaming
9
+ // duplicates straddling an offset), and double-counting is permanent under an
10
+ // absolute upsert. Deduping against one cross-run set fixes both.
11
+ //
12
+ // State: ~/.tokaholics/sync-state.json
13
+ // { offsets: {"<path>": <bytes>}, totals: {"<day>\t<model>": {...}}, seen: ["<id>", ...] }
14
+
15
+ import { open, readdir, stat, readFile, writeFile, rename, mkdir } from 'node:fs/promises';
16
+ import { createReadStream } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import { createInterface } from 'node:readline';
20
+
21
+ const DIR = join(homedir(), '.tokaholics');
22
+ const STATE = join(DIR, 'sync-state.json');
23
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
24
+
25
+ function localDay(iso) {
26
+ const d = new Date(iso);
27
+ if (Number.isNaN(d.getTime())) return null;
28
+ const y = d.getFullYear();
29
+ const m = String(d.getMonth() + 1).padStart(2, '0');
30
+ const day = String(d.getDate()).padStart(2, '0');
31
+ return `${y}-${m}-${day}`;
32
+ }
33
+
34
+ async function* walkJsonl(dir) {
35
+ let entries;
36
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
37
+ for (const e of entries) {
38
+ const full = join(dir, e.name);
39
+ if (e.isDirectory()) yield* walkJsonl(full);
40
+ else if (e.isFile() && e.name.endsWith('.jsonl')) yield full;
41
+ }
42
+ }
43
+
44
+ async function loadState() {
45
+ try { return JSON.parse(await readFile(STATE, 'utf8')); } catch { return null; }
46
+ }
47
+ async function saveState({ offsets, totals, seen }) {
48
+ await mkdir(DIR, { recursive: true });
49
+ const tmp = STATE + '.tmp';
50
+ await writeFile(tmp, JSON.stringify({ offsets, totals, seen: [...seen] }));
51
+ await rename(tmp, STATE); // atomic: never leave a half-written state file
52
+ }
53
+
54
+ function emptyTotal(day, model) {
55
+ return { day, model, input_tokens: 0, output_tokens: 0,
56
+ cache_write_tokens: 0, cache_read_tokens: 0, message_count: 0 };
57
+ }
58
+ function addUsage(t, usage) {
59
+ t.input_tokens += usage.input_tokens || 0;
60
+ t.output_tokens += usage.output_tokens || 0;
61
+ t.cache_write_tokens += usage.cache_creation_input_tokens || 0;
62
+ t.cache_read_tokens += usage.cache_read_input_tokens || 0;
63
+ t.message_count += 1;
64
+ }
65
+ function rowsFor(totals, days) {
66
+ const out = [];
67
+ for (const t of Object.values(totals)) {
68
+ if (!days || days.has(t.day)) out.push(t);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Apply one assistant-usage line to totals + seen (global dedupe). Returns the
74
+ // day it touched, or null.
75
+ function applyLine(line, totals, seen) {
76
+ if (!line || line[0] !== '{') return null;
77
+ let obj; try { obj = JSON.parse(line); } catch { return null; }
78
+ const msg = obj.message; const usage = msg?.usage;
79
+ if (!usage || !msg?.id || seen.has(msg.id)) return null;
80
+ seen.add(msg.id);
81
+ const day = localDay(obj.timestamp); if (!day) return null;
82
+ const key = `${day}\t${msg.model || 'unknown'}`;
83
+ if (!totals[key]) totals[key] = emptyTotal(day, msg.model || 'unknown');
84
+ addUsage(totals[key], usage);
85
+ return day;
86
+ }
87
+
88
+ // First-run seed: a full streaming scan that records totals + the global seen set
89
+ // + per-file offsets (so subsequent runs only read appended bytes).
90
+ async function seed() {
91
+ const totals = {}; const seen = new Set(); const offsets = {};
92
+ for await (const file of walkJsonl(PROJECTS_DIR)) {
93
+ const rl = createInterface({
94
+ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
95
+ for await (const line of rl) applyLine(line, totals, seen);
96
+ offsets[file] = (await stat(file)).size;
97
+ }
98
+ await saveState({ offsets, totals, seen });
99
+ return { rows: rowsFor(totals, null), changedDays: new Set(Object.values(totals).map((t) => t.day)).size, seeded: true };
100
+ }
101
+
102
+ /**
103
+ * @returns {Promise<{rows: Array, changedDays: number, seeded: boolean}>}
104
+ * rows = absolute totals for days that changed this run (ready to upsert).
105
+ */
106
+ export async function aggregateIncremental() {
107
+ const state = await loadState();
108
+ if (!state || !state.totals || !Array.isArray(state.seen)) return seed();
109
+
110
+ const { offsets, totals } = state;
111
+ const seen = new Set(state.seen);
112
+ const changed = new Set();
113
+
114
+ for await (const file of walkJsonl(PROJECTS_DIR)) {
115
+ const size = (await stat(file)).size;
116
+ let offset = offsets[file] || 0;
117
+ if (size < offset) offset = 0; // file truncated/rotated → re-read
118
+ if (size <= offset) continue; // nothing new
119
+
120
+ const fh = await open(file, 'r');
121
+ try {
122
+ const len = size - offset;
123
+ const buf = Buffer.alloc(len);
124
+ await fh.read(buf, 0, len, offset);
125
+ const text = buf.toString('utf8');
126
+ const lastNL = text.lastIndexOf('\n');
127
+ if (lastNL === -1) continue; // no complete line yet
128
+ const complete = text.slice(0, lastNL + 1);
129
+ offsets[file] = offset + Buffer.byteLength(complete, 'utf8');
130
+ for (const line of complete.split('\n')) {
131
+ const day = applyLine(line, totals, seen);
132
+ if (day) changed.add(day);
133
+ }
134
+ } finally {
135
+ await fh.close();
136
+ }
137
+ }
138
+
139
+ await saveState({ offsets, totals, seen });
140
+ return { rows: rowsFor(totals, changed), changedDays: changed.size, seeded: false };
141
+ }
142
+
143
+ /** Drop incremental state so the next sync rebuilds from a full scan. */
144
+ export async function resetIncremental() {
145
+ try { await writeFile(STATE, JSON.stringify({})); } catch { /* */ }
146
+ }
package/src/parse.js ADDED
Binary file
package/src/store.js ADDED
@@ -0,0 +1,82 @@
1
+ // Local config + secure secret storage.
2
+ // • Non-secret config (supabase url, anon key, device_id) → ~/.tokaholics/config.json
3
+ // • Secret device JWT → macOS Keychain via the `security` CLI (never on disk in plaintext).
4
+ // On non-macOS we fall back to a 0600 file (documented tradeoff).
5
+
6
+ import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { homedir, platform } from 'node:os';
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+
12
+ const pexec = promisify(execFile);
13
+ const DIR = join(homedir(), '.tokaholics');
14
+ const CONFIG = join(DIR, 'config.json');
15
+ const KEYCHAIN_SERVICE = 'tokaholics-device-jwt';
16
+
17
+ async function ensureDir() {
18
+ await mkdir(DIR, { recursive: true });
19
+ }
20
+
21
+ export async function readConfig() {
22
+ try {
23
+ return JSON.parse(await readFile(CONFIG, 'utf8'));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export async function writeConfig(patch) {
30
+ await ensureDir();
31
+ const cur = await readConfig();
32
+ const next = { ...cur, ...patch };
33
+ await writeFile(CONFIG, JSON.stringify(next, null, 2));
34
+ await chmod(CONFIG, 0o600);
35
+ return next;
36
+ }
37
+
38
+ // ── secret (device JWT) ──────────────────────────────────────────────────────
39
+ export async function setSecret(jwt) {
40
+ if (platform() === 'darwin') {
41
+ // -U updates if present. Stored in the login keychain, ACL'd to this tool.
42
+ await pexec('security', [
43
+ 'add-generic-password', '-U',
44
+ '-s', KEYCHAIN_SERVICE,
45
+ '-a', 'default',
46
+ '-w', jwt,
47
+ ]);
48
+ } else {
49
+ await ensureDir();
50
+ const f = join(DIR, '.jwt');
51
+ await writeFile(f, jwt);
52
+ await chmod(f, 0o600);
53
+ }
54
+ }
55
+
56
+ export async function getSecret() {
57
+ if (platform() === 'darwin') {
58
+ try {
59
+ const { stdout } = await pexec('security', [
60
+ 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'default', '-w',
61
+ ]);
62
+ return stdout.trim();
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ try {
68
+ return (await readFile(join(DIR, '.jwt'), 'utf8')).trim();
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export async function clearSecret() {
75
+ if (platform() === 'darwin') {
76
+ try {
77
+ await pexec('security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', 'default']);
78
+ } catch { /* not found */ }
79
+ }
80
+ }
81
+
82
+ export const paths = { DIR, CONFIG };