ccclub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,647 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import { createInterface } from "readline/promises";
8
+ import { stdin, stdout } from "process";
9
+ import chalk2 from "chalk";
10
+ import ora2 from "ora";
11
+
12
+ // src/config.ts
13
+ import { readFile, writeFile, mkdir } from "fs/promises";
14
+ import { join } from "path";
15
+ import { homedir } from "os";
16
+ import { existsSync } from "fs";
17
+ import { randomBytes } from "crypto";
18
+
19
+ // ../shared/dist/constants.js
20
+ var BLOCK_DURATION_HOURS = 5;
21
+ var BLOCK_DURATION_MS = BLOCK_DURATION_HOURS * 60 * 60 * 1e3;
22
+ var DEFAULT_API_URL = "https://ccclub.dev";
23
+ var CLAUDE_PROJECTS_DIR = ".claude/projects";
24
+ var CCCLUB_CONFIG_DIR = ".ccclub";
25
+ var MODEL_PRICING = {
26
+ "claude-opus-4-6": { input: 15, output: 75, cacheCreation: 18.75, cacheRead: 1.5 },
27
+ "claude-sonnet-4-5-20250929": { input: 3, output: 15, cacheCreation: 3.75, cacheRead: 0.3 },
28
+ "claude-haiku-4-5-20251001": { input: 0.8, output: 4, cacheCreation: 1, cacheRead: 0.08 },
29
+ "claude-sonnet-4-20250514": { input: 3, output: 15, cacheCreation: 3.75, cacheRead: 0.3 },
30
+ "claude-3-5-sonnet-20241022": { input: 3, output: 15, cacheCreation: 3.75, cacheRead: 0.3 },
31
+ "claude-3-5-haiku-20241022": { input: 0.8, output: 4, cacheCreation: 1, cacheRead: 0.08 }
32
+ };
33
+ function calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
34
+ const pricing = MODEL_PRICING[model] || MODEL_PRICING["claude-sonnet-4-5-20250929"];
35
+ return (inputTokens * pricing.input + outputTokens * pricing.output + cacheCreationTokens * pricing.cacheCreation + cacheReadTokens * pricing.cacheRead) / 1e6;
36
+ }
37
+
38
+ // src/config.ts
39
+ function getConfigDir() {
40
+ return join(homedir(), CCCLUB_CONFIG_DIR);
41
+ }
42
+ function getConfigPath() {
43
+ return join(getConfigDir(), "config.json");
44
+ }
45
+ function getLastSyncPath() {
46
+ return join(getConfigDir(), "last-sync");
47
+ }
48
+ async function loadConfig() {
49
+ const path = getConfigPath();
50
+ if (!existsSync(path)) return null;
51
+ const content = await readFile(path, "utf-8");
52
+ return JSON.parse(content);
53
+ }
54
+ async function saveConfig(config) {
55
+ const dir = getConfigDir();
56
+ if (!existsSync(dir)) {
57
+ await mkdir(dir, { recursive: true });
58
+ }
59
+ await writeFile(getConfigPath(), JSON.stringify(config, null, 2));
60
+ }
61
+ function generateDeviceToken() {
62
+ return randomBytes(32).toString("hex");
63
+ }
64
+ function getApiUrl() {
65
+ return process.env.CCCLUB_API_URL || DEFAULT_API_URL;
66
+ }
67
+ async function requireConfig() {
68
+ const config = await loadConfig();
69
+ if (!config) {
70
+ throw new Error('Not initialized. Run "ccclub init" first.');
71
+ }
72
+ return config;
73
+ }
74
+
75
+ // src/heartbeat.ts
76
+ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
77
+ import { join as join2 } from "path";
78
+ import { homedir as homedir2 } from "os";
79
+ import { existsSync as existsSync2 } from "fs";
80
+ import { execFile } from "child_process";
81
+ var PLIST_NAME = "dev.ccclub.sync";
82
+ var LAUNCH_AGENTS_DIR = join2(homedir2(), "Library", "LaunchAgents");
83
+ var PLIST_PATH = join2(LAUNCH_AGENTS_DIR, `${PLIST_NAME}.plist`);
84
+ function getPlist() {
85
+ const logPath = join2(homedir2(), ".ccclub", "sync.log");
86
+ return `<?xml version="1.0" encoding="UTF-8"?>
87
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
88
+ <plist version="1.0">
89
+ <dict>
90
+ <key>Label</key>
91
+ <string>${PLIST_NAME}</string>
92
+ <key>ProgramArguments</key>
93
+ <array>
94
+ <string>/usr/bin/env</string>
95
+ <string>npx</string>
96
+ <string>ccclub</string>
97
+ <string>sync</string>
98
+ <string>--silent</string>
99
+ </array>
100
+ <key>StartInterval</key>
101
+ <integer>3600</integer>
102
+ <key>StandardOutPath</key>
103
+ <string>${logPath}</string>
104
+ <key>StandardErrorPath</key>
105
+ <string>${logPath}</string>
106
+ <key>RunAtLoad</key>
107
+ <true/>
108
+ <key>EnvironmentVariables</key>
109
+ <dict>
110
+ <key>PATH</key>
111
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
112
+ </dict>
113
+ </dict>
114
+ </plist>`;
115
+ }
116
+ async function installHeartbeat() {
117
+ if (process.platform !== "darwin") {
118
+ return false;
119
+ }
120
+ if (existsSync2(PLIST_PATH)) {
121
+ return true;
122
+ }
123
+ if (!existsSync2(LAUNCH_AGENTS_DIR)) {
124
+ await mkdir2(LAUNCH_AGENTS_DIR, { recursive: true });
125
+ }
126
+ await writeFile2(PLIST_PATH, getPlist());
127
+ try {
128
+ await new Promise((resolve, reject) => {
129
+ execFile("launchctl", ["load", PLIST_PATH], (err) => err ? reject(err) : resolve());
130
+ });
131
+ } catch {
132
+ }
133
+ return true;
134
+ }
135
+
136
+ // src/commands/sync.ts
137
+ import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
138
+ import { existsSync as existsSync3 } from "fs";
139
+ import chalk from "chalk";
140
+ import ora from "ora";
141
+
142
+ // src/collector.ts
143
+ import { readFile as readFile2 } from "fs/promises";
144
+ import { join as join3 } from "path";
145
+ import { homedir as homedir3 } from "os";
146
+ import { glob } from "glob";
147
+ async function collectUsageEntries() {
148
+ const projectsDir = join3(homedir3(), CLAUDE_PROJECTS_DIR);
149
+ const files = await glob("**/*.jsonl", { cwd: projectsDir, absolute: true });
150
+ if (files.length === 0) {
151
+ return [];
152
+ }
153
+ const entries = [];
154
+ const seen = /* @__PURE__ */ new Set();
155
+ for (const file of files) {
156
+ const content = await readFile2(file, "utf-8");
157
+ const lines = content.split("\n").filter((l) => l.trim());
158
+ for (const line of lines) {
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(line);
162
+ } catch {
163
+ continue;
164
+ }
165
+ if (parsed.type !== "assistant" || !parsed.message?.usage) {
166
+ continue;
167
+ }
168
+ const usage = parsed.message.usage;
169
+ const requestId = parsed.requestId || "";
170
+ const sessionId = parsed.sessionId || "";
171
+ const dedupeKey = `${sessionId}:${requestId}`;
172
+ if (seen.has(dedupeKey)) continue;
173
+ seen.add(dedupeKey);
174
+ const inputTokens = usage.input_tokens || 0;
175
+ const outputTokens = usage.output_tokens || 0;
176
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
177
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
178
+ entries.push({
179
+ timestamp: parsed.timestamp,
180
+ sessionId,
181
+ requestId,
182
+ model: parsed.message.model || "unknown",
183
+ inputTokens,
184
+ outputTokens,
185
+ cacheCreationTokens,
186
+ cacheReadTokens,
187
+ totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens,
188
+ costUSD: parsed.costUSD || 0
189
+ });
190
+ }
191
+ }
192
+ entries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
193
+ return entries;
194
+ }
195
+
196
+ // src/aggregator.ts
197
+ function floorToHour(date) {
198
+ const floored = new Date(date);
199
+ floored.setUTCMinutes(0, 0, 0);
200
+ return floored;
201
+ }
202
+ function aggregateToBlocks(entries) {
203
+ if (entries.length === 0) return [];
204
+ const blocks = [];
205
+ let blockStart = floorToHour(new Date(entries[0].timestamp));
206
+ let blockEnd = new Date(blockStart.getTime() + BLOCK_DURATION_MS);
207
+ let currentBlock = [];
208
+ function flushBlock() {
209
+ if (currentBlock.length === 0) return;
210
+ const models = /* @__PURE__ */ new Set();
211
+ let inputTokens = 0;
212
+ let outputTokens = 0;
213
+ let cacheCreationTokens = 0;
214
+ let cacheReadTokens = 0;
215
+ let totalTokens = 0;
216
+ let costUSD = 0;
217
+ for (const entry of currentBlock) {
218
+ inputTokens += entry.inputTokens;
219
+ outputTokens += entry.outputTokens;
220
+ cacheCreationTokens += entry.cacheCreationTokens;
221
+ cacheReadTokens += entry.cacheReadTokens;
222
+ totalTokens += entry.totalTokens;
223
+ models.add(entry.model);
224
+ if (entry.costUSD > 0) {
225
+ costUSD += entry.costUSD;
226
+ } else {
227
+ costUSD += calculateCost(
228
+ entry.model,
229
+ entry.inputTokens,
230
+ entry.outputTokens,
231
+ entry.cacheCreationTokens,
232
+ entry.cacheReadTokens
233
+ );
234
+ }
235
+ }
236
+ blocks.push({
237
+ blockStart: blockStart.toISOString(),
238
+ blockEnd: blockEnd.toISOString(),
239
+ inputTokens,
240
+ outputTokens,
241
+ cacheCreationTokens,
242
+ cacheReadTokens,
243
+ totalTokens,
244
+ costUSD: Math.round(costUSD * 1e4) / 1e4,
245
+ models: Array.from(models),
246
+ entryCount: currentBlock.length
247
+ });
248
+ }
249
+ for (const entry of entries) {
250
+ const entryTime = new Date(entry.timestamp);
251
+ while (entryTime >= blockEnd) {
252
+ flushBlock();
253
+ currentBlock = [];
254
+ blockStart = new Date(blockEnd);
255
+ blockEnd = new Date(blockStart.getTime() + BLOCK_DURATION_MS);
256
+ }
257
+ currentBlock.push(entry);
258
+ }
259
+ flushBlock();
260
+ return blocks;
261
+ }
262
+
263
+ // src/commands/sync.ts
264
+ async function syncCommand(options) {
265
+ await doSync(false, options.silent);
266
+ }
267
+ async function doSync(firstSync = false, silent = false) {
268
+ const config = await requireConfig();
269
+ const log = silent ? () => {
270
+ } : console.log;
271
+ const spinner = silent ? null : ora("Collecting usage data...").start();
272
+ try {
273
+ const entries = await collectUsageEntries();
274
+ if (spinner) spinner.text = `Found ${entries.length} entries`;
275
+ if (entries.length === 0) {
276
+ if (spinner) spinner.warn("No usage data found in ~/.claude/projects/");
277
+ return;
278
+ }
279
+ const allBlocks = aggregateToBlocks(entries);
280
+ const lastSyncPath = getLastSyncPath();
281
+ let lastSync = null;
282
+ if (existsSync3(lastSyncPath)) {
283
+ lastSync = (await readFile3(lastSyncPath, "utf-8")).trim() || null;
284
+ }
285
+ let blocksToSync;
286
+ if (lastSync && !firstSync) {
287
+ const lastSyncTime = new Date(lastSync).getTime();
288
+ blocksToSync = allBlocks.filter((b) => new Date(b.blockStart).getTime() >= lastSyncTime);
289
+ } else {
290
+ blocksToSync = allBlocks;
291
+ }
292
+ if (blocksToSync.length === 0) {
293
+ if (spinner) spinner.succeed("Already up to date");
294
+ return;
295
+ }
296
+ if (spinner) spinner.text = `Uploading ${blocksToSync.length} blocks...`;
297
+ const res = await fetch(`${config.apiUrl}/api/sync`, {
298
+ method: "POST",
299
+ headers: {
300
+ "Content-Type": "application/json",
301
+ Authorization: `Bearer ${config.token}`
302
+ },
303
+ body: JSON.stringify({ blocks: blocksToSync })
304
+ });
305
+ if (!res.ok) {
306
+ const err = await res.json().catch(() => ({ error: res.statusText }));
307
+ if (spinner) spinner.fail(`Sync failed: ${err.error}`);
308
+ return;
309
+ }
310
+ const data = await res.json();
311
+ const latest = blocksToSync[blocksToSync.length - 1];
312
+ await writeFile3(lastSyncPath, latest.blockStart);
313
+ const totalTokens = blocksToSync.reduce((s, b) => s + b.totalTokens, 0);
314
+ const totalCost = blocksToSync.reduce((s, b) => s + b.costUSD, 0);
315
+ if (spinner) {
316
+ spinner.succeed(`Synced ${data.synced} blocks`);
317
+ log(chalk.dim(` Tokens: ${totalTokens.toLocaleString()} Cost: $${totalCost.toFixed(4)}`));
318
+ }
319
+ } catch (err) {
320
+ if (spinner) spinner.fail(`Sync error: ${err instanceof Error ? err.message : err}`);
321
+ }
322
+ }
323
+
324
+ // src/commands/init.ts
325
+ async function initCommand() {
326
+ const existing = await loadConfig();
327
+ if (existing) {
328
+ console.log(chalk2.yellow("Already initialized!"));
329
+ console.log(` User: ${existing.displayName}`);
330
+ console.log(` Groups: ${existing.groups.join(", ") || "(none)"}`);
331
+ console.log(chalk2.dim('\n Run "ccclub rank" to see rankings'));
332
+ return;
333
+ }
334
+ const rl = createInterface({ input: stdin, output: stdout });
335
+ try {
336
+ const displayName = await rl.question(chalk2.bold("Your display name: "));
337
+ if (!displayName.trim()) {
338
+ console.error(chalk2.red("Name cannot be empty"));
339
+ return;
340
+ }
341
+ const spinner = ora2("Setting up...").start();
342
+ const token = generateDeviceToken();
343
+ const apiUrl = getApiUrl();
344
+ const res = await fetch(`${apiUrl}/api/init`, {
345
+ method: "POST",
346
+ headers: { "Content-Type": "application/json" },
347
+ body: JSON.stringify({ token, displayName: displayName.trim() })
348
+ });
349
+ if (!res.ok) {
350
+ const err = await res.json().catch(() => ({ error: res.statusText }));
351
+ spinner.fail(`Setup failed: ${err.error}`);
352
+ return;
353
+ }
354
+ const data = await res.json();
355
+ await saveConfig({
356
+ apiUrl,
357
+ token,
358
+ userId: data.userId,
359
+ displayName: displayName.trim(),
360
+ groups: [data.groupCode]
361
+ });
362
+ const heartbeatOk = await installHeartbeat();
363
+ spinner.succeed("CCClub initialized!");
364
+ console.log("");
365
+ console.log(chalk2.bold(" Your invite code:"));
366
+ console.log(chalk2.cyan.bold(`
367
+ ${data.groupCode}
368
+ `));
369
+ console.log(chalk2.dim(" Share with friends: ") + chalk2.white(`npx ccclub join ${data.groupCode}`));
370
+ if (heartbeatOk) {
371
+ console.log(chalk2.dim(" Heartbeat: installed (syncs every hour)"));
372
+ } else {
373
+ console.log(chalk2.dim(' Tip: run "ccclub sync" periodically to update data'));
374
+ }
375
+ console.log("");
376
+ await doSync(true);
377
+ } finally {
378
+ rl.close();
379
+ }
380
+ }
381
+
382
+ // src/commands/join.ts
383
+ import { createInterface as createInterface2 } from "readline/promises";
384
+ import { stdin as stdin2, stdout as stdout2 } from "process";
385
+ import chalk3 from "chalk";
386
+ import ora3 from "ora";
387
+ async function joinCommand(inviteCode) {
388
+ let config = await loadConfig();
389
+ const apiUrl = getApiUrl();
390
+ let token;
391
+ let displayName;
392
+ if (config) {
393
+ token = config.token;
394
+ displayName = config.displayName;
395
+ } else {
396
+ const rl = createInterface2({ input: stdin2, output: stdout2 });
397
+ try {
398
+ displayName = await rl.question(chalk3.bold("Your display name: "));
399
+ if (!displayName.trim()) {
400
+ console.error(chalk3.red("Name cannot be empty"));
401
+ return;
402
+ }
403
+ displayName = displayName.trim();
404
+ } finally {
405
+ rl.close();
406
+ }
407
+ token = generateDeviceToken();
408
+ }
409
+ const spinner = ora3("Joining group...").start();
410
+ const res = await fetch(`${apiUrl}/api/join`, {
411
+ method: "POST",
412
+ headers: { "Content-Type": "application/json" },
413
+ body: JSON.stringify({ token, displayName, inviteCode })
414
+ });
415
+ if (!res.ok) {
416
+ const err = await res.json().catch(() => ({ error: res.statusText }));
417
+ spinner.fail(`Join failed: ${err.error}`);
418
+ return;
419
+ }
420
+ const data = await res.json();
421
+ if (config) {
422
+ if (!config.groups.includes(data.groupCode)) {
423
+ config.groups.push(data.groupCode);
424
+ }
425
+ await saveConfig(config);
426
+ } else {
427
+ await saveConfig({
428
+ apiUrl,
429
+ token,
430
+ userId: data.userId,
431
+ displayName,
432
+ groups: [data.groupCode]
433
+ });
434
+ await installHeartbeat();
435
+ }
436
+ spinner.succeed(`Joined "${data.groupName}"!`);
437
+ console.log(chalk3.dim(`
438
+ Dashboard: ${apiUrl}/g/${data.groupCode}`));
439
+ console.log(chalk3.dim(' Run "ccclub rank" to see rankings'));
440
+ if (!config) {
441
+ console.log("");
442
+ await doSync(true);
443
+ }
444
+ }
445
+
446
+ // src/commands/rank.ts
447
+ import chalk4 from "chalk";
448
+ import Table from "cli-table3";
449
+ import ora4 from "ora";
450
+ async function rankCommand(options) {
451
+ const config = await requireConfig();
452
+ const spinner = ora4("Fetching rankings...").start();
453
+ const isGlobal = options.global === true;
454
+ const groupCode = isGlobal ? "global" : options.group || config.groups[0];
455
+ if (!groupCode) {
456
+ spinner.fail("No group found. Run 'ccclub init' or 'ccclub join <code>' first.");
457
+ return;
458
+ }
459
+ const period = options.period || "daily";
460
+ const url = `${config.apiUrl}/api/rank/${groupCode}?period=${period}`;
461
+ try {
462
+ const res = await fetch(url);
463
+ if (!res.ok) {
464
+ spinner.fail("Failed to fetch rankings");
465
+ return;
466
+ }
467
+ const data = await res.json();
468
+ spinner.stop();
469
+ if (data.rankings.length === 0) {
470
+ console.log(chalk4.yellow("\n No rankings data for this period"));
471
+ console.log(chalk4.dim(' Run "ccclub sync" to upload your usage data'));
472
+ return;
473
+ }
474
+ console.log(chalk4.bold(`
475
+ ${data.group.name}`));
476
+ console.log(chalk4.dim(` ${period.toUpperCase()} \xB7 ${data.start.slice(0, 10)} \u2192 ${data.end.slice(0, 10)} \xB7 ${data.group.memberCount} members
477
+ `));
478
+ const table = new Table({
479
+ head: ["#", "Name", "Tokens", "Cost", "Calls"].map((h) => chalk4.cyan(h)),
480
+ style: { head: [], border: [] },
481
+ colWidths: [5, 20, 16, 12, 8]
482
+ });
483
+ for (const entry of data.rankings) {
484
+ const isMe = entry.userId === config.userId;
485
+ const marker = isMe ? chalk4.green("\u2192") : " ";
486
+ const name = isMe ? chalk4.green.bold(entry.displayName) : entry.displayName;
487
+ const rankColor = entry.rank <= 3 ? chalk4.yellow : chalk4.white;
488
+ table.push([
489
+ `${marker}${rankColor(String(entry.rank))}`,
490
+ name,
491
+ entry.totalTokens.toLocaleString(),
492
+ `$${entry.costUSD.toFixed(2)}`,
493
+ String(entry.entryCount)
494
+ ]);
495
+ }
496
+ console.log(table.toString());
497
+ console.log(chalk4.dim(`
498
+ Dashboard: ${config.apiUrl}/g/${groupCode}`));
499
+ } catch (err) {
500
+ spinner.fail(`Error: ${err instanceof Error ? err.message : err}`);
501
+ }
502
+ }
503
+
504
+ // src/commands/profile.ts
505
+ import chalk5 from "chalk";
506
+ import ora5 from "ora";
507
+ async function profileCommand(options) {
508
+ const config = await requireConfig();
509
+ const hasUpdate = options.name !== void 0 || options.avatar !== void 0 || options.public || options.private;
510
+ if (!hasUpdate) {
511
+ const spinner2 = ora5("Fetching profile...").start();
512
+ try {
513
+ const res = await fetch(`${config.apiUrl}/api/profile`, {
514
+ headers: { Authorization: `Bearer ${config.token}` }
515
+ });
516
+ if (!res.ok) {
517
+ spinner2.fail("Failed to fetch profile");
518
+ return;
519
+ }
520
+ const profile = await res.json();
521
+ spinner2.stop();
522
+ console.log(chalk5.bold("\n Your Profile"));
523
+ console.log(` Name: ${profile.displayName}`);
524
+ console.log(` Avatar: ${profile.avatar || chalk5.dim("(default)")}`);
525
+ console.log(` Visibility: ${profile.visibility === "public" ? chalk5.green("public") : chalk5.dim("private")}`);
526
+ console.log();
527
+ } catch (err) {
528
+ spinner2.fail(`Error: ${err instanceof Error ? err.message : err}`);
529
+ }
530
+ return;
531
+ }
532
+ const body = {};
533
+ if (options.name !== void 0) body.displayName = options.name;
534
+ if (options.avatar !== void 0) body.avatar = options.avatar;
535
+ if (options.public) body.visibility = "public";
536
+ if (options.private) body.visibility = "private";
537
+ const spinner = ora5("Updating profile...").start();
538
+ try {
539
+ const res = await fetch(`${config.apiUrl}/api/profile`, {
540
+ method: "POST",
541
+ headers: {
542
+ "Content-Type": "application/json",
543
+ Authorization: `Bearer ${config.token}`
544
+ },
545
+ body: JSON.stringify(body)
546
+ });
547
+ if (!res.ok) {
548
+ spinner.fail("Failed to update profile");
549
+ return;
550
+ }
551
+ const profile = await res.json();
552
+ spinner.stop();
553
+ if (body.displayName && body.displayName !== config.displayName) {
554
+ config.displayName = body.displayName;
555
+ await saveConfig(config);
556
+ }
557
+ console.log(chalk5.green("\n Profile updated!"));
558
+ console.log(` Name: ${profile.displayName}`);
559
+ console.log(` Avatar: ${profile.avatar || chalk5.dim("(default)")}`);
560
+ console.log(` Visibility: ${profile.visibility === "public" ? chalk5.green("public") : chalk5.dim("private")}`);
561
+ console.log();
562
+ } catch (err) {
563
+ spinner.fail(`Error: ${err instanceof Error ? err.message : err}`);
564
+ }
565
+ }
566
+
567
+ // src/commands/show-data.ts
568
+ import chalk6 from "chalk";
569
+ async function showDataCommand() {
570
+ console.log(chalk6.bold("\n What CCClub uploads:\n"));
571
+ console.log(chalk6.dim(" Only aggregated 5-hour block summaries. No conversation content,"));
572
+ console.log(chalk6.dim(" no file paths, no project names, no session details.\n"));
573
+ const entries = await collectUsageEntries();
574
+ const blocks = aggregateToBlocks(entries);
575
+ if (blocks.length === 0) {
576
+ console.log(chalk6.yellow(" No usage data found in ~/.claude/projects/"));
577
+ return;
578
+ }
579
+ console.log(chalk6.dim(` Total entries found: ${entries.length}`));
580
+ console.log(chalk6.dim(` Aggregated into: ${blocks.length} blocks
581
+ `));
582
+ const recent = blocks.slice(-5);
583
+ console.log(chalk6.bold(" Last 5 blocks (this is exactly what gets uploaded):\n"));
584
+ for (const block of recent) {
585
+ console.log(chalk6.cyan(` ${block.blockStart.slice(0, 16)} \u2192 ${block.blockEnd.slice(11, 16)}`));
586
+ console.log(chalk6.dim(` tokens: ${block.totalTokens.toLocaleString()} cost: $${block.costUSD.toFixed(4)} calls: ${block.entryCount} models: ${block.models.join(", ")}`));
587
+ }
588
+ const totalTokens = blocks.reduce((s, b) => s + b.totalTokens, 0);
589
+ const totalCost = blocks.reduce((s, b) => s + b.costUSD, 0);
590
+ console.log(chalk6.bold(`
591
+ All-time total: ${totalTokens.toLocaleString()} tokens \xB7 $${totalCost.toFixed(2)}`));
592
+ }
593
+
594
+ // src/commands/group.ts
595
+ import { createInterface as createInterface3 } from "readline/promises";
596
+ import { stdin as stdin3, stdout as stdout3 } from "process";
597
+ import chalk7 from "chalk";
598
+ import ora6 from "ora";
599
+ async function createGroupCommand() {
600
+ const config = await requireConfig();
601
+ const rl = createInterface3({ input: stdin3, output: stdout3 });
602
+ try {
603
+ const name = await rl.question(chalk7.bold("Group name: "));
604
+ if (!name.trim()) {
605
+ console.error(chalk7.red("Name cannot be empty"));
606
+ return;
607
+ }
608
+ const spinner = ora6("Creating group...").start();
609
+ const res = await fetch(`${config.apiUrl}/api/group/create`, {
610
+ method: "POST",
611
+ headers: {
612
+ "Content-Type": "application/json",
613
+ Authorization: `Bearer ${config.token}`
614
+ },
615
+ body: JSON.stringify({ name: name.trim() })
616
+ });
617
+ if (!res.ok) {
618
+ const err = await res.json().catch(() => ({ error: res.statusText }));
619
+ spinner.fail(`Failed: ${err.error}`);
620
+ return;
621
+ }
622
+ const data = await res.json();
623
+ if (!config.groups.includes(data.groupCode)) {
624
+ config.groups.push(data.groupCode);
625
+ await saveConfig(config);
626
+ }
627
+ spinner.succeed(`Created "${data.groupName}"`);
628
+ console.log(chalk7.bold(`
629
+ Invite code: `) + chalk7.cyan.bold(data.groupCode));
630
+ console.log(chalk7.dim(` Share: npx ccclub join ${data.groupCode}`));
631
+ console.log(chalk7.dim(` Dashboard: ${config.apiUrl}/g/${data.groupCode}`));
632
+ } finally {
633
+ rl.close();
634
+ }
635
+ }
636
+
637
+ // src/index.ts
638
+ var program = new Command();
639
+ program.name("ccclub").description("CCClub - Compare Claude Code usage with friends").version("0.1.0");
640
+ program.command("init").description("Initialize CCClub (one-time setup)").action(initCommand);
641
+ program.command("join").description("Join a friend's group").argument("<invite-code>", "6-character invite code").action(joinCommand);
642
+ program.command("sync").description("Sync local usage data to server").option("-s, --silent", "No output (used by heartbeat)").action(syncCommand);
643
+ program.command("rank").description("Show leaderboard rankings").option("-p, --period <period>", "daily, weekly, monthly, all-time", "daily").option("-g, --group <code>", "Group invite code").option("--global", "Show global public ranking").action(rankCommand);
644
+ program.command("profile").description("View or update your profile").option("-n, --name <name>", "Set display name").option("--avatar <url>", "Set avatar URL (empty string to reset)").option("--public", "Set profile visibility to public").option("--private", "Set profile visibility to private").action(profileCommand);
645
+ program.command("create").description("Create a new group").action(createGroupCommand);
646
+ program.command("show-data").description("Show exactly what data CCClub uploads (privacy audit)").action(showDataCommand);
647
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "ccclub",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "See how much Claude Code you and your friends are using",
6
+ "bin": {
7
+ "ccclub": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup src/index.ts --format esm --dts --clean",
14
+ "dev": "tsx src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "chalk": "^5.3.0",
18
+ "cli-table3": "^0.6.5",
19
+ "commander": "^12.1.0",
20
+ "glob": "^11.0.0",
21
+ "ora": "^8.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@ccclub/shared": "workspace:*",
25
+ "tsup": "^8.3.0",
26
+ "tsx": "^4.19.0",
27
+ "typescript": "^5.7.0",
28
+ "@types/node": "^22.0.0"
29
+ },
30
+ "keywords": [
31
+ "claude",
32
+ "claude-code",
33
+ "usage",
34
+ "ranking",
35
+ "leaderboard"
36
+ ],
37
+ "license": "MIT"
38
+ }