claude-rpc 0.13.7 → 0.15.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  # claude-rpc
9
9
 
10
- **Discord Rich Presence for [Claude Code](https://claude.com/claude-code).**
10
+ **Discord Rich Presence (RPC) for [Claude Code](https://claude.com/claude-code).**
11
11
  Your live model, project, current tool, tokens, and lifetime stats — in your Discord profile. Driven by the hooks Claude Code already fires. Zero polling between sessions.
12
12
 
13
13
  **→ [claude-rpc.vercel.app](https://claude-rpc.vercel.app)** — what it looks like, in one page.
@@ -38,9 +38,11 @@ A small Node daemon that takes the lifecycle events Claude Code already fires an
38
38
  **macOS / Linux / any Node 18+** — one command:
39
39
 
40
40
  ```sh
41
- npx claude-rpc setup
41
+ npx claude-rpc@latest setup
42
42
  ```
43
43
 
44
+ (The `@latest` matters — bare `npx claude-rpc` will happily reuse a stale cached copy.)
45
+
44
46
  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
47
 
46
48
  **Prefer a one-liner that figures it out for you?**
@@ -312,6 +314,8 @@ npm run build:exe # SEA single-file binary for the current OS
312
314
 
313
315
  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.
314
316
 
317
+ Where the project is headed (and what it will deliberately never do) lives in [`ROADMAP.md`](ROADMAP.md).
318
+
315
319
  ## license
316
320
 
317
321
  [MIT](LICENSE) © Archer Simmons
package/SECURITY.md CHANGED
@@ -124,7 +124,25 @@ Publishes a badge SVG to *your own* GitHub gist via the `gh` CLI or a
124
124
  `gist.github.com`. Never runs unattended, never on install, never from the
125
125
  daemon.
126
126
 
127
- ### 3c. Presence GIF assetsDiscord-side only
127
+ ### 3c. Squads & web login opt-in, profile-derived
128
+
129
+ **Source:** `worker/src/index.js` (+ `worker/src/auth.js`), `src/cli.js`
130
+ (`squad` command), `site/squad.html`. Squads are private mini-leaderboards
131
+ that regroup stats you **already publish** via the opt-in profile — joining
132
+ one sends nothing new from your machine; the worker derives weekly standings
133
+ from the same clamped lifetime totals the public board uses.
134
+
135
+ "Log in with GitHub" on the website is plain OAuth (no scopes — public
136
+ identity only; we never see email or repos). Sessions are stateless signed
137
+ tokens holding **only your public GitHub login**, stored in your browser's
138
+ localStorage and expiring after 7 days. The browser never receives an
139
+ `instanceId` — that remains the CLI's local credential; the worker resolves
140
+ GitHub login → profile via the link your own gist verification created.
141
+ Worker-side storage adds: `gh:<login>` → profile link, `squad:*` membership
142
+ records, and weekly baseline snapshots (auto-expiring). Leaving your last
143
+ squad deletes its record.
144
+
145
+ ### 3d. Presence GIF assets — Discord-side only
128
146
 
129
147
  `default-config.js` references `https://cdn.qualit.ly/clawd-*.gif`. These URLs
130
148
  are handed to Discord as image keys; **Discord's** client fetches them to render
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.13.7",
3
+ "version": "0.15.0",
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",
@@ -32,7 +32,7 @@
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
34
  "test": "node --test test/*.test.js",
35
- "lint": "eslint src test",
35
+ "lint": "eslint src test vscode-extension",
36
36
  "format": "prettier --write \"src/**/*.js\" \"test/**/*.js\"",
37
37
  "format:check": "prettier --check \"src/**/*.js\" \"test/**/*.js\"",
38
38
  "typecheck": "tsc -p jsconfig.json"
package/src/cli.js CHANGED
@@ -12,7 +12,7 @@ if (process.platform === 'win32' && process.stdout.isTTY) {
12
12
  }
13
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
- import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses } from './format.js';
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
18
  import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand } from './install.js';
@@ -22,10 +22,11 @@ import { maybeNudge } from './nudge.js';
22
22
  import { badgeSvg } from './badge.js';
23
23
  import { fmtCost } from './pricing.js';
24
24
  import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
25
+ import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
25
26
  import { loadConfig, hasUserConfig } from './config.js';
26
27
  import * as lb from './leaderboard.js';
27
28
  import { VERSION } from './version.js';
28
- import { fail, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
29
+ import { fail, tailLines, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
29
30
  import { randomUUID } from 'node:crypto';
30
31
  import { createInterface } from 'node:readline';
31
32
  import { basename } from 'node:path';
@@ -110,7 +111,7 @@ function box(title, lines, minWidth = 64) {
110
111
  const termWidth = process.stdout.columns || 100;
111
112
  const maxAllowed = Math.max(40, termWidth - 2);
112
113
  const width = Math.min(maxAllowed, Math.max(minWidth, longest + 4, title.length + 8));
113
- const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width - 4 - title.length))}┐${c.reset}`;
114
+ const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width - 5 - title.length))}┐${c.reset}`;
114
115
  const bottom = `${c.gray}└${'─'.repeat(width - 2)}┘${c.reset}`;
115
116
  console.log(top);
116
117
  for (const raw of lines) {
@@ -932,6 +933,230 @@ function doPrivacy() {
932
933
  console.log('');
933
934
  }
934
935
 
936
+ // ── Pause / resume ───────────────────────────────────────────────────────
937
+ //
938
+ // `claude-rpc pause [30m|2h|1h30m|90]` → snooze the Discord card globally
939
+ // (default 1h). `claude-rpc resume` (or `pause off`) lifts it early; expiry
940
+ // lifts it automatically. Privacy controls are per-cwd — this is the
941
+ // "I'm screen-sharing" switch that hides everything regardless of project.
942
+
943
+ function fmtClock(ts) {
944
+ const d = new Date(ts);
945
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
946
+ }
947
+
948
+ function doPause(argv) {
949
+ const arg = (argv[0] || '').toLowerCase();
950
+ if (arg === 'off' || arg === 'resume') return doResume();
951
+ if (arg === 'status') {
952
+ const until = pauseUntil();
953
+ if (until) console.log(`${c.yellow}●${c.reset} paused until ${c.cyan}${fmtClock(until)}${c.reset}`);
954
+ else console.log(`${c.green}○${c.reset} not paused`);
955
+ return;
956
+ }
957
+ const ms = parseDuration(argv[0]);
958
+ if (ms === null) {
959
+ fail(`could not parse duration: ${argv[0]}`,
960
+ { hint: 'use 30m, 2h, 1h30m, or a bare number of minutes (default: 1h)', code: EX_USER_ERROR });
961
+ }
962
+ const until = setPause(ms);
963
+ console.log(`${c.green}✓${c.reset} presence paused until ${c.cyan}${fmtClock(until)}${c.reset}`);
964
+ console.log(`${c.dim} the daemon clears the card within a few seconds. Resume early with ${c.reset}${c.cyan}claude-rpc resume${c.reset}`);
965
+ }
966
+
967
+ function doResume() {
968
+ const was = pauseUntil();
969
+ clearPause();
970
+ if (was) {
971
+ console.log(`${c.green}✓${c.reset} presence resumed ${c.dim}(was paused until ${fmtClock(was)})${c.reset}`);
972
+ } else {
973
+ console.log(`${c.dim}presence wasn't paused.${c.reset}`);
974
+ }
975
+ }
976
+
977
+ // ── Export ───────────────────────────────────────────────────────────────
978
+ //
979
+ // `claude-rpc export [--csv] [--out <file>]` — the full aggregate as JSON, or
980
+ // the per-day breakdown as CSV (same shape the dashboard's /api/export routes
981
+ // serve, without needing the server up). Writes to stdout unless --out, so it
982
+ // pipes cleanly into jq / a spreadsheet import.
983
+
984
+ async function doExport(argv) {
985
+ const csv = argv.includes('--csv');
986
+ let out = '';
987
+ const i = argv.findIndex((a) => a === '--out' || a === '-o');
988
+ if (i !== -1) out = argv[i + 1] || '';
989
+ const aggregate = readAggregate();
990
+ if (!aggregate) {
991
+ fail('no aggregate yet — nothing to export', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
992
+ }
993
+ let payload;
994
+ if (csv) {
995
+ const { aggregateToCsv } = await import('./server/api.js');
996
+ payload = aggregateToCsv(aggregate);
997
+ } else {
998
+ payload = JSON.stringify(aggregate, null, 2) + '\n';
999
+ }
1000
+ if (out) {
1001
+ writeFileSync(out, payload);
1002
+ console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${out}${c.reset} (${payload.length} bytes, ${csv ? 'CSV' : 'JSON'})`);
1003
+ } else {
1004
+ process.stdout.write(payload);
1005
+ }
1006
+ }
1007
+
1008
+ // ── Squads — private mini-leaderboards (terminal parity for the web UI) ───
1009
+ //
1010
+ // `claude-rpc squad create <name>` → invite code + link
1011
+ // `claude-rpc squad join <code>`
1012
+ // `claude-rpc squad` / `squad status` → standings for every squad you're in
1013
+ // `claude-rpc squad leave [id]`
1014
+ // The web flow (claude-rpc.vercel.app + GitHub login) drives the same worker
1015
+ // endpoints; the CLI authenticates with the community instanceId it already has.
1016
+
1017
+ function squadAuth() {
1018
+ const cfg = loadConfig();
1019
+ const endpoint = (cfg.community?.endpoint || '').replace(/\/+$/, '');
1020
+ const instanceId = cfg.community?.instanceId;
1021
+ if (!endpoint) fail('no community endpoint configured', { code: EX_BAD_STATE });
1022
+ if (!instanceId) {
1023
+ fail('squads need an identity', {
1024
+ hint: 'run `claude-rpc profile set --handle <name> && claude-rpc profile on` first',
1025
+ code: EX_BAD_STATE,
1026
+ });
1027
+ }
1028
+ const post = async (path, body) => {
1029
+ const res = await fetch(endpoint + path, {
1030
+ method: 'POST',
1031
+ headers: { 'Content-Type': 'application/json' },
1032
+ body: JSON.stringify({ instanceId, ...body }),
1033
+ });
1034
+ return { status: res.status, json: await res.json().catch(() => ({})) };
1035
+ };
1036
+ const get = async (path) => {
1037
+ const res = await fetch(endpoint + path);
1038
+ return { status: res.status, json: await res.json().catch(() => ({})) };
1039
+ };
1040
+ return { cfg, endpoint, instanceId, post, get };
1041
+ }
1042
+
1043
+ function squadPageUrl(id) { return `https://claude-rpc.vercel.app/squad/${id}`; }
1044
+
1045
+ function printSquadInvite(squad) {
1046
+ console.log('');
1047
+ console.log(` ${c.green}✓${c.reset} squad ${c.bold}${squad.name}${c.reset}`);
1048
+ console.log(` ${c.dim}invite code:${c.reset} ${c.cyan}${squad.code}${c.reset}`);
1049
+ console.log(` ${c.dim}standings: ${c.reset} ${c.cyan}${squadPageUrl(squad.id)}${c.reset}`);
1050
+ console.log('');
1051
+ console.log(` ${c.dim}send your crew this:${c.reset}`);
1052
+ console.log(` join my Claude Code squad "${squad.name}" — npx claude-rpc@latest setup, then:`);
1053
+ console.log(` ${c.cyan}claude-rpc squad join ${squad.code}${c.reset} ${c.dim}(or join in the browser: ${squadPageUrl(squad.id)})${c.reset}`);
1054
+ console.log('');
1055
+ }
1056
+
1057
+ async function squadStatus({ post, get }) {
1058
+ const mine = await post('/squads/mine', {});
1059
+ if (!mine.json?.squads) return fail(`could not load squads: ${mine.json?.error || mine.status}`, { code: EX_SYS_ERROR });
1060
+ if (!mine.json.squads.length) {
1061
+ console.log('');
1062
+ console.log(` ${c.dim}no squads yet — start one:${c.reset} ${c.cyan}claude-rpc squad create "the night shift"${c.reset}`);
1063
+ console.log('');
1064
+ return;
1065
+ }
1066
+ for (const s of mine.json.squads) {
1067
+ const r = await get(`/squad?id=${encodeURIComponent(s.id)}`);
1068
+ const standings = r.json?.standings || [];
1069
+ const lines = standings.map((row) => {
1070
+ const who = `${row.displayName || '@' + row.handle}${row.verified ? ' ✓' : ''}${row.owner ? ` ${c.dim}(owner)${c.reset}` : ''}`;
1071
+ return `${c.bold}#${row.rank}${c.reset} ${who.padEnd(28)} ${c.cyan}${fmtNum(row.weekTokens)}${c.reset} ${c.dim}this week${c.reset} · ${fmtNum(row.tokens)} ${c.dim}lifetime${c.reset}`;
1072
+ });
1073
+ lines.push('');
1074
+ lines.push(`${c.dim}week ${r.json?.squad?.week || ''} · invite ${c.reset}${c.cyan}${s.code}${c.reset}${c.dim} · ${squadPageUrl(s.id)}${c.reset}`);
1075
+ box(`${s.name} (${s.members})`, lines, 70);
1076
+ console.log('');
1077
+ }
1078
+ }
1079
+
1080
+ async function doSquadCmd(argv) {
1081
+ const sub = (argv[0] || 'status').toLowerCase();
1082
+ const ctx = squadAuth();
1083
+ if (sub === 'status' || sub === '') return squadStatus(ctx);
1084
+ if (sub === 'create') {
1085
+ const name = argv.slice(1).join(' ').trim();
1086
+ if (!name) return fail('usage: claude-rpc squad create <name>', { code: EX_USER_ERROR });
1087
+ const r = await ctx.post('/squad/create', { name });
1088
+ if (r.status !== 200) return fail(`create failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1089
+ return printSquadInvite(r.json.squad);
1090
+ }
1091
+ if (sub === 'join') {
1092
+ const code = (argv[1] || '').trim();
1093
+ if (!code) return fail('usage: claude-rpc squad join SQ-XXXXXX', { code: EX_USER_ERROR });
1094
+ const r = await ctx.post('/squad/join', { code });
1095
+ if (r.status !== 200) return fail(`join failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1096
+ const s = r.json.squad;
1097
+ console.log(`${c.green}✓${c.reset} ${s.alreadyMember ? 'already in' : 'joined'} ${c.bold}${s.name}${c.reset} (${s.members} member${s.members === 1 ? '' : 's'})`);
1098
+ console.log(` ${c.dim}standings: ${squadPageUrl(s.id)} · claude-rpc squad${c.reset}`);
1099
+ return;
1100
+ }
1101
+ if (sub === 'leave') {
1102
+ const mine = await ctx.post('/squads/mine', {});
1103
+ const squads = mine.json?.squads || [];
1104
+ if (!squads.length) return fail('you are not in any squads', { code: EX_BAD_STATE });
1105
+ let target = null;
1106
+ const wanted = (argv[1] || '').toLowerCase();
1107
+ if (wanted) target = squads.find((s) => s.id === wanted || s.name.toLowerCase() === wanted);
1108
+ else if (squads.length === 1) target = squads[0];
1109
+ if (!target) {
1110
+ return fail(squads.length > 1 && !wanted ? 'you are in several squads — name one' : `no squad matching "${argv[1]}"`, {
1111
+ hint: `claude-rpc squad leave <id|name> — yours: ${squads.map((s) => `${s.name} (${s.id})`).join(', ')}`,
1112
+ code: EX_USER_ERROR,
1113
+ });
1114
+ }
1115
+ const r = await ctx.post('/squad/leave', { squadId: target.id });
1116
+ if (r.status !== 200) return fail(`leave failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1117
+ console.log(`${c.green}✓${c.reset} left ${c.bold}${target.name}${c.reset}${r.json.dissolved ? ` ${c.dim}(last member — squad dissolved)${c.reset}` : ''}`);
1118
+ return;
1119
+ }
1120
+ fail(`unknown squad subcommand: ${sub}`, {
1121
+ hint: 'try: squad [status|create <name>|join <code>|leave [id]]',
1122
+ code: EX_USER_ERROR,
1123
+ });
1124
+ }
1125
+
1126
+ // ── Link (CLI ↔ web pairing) ─────────────────────────────────────────────
1127
+ //
1128
+ // `claude-rpc link <code>` — the code comes from claude-rpc.vercel.app/squads
1129
+ // while logged in with GitHub. Claims it against this install's instanceId,
1130
+ // which verifies the profile (✓) and unlocks managing squads from the
1131
+ // browser. Replaces the gist dance for anyone who uses the website.
1132
+
1133
+ async function doLink(argv) {
1134
+ const code = (argv[0] || '').trim();
1135
+ if (!code) {
1136
+ fail('usage: claude-rpc link <code>', {
1137
+ hint: 'log in at https://claude-rpc.vercel.app/squads — it shows you the code',
1138
+ code: EX_USER_ERROR,
1139
+ });
1140
+ }
1141
+ const ctx = squadAuth();
1142
+ // Make sure the profile row exists server-side before claiming — same
1143
+ // pre-publish profileVerify does, so link works on a fresh `profile on`.
1144
+ if (lb.profileIsPublishable(ctx.cfg.profile || {})) {
1145
+ const { flushProfile } = await import('./community.js');
1146
+ await flushProfile(ctx.cfg);
1147
+ }
1148
+ const r = await ctx.post('/pair/claim', { code });
1149
+ if (r.status !== 200) {
1150
+ return fail(`link failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1151
+ }
1152
+ // Mirror the verified identity locally so `profile status` agrees.
1153
+ const userCfg = readJson(CONFIG_PATH, {});
1154
+ userCfg.profile = { ...(userCfg.profile || {}), githubUser: r.json.githubUser, verified: true };
1155
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1156
+ console.log(`${c.green}✓${c.reset} linked as ${c.cyan}@${r.json.githubUser}${c.reset} — profile verified, squads unlocked in the browser.`);
1157
+ console.log(` ${c.dim}head back to https://claude-rpc.vercel.app/squads — it picks the link up automatically.${c.reset}`);
1158
+ }
1159
+
935
1160
  // ── Community totals ─────────────────────────────────────────────────────
936
1161
  //
937
1162
  // `claude-rpc community` → show current state + endpoint
@@ -1060,16 +1285,46 @@ function readFlag(argv, name) {
1060
1285
 
1061
1286
  function profileStatus() {
1062
1287
  const p = (loadConfig().profile) || {};
1288
+ const handleOk = lb.isValidHandle(p.handle);
1289
+ const boardUrl = handleOk ? `https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}` : '';
1290
+
1063
1291
  console.log('');
1064
- console.log(` ${c.bold}claude-rpc profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
1292
+ console.log(` ${c.bold}${c.magenta}◆ profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
1065
1293
  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}`);
1294
+
1295
+ const githubLine = p.githubUser
1296
+ ? `${p.githubUser}${p.verified ? ` ${c.green}✓ verified${c.reset}` : ` ${c.dim}(unverified)${c.reset}`}`
1297
+ : `${c.dim}—${c.reset}`;
1298
+ box('profile', [
1299
+ pair('state', p.enabled ? `${c.green}● publishing${c.reset}` : `${c.dim}○ off${c.reset}`, ''),
1300
+ pair('handle', handleOk ? `${c.cyan}${p.handle}${c.reset}` : `${c.dim}(unset)${c.reset}`, ''),
1301
+ pair('name', p.displayName || `${c.dim}—${c.reset}`, ''),
1302
+ pair('github', githubLine, ''),
1303
+ ...(p.enabled && handleOk ? [pair('board', boardUrl, c.cyan)] : []),
1304
+ ]);
1070
1305
  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`);
1306
+
1307
+ // Setup checklist same shape every time, so the user always sees where
1308
+ // they are and the exact next command. This is the screen the daemon's
1309
+ // breadcrumbs point back to.
1310
+ const steps = [
1311
+ { done: handleOk, label: 'set a handle', cmd: 'claude-rpc profile set --handle <name>', note: p.handle },
1312
+ { done: !!p.enabled, label: 'enable publishing', cmd: 'claude-rpc profile on', note: 'daemon republishes automatically' },
1313
+ { done: !!p.verified, label: 'verify on GitHub', cmd: 'claude-rpc profile verify', note: p.githubUser ? `@${p.githubUser}` : '' },
1314
+ ];
1315
+ if (steps.every((s) => s.done)) {
1316
+ console.log(` ${c.green}✓${c.reset} all set — you're live at ${c.cyan}${boardUrl}${c.reset}`);
1317
+ } else {
1318
+ const nextIdx = steps.findIndex((s) => !s.done);
1319
+ box('next steps', steps.map((s, i) => {
1320
+ const mark = s.done ? `${c.green}✓${c.reset}` : (i === nextIdx ? `${c.yellow}○${c.reset}` : `${c.dim}○${c.reset}`);
1321
+ const label = s.done ? `${c.dim}${s.label}${c.reset}` : `${c.bold}${s.label}${c.reset}`;
1322
+ const tail = s.done
1323
+ ? `${c.dim}${s.note || 'done'}${c.reset}`
1324
+ : `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
1325
+ return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
1326
+ }));
1327
+ }
1073
1328
  console.log('');
1074
1329
  }
1075
1330
 
@@ -1094,6 +1349,9 @@ function profileSet(argv) {
1094
1349
  if (!u) return fail(`invalid GitHub username: ${rawGh}`, { code: EX_USER_ERROR });
1095
1350
  next.githubUser = u;
1096
1351
  }
1352
+ // The ✓ belongs to the account that was verified — switching accounts
1353
+ // means re-verifying.
1354
+ if (next.githubUser !== (userCfg.profile || {}).githubUser) delete next.verified;
1097
1355
  }
1098
1356
 
1099
1357
  userCfg.profile = next;
@@ -1127,7 +1385,7 @@ function profileEnable(on) {
1127
1385
  console.log(`${c.green}✓${c.reset} leaderboard publishing ${on ? 'enabled' : 'disabled'}`);
1128
1386
  if (on) {
1129
1387
  console.log(` ${c.dim}publish now with ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}${c.dim} (or wait for the next daemon flush).${c.reset}`);
1130
- console.log(` ${c.dim}earn the ✓ with ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim}.${c.reset}`);
1388
+ profileStatus();
1131
1389
  }
1132
1390
  }
1133
1391
 
@@ -1158,10 +1416,12 @@ async function profileVerify() {
1158
1416
  const cfg = loadConfig();
1159
1417
  const profile = cfg.profile || {};
1160
1418
  const community = cfg.community || {};
1419
+ // No --github required up front: the worker treats it as a hint only and
1420
+ // takes the authoritative identity from whoever owns the proof gist —
1421
+ // and publishing that gist already requires gh auth, so the account is
1422
+ // known by the time it matters.
1161
1423
  if (!profile.githubUser) {
1162
- return fail('set a GitHub username first', {
1163
- hint: 'claude-rpc profile set --github <user>', code: EX_BAD_STATE,
1164
- });
1424
+ console.log(`${c.dim}no --github set your verified identity will be the account that owns the proof gist.${c.reset}`);
1165
1425
  }
1166
1426
  if (!community.instanceId) {
1167
1427
  return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
@@ -1186,7 +1446,7 @@ async function profileVerify() {
1186
1446
  await flushProfile(cfg);
1187
1447
  }
1188
1448
  console.log(`${c.dim}requesting a verification token…${c.reset}`);
1189
- const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser });
1449
+ const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser || null });
1190
1450
  if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
1191
1451
  const token = start.json.token;
1192
1452
 
@@ -1206,13 +1466,11 @@ async function profileVerify() {
1206
1466
  const check = await post('/verify/check', { instanceId: community.instanceId, gistId: gist.id });
1207
1467
  if (check.json?.verified) {
1208
1468
  const who = check.json.githubUser || gist.owner || profile.githubUser;
1209
- // Persist the authoritative owner so the local profile + future publishes
1210
- // match what got verified.
1211
- if (who && who !== profile.githubUser) {
1212
- const userCfg = readJson(CONFIG_PATH, {});
1213
- userCfg.profile = { ...(userCfg.profile || {}), githubUser: who };
1214
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1215
- }
1469
+ // Persist the authoritative owner + a local verified marker so the
1470
+ // profile checklist and future publishes match what got verified.
1471
+ const userCfg = readJson(CONFIG_PATH, {});
1472
+ userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
1473
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1216
1474
  console.log(`${c.green}✓${c.reset} verified as @${who} — you'll show the ✓ on the board.`);
1217
1475
  if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
1218
1476
  console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account.)${c.reset}`);
@@ -1260,8 +1518,7 @@ function tailLog() {
1260
1518
  return;
1261
1519
  }
1262
1520
  // Print the last ~30 lines, then follow.
1263
- const raw = readFileSync(LOG_PATH, 'utf8').split('\n');
1264
- const tail = raw.slice(-31, -1);
1521
+ const tail = tailLines(readFileSync(LOG_PATH, 'utf8'));
1265
1522
  for (const line of tail) process.stdout.write(line + '\n');
1266
1523
  let lastSize = readFileSync(LOG_PATH).length;
1267
1524
  console.log(`${c.dim}-- tailing ${LOG_PATH} (Ctrl-C to stop) --${c.reset}`);
@@ -1375,11 +1632,16 @@ function help() {
1375
1632
  ['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
1376
1633
  ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
1377
1634
  ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
1635
+ ['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
1636
+ ['resume', 'Lift a pause early'],
1637
+ ['export', 'Dump the aggregate as JSON, or daily rows as CSV (--csv --out)'],
1378
1638
  ['private', 'Mark the current directory as private (hide from Discord)'],
1379
1639
  ['public', 'Un-mark the current directory'],
1380
1640
  ['privacy', 'Show resolved visibility for the current directory'],
1381
1641
  ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
1382
1642
  ['profile', 'Public leaderboard identity (status|set|on|off|publish|verify)'],
1643
+ ['squad', 'Private mini-leaderboards with friends (create|join|leave|status)'],
1644
+ ['link', 'Pair this install with your web login (code from /squads page)'],
1383
1645
  ['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
1384
1646
  ['tail', 'Tail the daemon log file'],
1385
1647
  ['daemon', 'Run daemon in foreground (debug)'],
@@ -1483,11 +1745,17 @@ const packagedDefault = IS_PACKAGED && !cmd;
1483
1745
  break;
1484
1746
  }
1485
1747
  case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
1748
+ case 'pause': doPause(process.argv.slice(3)); break;
1749
+ case 'resume':
1750
+ case 'unpause': doResume(); break;
1751
+ case 'export': await doExport(process.argv.slice(3)); break;
1486
1752
  case 'private': doPrivate(); break;
1487
1753
  case 'public': doPublic(); break;
1488
1754
  case 'privacy': doPrivacy(); break;
1489
1755
  case 'community': await doCommunity(process.argv.slice(3)); break;
1490
1756
  case 'profile': await doProfile(process.argv.slice(3)); break;
1757
+ case 'squad': await doSquadCmd(process.argv.slice(3)); break;
1758
+ case 'link': await doLink(process.argv.slice(3)); break;
1491
1759
  case 'doctor': {
1492
1760
  const { runDoctor, fixPlan } = await import('./doctor.js');
1493
1761
  const fix = process.argv.includes('--fix');
@@ -1579,8 +1847,11 @@ const packagedDefault = IS_PACKAGED && !cmd;
1579
1847
  // click with no args" install-and-start flow.
1580
1848
  overview();
1581
1849
  } else {
1582
- fail(`unknown command: ${cmd}`,
1583
- { hint: 'run `claude-rpc --help` for the full list', code: EX_USER_ERROR });
1850
+ // Version in the error line is deliberate: the #1 cause of "unknown
1851
+ // command" in the wild is a stale global install resolving instead of
1852
+ // the version the user read the docs for. Make the skew visible.
1853
+ fail(`unknown command: ${cmd} (claude-rpc v${VERSION})`,
1854
+ { hint: 'run `claude-rpc --help` for the full list — if this command should exist, update first: npm install -g claude-rpc@latest', code: EX_USER_ERROR });
1584
1855
  }
1585
1856
  }
1586
1857
  }