claude-rpc 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +86 -16
- package/package.json +1 -1
- package/src/cli.js +92 -35
- package/src/community.js +7 -5
- package/src/daemon.js +135 -150
- package/src/doctor.js +7 -2
- package/src/format.js +47 -27
- package/src/gist.js +24 -3
- package/src/git.js +10 -1
- package/src/hook.js +36 -26
- package/src/install.js +49 -3
- package/src/mcp.js +10 -4
- package/src/notify.js +17 -0
- package/src/paths.js +15 -4
- package/src/presence.js +75 -0
- package/src/scanner.js +20 -2
- package/src/server/api.js +15 -1
- package/src/server/assets/dashboard.client.js +8 -3
- package/src/server/index.js +19 -1
- package/src/state.js +22 -4
- package/src/tui.js +3 -8
- package/src/version.js +1 -1
- package/src/watch-poll.js +37 -0
package/SECURITY.md
CHANGED
|
@@ -18,11 +18,12 @@ fetch-and-execute anywhere in `src/`.
|
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
19
|
| Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
|
|
20
20
|
| Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
|
|
21
|
-
| Outbound network | `src/community.js`, `src/gist.js`, `default-config.js`
|
|
22
|
-
| Local subprocess | `reg.exe`, `git`, `gh` | Static args, no shell interpolation of untrusted input | n/a |
|
|
21
|
+
| Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
|
|
22
|
+
| Local subprocess | `reg.exe`, `wscript`, `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
|
|
23
23
|
|
|
24
|
-
No credential access
|
|
25
|
-
|
|
24
|
+
No credential access beyond the read-only Claude Code OAuth-token read for usage
|
|
25
|
+
polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
|
|
26
|
+
transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
|
|
26
27
|
|
|
27
28
|
## 1. Startup persistence (Windows Run key)
|
|
28
29
|
|
|
@@ -53,9 +54,9 @@ and skips it).
|
|
|
53
54
|
|
|
54
55
|
**Source:** `src/install.js`, `installHooks` / `uninstallHooks`.
|
|
55
56
|
|
|
56
|
-
`setup` adds command hooks to Claude Code's `settings.json` for
|
|
57
|
+
`setup` adds command hooks to Claude Code's `settings.json` for nine lifecycle
|
|
57
58
|
events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
58
|
-
`Stop`, `SubagentStop`, `Notification`, `SessionEnd`. Each entry looks like:
|
|
59
|
+
`Stop`, `SubagentStop`, `Notification`, `SessionEnd`, `PreCompact`. Each entry looks like:
|
|
59
60
|
|
|
60
61
|
```jsonc
|
|
61
62
|
{ "matcher": "", "hooks": [{ "type": "command", "command": "\"<exe>\" hook PostToolUse" }] }
|
|
@@ -78,9 +79,11 @@ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
|
78
79
|
|
|
79
80
|
## 3. Outbound network
|
|
80
81
|
|
|
81
|
-
There are
|
|
82
|
-
publishing (3b), squads/web login (3c), subscription-usage polling (3d),
|
|
83
|
-
the cosmetic GIF assets (3e). Each is
|
|
82
|
+
There are six distinct network behaviors: community totals (3a), gist
|
|
83
|
+
publishing (3b), squads/web login (3c), subscription-usage polling (3d),
|
|
84
|
+
the cosmetic GIF assets (3e), and the opt-in status webhook (3f). Each is
|
|
85
|
+
independently optional. The separate desktop dashboard app, if installed,
|
|
86
|
+
additionally auto-updates itself (3g).
|
|
84
87
|
|
|
85
88
|
### 3a. Community totals (telemetry) — ON by default for fresh installs
|
|
86
89
|
|
|
@@ -144,6 +147,31 @@ Worker-side storage adds: `gh:<login>` → profile link, `squad:*` membership
|
|
|
144
147
|
records, and weekly baseline snapshots (auto-expiring). Leaving your last
|
|
145
148
|
squad deletes its record.
|
|
146
149
|
|
|
150
|
+
When the opt-in public profile is enabled (`profile on` + a handle), the daemon
|
|
151
|
+
also POSTs to `<endpoint>/profile` on the same 30-minute timer. Unlike the
|
|
152
|
+
anonymous 3a report, this one carries your chosen public identity. The
|
|
153
|
+
**complete** payload (`buildProfilePayload`, enforced by the worker's
|
|
154
|
+
`validateProfile`) is:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"instanceId": "<your local UUID>",
|
|
159
|
+
"handle": "ada",
|
|
160
|
+
"displayName": "Ada L.",
|
|
161
|
+
"githubUser": "ada",
|
|
162
|
+
"tokens": 142000000,
|
|
163
|
+
"sessions": 1200,
|
|
164
|
+
"activeMs": 360000000,
|
|
165
|
+
"streak": 23,
|
|
166
|
+
"version": "0.16.2",
|
|
167
|
+
"osFamily": "linux",
|
|
168
|
+
"ts": 1716500000000
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
It sends absolute totals (not deltas) and is idempotent worker-side (a SET, not
|
|
173
|
+
an add). `profile off` stops it.
|
|
174
|
+
|
|
147
175
|
### 3d. Subscription usage — your own token, to its issuer, ON by default
|
|
148
176
|
|
|
149
177
|
**Source:** `src/usage.js`; consumed by the daemon poll, `claude-rpc usage`,
|
|
@@ -180,20 +208,62 @@ are handed to Discord as image keys; **Discord's** client fetches them to render
|
|
|
180
208
|
the card. The daemon itself doesn't download them. Swap them for your own URLs
|
|
181
209
|
in `config.json` if you prefer.
|
|
182
210
|
|
|
211
|
+
### 3f. Status webhook — opt-in, OFF by default
|
|
212
|
+
|
|
213
|
+
**Source:** `src/notify.js` (`postWebhook`), fired from the daemon's
|
|
214
|
+
`fireStatusSideEffects` (`src/daemon.js`). Dormant unless you set `webhook.url`
|
|
215
|
+
and list statuses in `webhook.on`. On a matching status transition the daemon
|
|
216
|
+
POSTs to your configured URL (a Slack/Discord channel or your own endpoint):
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{ "status": "notification", "project": "my-app", "model": "claude-opus-4-8", "justShipped": null, "ts": 1716500000000 }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`project` is the cwd-derived name — redacted to `"Claude Code"` when the
|
|
223
|
+
directory is privacy=hidden, and run through `sanitizeLabel` (strips shell /
|
|
224
|
+
PowerShell metacharacters) first; `model` is always sent. The webhook is
|
|
225
|
+
suppressed entirely while the card is paused or privacy=hidden. Turn it off by
|
|
226
|
+
removing `webhook.url`.
|
|
227
|
+
|
|
228
|
+
### 3g. Desktop dashboard auto-update — the optional Electron app only
|
|
229
|
+
|
|
230
|
+
**Source:** `dashboard/main.js` (`initAutoUpdater`, electron-updater). The npm
|
|
231
|
+
CLI package never auto-updates. The *separate* desktop dashboard app, if you
|
|
232
|
+
install it, polls GitHub Releases hourly and downloads + installs updates on
|
|
233
|
+
quit (`autoDownload` / `autoInstallOnAppQuit`) over HTTPS. The release binaries
|
|
234
|
+
are currently **unsigned**, so update integrity rests on GitHub Releases + TLS
|
|
235
|
+
rather than a code signature — a known gap tracked for provenance + published
|
|
236
|
+
checksums. Avoid it by not installing the dashboard.
|
|
237
|
+
|
|
183
238
|
## 4. Local subprocesses
|
|
184
239
|
|
|
185
|
-
|
|
240
|
+
Every binary the package can spawn, with its trigger and argument shape. All
|
|
241
|
+
arguments are static constants or values we control — none interpolate
|
|
242
|
+
untrusted or remote input into a shell:
|
|
186
243
|
|
|
187
|
-
- `reg.exe add/delete` — the Run key
|
|
244
|
+
- `reg.exe add/delete` — the Windows Run key (`src/install.js`).
|
|
245
|
+
- `wscript.exe` — runs the generated windowless startup shim (`src/install.js`).
|
|
246
|
+
- `chcp.com 65001` — set the console to UTF-8 on Windows TTYs (`src/cli.js`).
|
|
188
247
|
- `git` — read last commit subject / branch for the "just shipped" card
|
|
189
248
|
(`src/git.js`).
|
|
190
249
|
- `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
|
|
191
250
|
(`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
|
|
192
|
-
- `gh gist` — only under 3b
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`
|
|
196
|
-
|
|
251
|
+
- `gh gist` / `gh --version` — gist badge publishing, only under 3b
|
|
252
|
+
(`src/gist.js`).
|
|
253
|
+
- `npm root -g` / `npm install -g` — resolve / promote the global install during
|
|
254
|
+
`setup` (`src/install.js`, `src/cli.js`).
|
|
255
|
+
- `claude mcp add/remove` — register / unregister the MCP server on
|
|
256
|
+
`mcp install` / `mcp uninstall` (`src/install.js`).
|
|
257
|
+
- `security find-generic-password` — read Claude Code's OAuth token from the
|
|
258
|
+
macOS login keychain for usage polling (`src/usage.js`, §3d). Read-only; may
|
|
259
|
+
prompt for keychain access.
|
|
260
|
+
- `osascript` / `powershell` / `notify-send` — the opt-in desktop notification
|
|
261
|
+
(`src/notify.js`); off unless `notify.enabled`. The project label is
|
|
262
|
+
interpolated but sanitized first (`sanitizeLabel`).
|
|
263
|
+
|
|
264
|
+
No subprocess passes untrusted or remote input to a shell — arguments are
|
|
265
|
+
static or escaped. The historical `shell: true` paths (`verifyHookPipe`, and the
|
|
266
|
+
gist `gh` wrapper on Windows) use only trusted args.
|
|
197
267
|
|
|
198
268
|
## 5. What it stores locally
|
|
199
269
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync, watchFile, unlinkSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, watchFile, unlinkSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
|
|
6
6
|
// Force the console code page to UTF-8 (65001) on Windows so Unicode box
|
|
@@ -15,7 +15,7 @@ import { readState } from './state.js';
|
|
|
15
15
|
import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } 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, installMcp, uninstallMcp,
|
|
18
|
+
import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } from './install.js';
|
|
19
19
|
import { startTui } from './tui.js';
|
|
20
20
|
import { generateInsights } from './insights.js';
|
|
21
21
|
import { maybeNudge, pickTodayMilestone } from './nudge.js';
|
|
@@ -30,7 +30,7 @@ import { VERSION } from './version.js';
|
|
|
30
30
|
import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
31
31
|
import { randomUUID } from 'node:crypto';
|
|
32
32
|
import { createInterface } from 'node:readline';
|
|
33
|
-
import { basename, join } from 'node:path';
|
|
33
|
+
import { basename, join, dirname } from 'node:path';
|
|
34
34
|
|
|
35
35
|
const cmd = process.argv[2];
|
|
36
36
|
|
|
@@ -56,6 +56,29 @@ function readJson(path, fallback) {
|
|
|
56
56
|
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Persist a mutated config.json. The config dir doesn't exist until `setup`
|
|
60
|
+
// runs (npm/npx install no install script), so a bare writeFileSync threw an
|
|
61
|
+
// uncaught ENOENT for any config-mutating command run before setup
|
|
62
|
+
// (`profile set`, `community on`, `link`, …). mkdirSync first.
|
|
63
|
+
function writeUserConfig(userCfg) {
|
|
64
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
65
|
+
writeUserConfig(userCfg);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate a flag's value taken with `argv[++i]`. A value that's missing or
|
|
69
|
+
// itself looks like a flag (`--out --gist` → out='--gist') is almost always a
|
|
70
|
+
// forgotten argument, so fail loudly instead of silently doing the wrong thing.
|
|
71
|
+
// The `--flag=value` form stays the escape hatch for legit `-`-leading values.
|
|
72
|
+
function takeValue(v, flag) {
|
|
73
|
+
if (v === undefined || (typeof v === 'string' && v.startsWith('-'))) {
|
|
74
|
+
fail(`${flag} needs a value`, {
|
|
75
|
+
hint: v === undefined ? 'nothing followed it' : `got \`${v}\` — use ${flag}=<value> if the value really starts with "-"`,
|
|
76
|
+
code: EX_USER_ERROR,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return v;
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
function isAlive(pid) {
|
|
60
83
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
61
84
|
}
|
|
@@ -98,12 +121,23 @@ function stopDaemon({ quiet = false } = {}) {
|
|
|
98
121
|
}
|
|
99
122
|
|
|
100
123
|
function restartDaemon() {
|
|
101
|
-
if (stopDaemon({ quiet: true })) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
if (!stopDaemon({ quiet: true })) { startDaemon(); return; }
|
|
125
|
+
// Poll for the old daemon to release the PID file rather than guessing with a
|
|
126
|
+
// fixed sleep — under load 600ms wasn't always enough, and startDaemon would
|
|
127
|
+
// then see it "still up" and no-op, leaving NO daemon running once the old
|
|
128
|
+
// one exited. Give up after ~3s, force-kill the wedged pid, and start anyway.
|
|
129
|
+
const deadline = Date.now() + 3000;
|
|
130
|
+
const tick = () => {
|
|
131
|
+
if (!daemonPid()) { startDaemon(); return; }
|
|
132
|
+
if (Date.now() >= deadline) {
|
|
133
|
+
const pid = daemonPid();
|
|
134
|
+
if (pid) { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
|
|
135
|
+
startDaemon();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setTimeout(tick, 50);
|
|
139
|
+
};
|
|
140
|
+
setTimeout(tick, 50);
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
// ── Box drawing — auto-widens to fit longest line ────────────────────────────
|
|
@@ -795,10 +829,10 @@ function parseBadgeArgs(argv) {
|
|
|
795
829
|
const out = { metric: 'hours', range: '7d', out: '', gist: false };
|
|
796
830
|
for (let i = 0; i < argv.length; i++) {
|
|
797
831
|
const a = argv[i];
|
|
798
|
-
if (a === '--metric' || a === '-m') out.metric = argv[++i];
|
|
799
|
-
else if (a === '--range' || a === '-r') out.range = argv[++i];
|
|
800
|
-
else if (a === '--out' || a === '-o') out.out = argv[++i];
|
|
801
|
-
else if (a === '--label' || a === '-l') out.label = argv[++i];
|
|
832
|
+
if (a === '--metric' || a === '-m') out.metric = takeValue(argv[++i], '--metric');
|
|
833
|
+
else if (a === '--range' || a === '-r') out.range = takeValue(argv[++i], '--range');
|
|
834
|
+
else if (a === '--out' || a === '-o') out.out = takeValue(argv[++i], '--out');
|
|
835
|
+
else if (a === '--label' || a === '-l') out.label = takeValue(argv[++i], '--label');
|
|
802
836
|
else if (a === '--gist') out.gist = true;
|
|
803
837
|
}
|
|
804
838
|
return out;
|
|
@@ -851,7 +885,7 @@ async function publishBadgeToGist(svg, opts) {
|
|
|
851
885
|
filename,
|
|
852
886
|
};
|
|
853
887
|
if (stored.public !== undefined) userCfg.gist.public = stored.public;
|
|
854
|
-
|
|
888
|
+
writeUserConfig(userCfg);
|
|
855
889
|
const wasUpdate = !!stored.id;
|
|
856
890
|
console.log('');
|
|
857
891
|
console.log(` ${c.green}✓${c.reset} ${wasUpdate ? 'updated' : 'created'} gist ${c.cyan}${result.id}${c.reset}`);
|
|
@@ -876,8 +910,8 @@ function parseCardArgs(argv) {
|
|
|
876
910
|
const out = { range: 'year', out: '' };
|
|
877
911
|
for (let i = 0; i < argv.length; i++) {
|
|
878
912
|
const a = argv[i];
|
|
879
|
-
if (a === '--range' || a === '-r') out.range = argv[++i];
|
|
880
|
-
else if (a === '--out' || a === '-o') out.out = argv[++i];
|
|
913
|
+
if (a === '--range' || a === '-r') out.range = takeValue(argv[++i], '--range');
|
|
914
|
+
else if (a === '--out' || a === '-o') out.out = takeValue(argv[++i], '--out');
|
|
881
915
|
}
|
|
882
916
|
return out;
|
|
883
917
|
}
|
|
@@ -906,8 +940,8 @@ function parseGithubStatArgs(argv) {
|
|
|
906
940
|
const out = { out: '', gist: false, handle: '' };
|
|
907
941
|
for (let i = 0; i < argv.length; i++) {
|
|
908
942
|
const a = argv[i];
|
|
909
|
-
if (a === '--out' || a === '-o') out.out = argv[++i];
|
|
910
|
-
else if (a === '--handle' || a === '-u') out.handle = argv[++i];
|
|
943
|
+
if (a === '--out' || a === '-o') out.out = takeValue(argv[++i], '--out');
|
|
944
|
+
else if (a === '--handle' || a === '-u') out.handle = takeValue(argv[++i], '--handle');
|
|
911
945
|
else if (a === '--gist') out.gist = true;
|
|
912
946
|
}
|
|
913
947
|
return out;
|
|
@@ -950,7 +984,7 @@ function liveVars() {
|
|
|
950
984
|
function doStatusline(argv) {
|
|
951
985
|
let tpl = '{statusVerbose} · {project} · {modelPretty}{tokensLabelPad}';
|
|
952
986
|
for (let i = 0; i < argv.length; i++) {
|
|
953
|
-
if (argv[i] === '--template' || argv[i] === '-t') tpl = argv[++i]
|
|
987
|
+
if (argv[i] === '--template' || argv[i] === '-t') tpl = takeValue(argv[++i], '--template');
|
|
954
988
|
}
|
|
955
989
|
const { vars } = liveVars();
|
|
956
990
|
vars.tokensLabelPad = vars.tokensLabel ? ` · ${vars.tokensLabel}` : '';
|
|
@@ -961,7 +995,7 @@ function doStatusline(argv) {
|
|
|
961
995
|
async function doCalendar(argv) {
|
|
962
996
|
const opts = { out: '', gist: false };
|
|
963
997
|
for (let i = 0; i < argv.length; i++) {
|
|
964
|
-
if (argv[i] === '--out' || argv[i] === '-o') opts.out = argv[++i];
|
|
998
|
+
if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
|
|
965
999
|
else if (argv[i] === '--gist') opts.gist = true;
|
|
966
1000
|
}
|
|
967
1001
|
const aggregate = readAggregate();
|
|
@@ -979,7 +1013,7 @@ async function doCalendar(argv) {
|
|
|
979
1013
|
async function doSessionCard(argv) {
|
|
980
1014
|
const opts = { out: '' };
|
|
981
1015
|
for (let i = 0; i < argv.length; i++) {
|
|
982
|
-
if (argv[i] === '--out' || argv[i] === '-o') opts.out = argv[++i];
|
|
1016
|
+
if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
|
|
983
1017
|
}
|
|
984
1018
|
const { vars } = liveVars();
|
|
985
1019
|
const { renderSessionCard } = await import('./session-card.js');
|
|
@@ -1170,16 +1204,27 @@ function squadAuth() {
|
|
|
1170
1204
|
code: EX_BAD_STATE,
|
|
1171
1205
|
});
|
|
1172
1206
|
}
|
|
1207
|
+
const netFail = (e) => fail(`couldn't reach the squads service: ${e.message}`, {
|
|
1208
|
+
hint: 'check your connection and retry — this never blocks Claude Code',
|
|
1209
|
+
code: EX_SYS_ERROR,
|
|
1210
|
+
});
|
|
1173
1211
|
const post = async (path, body) => {
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1212
|
+
let res;
|
|
1213
|
+
try {
|
|
1214
|
+
res = await fetch(endpoint + path, {
|
|
1215
|
+
method: 'POST',
|
|
1216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1217
|
+
body: JSON.stringify({ instanceId, ...body }),
|
|
1218
|
+
signal: AbortSignal.timeout(10_000),
|
|
1219
|
+
});
|
|
1220
|
+
} catch (e) { return netFail(e); }
|
|
1179
1221
|
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
1180
1222
|
};
|
|
1181
1223
|
const get = async (path) => {
|
|
1182
|
-
|
|
1224
|
+
let res;
|
|
1225
|
+
try {
|
|
1226
|
+
res = await fetch(endpoint + path, { signal: AbortSignal.timeout(10_000) });
|
|
1227
|
+
} catch (e) { return netFail(e); }
|
|
1183
1228
|
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
1184
1229
|
};
|
|
1185
1230
|
return { cfg, endpoint, instanceId, post, get };
|
|
@@ -1334,7 +1379,7 @@ async function doLink(argv) {
|
|
|
1334
1379
|
// Mirror the verified identity locally so `profile status` agrees.
|
|
1335
1380
|
const userCfg = readJson(CONFIG_PATH, {});
|
|
1336
1381
|
userCfg.profile = { ...(userCfg.profile || {}), githubUser: r.json.githubUser, verified: true };
|
|
1337
|
-
|
|
1382
|
+
writeUserConfig(userCfg);
|
|
1338
1383
|
console.log(` ${c.green}✓${c.reset} linked as ${c.cyan}@${r.json.githubUser}${c.reset} — profile verified, squads unlocked in the browser`);
|
|
1339
1384
|
if (r.json.merged) {
|
|
1340
1385
|
// This machine joined an existing identity: its stats now roll up under the
|
|
@@ -1422,7 +1467,7 @@ async function communityOn() {
|
|
|
1422
1467
|
instanceId: userCfg.community?.instanceId || community.instanceId || randomUUID(),
|
|
1423
1468
|
};
|
|
1424
1469
|
userCfg.community = next;
|
|
1425
|
-
|
|
1470
|
+
writeUserConfig(userCfg);
|
|
1426
1471
|
console.log('');
|
|
1427
1472
|
console.log(` ${c.green}✓${c.reset} community totals enabled`);
|
|
1428
1473
|
console.log(` ${c.dim}id: …${next.instanceId.slice(-8)}${c.reset}`);
|
|
@@ -1437,7 +1482,7 @@ function communityOff() {
|
|
|
1437
1482
|
return;
|
|
1438
1483
|
}
|
|
1439
1484
|
userCfg.community = { ...userCfg.community, enabled: false };
|
|
1440
|
-
|
|
1485
|
+
writeUserConfig(userCfg);
|
|
1441
1486
|
console.log(` ${c.green}✓${c.reset} community totals disabled ${c.dim}(instanceId retained for re-enable continuity)${c.reset}`);
|
|
1442
1487
|
}
|
|
1443
1488
|
|
|
@@ -1465,7 +1510,10 @@ async function communityReport() {
|
|
|
1465
1510
|
// daemon flush runs with profile.enabled + a valid handle (Phase 2).
|
|
1466
1511
|
function readFlag(argv, name) {
|
|
1467
1512
|
const i = argv.indexOf(`--${name}`);
|
|
1468
|
-
|
|
1513
|
+
// Don't consume the next token as the value when it's itself a flag
|
|
1514
|
+
// (`profile set --handle --name x` must not save handle '--name'); fall
|
|
1515
|
+
// through to the --name=value form, which stays the escape hatch.
|
|
1516
|
+
if (i !== -1 && i + 1 < argv.length && !argv[i + 1].startsWith('-')) return argv[i + 1];
|
|
1469
1517
|
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
|
1470
1518
|
return eq ? eq.slice(name.length + 3) : undefined;
|
|
1471
1519
|
}
|
|
@@ -1572,7 +1620,7 @@ function profileSet(argv) {
|
|
|
1572
1620
|
}
|
|
1573
1621
|
|
|
1574
1622
|
userCfg.profile = next;
|
|
1575
|
-
|
|
1623
|
+
writeUserConfig(userCfg);
|
|
1576
1624
|
// One-line confirmation + a pointer at the next step. The full dashboard
|
|
1577
1625
|
// stays behind `claude-rpc profile` — mutations shouldn't re-render it.
|
|
1578
1626
|
const saved = [];
|
|
@@ -1604,7 +1652,7 @@ function profileEnable(on) {
|
|
|
1604
1652
|
}
|
|
1605
1653
|
}
|
|
1606
1654
|
userCfg.profile = next;
|
|
1607
|
-
|
|
1655
|
+
writeUserConfig(userCfg);
|
|
1608
1656
|
if (on) {
|
|
1609
1657
|
console.log(` ${c.green}✓${c.reset} publishing enabled ${c.dim}— board syncs on the next flush, or now: ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}`);
|
|
1610
1658
|
profileNextStep();
|
|
@@ -1699,7 +1747,7 @@ async function profileVerify() {
|
|
|
1699
1747
|
// profile checklist and future publishes match what got verified.
|
|
1700
1748
|
const userCfg = readJson(CONFIG_PATH, {});
|
|
1701
1749
|
userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
|
|
1702
|
-
|
|
1750
|
+
writeUserConfig(userCfg);
|
|
1703
1751
|
console.log(` ${c.green}✓${c.reset} verified as ${c.cyan}@${who}${c.reset} — you'll show the ✓ on the board`);
|
|
1704
1752
|
if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
|
|
1705
1753
|
console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account)${c.reset}`);
|
|
@@ -1845,7 +1893,7 @@ function help() {
|
|
|
1845
1893
|
['start', 'Start the Discord RPC daemon (detached)'],
|
|
1846
1894
|
['stop', 'Stop the daemon'],
|
|
1847
1895
|
['restart', 'Stop then start the daemon'],
|
|
1848
|
-
['status', '
|
|
1896
|
+
['status', 'Interactive stats TUI; --dump (or piped) prints static text'],
|
|
1849
1897
|
['today', 'Focus view: today\'s stats + 24h activity histogram'],
|
|
1850
1898
|
['week', 'Focus view: this week, daily breakdown'],
|
|
1851
1899
|
['usage', 'Subscription limits — session + weekly % (what /usage shows)'],
|
|
@@ -1862,6 +1910,7 @@ function help() {
|
|
|
1862
1910
|
['calendar', 'Year activity heatmap SVG (--out --gist)'],
|
|
1863
1911
|
['session-card', 'Recap card for the current session (--out)'],
|
|
1864
1912
|
['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
|
|
1913
|
+
['mcp uninstall', 'Remove the stats MCP server from Claude Code'],
|
|
1865
1914
|
['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
|
|
1866
1915
|
['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
|
|
1867
1916
|
['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
|
|
@@ -1903,6 +1952,14 @@ function help() {
|
|
|
1903
1952
|
// Dev mode keeps the original `help` fallback so behavior is unchanged.
|
|
1904
1953
|
const packagedDefault = IS_PACKAGED && !cmd;
|
|
1905
1954
|
|
|
1955
|
+
// Floor for any rejection that escapes a command handler (a bare `await fetch`
|
|
1956
|
+
// going offline, etc.): the rejected IIFE promise lands here instead of
|
|
1957
|
+
// printing a raw stack + an unhandled-rejection warning, keeping the documented
|
|
1958
|
+
// 0/1/2/3 exit-code contract intact.
|
|
1959
|
+
process.on('unhandledRejection', (e) => {
|
|
1960
|
+
fail(`unexpected error: ${e?.message || e}`, { code: EX_SYS_ERROR });
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1906
1963
|
// Wrapped in an async IIFE so the same source compiles cleanly under both
|
|
1907
1964
|
// ESM (dev) and CommonJS (esbuild → SEA bundle) — CJS doesn't allow
|
|
1908
1965
|
// top-level await.
|
package/src/community.js
CHANGED
|
@@ -82,11 +82,11 @@ export function buildPayload(aggregate, cursor, { instanceId, now = Date.now() }
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// ── leaderboard profile flush ──────────────────────────────────────────
|
|
85
|
-
// Publishes the opt-in public profile (identity +
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
85
|
+
// Publishes the opt-in public profile (identity + lifetime totals) to the
|
|
86
|
+
// worker's /profile endpoint. Reuses the anonymous community instanceId as the
|
|
87
|
+
// profile's row key. Unlike flushCommunity this is cursor-free and idempotent —
|
|
88
|
+
// it POSTs absolute totals, so the worker SETs (never accumulates) and a resend
|
|
89
|
+
// is a no-op. Same safety guarantees: never throws, sends only documented fields.
|
|
90
90
|
|
|
91
91
|
function totalTokens(aggregate) {
|
|
92
92
|
return (aggregate?.inputTokens || 0)
|
|
@@ -140,6 +140,7 @@ export async function flushProfile(cfg, {
|
|
|
140
140
|
method: 'POST',
|
|
141
141
|
headers: { 'Content-Type': 'application/json' },
|
|
142
142
|
body: JSON.stringify(payload),
|
|
143
|
+
signal: AbortSignal.timeout(10_000), // bare fetch never times out; a hung peer would wedge the 30-min flush loop
|
|
143
144
|
});
|
|
144
145
|
} catch (e) {
|
|
145
146
|
return { ok: false, reason: 'network', error: e.message };
|
|
@@ -182,6 +183,7 @@ export async function flushCommunity(cfg, {
|
|
|
182
183
|
method: 'POST',
|
|
183
184
|
headers: { 'Content-Type': 'application/json' },
|
|
184
185
|
body: JSON.stringify(payload),
|
|
186
|
+
signal: AbortSignal.timeout(10_000), // bare fetch never times out; a hung peer would wedge the 30-min flush loop
|
|
185
187
|
});
|
|
186
188
|
} catch (e) {
|
|
187
189
|
return { ok: false, reason: 'network', error: e.message };
|