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 +31 -9
- package/package.json +12 -3
- package/src/calendar.js +1 -1
- package/src/card.js +1 -1
- package/src/cli.js +261 -18
- package/src/community.js +89 -0
- package/src/daemon.js +78 -28
- package/src/default-config.js +23 -1
- package/src/doctor.js +36 -14
- package/src/format.js +11 -5
- package/src/git.js +1 -1
- package/src/hook.js +56 -13
- package/src/install.js +87 -22
- package/src/leaderboard.js +0 -0
- package/src/mcp.js +20 -4
- package/src/notify.js +33 -6
- package/src/nudge.js +87 -0
- package/src/paths.js +7 -0
- package/src/profile.js +1 -1
- package/src/scanner.js +119 -26
- package/src/server/assets/dashboard.client.js +1 -1
- package/src/server/assets/wrapped.client.js +26 -5
- package/src/session-card.js +1 -1
- package/src/state.js +102 -10
- package/src/version.js +1 -1
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
|
-
**
|
|
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.
|
|
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.
|
|
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`
|
|
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 #
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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 ·
|
|
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',
|
|
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':
|
|
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':
|
|
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
|
-
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
}
|
|
1240
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
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.
|