claude-rpc 0.12.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,14 +35,35 @@ A small Node daemon that takes the lifecycle events Claude Code already fires an
35
35
 
36
36
  ## install
37
37
 
38
- **Windows (no Node required)** [grab the portable exe from the latest release](https://github.com/rar-file/claude-rpc/releases/latest):
38
+ **macOS / Linux / any Node 18+** one command:
39
+
40
+ ```sh
41
+ npx claude-rpc setup
42
+ ```
43
+
44
+ That installs `claude-rpc` globally, wires the hooks into Claude Code, and starts the daemon — no separate `start` step. Open Claude Code in any project and the card appears within a second. Something looks wrong? `claude-rpc doctor` (or `claude-rpc doctor --fix` to auto-repair).
45
+
46
+ **Prefer a one-liner that figures it out for you?**
47
+
48
+ ```sh
49
+ curl -fsSL https://claude-rpc.vercel.app/install | sh
50
+ ```
51
+
52
+ Detects Node (installs the npm package) or falls back to the prebuilt Apple-Silicon binary, then runs `setup` for you.
53
+
54
+ **Homebrew** (macOS / Linux):
55
+
56
+ ```sh
57
+ brew install rar-file/claude-rpc/claude-rpc && claude-rpc setup
58
+ ```
59
+
60
+ **Windows (no Node required)** — [grab the portable exe from the latest release](https://github.com/rar-file/claude-rpc/releases/latest), then:
39
61
 
40
62
  ```sh
41
63
  claude-rpc setup
42
- claude-rpc start
43
64
  ```
44
65
 
45
- That's the whole pitch. Open Claude Code in any project — the daemon picks it up within a second. Something looks wrong? `claude-rpc doctor`.
66
+ That's the whole pitch.
46
67
 
47
68
  > `setup` registers a Windows startup entry and wires hooks into Claude Code's `settings.json`, and the daemon reports anonymous totals by default. All of it is reversible (`claude-rpc uninstall`, `community off`) and fully documented in [`SECURITY.md`](SECURITY.md) — read it first if you want to know exactly what runs.
48
69
 
@@ -55,11 +76,10 @@ The Discord *desktop* app must be running. The browser client doesn't expose the
55
76
  git clone https://github.com/rar-file/claude-rpc.git
56
77
  cd claude-rpc
57
78
  npm install
58
- node ./src/cli.js setup
59
- node ./src/cli.js start
79
+ node ./src/cli.js setup # wires hooks + starts the daemon
60
80
  ```
61
81
 
62
- Or `npm install -g claude-rpc` for the global bin. Both modes survive `npm update` without losing your `clientId` — user config lives under the per-OS config dir, not inside `node_modules`.
82
+ Or `npm install -g claude-rpc && claude-rpc setup` for the global bin. `setup` starts the daemon for you; manage it afterward with `claude-rpc start | stop | status`. Every mode survives `npm update` without losing your `clientId` — user config lives under the per-OS config dir, not inside `node_modules`.
63
83
  </details>
64
84
 
65
85
  <details>
@@ -282,14 +302,15 @@ Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--
282
302
  ## development
283
303
 
284
304
  ```sh
285
- npm test # 200+ tests, ~1.7s
305
+ npm test # 270+ tests, ~2s
306
+ npm run lint # eslint over src + test
286
307
  npm run start # run daemon in foreground
287
308
  npm run serve # web dashboard against your real data
288
309
  npm run dashboard # Electron settings GUI (dev mode)
289
310
  npm run build:exe # SEA single-file binary for the current OS
290
311
  ```
291
312
 
292
- Tests are `node --test` with zero deps. The CI pipeline ([release.yml](.github/workflows/release.yml)) gates the matrix build and the npm publish behind the test job. Every public export of `src/*.js` is exercised at least once.
313
+ Tests are `node --test` with zero deps. The CI pipeline ([release.yml](.github/workflows/release.yml)) runs the suite (plus the Cloudflare Worker's own tests) across Node 18/20/22 and gates the matrix build and the npm publish behind it. Every pure/logic module in `src/*.js` is unit-tested, including the MCP transport and the SVG renderers; the long-running daemon, the TUI, and the CLI dispatcher are covered by integration and manual smoke testing rather than unit tests.
293
314
 
294
315
  ## license
295
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.12.1",
3
+ "version": "0.13.2",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,14 +31,23 @@
31
31
  "prep:dashboard": "node ./scripts/prep-dashboard.js",
32
32
  "dist:mac": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:mac",
33
33
  "dist:win": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:win",
34
- "test": "node --test test/*.test.js"
34
+ "test": "node --test test/*.test.js",
35
+ "lint": "eslint src test",
36
+ "format": "prettier --write \"src/**/*.js\" \"test/**/*.js\"",
37
+ "format:check": "prettier --check \"src/**/*.js\" \"test/**/*.js\"",
38
+ "typecheck": "tsc -p jsconfig.json"
35
39
  },
36
40
  "dependencies": {
37
41
  "@xhayper/discord-rpc": "^1.2.1"
38
42
  },
39
43
  "devDependencies": {
44
+ "@eslint/js": "^10.0.1",
40
45
  "esbuild": "^0.24.0",
41
- "postject": "^1.0.0-alpha.6"
46
+ "eslint": "^10.4.1",
47
+ "globals": "^17.6.0",
48
+ "postject": "^1.0.0-alpha.6",
49
+ "prettier": "^3.8.3",
50
+ "typescript": "^6.0.3"
42
51
  },
43
52
  "engines": {
44
53
  "node": ">=18"
package/src/calendar.js CHANGED
@@ -86,7 +86,7 @@ export function renderCalendar(aggregate, { weeks = 53, generatedAt = new Date()
86
86
  <text x="${W - 170}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">less</text>
87
87
  ${legend}
88
88
  <text x="${W - 28}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">more</text>
89
- <text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc v${VERSION}</text>
89
+ <text x="${padX}" y="${H - 8}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">claude-rpc.vercel.app · v${VERSION}</text>
90
90
  </svg>`;
91
91
  }
92
92
 
package/src/card.js CHANGED
@@ -334,7 +334,7 @@ export function renderCard(aggregate, { range = 'year', generatedAt = new Date()
334
334
  <!-- ── credits ── -->
335
335
  <g transform="translate(${W - 60} ${H - 24})" text-anchor="end">
336
336
  <text font-family="JetBrains Mono, ui-monospace, monospace" font-size="10"
337
- fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · github.com/rar-file/claude-rpc</text>
337
+ fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · claude-rpc.vercel.app</text>
338
338
  </g>
339
339
  </svg>`;
340
340
  }
package/src/cli.js CHANGED
@@ -10,7 +10,7 @@ import process from 'node:process';
10
10
  if (process.platform === 'win32' && process.stdout.isTTY) {
11
11
  try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
12
12
  }
13
- import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, EXE_PATH, CANONICAL_EXE } from './paths.js';
13
+ import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH, CANONICAL_EXE } from './paths.js';
14
14
  import { readState } from './state.js';
15
15
  import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses } from './format.js';
