claude-rpc 0.12.1 → 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 +29 -8
- package/package.json +12 -3
- package/src/calendar.js +1 -1
- package/src/card.js +1 -1
- package/src/cli.js +223 -15
- 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 +58 -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>
|
|
@@ -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 #
|
|
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
|
|
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.
|
|
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,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,158 @@ 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) 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
|
+
|
|
1041
1202
|
async function doCommunity(argv) {
|
|
1042
1203
|
const sub = (argv[0] || 'status').toLowerCase();
|
|
1043
1204
|
if (sub === 'on') return communityOn();
|
|
@@ -1175,6 +1336,7 @@ function help() {
|
|
|
1175
1336
|
['public', 'Un-mark the current directory'],
|
|
1176
1337
|
['privacy', 'Show resolved visibility for the current directory'],
|
|
1177
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)'],
|
|
1178
1340
|
['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
|
|
1179
1341
|
['tail', 'Tail the daemon log file'],
|
|
1180
1342
|
['daemon', 'Run daemon in foreground (debug)'],
|
|
@@ -1219,7 +1381,30 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1219
1381
|
// startup, install = with) but in practice users expect one command
|
|
1220
1382
|
// to do everything. Non-Windows: addStartupEntry is a no-op + warning.
|
|
1221
1383
|
case 'setup':
|
|
1222
|
-
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;
|
|
1223
1408
|
case 'uninstall': await runUninstall(); break;
|
|
1224
1409
|
case 'upgrade-config': migrateConfig(); break;
|
|
1225
1410
|
case 'start': startDaemon(); break;
|
|
@@ -1259,23 +1444,46 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1259
1444
|
case 'public': doPublic(); break;
|
|
1260
1445
|
case 'privacy': doPrivacy(); break;
|
|
1261
1446
|
case 'community': await doCommunity(process.argv.slice(3)); break;
|
|
1447
|
+
case 'profile': await doProfile(process.argv.slice(3)); break;
|
|
1262
1448
|
case 'doctor': {
|
|
1263
|
-
const { runDoctor } = await import('./doctor.js');
|
|
1449
|
+
const { runDoctor, fixPlan } = await import('./doctor.js');
|
|
1264
1450
|
const fix = process.argv.includes('--fix');
|
|
1265
1451
|
const code = runDoctor();
|
|
1266
1452
|
if (!fix) process.exit(code);
|
|
1267
|
-
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
}
|
|
1275
|
-
|
|
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
|
+
}
|
|
1276
1482
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
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}`);
|
|
1279
1487
|
break; // let the restart timer fire before the process drains
|
|
1280
1488
|
}
|
|
1281
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.
|