ccclub 0.1.12 → 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.
Files changed (2) hide show
  1. package/dist/index.js +125 -73
  2. package/package.json +5 -3
package/dist/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
2
8
 
3
9
  // src/index.ts
4
10
  import { Command } from "commander";
@@ -12,9 +18,10 @@ import ora2 from "ora";
12
18
  // src/config.ts
13
19
  import { readFile, writeFile, mkdir } from "fs/promises";
14
20
  import { join } from "path";
15
- import { homedir } from "os";
21
+ import { homedir, userInfo } from "os";
16
22
  import { existsSync } from "fs";
17
23
  import { randomBytes } from "crypto";
24
+ import { execSync } from "child_process";
18
25
 
19
26
  // ../shared/dist/constants.js
20
27
  var BLOCK_DURATION_HOURS = 5;
@@ -64,6 +71,30 @@ function generateDeviceToken() {
64
71
  function getApiUrl() {
65
72
  return process.env.CCCLUB_API_URL || DEFAULT_API_URL;
66
73
  }
74
+ function getDefaultDisplayName() {
75
+ try {
76
+ const name = execSync("git config --global user.name", {
77
+ encoding: "utf-8",
78
+ stdio: ["pipe", "pipe", "pipe"]
79
+ }).trim();
80
+ if (name) return name;
81
+ } catch {
82
+ }
83
+ try {
84
+ const name = execSync("id -F", {
85
+ encoding: "utf-8",
86
+ stdio: ["pipe", "pipe", "pipe"]
87
+ }).trim();
88
+ if (name) return name;
89
+ } catch {
90
+ }
91
+ try {
92
+ const name = userInfo().username;
93
+ if (name) return name;
94
+ } catch {
95
+ }
96
+ return null;
97
+ }
67
98
  async function requireConfig() {
68
99
  const config = await loadConfig();
69
100
  if (!config) {
@@ -73,75 +104,70 @@ async function requireConfig() {
73
104
  return config;
74
105
  }
75
106
 
76
- // src/heartbeat.ts
77
- import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
107
+ // src/hook.ts
108
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
78
109
  import { join as join2 } from "path";
79
110
  import { homedir as homedir2 } from "os";
80
111
  import { existsSync as existsSync2 } from "fs";
81
- import { execFile } from "child_process";
82
- var PLIST_NAME = "dev.ccclub.sync";
83
- var LAUNCH_AGENTS_DIR = join2(homedir2(), "Library", "LaunchAgents");
84
- var PLIST_PATH = join2(LAUNCH_AGENTS_DIR, `${PLIST_NAME}.plist`);
85
- function getPlist() {
86
- const logPath = join2(homedir2(), ".ccclub", "sync.log");
87
- return `<?xml version="1.0" encoding="UTF-8"?>
88
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
89
- <plist version="1.0">
90
- <dict>
91
- <key>Label</key>
92
- <string>${PLIST_NAME}</string>
93
- <key>ProgramArguments</key>
94
- <array>
95
- <string>/usr/bin/env</string>
96
- <string>npx</string>
97
- <string>ccclub</string>
98
- <string>sync</string>
99
- <string>--silent</string>
100
- </array>
101
- <key>StartInterval</key>
102
- <integer>3600</integer>
103
- <key>StandardOutPath</key>
104
- <string>${logPath}</string>
105
- <key>StandardErrorPath</key>
106
- <string>${logPath}</string>
107
- <key>RunAtLoad</key>
108
- <true/>
109
- <key>EnvironmentVariables</key>
110
- <dict>
111
- <key>PATH</key>
112
- <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
113
- </dict>
114
- </dict>
115
- </plist>`;
112
+ var CLAUDE_SETTINGS_DIR = join2(homedir2(), ".claude");
113
+ var CLAUDE_SETTINGS_PATH = join2(CLAUDE_SETTINGS_DIR, "settings.json");
114
+ var HOOK_COMMAND = "ccclub sync --silent";
115
+ function hasOurHook(settings) {
116
+ const sessionEndHooks = settings.hooks?.SessionEnd;
117
+ if (!Array.isArray(sessionEndHooks)) return false;
118
+ return sessionEndHooks.some((group) => {
119
+ const g = group;
120
+ return g.hooks?.some((h) => h.command === HOOK_COMMAND);
121
+ });
116
122
  }
117
- async function installHeartbeat() {
118
- if (process.platform !== "darwin") {
119
- return false;
120
- }
121
- if (existsSync2(PLIST_PATH)) {
123
+ async function installHook() {
124
+ try {
125
+ if (!existsSync2(CLAUDE_SETTINGS_DIR)) {
126
+ await mkdir2(CLAUDE_SETTINGS_DIR, { recursive: true });
127
+ }
128
+ let settings = {};
129
+ if (existsSync2(CLAUDE_SETTINGS_PATH)) {
130
+ const raw = await readFile2(CLAUDE_SETTINGS_PATH, "utf-8");
131
+ settings = JSON.parse(raw);
132
+ }
133
+ if (hasOurHook(settings)) return true;
134
+ if (!settings.hooks) settings.hooks = {};
135
+ if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
136
+ settings.hooks.SessionEnd.push({
137
+ hooks: [
138
+ {
139
+ type: "command",
140
+ command: HOOK_COMMAND,
141
+ async: true,
142
+ timeout: 30
143
+ }
144
+ ]
145
+ });
146
+ await writeFile2(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
122
147
  return true;
148
+ } catch {
149
+ return false;
123
150
  }
124
- if (!existsSync2(LAUNCH_AGENTS_DIR)) {
125
- await mkdir2(LAUNCH_AGENTS_DIR, { recursive: true });
126
- }
127
- await writeFile2(PLIST_PATH, getPlist());
151
+ }
152
+ function isHookInstalled() {
128
153
  try {
129
- await new Promise((resolve, reject) => {
130
- execFile("launchctl", ["load", PLIST_PATH], (err) => err ? reject(err) : resolve());
131
- });
154
+ if (!existsSync2(CLAUDE_SETTINGS_PATH)) return false;
155
+ const raw = __require("fs").readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
156
+ const settings = JSON.parse(raw);
157
+ return hasOurHook(settings);
132
158
  } catch {
159
+ return false;
133
160
  }
134
- return true;
135
161
  }
136
162
 
137
163
  // src/commands/sync.ts
138
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
164
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
139
165
  import { existsSync as existsSync3 } from "fs";
140
166
  import chalk from "chalk";
141
167
  import ora from "ora";
142
168
 
143
169
  // src/collector.ts
144
- import { readFile as readFile2 } from "fs/promises";
170
+ import { readFile as readFile3 } from "fs/promises";
145
171
  import { join as join3 } from "path";
146
172
  import { homedir as homedir3 } from "os";
147
173
  import { glob } from "glob";
@@ -154,7 +180,7 @@ async function collectUsageEntries() {
154
180
  const entries = [];
155
181
  const seen = /* @__PURE__ */ new Set();
156
182
  for (const file of files) {
157
- const content = await readFile2(file, "utf-8");
183
+ const content = await readFile3(file, "utf-8");
158
184
  const lines = content.split("\n").filter((l) => l.trim());
159
185
  for (const line of lines) {
160
186
  let parsed;
@@ -281,7 +307,7 @@ async function doSync(firstSync = false, silent = false) {
281
307
  const lastSyncPath = getLastSyncPath();
282
308
  let lastSync = null;
283
309
  if (existsSync3(lastSyncPath)) {
284
- lastSync = (await readFile3(lastSyncPath, "utf-8")).trim() || null;
310
+ lastSync = (await readFile4(lastSyncPath, "utf-8")).trim() || null;
285
311
  }
286
312
  let blocksToSync;
287
313
  if (lastSync && !firstSync) {
@@ -334,8 +360,11 @@ async function initCommand() {
334
360
  }
335
361
  const rl = createInterface({ input: stdin, output: stdout });
336
362
  try {
337
- const displayName = await rl.question(chalk2.bold("Your display name: "));
338
- if (!displayName.trim()) {
363
+ const defaultName = getDefaultDisplayName();
364
+ const prompt = defaultName ? chalk2.bold(`Your display name (${defaultName}): `) : chalk2.bold("Your display name: ");
365
+ const input = await rl.question(prompt);
366
+ const displayName = input.trim() || defaultName || "";
367
+ if (!displayName) {
339
368
  console.error(chalk2.red("Name cannot be empty"));
340
369
  return;
341
370
  }
@@ -345,7 +374,7 @@ async function initCommand() {
345
374
  const res = await fetch(`${apiUrl}/api/init`, {
346
375
  method: "POST",
347
376
  headers: { "Content-Type": "application/json" },
348
- body: JSON.stringify({ token, displayName: displayName.trim() })
377
+ body: JSON.stringify({ token, displayName })
349
378
  });
350
379
  if (!res.ok) {
351
380
  const err = await res.json().catch(() => ({ error: res.statusText }));
@@ -360,7 +389,7 @@ async function initCommand() {
360
389
  displayName: displayName.trim(),
361
390
  groups: [data.groupCode]
362
391
  });
363
- const heartbeatOk = await installHeartbeat();
392
+ const hookOk = await installHook();
364
393
  spinner.succeed("CCClub initialized!");
365
394
  console.log("");
366
395
  console.log(chalk2.bold(" Your invite code:"));
@@ -368,10 +397,10 @@ async function initCommand() {
368
397
  ${data.groupCode}
369
398
  `));
370
399
  console.log(chalk2.dim(" Share with friends: ") + chalk2.white(`npx ccclub join ${data.groupCode}`));
371
- if (heartbeatOk) {
372
- console.log(chalk2.dim(" Auto-sync: on (every hour)"));
400
+ if (hookOk) {
401
+ console.log(chalk2.dim(" Auto-sync: on (via Claude Code hook)"));
373
402
  } else {
374
- console.log(chalk2.dim(' Tip: run "ccclub sync" periodically to update data'));
403
+ console.log(chalk2.dim(' Tip: run "ccclub hook" to set up auto-sync'));
375
404
  }
376
405
  console.log("");
377
406
  await doSync(true);
@@ -414,12 +443,14 @@ async function joinCommand(inviteCode) {
414
443
  } else {
415
444
  const rl = createInterface2({ input: stdin2, output: stdout2 });
416
445
  try {
417
- displayName = await rl.question(chalk3.bold("Your display name: "));
418
- if (!displayName.trim()) {
446
+ const defaultName = getDefaultDisplayName();
447
+ const prompt = defaultName ? chalk3.bold(`Your display name (${defaultName}): `) : chalk3.bold("Your display name: ");
448
+ const input = await rl.question(prompt);
449
+ displayName = input.trim() || defaultName || "";
450
+ if (!displayName) {
419
451
  console.error(chalk3.red("Name cannot be empty"));
420
452
  return;
421
453
  }
422
- displayName = displayName.trim();
423
454
  } finally {
424
455
  rl.close();
425
456
  }
@@ -450,7 +481,7 @@ async function joinCommand(inviteCode) {
450
481
  displayName,
451
482
  groups: [data.groupCode]
452
483
  });
453
- await installHeartbeat();
484
+ await installHook();
454
485
  }
455
486
  spinner.succeed(`Joined "${data.groupName}"!`);
456
487
  if (!config) {
@@ -477,6 +508,7 @@ import Table from "cli-table3";
477
508
  import ora4 from "ora";
478
509
  async function rankCommand(options) {
479
510
  const config = await requireConfig();
511
+ await doSync(false, true);
480
512
  const isGlobal = options.global === true;
481
513
  const period = options.period || "daily";
482
514
  let codes;
@@ -508,7 +540,7 @@ async function rankCommand(options) {
508
540
  printGroup(data, code, period, config);
509
541
  if (i < codes.length - 1) console.log("");
510
542
  }
511
- console.log(chalk4.dim("\n Data syncs automatically ") + chalk4.white("every hour") + chalk4.dim(". Run ") + chalk4.white("ccclub sync") + chalk4.dim(" to update now."));
543
+ console.log(chalk4.dim("\n Data syncs automatically when each Claude Code session ends."));
512
544
  } catch (err) {
513
545
  spinner.fail(`Error: ${err instanceof Error ? err.message : err}`);
514
546
  }
@@ -680,9 +712,28 @@ async function createGroupCommand() {
680
712
  }
681
713
  }
682
714
 
715
+ // src/commands/hook.ts
716
+ import chalk8 from "chalk";
717
+ async function hookCommand() {
718
+ if (isHookInstalled()) {
719
+ console.log(chalk8.green(" Claude Code hook already installed."));
720
+ console.log(chalk8.dim(" Usage syncs automatically when each Claude Code session ends."));
721
+ return;
722
+ }
723
+ const ok = await installHook();
724
+ if (ok) {
725
+ console.log(chalk8.green(" Claude Code hook installed!"));
726
+ console.log(chalk8.dim(" Usage will sync automatically when each Claude Code session ends."));
727
+ } else {
728
+ console.log(chalk8.red(" Failed to install hook."));
729
+ console.log(chalk8.dim(" You can manually add to ~/.claude/settings.json:"));
730
+ console.log(chalk8.dim(' {"hooks":{"SessionEnd":[{"hooks":[{"type":"command","command":"ccclub sync --silent","async":true}]}]}}'));
731
+ }
732
+ }
733
+
683
734
  // src/global-install.ts
684
735
  import { exec } from "child_process";
685
- import chalk8 from "chalk";
736
+ import chalk9 from "chalk";
686
737
  function run(cmd) {
687
738
  return new Promise((resolve) => {
688
739
  exec(cmd, (err, stdout4) => resolve(err ? "" : stdout4.trim()));
@@ -691,19 +742,19 @@ function run(cmd) {
691
742
  async function ensureGlobalInstall() {
692
743
  const globalList = await run("npm list -g ccclub --depth=0");
693
744
  if (globalList.includes("ccclub@")) return;
694
- console.log(chalk8.dim("\n Installing ccclub globally so you can run it directly..."));
745
+ console.log(chalk9.dim("\n Installing ccclub globally so you can run it directly..."));
695
746
  const result = await run("npm install -g ccclub");
696
747
  if (result) {
697
- console.log(chalk8.green(" Done!") + chalk8.dim(" You can now use ") + chalk8.white("ccclub") + chalk8.dim(" directly."));
748
+ console.log(chalk9.green(" Done!") + chalk9.dim(" You can now use ") + chalk9.white("ccclub") + chalk9.dim(" directly."));
698
749
  } else {
699
- console.log(chalk8.dim(" Could not auto-install. Run manually:"));
700
- console.log(chalk8.white(" npm install -g ccclub"));
750
+ console.log(chalk9.dim(" Could not auto-install. Run manually:"));
751
+ console.log(chalk9.white(" npm install -g ccclub"));
701
752
  }
702
753
  }
703
754
 
704
755
  // src/index.ts
705
756
  var program = new Command();
706
- program.name("ccclub").description("CCClub - Compare Claude Code usage with friends").version("0.1.12");
757
+ program.name("ccclub").description("CCClub - Compare Claude Code usage with friends").version("0.2.0");
707
758
  program.hook("postAction", () => ensureGlobalInstall());
708
759
  program.command("init").description("Initialize CCClub (one-time setup)").action(initCommand);
709
760
  program.command("join").description("Join a friend's group").argument("<invite-code>", "6-character invite code").action(joinCommand);
@@ -712,4 +763,5 @@ program.command("rank", { isDefault: true }).description("Show leaderboard ranki
712
763
  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);
713
764
  program.command("create").description("Create a new group").action(createGroupCommand);
714
765
  program.command("show-data").description("Show exactly what data CCClub uploads (privacy audit)").action(showDataCommand);
766
+ program.command("hook").description("Set up Claude Code hook for auto-sync on session end").action(hookCommand);
715
767
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccclub",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "See how much Claude Code you and your friends are using",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "tsup src/index.ts --format esm --dts --clean",
14
- "dev": "tsx src/index.ts"
14
+ "dev": "tsx src/index.ts",
15
+ "test": "vitest run"
15
16
  },
16
17
  "dependencies": {
17
18
  "chalk": "^5.3.0",
@@ -22,10 +23,11 @@
22
23
  },
23
24
  "devDependencies": {
24
25
  "@ccclub/shared": "workspace:*",
26
+ "@types/node": "^22.0.0",
25
27
  "tsup": "^8.3.0",
26
28
  "tsx": "^4.19.0",
27
29
  "typescript": "^5.7.0",
28
- "@types/node": "^22.0.0"
30
+ "vitest": "^4.0.18"
29
31
  },
30
32
  "keywords": [
31
33
  "claude",