16
16
  import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
@@ -18,12 +18,14 @@ import { runHookCli } from './hook.js';
18
18
  import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand } from './install.js';
19
19
  import { startTui } from './tui.js';
20
20
  import { generateInsights } from './insights.js';
21
+ import { maybeNudge } from './nudge.js';
21
22
  import { badgeSvg } from './badge.js';
22
23
  import { fmtCost } from './pricing.js';
23
24
  import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
24
25
  import { loadConfig, hasUserConfig } from './config.js';
26
+ import * as lb from './leaderboard.js';
25
27
  import { VERSION } from './version.js';
26
- import { fail, EX_USER_ERROR, EX_BAD_STATE } from './ui.js';
28
+ import { fail, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
27
29
  import { randomUUID } from 'node:crypto';
28
30
  import { createInterface } from 'node:readline';
29
31
  import { basename } from 'node:path';
@@ -458,6 +460,13 @@ function showToday() {
458
460
  box('when you code · hour of day', renderHourHistogram(aggregate.byHour, { peakHour: vars.peakHourNum }), 40);
459
461
  console.log('');
460
462
  }
463
+
464
+ // Share nudge — only on a TTY (keeps piped/scripted output clean) and only
465
+ // when a new milestone was crossed. maybeNudge marks it shown internally.
466
+ if (process.stdout.isTTY) {
467
+ const nudge = maybeNudge(aggregate, config);
468
+ if (nudge) console.log(` ${c.dim}↗ share${c.reset} ${nudge}\n`);
469
+ }
461
470
  }
462
471
 
