claude-rpc 0.12.0 → 0.13.1

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>
@@ -258,7 +278,8 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
258
278
  | `calendar` | Year activity heatmap SVG (`--out` `--gist`) |
259
279
  | `session-card` | Recap card for the current session (`--out`) |
260
280
  | `statusline` | One-line status for tmux/shell prompts (`--template`) |
261
- | `mcp` | Run as an MCP server expose your stats to Claude Code |
281
+ | `mcp install` | Wire the stats MCP server into Claude Code (one command) |
282
+ | `mcp` | Run the MCP server (stdio) for Claude Code |
262
283
  | `wrapped` | Open your animated year-in-review (Claude Wrapped) |
263
284
  | `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
264
285
  | `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
@@ -281,14 +302,15 @@ Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--
281
302
  ## development
282
303
 
283
304
  ```sh
284
- npm test # 200+ tests, ~1.7s
305
+ npm test # 270+ tests, ~2s
306
+ npm run lint # eslint over src + test
285
307
  npm run start # run daemon in foreground
286
308
  npm run serve # web dashboard against your real data
287
309
  npm run dashboard # Electron settings GUI (dev mode)
288
310
  npm run build:exe # SEA single-file binary for the current OS
289
311
  ```
290
312
 
291
- 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.
292
314
 
293
315
  ## license
294
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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,20 +10,22 @@ 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';
17
17
  import { runHookCli } from './hook.js';