463
472
  function showWeek() {
@@ -1038,6 +1047,195 @@ async function communityReport() {
1038
1047
  console.log('');
1039
1048
  }
1040
1049
 
1050
+ // ── leaderboard profile (local) ───────────────────────────────────────
1051
+ // `profile` manages the local, opt-in identity used by the public leaderboard.
1052
+ // Everything is stored locally in config.json; nothing is published until the
1053
+ // daemon flush runs with profile.enabled + a valid handle (Phase 2).
1054
+ function readFlag(argv, name) {
1055
+ const i = argv.indexOf(`--${name}`);
1056
+ if (i !== -1 && i + 1 < argv.length) return argv[i + 1];
1057
+ const eq = argv.find((a) => a.startsWith(`--${name}=`));
1058
+ return eq ? eq.slice(name.length + 3) : undefined;
1059
+ }
1060
+
1061
+ function profileStatus() {
1062
+ const p = (loadConfig().profile) || {};
1063
+ console.log('');
1064
+ console.log(` ${c.bold}claude-rpc profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
1065
+ console.log('');
1066
+ console.log(` state: ${p.enabled ? `${c.green}on${c.reset}` : `${c.dim}off${c.reset}`}`);
1067
+ console.log(` handle: ${p.handle ? c.cyan + p.handle + c.reset : c.dim + '(unset)' + c.reset}`);
1068
+ console.log(` name: ${p.displayName ? p.displayName : c.dim + '(unset)' + c.reset}`);
1069
+ console.log(` github: ${p.githubUser ? `${p.githubUser} ${c.dim}(verify to earn ✓)${c.reset}` : c.dim + '(unset — unverified)' + c.reset}`);
1070
+ console.log('');
1071
+ if (!p.handle) console.log(` ${c.dim}set one with:${c.reset} claude-rpc profile set --handle <name> [--name "..."] [--github <user>]`);
1072
+ else if (!p.enabled) console.log(` ${c.dim}publish to the board with:${c.reset} claude-rpc profile on`);
1073
+ console.log('');
1074
+ }
1075
+
1076
+ function profileSet(argv) {
1077
+ const { normalizeHandle, cleanDisplayName, normalizeGithubUser } = lb;
1078
+ const userCfg = readJson(CONFIG_PATH, {});
1079
+ const next = { ...(userCfg.profile || {}) };
1080
+
1081
+ const rawHandle = readFlag(argv, 'handle');
1082
+ if (rawHandle !== undefined) {
1083
+ const h = normalizeHandle(rawHandle);
1084
+ if (!h) return fail('invalid handle — use 2–32 chars of letters, numbers, and dashes', { code: EX_USER_ERROR });
1085
+ next.handle = h;
1086
+ }
1087
+ const rawName = readFlag(argv, 'name');
1088
+ if (rawName !== undefined) next.displayName = cleanDisplayName(rawName);
1089
+ const rawGh = readFlag(argv, 'github');
1090
+ if (rawGh !== undefined) {
1091
+ if (rawGh === '') next.githubUser = null;
1092
+ else {
1093
+ const u = normalizeGithubUser(rawGh);
1094
+ if (!u) return fail(`invalid GitHub username: ${rawGh}`, { code: EX_USER_ERROR });
1095
+ next.githubUser = u;
1096
+ }
1097
+ }
1098
+
1099
+ userCfg.profile = next;
1100
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1101
+ console.log(`${c.green}✓${c.reset} profile saved`);
1102
+ profileStatus();
1103
+ }
1104
+
1105
+ function profileEnable(on) {
1106
+ const userCfg = readJson(CONFIG_PATH, {});
1107
+ const next = { ...(userCfg.profile || {}) };
1108
+ if (on && !lb.isValidHandle(next.handle)) {
1109
+ return fail('set a handle before going on', {
1110
+ hint: 'claude-rpc profile set --handle <name>',
1111
+ code: EX_BAD_STATE,
1112
+ });
1113
+ }
1114
+ next.enabled = on;
1115
+ // Publishing reuses the anonymous community instanceId as the profile's row
1116
+ // key. Mint one if the user never opted into community totals, so a
1117
+ // profile-only user can still publish.
1118
+ if (on) {
1119
+ const community = { ...(userCfg.community || {}) };
1120
+ if (!community.instanceId) {
1121
+ community.instanceId = randomUUID();
1122
+ userCfg.community = community;
1123
+ }
1124
+ }
1125
+ userCfg.profile = next;
1126
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1127
+ console.log(`${c.green}✓${c.reset} leaderboard publishing ${on ? 'enabled' : 'disabled'}`);
1128
+ if (on) {
1129
+ console.log(` ${c.dim}publish now with ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}${c.dim} (or wait for the next daemon flush).${c.reset}`);
1130
+ console.log(` ${c.dim}earn the ✓ with ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim}.${c.reset}`);
1131
+ }
1132
+ }
1133
+
1134
+ // One-shot publish so you appear on the board immediately, instead of waiting
1135
+ // for the daemon's next flush.
1136
+ async function profilePublish() {
1137
+ const cfg = loadConfig();
1138
+ if (!lb.profileIsPublishable(cfg.profile || {})) {
1139
+ return fail('enable the profile first', {
1140
+ hint: 'claude-rpc profile set --handle <name> && claude-rpc profile on', code: EX_BAD_STATE,
1141
+ });
1142
+ }
1143
+ const { flushProfile } = await import('./community.js');
1144
+ console.log(`${c.dim}publishing @${cfg.profile.handle} to the board…${c.reset}`);
1145
+ const r = await flushProfile(cfg);
1146
+ if (r.ok) {
1147
+ console.log(`${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(cfg.profile.handle)}${c.reset}`);
1148
+ } else if (r.reason === 'rate-limited') {
1149
+ console.log(`${c.yellow}!${c.reset} rate-limited — already published in the last minute; the board has you.`);
1150
+ } else {
1151
+ return fail(`publish failed: ${r.reason}${r.error ? ' (' + r.error + ')' : ''}`, { code: EX_SYS_ERROR });
1152
+ }
1153
+ }
1154
+
1155
+ // GitHub verification: ask the worker for a one-time token, publish it in a
1156
+ // public gist (reusing the gist helper), then have the worker confirm it.
1157
+ async function profileVerify() {
1158
+ const cfg = loadConfig();
1159
+ const profile = cfg.profile || {};
1160
+ const community = cfg.community || {};
1161
+ if (!profile.githubUser) {
1162
+ return fail('set a GitHub username first', {
1163
+ hint: 'claude-rpc profile set --github <user>', code: EX_BAD_STATE,
1164
+ });
1165
+ }
1166
+ if (!community.instanceId) {
1167
+ return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
1168
+ }
1169
+ const endpoint = (community.endpoint || '').replace(/\/+$/, '');
1170
+ if (!endpoint) return fail('no community endpoint configured', { code: EX_BAD_STATE });
1171
+
1172
+ const post = async (path, body) => {
1173
+ const res = await fetch(endpoint + path, {
1174
+ method: 'POST',
1175
+ headers: { 'Content-Type': 'application/json' },
1176
+ body: JSON.stringify(body),
1177
+ });
1178
+ return { status: res.status, json: await res.json().catch(() => ({})) };
1179
+ };
1180
+
1181
+ try {
1182
+ console.log(`${c.dim}requesting a verification token…${c.reset}`);
1183
+ const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser });
1184
+ if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
1185
+ const token = start.json.token;
1186
+
1187
+ const { publishGistFile } = await import('./gist.js');
1188
+ console.log(`${c.dim}publishing a public proof gist…${c.reset}`);
1189
+ const gist = await publishGistFile({
1190
+ svg: `claude-rpc leaderboard verification\n${token}\n`,
1191
+ filename: 'claude-rpc-verify.txt',
1192
+ description: 'claude-rpc profile verification',
1193
+ isPublic: true,
1194
+ });
1195
+
1196
+ // Hand the worker the gist ID so it fetches that gist directly (no
1197
+ // gist-list lag) and reads the real owner — instant, and the owner becomes
1198
+ // the verified identity regardless of what --github was set to.
1199
+ console.log(`${c.dim}confirming with the server…${c.reset}`);
1200
+ const check = await post('/verify/check', { instanceId: community.instanceId, gistId: gist.id });
1201
+ if (check.json?.verified) {
1202
+ const who = check.json.githubUser || gist.owner || profile.githubUser;
1203
+ // Persist the authoritative owner so the local profile + future publishes
1204
+ // match what got verified.
1205
+ if (who && who !== profile.githubUser) {
1206
+ const userCfg = readJson(CONFIG_PATH, {});
1207
+ userCfg.profile = { ...(userCfg.profile || {}), githubUser: who };
1208
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1209
+ }
1210
+ console.log(`${c.green}✓${c.reset} verified as @${who} — you'll show the ✓ on the board.`);
1211
+ if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
1212
+ console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account.)${c.reset}`);
1213
+ }
1214
+ } else {
1215
+ console.log(`${c.yellow}!${c.reset} not confirmed: ${check.json?.error || check.status}`);
1216
+ console.log(` ${c.dim}make sure the gist is public, then re-run ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim}.${c.reset}`);
1217
+ }
1218
+ } catch (e) {
1219
+ return fail(`verification failed: ${e.message}`, {
1220
+ hint: 'needs `gh` logged in or GH_TOKEN with gist scope', code: EX_SYS_ERROR,
1221
+ });
1222
+ }
1223
+ }
1224
+
1225
+ async function doProfile(argv) {
1226
+ const sub = (argv[0] || 'status').toLowerCase();
1227
+ if (sub === 'status' || sub === '') return profileStatus();
1228
+ if (sub === 'set') return profileSet(argv.slice(1));
1229
+ if (sub === 'on') return profileEnable(true);
1230
+ if (sub === 'off') return profileEnable(false);
1231
+ if (sub === 'verify') return profileVerify();
1232
+ if (sub === 'publish') return profilePublish();
1233
+ fail(`unknown profile subcommand: ${sub}`, {
1234
+ hint: 'try: profile [status|set|on|off|verify|publish]',
1235
+ code: EX_USER_ERROR,
1236
+ });
1237
+ }
1238
+
1041
1239
  async function doCommunity(argv) {
1042
1240
  const sub = (argv[0] || 'status').toLowerCase();
1043
1241
  if (sub === 'on') return communityOn();
@@ -1175,6 +1373,7 @@ function help() {
1175
1373
  ['public', 'Un-mark the current directory'],
1176
1374
  ['privacy', 'Show resolved visibility for the current directory'],
1177
1375
  ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
1376
+ ['profile', 'Public leaderboard identity (status|set|on|off|publish|verify)'],
1178
1377
  ['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
1179
1378
  ['tail', 'Tail the daemon log file'],
1180
1379
  ['daemon', 'Run daemon in foreground (debug)'],
@@ -1219,7 +1418,30 @@ const packagedDefault = IS_PACKAGED && !cmd;
1219
1418
  // startup, install = with) but in practice users expect one command
1220
1419
  // to do everything. Non-Windows: addStartupEntry is a no-op + warning.
1221
1420
  case 'setup':
1222
- case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
1421
+ case 'install':
1422
+ await runInstall({ exePath: EXE_PATH || process.execPath });
1423
+ // Slimmer first run: bring the daemon up now so the card appears
1424
+ // immediately, instead of making the user run a separate `start`.
1425
+ // Best-effort — a start hiccup must never make `setup` look failed.
1426
+ try {
1427
+ if (IS_NPX) {
1428
+ // Our own tree is npm's throwaway _npx cache; launch from the global
1429
+ // install setup just promoted to, via the PATH-resolved bin.
1430
+ if (!daemonPid()) {
1431
+ spawn('claude-rpc', ['daemon'], {
1432
+ detached: true, stdio: 'ignore', windowsHide: true,
1433
+ shell: process.platform === 'win32',
1434
+ }).unref();
1435
+ console.log(`${c.green}✓${c.reset} Daemon launched ${c.dim}logs: ${LOG_PATH}${c.reset}`);
1436
+ }
1437
+ } else {
1438
+ startDaemon();
1439
+ }
1440
+ } catch (e) {
1441
+ console.log(`${c.yellow}!${c.reset} Couldn't auto-start the daemon: ${e.message}`);
1442
+ console.log(` ${c.dim}↳ run \`claude-rpc start\` when you're ready${c.reset}`);
1443
+ }
1444
+ break;
1223
1445
  case 'uninstall': await runUninstall(); break;
1224
1446
  case 'upgrade-config': migrateConfig(); break;
1225
1447
  case 'start': startDaemon(); break;
@@ -1259,23 +1481,46 @@ const packagedDefault = IS_PACKAGED && !cmd;
1259
1481
  case 'public': doPublic(); break;
1260
1482
  case 'privacy': doPrivacy(); break;
1261
1483
  case 'community': await doCommunity(process.argv.slice(3)); break;
1484
+ case 'profile': await doProfile(process.argv.slice(3)); break;
1262
1485
  case 'doctor': {
1263
- const { runDoctor } = await import('./doctor.js');
1486
+ const { runDoctor, fixPlan } = await import('./doctor.js');
1264
1487
  const fix = process.argv.includes('--fix');
1265
1488
  const code = runDoctor();
1266
1489
  if (!fix) process.exit(code);
1267
- // --fix: re-run setup (re-seeds/migrates config, re-wires hooks — all
1268
- // idempotent) and restart the daemon to pick it up. Covers the common
1269
- // breakages doctor flags: missing/stale hooks, bricked config, dead daemon.
1270
- console.log(`\n ${c.cyan}◆ --fix${c.reset} ${c.dim}— re-running setup and restarting the daemon${c.reset}`);
1271
- try {
1272
- await runInstall({ exePath: EXE_PATH || process.execPath });
1273
- console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1274
- } catch (e) {
1275
- console.log(` ${c.red}✗${c.reset} setup step failed: ${e.message}`);
1490
+
1491
+ // --fix: apply ONLY the repairs the checklist flagged, in dependency
1492
+ // order, reporting each instead of blindly re-running everything.
1493
+ const plan = fixPlan();
1494
+ if (plan.length === 0) {
1495
+ console.log(`\n ${c.green}◆ --fix${c.reset} ${c.dim}— nothing to repair; everything that can be auto-fixed already passes.${c.reset}`);
1496
+ process.exit(code);
1497
+ }
1498
+ console.log(`\n ${c.cyan}◆ --fix${c.reset} ${c.dim}— applying ${plan.length} targeted repair${plan.length === 1 ? '' : 's'}: ${plan.join(', ')}${c.reset}`);
1499
+
1500
+ let restarted = false;
1501
+ for (const kind of plan) {
1502
+ try {
1503
+ if (kind === 'setup') {
1504
+ await runInstall({ exePath: EXE_PATH || process.execPath });
1505
+ console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1506
+ } else if (kind === 'rescan') {
1507
+ doScan(true);
1508
+ console.log(` ${c.green}✓${c.reset} aggregate rebuilt from transcripts`);
1509
+ } else if (kind === 'daemon') {
1510
+ restartDaemon();
1511
+ restarted = true;
1512
+ console.log(` ${c.green}✓${c.reset} daemon (re)starting`);
1513
+ } else if (kind === 'discord') {
1514
+ console.log(` ${c.yellow}!${c.reset} discord IPC is down — open the Discord ${c.bold}desktop${c.reset} app (RPC isn't exposed by the browser client). Not auto-fixable.`);
1515
+ }
1516
+ } catch (e) {
1517
+ console.log(` ${c.red}✗${c.reset} ${kind} step failed: ${e.message}`);
1518
+ }
1276
1519
  }
1277
- restartDaemon();
1278
- console.log(` ${c.green}✓${c.reset} daemon restarting run ${c.cyan}claude-rpc doctor${c.reset} again in a few seconds to confirm.`);
1520
+ // A 'setup' rewire only takes effect once the daemon reloads, so ensure a
1521
+ // restart even if the daemon wasn't itself flagged.
1522
+ if (plan.includes('setup') && !restarted) restartDaemon();
1523
+ console.log(` ${c.dim}run ${c.cyan}claude-rpc doctor${c.reset}${c.dim} again in a few seconds to confirm.${c.reset}`);
1279
1524
  break; // let the restart timer fire before the process drains
1280
1525
  }
1281
1526
  case 'tail':
package/src/community.js CHANGED
@@ -23,8 +23,10 @@ import { join, dirname } from 'node:path';
23
23
  import { platform } from 'node:os';
24
24
  import { AGGREGATE_PATH, STATE_DIR } from './paths.js';
25
25
  import { VERSION } from './version.js';
26
+ import { profileIsPublishable } from './leaderboard.js';
26
27
 
27
28
  const CURSOR_PATH = join(STATE_DIR, 'community-cursor.json');
29
+ const PROFILE_CURSOR_PATH = join(STATE_DIR, 'profile-cursor.json');
28
30
 
29
31
  export function readCursor(path = CURSOR_PATH) {
30
32
  if (!existsSync(path)) return { sessions: 0, tokens: 0, ts: 0 };
@@ -71,6 +73,93 @@ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }
71
73
  };