18
- import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe } from './install.js';
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() {
@@ -847,6 +856,34 @@ async function doMcp() {
847
856
  runMcpServer();
848
857
  }
849
858
 
859
+ // One-command wiring into Claude Code — runs `claude mcp add` for the user.
860
+ function doMcpInstall(argv) {
861
+ const scope = argv.includes('--project') ? 'project' : argv.includes('--local') ? 'local' : 'user';
862
+ const res = installMcp({ exePath: EXE_PATH || process.execPath, scope });
863
+ const manual = (r) => `claude mcp add claude-rpc --scope ${scope} -- ${r.command} ${r.args.join(' ')}`;
864
+ if (res.ok) {
865
+ console.log('');
866
+ console.log(` ${c.green}✓${c.reset} Registered the ${c.cyan}claude-rpc${c.reset} MCP server with Claude Code (scope: ${scope}).`);
867
+ console.log(` ${c.dim}Restart Claude Code (or run /mcp), then ask: "how long have I coded today?"${c.reset}`);
868
+ console.log('');
869
+ } else if (res.reason === 'no-claude') {
870
+ fail('the `claude` CLI was not found on your PATH', {
871
+ hint: `install Claude Code first, then run: ${manual(res)}`,
872
+ code: EX_USER_ERROR,
873
+ });
874
+ } else {
875
+ fail(`\`claude mcp add\` failed (exit ${res.code})`, { hint: `try it manually: ${manual(res)}`, code: EX_USER_ERROR });
876
+ }
877
+ }
878
+
879
+ function doMcpUninstall(argv) {
880
+ const scope = argv.includes('--project') ? 'project' : argv.includes('--local') ? 'local' : 'user';
881
+ const res = uninstallMcp({ scope });
882
+ if (res.ok) console.log(`${c.green}✓${c.reset} Removed the claude-rpc MCP server (scope: ${scope}).`);
883
+ else if (res.reason === 'no-claude') fail('the `claude` CLI was not found on your PATH', { code: EX_USER_ERROR });
884
+ else fail('could not remove the MCP server', { hint: 'claude mcp remove claude-rpc', code: EX_USER_ERROR });
885
+ }
886
+
850
887
  // ── Privacy commands ─────────────────────────────────────────────────────
851
888
  //
852
889
  // `claude-rpc private` → add current cwd to ~/.claude-rpc/private-list.json
@@ -1010,6 +1047,158 @@ async function communityReport() {
1010
1047
  console.log('');
1011
1048
  }
1012
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) console.log(` ${c.dim}your stats publish on the next daemon flush. run ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim} to earn the ✓.${c.reset}`);
1129
+ }
1130
+
1131
+ // GitHub verification: ask the worker for a one-time token, publish it in a
1132
+ // public gist (reusing the gist helper), then have the worker confirm it.
1133
+ async function profileVerify() {
1134
+ const cfg = loadConfig();
1135
+ const profile = cfg.profile || {};
1136
+ const community = cfg.community || {};
1137
+ if (!profile.githubUser) {
1138
+ return fail('set a GitHub username first', {
1139
+ hint: 'claude-rpc profile set --github <user>', code: EX_BAD_STATE,
1140
+ });
1141
+ }
1142
+ if (!community.instanceId) {
1143
+ return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
1144
+ }
1145
+ const endpoint = (community.endpoint || '').replace(/\/+$/, '');
1146
+ if (!endpoint) return fail('no community endpoint configured', { code: EX_BAD_STATE });
1147
+
1148
+ const post = async (path, body) => {
1149
+ const res = await fetch(endpoint + path, {
1150
+ method: 'POST',
1151
+ headers: { 'Content-Type': 'application/json' },
1152
+ body: JSON.stringify(body),
1153
+ });
1154
+ return { status: res.status, json: await res.json().catch(() => ({})) };
1155
+ };
1156
+
1157
+ try {
1158
+ console.log(`${c.dim}requesting a verification token…${c.reset}`);
1159
+ const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser });
1160
+ if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
1161
+ const token = start.json.token;
1162
+
1163
+ const { publishGistFile } = await import('./gist.js');
1164
+ console.log(`${c.dim}publishing a proof gist as @${profile.githubUser}…${c.reset}`);
1165
+ await publishGistFile({
1166
+ svg: `claude-rpc leaderboard verification for @${profile.githubUser}\n${token}\n`,
1167
+ filename: 'claude-rpc-verify.txt',
1168
+ description: 'claude-rpc profile verification',
1169
+ isPublic: true,
1170
+ });
1171
+
1172
+ console.log(`${c.dim}asking the server to confirm…${c.reset}`);
1173
+ // Small delay so the gist is visible to GitHub's API before we check.
1174
+ await new Promise((r) => setTimeout(r, 2500));
1175
+ const check = await post('/verify/check', { instanceId: community.instanceId });
1176
+ if (check.json?.verified) {
1177
+ console.log(`${c.green}✓${c.reset} verified as @${profile.githubUser} — you'll show the ✓ on the board.`);
1178
+ } else {
1179
+ console.log(`${c.yellow}!${c.reset} not confirmed yet: ${check.json?.error || check.status}`);
1180
+ console.log(` ${c.dim}the gist may take a moment to propagate — re-run ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim} shortly.${c.reset}`);
1181
+ }
1182
+ } catch (e) {
1183
+ return fail(`verification failed: ${e.message}`, {
1184
+ hint: 'needs `gh` logged in or GH_TOKEN with gist scope', code: EX_SYS_ERROR,
1185
+ });
1186
+ }
1187
+ }
1188
+
1189
+ async function doProfile(argv) {
1190
+ const sub = (argv[0] || 'status').toLowerCase();
1191
+ if (sub === 'status' || sub === '') return profileStatus();
1192
+ if (sub === 'set') return profileSet(argv.slice(1));
1193
+ if (sub === 'on') return profileEnable(true);
1194
+ if (sub === 'off') return profileEnable(false);
1195
+ if (sub === 'verify') return profileVerify();
1196
+ fail(`unknown profile subcommand: ${sub}`, {
1197
+ hint: 'try: profile [status|set|on|off|verify]',
1198
+ code: EX_USER_ERROR,
1199
+ });
1200
+ }
1201
+
1013
1202
  async function doCommunity(argv) {
1014
1203
  const sub = (argv[0] || 'status').toLowerCase();
1015
1204
  if (sub === 'on') return communityOn();
@@ -1140,12 +1329,14 @@ function help() {
1140
1329
  ['statusline', 'One-line status for tmux/shell prompts (--template)'],
1141
1330
  ['calendar', 'Year activity heatmap SVG (--out --gist)'],
1142
1331
  ['session-card', 'Recap card for the current session (--out)'],
1143
- ['mcp', 'Run as an MCP server expose your stats to Claude Code'],
1332
+ ['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
1333
+ ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
1144
1334
  ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
1145
1335
  ['private', 'Mark the current directory as private (hide from Discord)'],
1146
1336
  ['public', 'Un-mark the current directory'],
1147
1337
  ['privacy', 'Show resolved visibility for the current directory'],
1148
1338
  ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
1339
+ ['profile', 'Public leaderboard identity — set handle/name/github (status|set|on|off)'],
1149
1340
  ['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
1150
1341
  ['tail', 'Tail the daemon log file'],
1151
1342
  ['daemon', 'Run daemon in foreground (debug)'],
@@ -1190,7 +1381,30 @@ const packagedDefault = IS_PACKAGED && !cmd;
1190
1381
  // startup, install = with) but in practice users expect one command
1191
1382
  // to do everything. Non-Windows: addStartupEntry is a no-op + warning.
1192
1383
  case 'setup':
1193
- case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
1384
+ case 'install':
1385
+ await runInstall({ exePath: EXE_PATH || process.execPath });
1386
+ // Slimmer first run: bring the daemon up now so the card appears
1387
+ // immediately, instead of making the user run a separate `start`.
1388
+ // Best-effort — a start hiccup must never make `setup` look failed.
1389
+ try {
1390
+ if (IS_NPX) {
1391
+ // Our own tree is npm's throwaway _npx cache; launch from the global
1392
+ // install setup just promoted to, via the PATH-resolved bin.
1393
+ if (!daemonPid()) {
1394
+ spawn('claude-rpc', ['daemon'], {
1395
+ detached: true, stdio: 'ignore', windowsHide: true,
1396
+ shell: process.platform === 'win32',
1397
+ }).unref();
1398
+ console.log(`${c.green}✓${c.reset} Daemon launched ${c.dim}logs: ${LOG_PATH}${c.reset}`);
1399
+ }
1400
+ } else {
1401
+ startDaemon();
1402
+ }
1403
+ } catch (e) {
1404
+ console.log(`${c.yellow}!${c.reset} Couldn't auto-start the daemon: ${e.message}`);
1405
+ console.log(` ${c.dim}↳ run \`claude-rpc start\` when you're ready${c.reset}`);
1406
+ }
1407
+ break;
1194
1408
  case 'uninstall': await runUninstall(); break;
1195
1409
  case 'upgrade-config': migrateConfig(); break;
1196
1410
  case 'start': startDaemon(); break;
@@ -1218,29 +1432,58 @@ const packagedDefault = IS_PACKAGED && !cmd;
1218
1432
  case 'statusline': doStatusline(process.argv.slice(3)); break;
1219
1433
  case 'calendar': await doCalendar(process.argv.slice(3)); break;
1220
1434
  case 'session-card': await doSessionCard(process.argv.slice(3)); break;
1221
- case 'mcp': await doMcp(); break;
1435
+ case 'mcp': {
1436
+ const sub = process.argv[3];
1437
+ if (sub === 'install') { doMcpInstall(process.argv.slice(4)); break; }
1438
+ if (sub === 'uninstall') { doMcpUninstall(process.argv.slice(4)); break; }
1439
+ await doMcp();
1440
+ break;
1441
+ }
1222
1442
  case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
1223
1443
  case 'private': doPrivate(); break;
1224
1444
  case 'public': doPublic(); break;
1225
1445
  case 'privacy': doPrivacy(); break;
1226
1446
  case 'community': await doCommunity(process.argv.slice(3)); break;
1447
+ case 'profile': await doProfile(process.argv.slice(3)); break;
1227
1448
  case 'doctor': {
1228
- const { runDoctor } = await import('./doctor.js');
1449
+ const { runDoctor, fixPlan } = await import('./doctor.js');
1229
1450
  const fix = process.argv.includes('--fix');
1230
1451
  const code = runDoctor();
1231
1452
  if (!fix) process.exit(code);
1232
- // --fix: re-run setup (re-seeds/migrates config, re-wires hooks — all
1233
- // idempotent) and restart the daemon to pick it up. Covers the common
1234
- // breakages doctor flags: missing/stale hooks, bricked config, dead daemon.
1235
- console.log(`\n ${c.cyan}◆ --fix${c.reset} ${c.dim}— re-running setup and restarting the daemon${c.reset}`);
1236
- try {
1237
- await runInstall({ exePath: EXE_PATH || process.execPath });
1238
- console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1239
- } catch (e) {
1240
- console.log(` ${c.red}✗${c.reset} setup step failed: ${e.message}`);
1453
+
1454
+ // --fix: apply ONLY the repairs the checklist flagged, in dependency
1455
+ // order, reporting each instead of blindly re-running everything.
1456
+ const plan = fixPlan();
1457
+ if (plan.length === 0) {
1458
+ console.log(`\n ${c.green}◆ --fix${c.reset} ${c.dim}— nothing to repair; everything that can be auto-fixed already passes.${c.reset}`);
1459
+ process.exit(code);
1460
+ }
1461
+ console.log(`\n ${c.cyan}◆ --fix${c.reset} ${c.dim}— applying ${plan.length} targeted repair${plan.length === 1 ? '' : 's'}: ${plan.join(', ')}${c.reset}`);
1462
+
1463
+ let restarted = false;
1464
+ for (const kind of plan) {
1465
+ try {
1466
+ if (kind === 'setup') {
1467
+ await runInstall({ exePath: EXE_PATH || process.execPath });
1468
+ console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1469
+ } else if (kind === 'rescan') {
1470
+ doScan(true);
1471
+ console.log(` ${c.green}✓${c.reset} aggregate rebuilt from transcripts`);
1472
+ } else if (kind === 'daemon') {
1473
+ restartDaemon();
1474
+ restarted = true;
1475
+ console.log(` ${c.green}✓${c.reset} daemon (re)starting`);
1476
+ } else if (kind === 'discord') {
1477
+ 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.`);
1478
+ }
1479
+ } catch (e) {
1480
+ console.log(` ${c.red}✗${c.reset} ${kind} step failed: ${e.message}`);
1481
+ }
1241
1482
  }
1242
- restartDaemon();
1243
- console.log(` ${c.green}✓${c.reset} daemon restarting run ${c.cyan}claude-rpc doctor${c.reset} again in a few seconds to confirm.`);
1483
+ // A 'setup' rewire only takes effect once the daemon reloads, so ensure a
1484
+ // restart even if the daemon wasn't itself flagged.
1485
+ if (plan.includes('setup') && !restarted) restartDaemon();
1486
+ console.log(` ${c.dim}run ${c.cyan}claude-rpc doctor${c.reset}${c.dim} again in a few seconds to confirm.${c.reset}`);
1244
1487
  break; // let the restart timer fire before the process drains
1245
1488
  }
1246
1489
  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.