72
74
  }
73
75
 
76
+ // ── leaderboard profile flush ──────────────────────────────────────────
77
+ // Publishes the opt-in public profile (identity + server-validated usage
78
+ // deltas) to the worker's /profile endpoint. Reuses the anonymous community
79
+ // instanceId as the profile's row key, and its own cursor (which also tracks
80
+ // activeMs). Same three guarantees as flushCommunity: never throws, sends only
81
+ // the documented fields, never advances the cursor on a failed flush.
82
+
83
+ function totalTokens(aggregate) {
84
+ return (aggregate?.inputTokens || 0)
85
+ + (aggregate?.outputTokens || 0)
86
+ + (aggregate?.cacheReadTokens || 0)
87
+ + (aggregate?.cacheWriteTokens || 0);
88
+ }
89
+
90
+ export function readProfileCursor(path = PROFILE_CURSOR_PATH) {
91
+ const base = { sessions: 0, tokens: 0, activeMs: 0, ts: 0 };
92
+ if (!existsSync(path)) return base;
93
+ try { return { ...base, ...JSON.parse(readFileSync(path, 'utf8')) }; }
94
+ catch { return base; }
95
+ }
96
+
97
+ export function buildProfilePayload(aggregate, profileCfg, cursor, { instanceId, now = Date.now() }) {
98
+ const sessions = aggregate?.sessions || 0;
99
+ const tokens = totalTokens(aggregate);
100
+ const activeMs = aggregate?.activeMs || 0;
101
+ return {
102
+ instanceId,
103
+ handle: profileCfg.handle,
104
+ displayName: profileCfg.displayName || null,
105
+ githubUser: profileCfg.githubUser || null,
106
+ sessionsDelta: Math.max(0, sessions - (cursor.sessions || 0)),
107
+ tokensDelta: Math.max(0, tokens - (cursor.tokens || 0)),
108
+ activeMsDelta: Math.max(0, activeMs - (cursor.activeMs || 0)),
109
+ streak: aggregate?.streak || 0,
110
+ version: VERSION,
111
+ osFamily: osFamily(),
112
+ ts: now,
113
+ };
114
+ }
115
+
116
+ export async function flushProfile(cfg, {
117
+ aggregatePath = AGGREGATE_PATH,
118
+ cursorPath = PROFILE_CURSOR_PATH,
119
+ fetchImpl = globalThis.fetch,
120
+ } = {}) {
121
+ const profile = cfg?.profile || {};
122
+ const community = cfg?.community || {};
123
+ if (!profileIsPublishable(profile)) return { ok: false, reason: 'disabled' };
124
+ const instanceId = community.instanceId;
125
+ if (!instanceId) return { ok: false, reason: 'no-instance-id' };
126
+ if (!community.endpoint) return { ok: false, reason: 'no-endpoint' };
127
+ if (!existsSync(aggregatePath)) return { ok: false, reason: 'no-aggregate' };
128
+
129
+ let aggregate;
130
+ try { aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')); }
131
+ catch { return { ok: false, reason: 'unreadable-aggregate' }; }
132
+
133
+ const cursor = readProfileCursor(cursorPath);
134
+ const payload = buildProfilePayload(aggregate, profile, cursor, { instanceId });
135
+
136
+ const url = community.endpoint.replace(/\/+$/, '') + '/profile';
137
+ let res;
138
+ try {
139
+ res = await fetchImpl(url, {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify(payload),
143
+ });
144
+ } catch (e) {
145
+ return { ok: false, reason: 'network', error: e.message };
146
+ }
147
+ if (!res.ok) {
148
+ if (res.status === 429) return { ok: false, reason: 'rate-limited' };
149
+ return { ok: false, reason: `http-${res.status}` };
150
+ }
151
+
152
+ // Advance the cursor only on acceptance (same reasoning as flushCommunity).
153
+ writeCursor({
154
+ sessions: (cursor.sessions || 0) + payload.sessionsDelta,
155
+ tokens: (cursor.tokens || 0) + payload.tokensDelta,
156
+ activeMs: (cursor.activeMs || 0) + payload.activeMsDelta,
157
+ ts: payload.ts,
158
+ }, cursorPath);
159
+
160
+ return { ok: true, delta: { sessions: payload.sessionsDelta, tokens: payload.tokensDelta, activeMs: payload.activeMsDelta } };
161
+ }
162
+
74
163
  // Single best-effort flush. Returns { ok, reason, delta? } — never throws.
75
164
  // Caller passes in the merged config so we can be tested without touching
76
165
  // disk for config too.