claude-rpc 0.14.0 → 0.15.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/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.14.0",
3
+ "version": "0.15.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",
@@ -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,10 +12,10 @@ 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
- import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand } from './install.js';
18
+ import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand, setupOutro } from './install.js';
19
19
  import { startTui } from './tui.js';
20
20
  import { generateInsights } from './insights.js';
21
21
  import { maybeNudge } from './nudge.js';
@@ -68,7 +68,7 @@ function daemonPid() {
68
68
  function startDaemon({ quiet = false } = {}) {
69
69
  const pid = daemonPid();
70
70
  if (pid) {
71
- if (!quiet) console.log(`${c.yellow}!${c.reset} Daemon already running (pid ${pid}). Run 'stop' first to restart.`);
71
+ if (!quiet) console.log(` ${c.yellow}!${c.reset} ${'daemon running'.padEnd(16)}${c.dim}already up (pid ${pid}) · bounce it with ${c.reset}${c.cyan}claude-rpc restart${c.reset}`);
72
72
  return false;
73
73
  }
74
74
  // In packaged mode the "daemon script" is the exe itself with a subcommand;
@@ -79,19 +79,19 @@ function startDaemon({ quiet = false } = {}) {
79
79
  const args = IS_PACKAGED ? ['daemon'] : [DAEMON_SCRIPT];
80
80
  const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true });
81
81
  child.unref();
82
- if (!quiet) console.log(`${c.green}✓${c.reset} Daemon launched (pid ${c.cyan}${child.pid}${c.reset}) ${c.dim}logs: ${LOG_PATH}${c.reset}`);
82
+ if (!quiet) console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
83
83
  return true;
84
84
  }
85
85
 
86
86
  function stopDaemon({ quiet = false } = {}) {
87
87
  const pid = daemonPid();
88
- if (!pid) { if (!quiet) console.log('Daemon not running.'); return false; }
88
+ if (!pid) { if (!quiet) console.log(` ${c.cyan}·${c.reset} daemon not running`); return false; }
89
89
  try {
90
90
  process.kill(pid, 'SIGTERM');
91
- if (!quiet) console.log(`${c.green}✓${c.reset} Sent SIGTERM to pid ${c.cyan}${pid}${c.reset}`);
91
+ if (!quiet) console.log(` ${c.green}✓${c.reset} ${'daemon stopping'.padEnd(16)}${c.dim}sent SIGTERM to pid ${pid}${c.reset}`);
92
92
  return true;
93
93
  } catch (e) {
94
- if (!quiet) console.log(`${c.red}✗${c.reset} Failed to stop: ${e.message}`);
94
+ if (!quiet) console.log(` ${c.red}✗${c.reset} ${'stop failed'.padEnd(16)}${c.dim}${e.message}${c.reset}`);
95
95
  return false;
96
96
  }
97
97
  }
@@ -581,24 +581,24 @@ function dumpVars() {
581
581
  }
582
582
 
583
583
  function doScan(force = false) {
584
- console.log(`${c.dim}Scanning ~/.claude/projects${c.reset}`, force ? '(force re-parse)' : '(incremental)');
584
+ console.log(` ${c.dim}scanning ~/.claude/projects ${force ? '(force re-parse)' : '(incremental)'}…${c.reset}`);
585
585
  const t0 = Date.now();
586
586
  let lastReport = 0;
587
587
  const result = scan({
588
588
  force,
589
589
  onProgress: ({ scanned, total }) => {
590
590
  if (Date.now() - lastReport > 500) {
591
- process.stdout.write(`\r parsed ${scanned}/${total}…`);
591
+ process.stdout.write(`\r ${c.dim}parsed ${scanned}/${total}…${c.reset}`);
592
592
  lastReport = Date.now();
593
593
  }
594
594
  },
595
595
  });
596
596
  process.stdout.write('\n');
597
- console.log(`${c.green}✓${c.reset} Done in ${Date.now() - t0}ms — ${c.cyan}${result.scanned}${c.reset} parsed · ${result.skipped} cached · ${result.removed} removed (${result.total} total)`);
597
+ console.log(` ${c.green}✓${c.reset} scan complete ${c.dim}${Date.now() - t0}ms — ${c.reset}${c.cyan}${result.scanned}${c.reset}${c.dim} parsed · ${result.skipped} cached · ${result.removed} removed (${result.total} total)${c.reset}`);
598
598
  if (result.dirs && result.dirs.length > 1) {
599
- console.log(`${c.dim}Scanned roots:${c.reset} ${result.dirs.join(', ')}`);
599
+ console.log(` ${c.dim}roots: ${result.dirs.join(', ')}${c.reset}`);
600
600
  }
601
- console.log(`${c.dim}Aggregate written to ${AGGREGATE_PATH}${c.reset}`);
601
+ console.log(` ${c.dim}aggregate ${AGGREGATE_PATH}${c.reset}`);
602
602
  }
603
603
 
604
604
  // Backfill from any folder that has .jsonl transcripts. Useful for:
@@ -617,9 +617,9 @@ function doBackfill(argv) {
617
617
  }
618
618
  if (!existsSync(path)) {
619
619
  fail(`path doesn't exist: ${path}`,
620
- { hint: 'check the spelling, or run `claude-rpc doctor` to see where transcripts live' });
620
+ { hint: 'check the spelling relative paths resolve from the current directory' });
621
621
  }
622
- console.log(`${c.dim}Backfilling from${c.reset} ${c.cyan}${path}${c.reset}…`);
622
+ console.log(` ${c.dim}backfilling from ${c.reset}${c.cyan}${path}${c.reset}${c.dim}…${c.reset}`);
623
623
  const t0 = Date.now();
624
624
  let lastReport = 0;
625
625
  // Pass `extraDirs` rather than `projectsDirs` — this way the default
@@ -630,16 +630,16 @@ function doBackfill(argv) {
630
630
  extraDirs: [path],
631
631
  onProgress: ({ scanned, total }) => {
632
632
  if (Date.now() - lastReport > 500) {
633
- process.stdout.write(`\r parsed ${scanned}/${total}…`);
633
+ process.stdout.write(`\r ${c.dim}parsed ${scanned}/${total}…${c.reset}`);
634
634
  lastReport = Date.now();
635
635
  }
636
636
  },
637
637
  });
638
638
  process.stdout.write('\n');
639
- console.log(`${c.green}✓${c.reset} Done in ${Date.now() - t0}ms — ${c.cyan}${result.scanned}${c.reset} new/changed · ${result.skipped} cached`);
640
- console.log(`${c.dim}Scanned roots:${c.reset} ${result.dirs.join(', ')}`);
639
+ console.log(` ${c.green}✓${c.reset} backfill complete ${c.dim}${Date.now() - t0}ms — ${c.reset}${c.cyan}${result.scanned}${c.reset}${c.dim} new/changed · ${result.skipped} cached${c.reset}`);
640
+ console.log(` ${c.dim}roots: ${result.dirs.join(', ')}${c.reset}`);
641
641
  const hours = ((result.aggregate.activeMs || 0) / 3_600_000).toFixed(1);
642
- console.log(`${c.dim}Aggregate now:${c.reset} ${result.aggregate.sessions} sessions · ${hours}h · ${result.aggregate.userMessages} prompts`);
642
+ console.log(` ${c.dim}aggregate now: ${result.aggregate.sessions} sessions · ${hours}h · ${result.aggregate.userMessages} prompts${c.reset}`);
643
643
  }
644
644
 
645
645
  function showInsights() {
@@ -678,7 +678,7 @@ async function doBadge(argv) {
678
678
  }
679
679
  if (opts.out) {
680
680
  writeFileSync(opts.out, svg);
681
- console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
681
+ console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
682
682
  } else {
683
683
  process.stdout.write(svg);
684
684
  }
@@ -715,11 +715,11 @@ async function publishBadgeToGist(svg, opts) {
715
715
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
716
716
  const wasUpdate = !!stored.id;
717
717
  console.log('');
718
- console.log(` ${c.green}✓${c.reset} ${wasUpdate ? 'Updated' : 'Created'} gist ${c.cyan}${result.id}${c.reset}`);
719
- console.log(` ${c.dim}raw:${c.reset} ${c.cyan}${result.rawUrl}${c.reset}`);
720
- if (result.htmlUrl) console.log(` ${c.dim}gist:${c.reset} ${c.dim}${result.htmlUrl}${c.reset}`);
718
+ console.log(` ${c.green}✓${c.reset} ${wasUpdate ? 'updated' : 'created'} gist ${c.cyan}${result.id}${c.reset}`);
719
+ console.log(` ${c.dim}raw: ${c.reset}${c.cyan}${result.rawUrl}${c.reset}`);
720
+ if (result.htmlUrl) console.log(` ${c.dim}gist: ${result.htmlUrl}${c.reset}`);
721
721
  console.log('');
722
- console.log(` ${c.dim}Paste into your README:${c.reset}`);
722
+ console.log(` ${c.dim}paste into your README:${c.reset}`);
723
723
  console.log(` ${gistMarkdown({ owner: result.owner, id: result.id, filename, label: 'Claude' })}`);
724
724
  console.log('');
725
725
  } catch (e) {
@@ -753,8 +753,8 @@ async function doCard(argv) {
753
753
  const svg = renderCard(aggregate, { range: opts.range });
754
754
  if (opts.out) {
755
755
  writeFileSync(opts.out, svg);
756
- console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
757
- console.log(`${c.dim}Tip: open in a browser, right-click Save as PNG. Or drop straight into a Discord message it'll render inline.${c.reset}`);
756
+ console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
757
+ console.log(` ${c.dim}tip: open in a browser and save as PNG or drop it straight into a Discord message; it renders inline${c.reset}`);
758
758
  } else {
759
759
  process.stdout.write(svg);
760
760
  }
@@ -788,8 +788,8 @@ async function doGithubStat(argv) {
788
788
  }
789
789
  if (opts.out) {
790
790
  writeFileSync(opts.out, svg);
791
- console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
792
- console.log(`${c.dim}Embed in your README: <img src="${opts.out}" alt="Claude Code stats" width="500" />${c.reset}`);
791
+ console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
792
+ console.log(` ${c.dim}embed in your README: <img src="${opts.out}" alt="Claude Code stats" width="500" />${c.reset}`);
793
793
  } else {
794
794
  process.stdout.write(svg);
795
795
  }
@@ -825,13 +825,13 @@ async function doCalendar(argv) {
825
825
  else if (argv[i] === '--gist') opts.gist = true;
826
826
  }
827
827
  const aggregate = readAggregate();
828
- if (!aggregate) fail('no aggregate yet — run `claude-rpc scan` first', { code: EX_BAD_STATE });
828
+ if (!aggregate) fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
829
829
  const { renderCalendar } = await import('./calendar.js');
830
830
  const svg = renderCalendar(aggregate, {});
831
831
  if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' });
832
832
  if (opts.out) {
833
833
  writeFileSync(opts.out, svg);
834
- console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
834
+ console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
835
835
  } else process.stdout.write(svg);
836
836
  }
837
837
 
@@ -846,7 +846,7 @@ async function doSessionCard(argv) {
846
846
  const svg = renderSessionCard(vars, {});
847
847
  if (opts.out) {
848
848
  writeFileSync(opts.out, svg);
849
- console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
849
+ console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
850
850
  } else process.stdout.write(svg);
851
851
  }
852
852
 
@@ -864,8 +864,8 @@ function doMcpInstall(argv) {
864
864
  const manual = (r) => `claude mcp add claude-rpc --scope ${scope} -- ${r.command} ${r.args.join(' ')}`;
865
865
  if (res.ok) {
866
866
  console.log('');
867
- console.log(` ${c.green}✓${c.reset} Registered the ${c.cyan}claude-rpc${c.reset} MCP server with Claude Code (scope: ${scope}).`);
868
- console.log(` ${c.dim}Restart Claude Code (or run /mcp), then ask: "how long have I coded today?"${c.reset}`);
867
+ console.log(` ${c.green}✓${c.reset} registered the ${c.cyan}claude-rpc${c.reset} MCP server with Claude Code ${c.dim}(scope: ${scope})${c.reset}`);
868
+ console.log(` ${c.dim}restart Claude Code (or run /mcp), then ask: "how long have I coded today?"${c.reset}`);
869
869
  console.log('');
870
870
  } else if (res.reason === 'no-claude') {
871
871
  fail('the `claude` CLI was not found on your PATH', {
@@ -880,7 +880,7 @@ function doMcpInstall(argv) {
880
880
  function doMcpUninstall(argv) {
881
881
  const scope = argv.includes('--project') ? 'project' : argv.includes('--local') ? 'local' : 'user';
882
882
  const res = uninstallMcp({ scope });
883
- if (res.ok) console.log(`${c.green}✓${c.reset} Removed the claude-rpc MCP server (scope: ${scope}).`);
883
+ if (res.ok) console.log(` ${c.green}✓${c.reset} removed the claude-rpc MCP server ${c.dim}(scope: ${scope})${c.reset}`);
884
884
  else if (res.reason === 'no-claude') fail('the `claude` CLI was not found on your PATH', { code: EX_USER_ERROR });
885
885
  else fail('could not remove the MCP server', { hint: 'claude mcp remove claude-rpc', code: EX_USER_ERROR });
886
886
  }
@@ -897,8 +897,8 @@ function doMcpUninstall(argv) {
897
897
  function doPrivate() {
898
898
  const cwd = process.cwd();
899
899
  const list = addPrivateCwd(cwd);
900
- console.log(`${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} marked private`);
901
- console.log(`${c.dim} ${list.length} ${list.length === 1 ? 'path' : 'paths'} in the private list. Daemon picks it up within ~5 min (cache TTL) or restart.${c.reset}`);
900
+ console.log(` ${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} marked private`);
901
+ console.log(` ${c.dim}${list.length} ${list.length === 1 ? 'path' : 'paths'} in the private list the daemon picks it up within ~5 min, or ${c.reset}${c.cyan}claude-rpc restart${c.reset}`);
902
902
  }
903
903
 
904
904
  function doPublic() {
@@ -906,9 +906,9 @@ function doPublic() {
906
906
  const before = listPrivateCwds().length;
907
907
  const list = removePrivateCwd(cwd);
908
908
  if (list.length === before) {
909
- console.log(`${c.yellow}!${c.reset} ${c.cyan}${cwd}${c.reset} wasn't in the private list`);
909
+ console.log(` ${c.yellow}!${c.reset} ${c.cyan}${cwd}${c.reset} wasn't in the private list`);
910
910
  } else {
911
- console.log(`${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} removed from the private list`);
911
+ console.log(` ${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} removed from the private list`);
912
912
  }
913
913
  }
914
914
 
@@ -950,8 +950,8 @@ function doPause(argv) {
950
950
  if (arg === 'off' || arg === 'resume') return doResume();
951
951
  if (arg === 'status') {
952
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`);
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
955
  return;
956
956
  }
957
957
  const ms = parseDuration(argv[0]);
@@ -960,17 +960,17 @@ function doPause(argv) {
960
960
  { hint: 'use 30m, 2h, 1h30m, or a bare number of minutes (default: 1h)', code: EX_USER_ERROR });
961
961
  }
962
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}`);
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 seconds resume early with ${c.reset}${c.cyan}claude-rpc resume${c.reset}`);
965
965
  }
966
966
 
967
967
  function doResume() {
968
968
  const was = pauseUntil();
969
969
  clearPause();
970
970
  if (was) {
971
- console.log(`${c.green}✓${c.reset} presence resumed ${c.dim}(was paused until ${fmtClock(was)})${c.reset}`);
971
+ console.log(` ${c.green}✓${c.reset} presence resumed ${c.dim}(was paused until ${fmtClock(was)})${c.reset}`);
972
972
  } else {
973
- console.log(`${c.dim}presence wasn't paused.${c.reset}`);
973
+ console.log(` ${c.cyan}·${c.reset} presence wasn't paused`);
974
974
  }
975
975
  }
976
976
 
@@ -999,12 +999,179 @@ async function doExport(argv) {
999
999
  }
1000
1000
  if (out) {
1001
1001
  writeFileSync(out, payload);
1002
- console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${out}${c.reset} (${payload.length} bytes, ${csv ? 'CSV' : 'JSON'})`);
1002
+ console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${out}${c.reset} ${c.dim}(${payload.length} bytes, ${csv ? 'CSV' : 'JSON'})${c.reset}`);
1003
1003
  } else {
1004
1004
  process.stdout.write(payload);
1005
1005
  }
1006
1006
  }
1007
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) {
1022
+ fail('no community endpoint configured', {
1023
+ hint: 'config.json is missing community.endpoint — re-run `claude-rpc setup` to restore the default',
1024
+ code: EX_BAD_STATE,
1025
+ });
1026
+ }
1027
+ if (!instanceId) {
1028
+ fail('squads need an identity', {
1029
+ hint: 'run `claude-rpc profile set --handle <name> && claude-rpc profile on` first',
1030
+ code: EX_BAD_STATE,
1031
+ });
1032
+ }
1033
+ const post = async (path, body) => {
1034
+ const res = await fetch(endpoint + path, {
1035
+ method: 'POST',
1036
+ headers: { 'Content-Type': 'application/json' },
1037
+ body: JSON.stringify({ instanceId, ...body }),
1038
+ });
1039
+ return { status: res.status, json: await res.json().catch(() => ({})) };
1040
+ };
1041
+ const get = async (path) => {
1042
+ const res = await fetch(endpoint + path);
1043
+ return { status: res.status, json: await res.json().catch(() => ({})) };
1044
+ };
1045
+ return { cfg, endpoint, instanceId, post, get };
1046
+ }
1047
+
1048
+ function squadPageUrl(id) { return `https://claude-rpc.vercel.app/squad/${id}`; }
1049
+
1050
+ function printSquadInvite(squad) {
1051
+ console.log('');
1052
+ console.log(` ${c.green}✓${c.reset} squad ${c.bold}${squad.name}${c.reset}`);
1053
+ console.log(` ${c.dim}invite code:${c.reset} ${c.cyan}${squad.code}${c.reset}`);
1054
+ console.log(` ${c.dim}standings: ${c.reset} ${c.cyan}${squadPageUrl(squad.id)}${c.reset}`);
1055
+ console.log('');
1056
+ console.log(` ${c.dim}send your crew this:${c.reset}`);
1057
+ console.log(` join my Claude Code squad "${squad.name}" — npx claude-rpc@latest setup, then:`);
1058
+ console.log(` ${c.cyan}claude-rpc squad join ${squad.code}${c.reset} ${c.dim}(or join in the browser: ${squadPageUrl(squad.id)})${c.reset}`);
1059
+ console.log('');
1060
+ }
1061
+
1062
+ async function squadStatus({ post, get }) {
1063
+ const mine = await post('/squads/mine', {});
1064
+ if (!mine.json?.squads) return fail(`could not load squads: ${mine.json?.error || mine.status}`, { code: EX_SYS_ERROR });
1065
+ if (!mine.json.squads.length) {
1066
+ console.log('');
1067
+ console.log(` ${c.dim}no squads yet — start one:${c.reset} ${c.cyan}claude-rpc squad create "the night shift"${c.reset}`);
1068
+ console.log('');
1069
+ return;
1070
+ }
1071
+ for (const s of mine.json.squads) {
1072
+ const r = await get(`/squad?id=${encodeURIComponent(s.id)}`);
1073
+ const standings = r.json?.standings || [];
1074
+ const lines = standings.map((row) => {
1075
+ const who = `${row.displayName || '@' + row.handle}${row.verified ? ' ✓' : ''}${row.owner ? ` ${c.dim}(owner)${c.reset}` : ''}`;
1076
+ 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}`;
1077
+ });
1078
+ lines.push('');
1079
+ 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}`);
1080
+ box(`${s.name} (${s.members})`, lines, 70);
1081
+ console.log('');
1082
+ }
1083
+ }
1084
+
1085
+ async function doSquadCmd(argv) {
1086
+ const sub = (argv[0] || 'status').toLowerCase();
1087
+ const ctx = squadAuth();
1088
+ if (sub === 'status' || sub === '') return squadStatus(ctx);
1089
+ if (sub === 'create') {
1090
+ const name = argv.slice(1).join(' ').trim();
1091
+ if (!name) {
1092
+ return fail('usage: claude-rpc squad create <name>',
1093
+ { hint: 'example: claude-rpc squad create "the night shift"', code: EX_USER_ERROR });
1094
+ }
1095
+ const r = await ctx.post('/squad/create', { name });
1096
+ if (r.status !== 200) return fail(`create failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1097
+ return printSquadInvite(r.json.squad);
1098
+ }
1099
+ if (sub === 'join') {
1100
+ const code = (argv[1] || '').trim();
1101
+ if (!code) {
1102
+ return fail('usage: claude-rpc squad join SQ-XXXXXX',
1103
+ { hint: 'the invite code comes from whoever created the squad (`claude-rpc squad create`)', code: EX_USER_ERROR });
1104
+ }
1105
+ const r = await ctx.post('/squad/join', { code });
1106
+ if (r.status !== 200) {
1107
+ return fail(`join failed: ${r.json?.error || r.status}`,
1108
+ { hint: 'double-check the invite code with whoever created the squad', code: EX_SYS_ERROR });
1109
+ }
1110
+ const s = r.json.squad;
1111
+ console.log(` ${c.green}✓${c.reset} ${s.alreadyMember ? 'already in' : 'joined'} ${c.bold}${s.name}${c.reset} ${c.dim}(${s.members} member${s.members === 1 ? '' : 's'})${c.reset}`);
1112
+ console.log(` ${c.dim}standings: ${squadPageUrl(s.id)} — or run ${c.reset}${c.cyan}claude-rpc squad${c.reset}`);
1113
+ return;
1114
+ }
1115
+ if (sub === 'leave') {
1116
+ const mine = await ctx.post('/squads/mine', {});
1117
+ const squads = mine.json?.squads || [];
1118
+ if (!squads.length) return fail('you are not in any squads', { code: EX_BAD_STATE });
1119
+ let target = null;
1120
+ const wanted = (argv[1] || '').toLowerCase();
1121
+ if (wanted) target = squads.find((s) => s.id === wanted || s.name.toLowerCase() === wanted);
1122
+ else if (squads.length === 1) target = squads[0];
1123
+ if (!target) {
1124
+ return fail(squads.length > 1 && !wanted ? 'you are in several squads — name one' : `no squad matching "${argv[1]}"`, {
1125
+ hint: `claude-rpc squad leave <id|name> — yours: ${squads.map((s) => `${s.name} (${s.id})`).join(', ')}`,
1126
+ code: EX_USER_ERROR,
1127
+ });
1128
+ }
1129
+ const r = await ctx.post('/squad/leave', { squadId: target.id });
1130
+ if (r.status !== 200) return fail(`leave failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1131
+ console.log(` ${c.green}✓${c.reset} left ${c.bold}${target.name}${c.reset}${r.json.dissolved ? ` ${c.dim}(last member — squad dissolved)${c.reset}` : ''}`);
1132
+ return;
1133
+ }
1134
+ fail(`unknown squad subcommand: ${sub}`, {
1135
+ hint: 'try: squad [status|create <name>|join <code>|leave [id]]',
1136
+ code: EX_USER_ERROR,
1137
+ });
1138
+ }
1139
+
1140
+ // ── Link (CLI ↔ web pairing) ─────────────────────────────────────────────
1141
+ //
1142
+ // `claude-rpc link <code>` — the code comes from claude-rpc.vercel.app/squads
1143
+ // while logged in with GitHub. Claims it against this install's instanceId,
1144
+ // which verifies the profile (✓) and unlocks managing squads from the
1145
+ // browser. Replaces the gist dance for anyone who uses the website.
1146
+
1147
+ async function doLink(argv) {
1148
+ const code = (argv[0] || '').trim();
1149
+ if (!code) {
1150
+ fail('usage: claude-rpc link <code>', {
1151
+ hint: 'log in at https://claude-rpc.vercel.app/squads — it shows you the code',
1152
+ code: EX_USER_ERROR,
1153
+ });
1154
+ }
1155
+ const ctx = squadAuth();
1156
+ // Make sure the profile row exists server-side before claiming — same
1157
+ // pre-publish profileVerify does, so link works on a fresh `profile on`.
1158
+ if (lb.profileIsPublishable(ctx.cfg.profile || {})) {
1159
+ const { flushProfile } = await import('./community.js');
1160
+ await flushProfile(ctx.cfg);
1161
+ }
1162
+ const r = await ctx.post('/pair/claim', { code });
1163
+ if (r.status !== 200) {
1164
+ return fail(`link failed: ${r.json?.error || r.status}`,
1165
+ { hint: 'grab a fresh code from https://claude-rpc.vercel.app/squads and try again', code: EX_SYS_ERROR });
1166
+ }
1167
+ // Mirror the verified identity locally so `profile status` agrees.
1168
+ const userCfg = readJson(CONFIG_PATH, {});
1169
+ userCfg.profile = { ...(userCfg.profile || {}), githubUser: r.json.githubUser, verified: true };
1170
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1171
+ console.log(` ${c.green}✓${c.reset} linked as ${c.cyan}@${r.json.githubUser}${c.reset} — profile verified, squads unlocked in the browser`);
1172
+ console.log(` ${c.dim}head back to https://claude-rpc.vercel.app/squads — it picks the link up automatically${c.reset}`);
1173
+ }
1174
+
1008
1175
  // ── Community totals ─────────────────────────────────────────────────────
1009
1176
  //
1010
1177
  // `claude-rpc community` → show current state + endpoint
@@ -1049,7 +1216,7 @@ async function communityOn() {
1049
1216
  const cfg = loadConfig();
1050
1217
  const community = cfg.community || {};
1051
1218
  if (community.enabled) {
1052
- console.log(`${c.green}✓${c.reset} community totals are already enabled`);
1219
+ console.log(` ${c.green}✓${c.reset} community totals are already enabled`);
1053
1220
  return;
1054
1221
  }
1055
1222
  console.log('');
@@ -1094,12 +1261,12 @@ async function communityOn() {
1094
1261
  function communityOff() {
1095
1262
  const userCfg = readJson(CONFIG_PATH, {});
1096
1263
  if (!userCfg.community?.enabled) {
1097
- console.log(`${c.dim}community totals are already off.${c.reset}`);
1264
+ console.log(` ${c.cyan}·${c.reset} community totals are already off`);
1098
1265
  return;
1099
1266
  }
1100
1267
  userCfg.community = { ...userCfg.community, enabled: false };
1101
1268
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1102
- console.log(`${c.green}✓${c.reset} community totals disabled. instanceId retained for re-enable continuity.`);
1269
+ console.log(` ${c.green}✓${c.reset} community totals disabled ${c.dim}(instanceId retained for re-enable continuity)${c.reset}`);
1103
1270
  }
1104
1271
 
1105
1272
  async function communityReport() {
@@ -1111,11 +1278,11 @@ async function communityReport() {
1111
1278
  const result = await flushCommunity(cfg);
1112
1279
  console.log('');
1113
1280
  if (result.ok && result.delta) {
1114
- console.log(` ${c.green}✓${c.reset} reported ${c.cyan}+${result.delta.sessions} sessions${c.reset} ${c.cyan}+${result.delta.tokens} tokens${c.reset}`);
1281
+ console.log(` ${c.green}✓${c.reset} reported ${c.cyan}+${result.delta.sessions} sessions${c.reset} ${c.cyan}+${result.delta.tokens} tokens${c.reset}`);
1115
1282
  } else if (result.ok) {
1116
- console.log(` ${c.dim}${result.reason}${c.reset}`);
1283
+ console.log(` ${c.cyan}·${c.reset} ${c.dim}${result.reason}${c.reset}`);
1117
1284
  } else {
1118
- console.log(` ${c.yellow}↳${c.reset} flush did not complete ${c.dim}(${result.reason}${result.error ? ': ' + result.error : ''})${c.reset}`);
1285
+ console.log(` ${c.yellow}!${c.reset} flush did not complete ${c.dim}(${result.reason}${result.error ? ': ' + result.error : ''})${c.reset}`);
1119
1286
  }
1120
1287
  console.log('');
1121
1288
  }
@@ -1131,6 +1298,32 @@ function readFlag(argv, name) {
1131
1298
  return eq ? eq.slice(name.length + 3) : undefined;
1132
1299
  }
1133
1300
 
1301
+ // Single source of truth for the profile checklist. `profile`/`profile status`
1302
+ // renders all of it; mutations point at just the first unfinished step.
1303
+ // Verification is `done` whichever way it happened — web pairing (doLink sets
1304
+ // profile.verified) or the gist fallback (profileVerify).
1305
+ function profileSteps(p) {
1306
+ return [
1307
+ { key: 'handle', done: lb.isValidHandle(p.handle), label: 'set a handle', cmd: 'claude-rpc profile set --handle <name>', note: p.handle },
1308
+ { key: 'publish', done: !!p.enabled, label: 'enable publishing', cmd: 'claude-rpc profile on', note: 'daemon republishes automatically' },
1309
+ { key: 'verify', done: !!p.verified, label: 'verify via GitHub', cmd: 'claude-rpc link <code>', note: p.githubUser ? `@${p.githubUser}` : '' },
1310
+ ];
1311
+ }
1312
+
1313
+ // One dim pointer at the next unfinished step — what mutations print instead
1314
+ // of re-rendering the whole dashboard.
1315
+ function profileNextStep() {
1316
+ const p = loadConfig().profile || {};
1317
+ const next = profileSteps(p).find((s) => !s.done);
1318
+ if (!next) {
1319
+ console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}${c.reset}`);
1320
+ } else if (next.key === 'verify') {
1321
+ console.log(` ${c.dim}→ next: log in at${c.reset} ${c.cyan}https://claude-rpc.vercel.app/squads${c.reset}${c.dim}, then${c.reset} ${c.cyan}claude-rpc link <code>${c.reset}`);
1322
+ } else {
1323
+ console.log(` ${c.dim}→ next:${c.reset} ${c.cyan}${next.cmd}${c.reset} ${c.dim}(${next.label})${c.reset}`);
1324
+ }
1325
+ }
1326
+
1134
1327
  function profileStatus() {
1135
1328
  const p = (loadConfig().profile) || {};
1136
1329
  const handleOk = lb.isValidHandle(p.handle);
@@ -1155,23 +1348,27 @@ function profileStatus() {
1155
1348
  // Setup checklist — same shape every time, so the user always sees where
1156
1349
  // they are and the exact next command. This is the screen the daemon's
1157
1350
  // breadcrumbs point back to.
1158
- const steps = [
1159
- { done: handleOk, label: 'set a handle', cmd: 'claude-rpc profile set --handle <name>', note: p.handle },
1160
- { done: !!p.enabled, label: 'enable publishing', cmd: 'claude-rpc profile on', note: 'daemon republishes automatically' },
1161
- { done: !!p.verified, label: 'verify on GitHub', cmd: 'claude-rpc profile verify', note: p.githubUser ? `@${p.githubUser}` : '' },
1162
- ];
1351
+ const steps = profileSteps(p);
1163
1352
  if (steps.every((s) => s.done)) {
1164
- console.log(` ${c.green}✓${c.reset} all set — you're live at ${c.cyan}${boardUrl}${c.reset}`);
1353
+ console.log(` ${c.green}✓${c.reset} all set — you're live at ${c.cyan}${boardUrl}${c.reset}`);
1165
1354
  } else {
1166
1355
  const nextIdx = steps.findIndex((s) => !s.done);
1167
- box('next steps', steps.map((s, i) => {
1356
+ const lines = steps.map((s, i) => {
1168
1357
  const mark = s.done ? `${c.green}✓${c.reset}` : (i === nextIdx ? `${c.yellow}○${c.reset}` : `${c.dim}○${c.reset}`);
1169
1358
  const label = s.done ? `${c.dim}${s.label}${c.reset}` : `${c.bold}${s.label}${c.reset}`;
1170
1359
  const tail = s.done
1171
1360
  ? `${c.dim}${s.note || 'done'}${c.reset}`
1172
1361
  : `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
1173
1362
  return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
1174
- }));
1363
+ });
1364
+ // Web pairing is the primary verify path; the gist dance stays available
1365
+ // for terminals with no browser nearby.
1366
+ if (!steps[2].done) {
1367
+ lines.push('');
1368
+ lines.push(`${c.dim}the code comes from${c.reset} ${c.cyan}https://claude-rpc.vercel.app/squads${c.reset} ${c.dim}(log in with GitHub)${c.reset}`);
1369
+ lines.push(`${c.dim}no browser? fall back to${c.reset} ${c.cyan}claude-rpc profile verify${c.reset}`);
1370
+ }
1371
+ box('next steps', lines);
1175
1372
  }
1176
1373
  console.log('');
1177
1374
  }
@@ -1204,8 +1401,14 @@ function profileSet(argv) {
1204
1401
 
1205
1402
  userCfg.profile = next;
1206
1403
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1207
- console.log(`${c.green}✓${c.reset} profile saved`);
1208
- profileStatus();
1404
+ // One-line confirmation + a pointer at the next step. The full dashboard
1405
+ // stays behind `claude-rpc profile` — mutations shouldn't re-render it.
1406
+ const saved = [];
1407
+ if (rawHandle !== undefined) saved.push(`handle ${next.handle}`);
1408
+ if (rawName !== undefined) saved.push(`name ${next.displayName || '—'}`);
1409
+ if (rawGh !== undefined) saved.push(`github ${next.githubUser || '—'}`);
1410
+ console.log(` ${c.green}✓${c.reset} profile saved${saved.length ? ` ${c.dim}${saved.join(' · ')}${c.reset}` : ''}`);
1411
+ profileNextStep();
1209
1412
  }
1210
1413
 
1211
1414
  function profileEnable(on) {
@@ -1230,10 +1433,11 @@ function profileEnable(on) {
1230
1433
  }
1231
1434
  userCfg.profile = next;
1232
1435
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1233
- console.log(`${c.green}✓${c.reset} leaderboard publishing ${on ? 'enabled' : 'disabled'}`);
1234
1436
  if (on) {
1235
- 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}`);
1236
- profileStatus();
1437
+ console.log(` ${c.green}✓${c.reset} leaderboard publishing enabled ${c.dim}(live on the next daemon flush — or now: ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}${c.dim})${c.reset}`);
1438
+ profileNextStep();
1439
+ } else {
1440
+ console.log(` ${c.green}✓${c.reset} leaderboard publishing disabled`);
1237
1441
  }
1238
1442
  }
1239
1443
 
@@ -1247,12 +1451,12 @@ async function profilePublish() {
1247
1451
  });
1248
1452
  }
1249
1453
  const { flushProfile } = await import('./community.js');
1250
- console.log(`${c.dim}publishing @${cfg.profile.handle} to the board…${c.reset}`);
1454
+ console.log(` ${c.dim}publishing @${cfg.profile.handle} to the board…${c.reset}`);
1251
1455
  const r = await flushProfile(cfg);
1252
1456
  if (r.ok) {
1253
- console.log(`${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(cfg.profile.handle)}${c.reset}`);
1457
+ console.log(` ${c.green}✓${c.reset} published — see it at ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(cfg.profile.handle)}${c.reset}`);
1254
1458
  } else if (r.reason === 'rate-limited') {
1255
- console.log(`${c.yellow}!${c.reset} rate-limited — already published in the last minute; the board has you.`);
1459
+ console.log(` ${c.yellow}!${c.reset} rate-limited — already published in the last minute; the board has you`);
1256
1460
  } else {
1257
1461
  return fail(`publish failed: ${r.reason}${r.error ? ' (' + r.error + ')' : ''}`, { code: EX_SYS_ERROR });
1258
1462
  }
@@ -1269,13 +1473,18 @@ async function profileVerify() {
1269
1473
  // and publishing that gist already requires gh auth, so the account is
1270
1474
  // known by the time it matters.
1271
1475
  if (!profile.githubUser) {
1272
- console.log(`${c.dim}no --github set — your verified identity will be the account that owns the proof gist.${c.reset}`);
1476
+ console.log(` ${c.dim}no --github set — your verified identity will be the account that owns the proof gist${c.reset}`);
1273
1477
  }
1274
1478
  if (!community.instanceId) {
1275
1479
  return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
1276
1480
  }
1277
1481
  const endpoint = (community.endpoint || '').replace(/\/+$/, '');
1278
- if (!endpoint) return fail('no community endpoint configured', { code: EX_BAD_STATE });
1482
+ if (!endpoint) {
1483
+ return fail('no community endpoint configured', {
1484
+ hint: 'config.json is missing community.endpoint — re-run `claude-rpc setup` to restore the default',
1485
+ code: EX_BAD_STATE,
1486
+ });
1487
+ }
1279
1488
 
1280
1489
  const post = async (path, body) => {
1281
1490
  const res = await fetch(endpoint + path, {
@@ -1293,13 +1502,13 @@ async function profileVerify() {
1293
1502
  const { flushProfile } = await import('./community.js');
1294
1503
  await flushProfile(cfg);
1295
1504
  }
1296
- console.log(`${c.dim}requesting a verification token…${c.reset}`);
1505
+ console.log(` ${c.dim}requesting a verification token…${c.reset}`);
1297
1506
  const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser || null });
1298
1507
  if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
1299
1508
  const token = start.json.token;
1300
1509
 
1301
1510
  const { publishGistFile } = await import('./gist.js');
1302
- console.log(`${c.dim}publishing a public proof gist…${c.reset}`);
1511
+ console.log(` ${c.dim}publishing a public proof gist…${c.reset}`);
1303
1512
  const gist = await publishGistFile({
1304
1513
  svg: `claude-rpc leaderboard verification\n${token}\n`,
1305
1514
  filename: 'claude-rpc-verify.txt',
@@ -1310,7 +1519,7 @@ async function profileVerify() {
1310
1519
  // Hand the worker the gist ID so it fetches that gist directly (no
1311
1520
  // gist-list lag) and reads the real owner — instant, and the owner becomes
1312
1521
  // the verified identity regardless of what --github was set to.
1313
- console.log(`${c.dim}confirming with the server…${c.reset}`);
1522
+ console.log(` ${c.dim}confirming with the server…${c.reset}`);
1314
1523
  const check = await post('/verify/check', { instanceId: community.instanceId, gistId: gist.id });
1315
1524
  if (check.json?.verified) {
1316
1525
  const who = check.json.githubUser || gist.owner || profile.githubUser;
@@ -1319,13 +1528,13 @@ async function profileVerify() {
1319
1528
  const userCfg = readJson(CONFIG_PATH, {});
1320
1529
  userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
1321
1530
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1322
- console.log(`${c.green}✓${c.reset} verified as @${who} — you'll show the ✓ on the board.`);
1531
+ console.log(` ${c.green}✓${c.reset} verified as ${c.cyan}@${who}${c.reset} — you'll show the ✓ on the board`);
1323
1532
  if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
1324
- console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account.)${c.reset}`);
1533
+ console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account)${c.reset}`);
1325
1534
  }
1326
1535
  } else {
1327
- console.log(`${c.yellow}!${c.reset} not confirmed: ${check.json?.error || check.status}`);
1328
- console.log(` ${c.dim}make sure the gist is public, then re-run ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim}.${c.reset}`);
1536
+ console.log(` ${c.yellow}!${c.reset} not confirmed: ${check.json?.error || check.status}`);
1537
+ console.log(` ${c.dim}make sure the gist is public, then re-run ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}`);
1329
1538
  }
1330
1539
  } catch (e) {
1331
1540
  return fail(`verification failed: ${e.message}`, {
@@ -1362,7 +1571,8 @@ async function doCommunity(argv) {
1362
1571
 
1363
1572
  function tailLog() {
1364
1573
  if (!existsSync(LOG_PATH)) {
1365
- console.log(`${c.yellow}No log yet at ${LOG_PATH}${c.reset}`);
1574
+ console.log(` ${c.yellow}!${c.reset} no log yet ${c.dim}${LOG_PATH}${c.reset}`);
1575
+ console.log(` ${c.gray}↳ the daemon creates it on first start: ${c.reset}${c.cyan}claude-rpc start${c.reset}`);
1366
1576
  return;
1367
1577
  }
1368
1578
  // Print the last ~30 lines, then follow.
@@ -1488,22 +1698,25 @@ function help() {
1488
1698
  ['privacy', 'Show resolved visibility for the current directory'],
1489
1699
  ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
1490
1700
  ['profile', 'Public leaderboard identity (status|set|on|off|publish|verify)'],
1701
+ ['squad', 'Private mini-leaderboards with friends (create|join|leave|status)'],
1702
+ ['link', 'Pair this install with your web login (code from /squads page)'],
1491
1703
  ['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
1492
1704
  ['tail', 'Tail the daemon log file'],
1493
1705
  ['daemon', 'Run daemon in foreground (debug)'],
1494
1706
  ];
1707
+ const colW = cmds.reduce((m, [name]) => Math.max(m, name.length), 0);
1495
1708
  console.log('');
1496
1709
  console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}— Discord Rich Presence for Claude Code${c.reset}`);
1497
1710
  console.log('');
1498
1711
  console.log(` ${c.dim}Commands:${c.reset}`);
1499
1712
  for (const [name, desc] of cmds) {
1500
- console.log(` ${c.cyan}${name.padEnd(10)}${c.reset} ${desc}`);
1713
+ console.log(` ${c.cyan}${name.padEnd(colW)}${c.reset} ${desc}`);
1501
1714
  }
1502
1715
  console.log('');
1503
1716
  console.log(` ${c.dim}First-time setup:${c.reset}`);
1504
- console.log(` 1. Set ${c.cyan}clientId${c.reset} in ${c.cyan}config.json${c.reset} to your Discord app id.`);
1505
- console.log(` 2. (Optional) Upload art under Rich Presence Art Assets: ${c.cyan}claude${c.reset}, ${c.cyan}working${c.reset}, ${c.cyan}idle${c.reset}, ${c.cyan}thinking${c.reset}.`);
1506
- console.log(` 3. ${c.cyan}npm install${c.reset} && ${c.cyan}claude-rpc setup${c.reset} && ${c.cyan}claude-rpc start${c.reset}.`);
1717
+ console.log(` 1. ${c.cyan}claude-rpc setup${c.reset} wires hooks, seeds config, starts the daemon.`);
1718
+ console.log(` 2. Open Claude Code and send a prompt the card appears in Discord.`);
1719
+ console.log(` 3. ${c.dim}(optional)${c.reset} Use your own Discord app: set ${c.cyan}clientId${c.reset} in ${c.cyan}config.json${c.reset} and upload art under Rich Presence → Art Assets: ${c.cyan}claude${c.reset}, ${c.cyan}working${c.reset}, ${c.cyan}idle${c.reset}, ${c.cyan}thinking${c.reset}.`);
1507
1720
  console.log('');
1508
1721
  console.log(` ${c.dim}Tip: ${c.reset}edit ${c.cyan}config.json${c.reset} to customize rotation frames. Run ${c.cyan}claude-rpc preview${c.reset} to see the result without Discord.`);
1509
1722
  console.log('');
@@ -1532,8 +1745,10 @@ const packagedDefault = IS_PACKAGED && !cmd;
1532
1745
  // startup, install = with) but in practice users expect one command
1533
1746
  // to do everything. Non-Windows: addStartupEntry is a no-op + warning.
1534
1747
  case 'setup':
1535
- case 'install':
1536
- await runInstall({ exePath: EXE_PATH || process.execPath });
1748
+ case 'install': {
1749
+ // runInstall prints the phased checklist and leaves the `daemon` phase
1750
+ // open; the launch row lands there, then setupOutro closes the screen.
1751
+ const target = await runInstall({ exePath: EXE_PATH || process.execPath });
1537
1752
  // Slimmer first run: bring the daemon up now so the card appears
1538
1753
  // immediately, instead of making the user run a separate `start`.
1539
1754
  // Best-effort — a start hiccup must never make `setup` look failed.
@@ -1542,20 +1757,23 @@ const packagedDefault = IS_PACKAGED && !cmd;
1542
1757
  // Our own tree is npm's throwaway _npx cache; launch from the global
1543
1758
  // install setup just promoted to, via the PATH-resolved bin.
1544
1759
  if (!daemonPid()) {
1545
- spawn('claude-rpc', ['daemon'], {
1760
+ const child = spawn('claude-rpc', ['daemon'], {
1546
1761
  detached: true, stdio: 'ignore', windowsHide: true,
1547
1762
  shell: process.platform === 'win32',
1548
- }).unref();
1549
- console.log(`${c.green}✓${c.reset} Daemon launched ${c.dim}logs: ${LOG_PATH}${c.reset}`);
1763
+ });
1764
+ child.unref();
1765
+ console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}log ${shortPath(LOG_PATH)}${c.reset}`);
1550
1766
  }
1551
1767
  } else {
1552
1768
  startDaemon();
1553
1769
  }
1554
1770
  } catch (e) {
1555
- console.log(`${c.yellow}!${c.reset} Couldn't auto-start the daemon: ${e.message}`);
1556
- console.log(` ${c.dim}↳ run \`claude-rpc start\` when you're ready${c.reset}`);
1771
+ console.log(` ${c.yellow}!${c.reset} ${'daemon start'.padEnd(16)}${c.dim}couldn't auto-start: ${e.message}${c.reset}`);
1772
+ console.log(` ${c.gray}↳ run \`claude-rpc start\` when you're ready${c.reset}`);
1557
1773
  }
1774
+ setupOutro(target);
1558
1775
  break;
1776
+ }
1559
1777
  case 'uninstall': await runUninstall(); break;
1560
1778
  case 'upgrade-config': migrateConfig(); break;
1561
1779
  case 'start': startDaemon(); break;
@@ -1600,6 +1818,8 @@ const packagedDefault = IS_PACKAGED && !cmd;
1600
1818
  case 'privacy': doPrivacy(); break;
1601
1819
  case 'community': await doCommunity(process.argv.slice(3)); break;
1602
1820
  case 'profile': await doProfile(process.argv.slice(3)); break;
1821
+ case 'squad': await doSquadCmd(process.argv.slice(3)); break;
1822
+ case 'link': await doLink(process.argv.slice(3)); break;
1603
1823
  case 'doctor': {
1604
1824
  const { runDoctor, fixPlan } = await import('./doctor.js');
1605
1825
  const fix = process.argv.includes('--fix');
@@ -1620,19 +1840,19 @@ const packagedDefault = IS_PACKAGED && !cmd;
1620
1840
  try {
1621
1841
  if (kind === 'setup') {
1622
1842
  await runInstall({ exePath: EXE_PATH || process.execPath });
1623
- console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1843
+ console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1624
1844
  } else if (kind === 'rescan') {
1625
1845
  doScan(true);
1626
- console.log(` ${c.green}✓${c.reset} aggregate rebuilt from transcripts`);
1846
+ console.log(` ${c.green}✓${c.reset} aggregate rebuilt from transcripts`);
1627
1847
  } else if (kind === 'daemon') {
1628
1848
  restartDaemon();
1629
1849
  restarted = true;
1630
- console.log(` ${c.green}✓${c.reset} daemon (re)starting`);
1850
+ console.log(` ${c.green}✓${c.reset} daemon (re)starting`);
1631
1851
  } else if (kind === 'discord') {
1632
- 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.`);
1852
+ 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.`);
1633
1853
  }
1634
1854
  } catch (e) {
1635
- console.log(` ${c.red}✗${c.reset} ${kind} step failed: ${e.message}`);
1855
+ console.log(` ${c.red}✗${c.reset} ${kind} step failed: ${e.message}`);
1636
1856
  }
1637
1857
  }
1638
1858
  // A 'setup' rewire only takes effect once the daemon reloads, so ensure a
@@ -1649,8 +1869,9 @@ const packagedDefault = IS_PACKAGED && !cmd;
1649
1869
  default: {
1650
1870
  if (packagedDefault) {
1651
1871
  if (!isInstalled()) {
1652
- await runInstall({ exePath: EXE_PATH || process.execPath });
1872
+ const target = await runInstall({ exePath: EXE_PATH || process.execPath });
1653
1873
  startDaemon();
1874
+ setupOutro(target);
1654
1875
  } else {
1655
1876
  // Self-heal an existing install. Two real failure modes this fixes:
1656
1877
  //
@@ -1668,13 +1889,14 @@ const packagedDefault = IS_PACKAGED && !cmd;
1668
1889
  // Refresh hooks against the canonical exe, migrate config blocks,
1669
1890
  // wipe state, restart daemon. Anything the user has customized in
1670
1891
  // config.json is preserved (migrateConfig is non-destructive).
1671
- console.log('Claude RPC is installed. Refreshing…');
1892
+ console.log('');
1893
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}— already installed; refreshing hooks + config${c.reset}`);
1672
1894
  try {
1673
1895
  const target = ensureCanonicalExe(process.execPath);
1674
1896
  migrateConfig();
1675
1897
  installHooks(target);
1676
1898
  } catch (e) {
1677
- console.warn(`refresh skipped: ${e.message}`);
1899
+ console.warn(` ${c.yellow}!${c.reset} ${'refresh skipped'.padEnd(16)}${c.dim}${e.message}${c.reset}`);
1678
1900
  }
1679
1901
  const wasRunning = stopDaemon({ quiet: true });
1680
1902
  try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch { /* state.json locked or already gone — next hook will recreate it */ }
@@ -1691,11 +1913,16 @@ const packagedDefault = IS_PACKAGED && !cmd;
1691
1913
  // click with no args" install-and-start flow.
1692
1914
  overview();
1693
1915
  } else {
1694
- // Version in the error line is deliberate: the #1 cause of "unknown
1916
+ // Version in the hint is deliberate: the #1 cause of "unknown
1695
1917
  // command" in the wild is a stale global install resolving instead of
1696
1918
  // the version the user read the docs for. Make the skew visible.
1697
- fail(`unknown command: ${cmd} (claude-rpc v${VERSION})`,
1698
- { 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 });
1919
+ fail(`unknown command: ${cmd}`, {
1920
+ hint: [
1921
+ 'run `claude-rpc --help` for the full command list',
1922
+ `this install is v${VERSION} — if the docs mention \`${cmd}\`, update first: npm install -g claude-rpc@latest`,
1923
+ ],
1924
+ code: EX_USER_ERROR,
1925
+ });
1699
1926
  }
1700
1927
  }
1701
1928
  }
package/src/doctor.js CHANGED
@@ -50,7 +50,7 @@ export function fixPlan() {
50
50
  }
51
51
 
52
52
  function section(title) {
53
- console.log(`\n${c.bold}${title}${c.reset}`);
53
+ console.log(`\n ${c.bold}${title}${c.reset}`);
54
54
  }
55
55
 
56
56
  // ── individual checks ────────────────────────────────────────────────────
@@ -363,7 +363,8 @@ function checkDataDir() {
363
363
  export function runDoctor() {
364
364
  counters.pass = 0; counters.fail = 0; counters.warn = 0;
365
365
  findings = [];
366
- console.log(`${c.bold}${c.cyan}claude-rpc doctor${c.reset} ${c.dim}— diagnostic checklist${c.reset}`);
366
+ console.log('');
367
+ console.log(` ${c.bold}${c.magenta}◆ doctor${c.reset} ${c.dim}— diagnostic checklist${c.reset}`);
367
368
 
368
369
  section('Runtime');
369
370
  checkNodeVersion();
package/src/install.js CHANGED
@@ -17,10 +17,23 @@ import {
17
17
  } from './paths.js';
18
18
  import { DEFAULT_CONFIG } from './default-config.js';
19
19
  import { VERSION } from './version.js';
20
+ import { c, SYM_OK, SYM_WARN, SYM_FAIL, SYM_INFO, hintLine } from './ui.js';
20
21
 
21
22
  const STARTUP_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
22
23
  const STARTUP_VALUE = 'ClaudeRPC';
23
24
 
25
+ // Setup output is a phased checklist: every row is `sym label detail`, with
26
+ // the label column fixed-width so the detail column lines up across phases.
27
+ // The same rows print standalone (doctor --fix, packaged refresh) and still
28
+ // read fine outside the phased layout.
29
+ const LABEL_W = 16;
30
+ function step(sym, label, detail = '', log = console.log) {
31
+ log(` ${sym} ${label.padEnd(LABEL_W)}${detail ? `${c.dim}${detail}${c.reset}` : ''}`);
32
+ }
33
+ function phase(title) {
34
+ console.log(`\n ${c.bold}${title}${c.reset}`);
35
+ }
36
+
24
37
  const EVENTS = [
25
38
  'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
26
39
  'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
@@ -70,7 +83,7 @@ export function installHooks(exePath) {
70
83
  }
71
84
  }
72
85
  writeJson(CLAUDE_SETTINGS, settings);
73
- console.log(` hooks → ${CLAUDE_SETTINGS}`);
86
+ step(SYM_OK, 'hooks wired', `${EVENTS.length} events → ${CLAUDE_SETTINGS}`);
74
87
  }
75
88
 
76
89
  export function uninstallHooks() {
@@ -85,7 +98,7 @@ export function uninstallHooks() {
85
98
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
86
99
  }
87
100
  writeJson(CLAUDE_SETTINGS, settings);
88
- console.log(` hooks removed from ${CLAUDE_SETTINGS}`);
101
+ step(SYM_OK, 'hooks removed', CLAUDE_SETTINGS);
89
102
  }
90
103
 
91
104
  function regCommand(args) {
@@ -106,13 +119,13 @@ export async function addStartupEntry(exePath) {
106
119
  '/d', `"${exePath}" daemon`,
107
120
  '/f',
108
121
  ]);
109
- console.log(` startup HKCU\\...\\Run\\${STARTUP_VALUE}`);
122
+ step(SYM_OK, 'startup entry', `HKCU\\…\\Run\\${STARTUP_VALUE} — daemon starts at login`);
110
123
  }
111
124
 
112
125
  export async function removeStartupEntry() {
113
126
  try {
114
127
  await regCommand(['delete', STARTUP_KEY, '/v', STARTUP_VALUE, '/f']);
115
- console.log(` startup entry removed`);
128
+ step(SYM_OK, 'startup entry', 'removed');
116
129
  } catch {
117
130
  // Already absent — fine.
118
131
  }
@@ -159,7 +172,7 @@ export function ensureCanonicalExe(currentExe) {
159
172
  const src = statSync(currentExe);
160
173
  const dst = statSync(CANONICAL_EXE);
161
174
  if (src.size === dst.size && Math.abs(src.mtimeMs - dst.mtimeMs) < 2000) {
162
- console.log(` exe already installed → ${CANONICAL_EXE}`);
175
+ step(SYM_OK, 'exe installed', `${CANONICAL_EXE} (unchanged)`);
163
176
  return CANONICAL_EXE;
164
177
  }
165
178
  } catch { /* stat failed — fall through to copy attempt */ }
@@ -176,14 +189,13 @@ export function ensureCanonicalExe(currentExe) {
176
189
  }
177
190
  copyFileSync(currentExe, CANONICAL_EXE);
178
191
  if (process.platform !== 'win32') chmodSync(CANONICAL_EXE, 0o755);
179
- console.log(` exe installed → ${CANONICAL_EXE}`);
180
- console.log(` (the copy at ${currentExe} can be safely deleted)`);
192
+ step(SYM_OK, 'exe installed', CANONICAL_EXE);
193
+ step(SYM_INFO, 'original copy', `${currentExe} safe to delete`);
181
194
  sweepStaleCanonicalBackups();
182
195
  return CANONICAL_EXE;
183
196
  } catch (e) {
184
- console.warn(` ! failed to copy exe to ${CANONICAL_EXE}: ${e.message}`);
185
- console.warn(` falling back to ${currentExe} — manual updates that change`);
186
- console.warn(` the exe path may require running 'setup' again.`);
197
+ step(SYM_WARN, 'exe copy failed', `${CANONICAL_EXE}: ${e.message}`, console.warn);
198
+ hintLine(`falling back to ${currentExe} — if that file moves, run \`claude-rpc setup\` again`, process.stderr);
187
199
  return currentExe;
188
200
  }
189
201
  }
@@ -199,17 +211,17 @@ export function seedConfig() {
199
211
  if (!existsSync(CONFIG_PATH) && existsSync(legacyPath)) {
200
212
  mkdirSync(USER_CONFIG_DIR, { recursive: true });
201
213
  copyFileSync(legacyPath, CONFIG_PATH);
202
- console.log(` config migrated → ${CONFIG_PATH}`);
203
- console.log(` (was: ${legacyPath} — safe to delete on next 'npm update')`);
214
+ step(SYM_OK, 'config migrated', CONFIG_PATH);
215
+ step(SYM_INFO, 'legacy copy', `${legacyPath} — safe to delete on the next npm update`);
204
216
  return false;
205
217
  }
206
218
  } catch (e) {
207
- console.warn(` ! legacy-config migration skipped: ${e.message}`);
219
+ step(SYM_WARN, 'config legacy', `migration skipped: ${e.message}`, console.warn);
208
220
  }
209
221
  }
210
222
 
211
223
  if (existsSync(CONFIG_PATH)) {
212
- console.log(` config exists → ${CONFIG_PATH}`);
224
+ step(SYM_OK, 'config found', CONFIG_PATH);
213
225
  return false;
214
226
  }
215
227
  mkdirSync(USER_CONFIG_DIR, { recursive: true });
@@ -221,9 +233,9 @@ export function seedConfig() {
221
233
  seeded.community.instanceId = randomUUID();
222
234
  }
223
235
  writeFileSync(CONFIG_PATH, JSON.stringify(seeded, null, 2));
224
- console.log(` config seeded → ${CONFIG_PATH}`);
236
+ step(SYM_OK, 'config seeded', CONFIG_PATH);
225
237
  if (seeded.community?.enabled && seeded.community.instanceId) {
226
- console.log(` community totals on by default opt out with \`claude-rpc community off\``);
238
+ step(SYM_INFO, 'community', `anonymous totals on by default · opt out: ${c.reset}${c.cyan}claude-rpc community off`);
227
239
  }
228
240
  return true;
229
241
  }
@@ -270,7 +282,7 @@ export function migrateConfig({ silent = false } = {}) {
270
282
  let cfg;
271
283
  try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
272
284
  catch (e) {
273
- if (!silent) console.warn(` ! could not read config for migration: ${e.message}`);
285
+ if (!silent) step(SYM_WARN, 'config migration', `could not read config: ${e.message}`, console.warn);
274
286
  return false;
275
287
  }
276
288
  if (!cfg || typeof cfg !== 'object') return false;
@@ -359,14 +371,11 @@ export function migrateConfig({ silent = false } = {}) {
359
371
  }
360
372
 
361
373
  if (added.length === 0) {
362
- if (!silent) console.log(` config up to date ${CONFIG_PATH}`);
374
+ if (!silent) step(SYM_OK, 'config current', 'no new defaults to merge');
363
375
  return false;
364
376
  }
365
377
  writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
366
- if (!silent) {
367
- console.log(` config migrated → ${CONFIG_PATH}`);
368
- console.log(` added: ${added.join(', ')}`);
369
- }
378
+ if (!silent) step(SYM_OK, 'config migrated', `added: ${added.join(', ')}`);
370
379
  return true;
371
380
  }
372
381
 
@@ -417,7 +426,7 @@ function verifyHookPipe(exePath) {
417
426
  // Best-effort + loud: a failed -g (perms, offline) returns false so the caller
418
427
  // can stop with the manual command rather than wire a dead hook.
419
428
  function promoteNpxToGlobal() {
420
- console.log(' npx is one-off — installing claude-rpc globally so hooks survive…');
429
+ step(SYM_INFO, 'npx detected', 'one-off cache — installing globally so hooks survive…');
421
430
  const r = spawnSync('npm', ['install', '-g', `claude-rpc@${VERSION}`], {
422
431
  stdio: 'inherit',
423
432
  shell: process.platform === 'win32', // npm is npm.cmd on Windows
@@ -442,72 +451,90 @@ function warnIfStale() {
442
451
  const [l, v] = [num(latest), num(VERSION)];
443
452
  const newer = l[0] !== v[0] ? l[0] > v[0] : l[1] !== v[1] ? l[1] > v[1] : l[2] > v[2];
444
453
  if (newer) {
445
- console.warn(` ! you're running v${VERSION} but v${latest} is published — npx may have served a stale cache.`);
446
- console.warn(` for the newest version, stop here and re-run: npx claude-rpc@latest setup`);
454
+ step(SYM_WARN, 'newer version', `v${latest} is published but this is v${VERSION} — npx may have served a stale cache`, console.warn);
455
+ hintLine('for the newest version, stop here and re-run: npx claude-rpc@latest setup', process.stderr);
447
456
  }
448
457
  } catch { /* offline or npm missing — a version check must never block setup */ }
449
458
  }
450
459
 
451
460
  export async function install({ exePath, withStartup = true } = {}) {
461
+ console.log('');
462
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc setup${c.reset} ${c.dim}v${VERSION}${c.reset}`);
452
463
  warnIfStale();
453
464
  if (IS_NPX) {
454
465
  if (!promoteNpxToGlobal()) {
455
- console.error('\n ✗ Global install failed. Run this once, then you\'re set:');
456
- console.error(' npm install -g claude-rpc && claude-rpc setup');
466
+ console.error('');
467
+ step(SYM_FAIL, 'global install', 'failed', console.error);
468
+ hintLine('run this once, then you\'re set: npm install -g claude-rpc && claude-rpc setup', process.stderr);
457
469
  const err = new Error('npx self-install failed');
458
470
  err.code = 3; // system error (see exit-code contract)
459
471
  throw err;
460
472
  }
461
- console.log(' claude-rpc installed globally\n');
462
- }
463
- if (process.platform !== 'win32' && withStartup) {
464
- console.warn('Note: startup registration only works on Windows; other steps still run.');
473
+ step(SYM_OK, 'global install', `claude-rpc@${VERSION}`);
465
474
  }
466
475
  const incoming = exePath || process.execPath;
467
476
  // Canonicalize first so hook + startup entries point at a stable location,
468
477
  // not at the temp/Downloads path the user happened to launch from.
478
+ if (IS_PACKAGED) phase('binary');
469
479
  const target = ensureCanonicalExe(incoming);
470
- console.log('Installing Claude RPC…');
480
+
481
+ phase('config');
471
482
  // Order matters: seed creates the file if missing, then migrate fills in
472
483
  // any blocks new exe versions added (e.g. presence.byStatus from v0.3.6).
473
484
  seedConfig();
474
485
  migrateConfig();
475
- installHooks(target);
476
- if (withStartup && process.platform === 'win32') {
477
- try { await addStartupEntry(target); }
478
- catch (e) { console.warn(` startup entry failed: ${e.message}`); }
479
- }
480
486
 
487
+ phase('claude code');
488
+ installHooks(target);
481
489
  // Proof the hook pipe actually fires. A setup that returns success
482
490
  // without verification is a lie — we caught broken-hook-path bugs
483
491
  // twice during v0.3.x because no one ran a real event after install.
484
492
  const probe = verifyHookPipe(target);
485
493
  if (probe.ok) {
486
- console.log(` hook pipe ✓ ${probe.detail}`);
494
+ step(SYM_OK, 'hook verified', probe.detail);
487
495
  } else {
488
- console.warn(` hook pipe ✗ ${probe.detail}`);
489
- console.warn(` ↳ run \`claude-rpc doctor\` for a full diagnostic`);
496
+ step(SYM_FAIL, 'hook verify', probe.detail, console.warn);
497
+ hintLine('run `claude-rpc doctor` for a full diagnostic', process.stderr);
490
498
  }
491
499
 
492
- console.log('\nDone.');
493
- console.log(`Config: ${CONFIG_PATH}`);
494
- console.log(` (a working Discord app is bundled — edit clientId only to use your own)`);
495
- // setup auto-starts the daemon (see cli.js), so we point at management +
496
- // verification rather than a start command. The packaged exe manages the
497
- // daemon via its own subcommands; the npm/dev bin uses `claude-rpc …`.
498
- if (IS_PACKAGED) {
499
- console.log(`\nManage the daemon: "${target}" daemon (start) · check with claude-rpc doctor`);
500
- } else {
501
- console.log(`\nManage the daemon: claude-rpc start | stop | status`);
502
- console.log(`Verify wiring: claude-rpc doctor`);
500
+ // The CLI's setup case launches the daemon right after this returns, so its
501
+ // row lands under this heading; setupOutro() then closes the screen.
502
+ phase('daemon');
503
+ if (withStartup) {
504
+ if (process.platform === 'win32') {
505
+ try { await addStartupEntry(target); }
506
+ catch (e) { step(SYM_WARN, 'startup entry', `failed: ${e.message}`, console.warn); }
507
+ } else {
508
+ step(SYM_INFO, 'startup entry', 'skipped — login autostart is Windows-only');
509
+ }
503
510
  }
511
+ return target;
512
+ }
513
+
514
+ // The single closing block of `claude-rpc setup` — what to do now, where the
515
+ // levers are. Printed by the CLI after the daemon launch so it always lands
516
+ // last; doctor --fix re-runs install() without it.
517
+ export function setupOutro(target) {
518
+ const point = (label, value, note = '') =>
519
+ console.log(` ${c.dim}→${c.reset} ${c.dim}${label.padEnd(14)}${c.reset} ${c.cyan}${value}${c.reset}${note ? ` ${c.dim}${note}${c.reset}` : ''}`);
520
+ console.log('');
521
+ console.log(` ${SYM_OK} ${c.bold}setup complete${c.reset} — open Claude Code and send a prompt; your card goes live in Discord.`);
522
+ point('verify wiring', 'claude-rpc doctor');
523
+ if (IS_PACKAGED) point('start daemon', `"${target}" daemon`, 'also runs automatically at login');
524
+ else point('manage daemon', 'claude-rpc start · stop · status');
525
+ point('config', CONFIG_PATH, 'a working Discord app is bundled — set clientId only to use your own');
526
+ console.log('');
504
527
  }
505
528
 
506
529
  export async function uninstall() {
507
- console.log('Uninstalling Claude RPC…');
530
+ console.log('');
531
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc uninstall${c.reset}`);
532
+ console.log('');
508
533
  uninstallHooks();
509
534
  if (process.platform === 'win32') await removeStartupEntry();
510
- console.log('\nDone. (Config at %APPDATA%\\claude-rpc\\ left intact — delete manually if you want.)');
535
+ console.log('');
536
+ console.log(` ${SYM_OK} ${c.bold}uninstalled${c.reset} — config at ${c.cyan}${USER_CONFIG_DIR}${c.reset} ${c.dim}left intact; delete it manually if you want.${c.reset}`);
537
+ console.log('');
511
538
  }
512
539
 
513
540
  export function isInstalled() {
package/src/ui.js CHANGED
@@ -43,12 +43,21 @@ export const EX_USER_ERROR = 1;
43
43
  export const EX_SYS_ERROR = 2;
44
44
  export const EX_BAD_STATE = 3;
45
45
 
46
- // Print a one-line message plus an indented dim hint on the next line.
47
- // Hint is the tired-user safety net: every failure surface tells you
48
- // what to type next. Empty hint omits the second line.
46
+ // Hint lines sit directly under the message they belong to, aligned with the
47
+ // label (the symbol column differs between TTY glyphs and [fail]-style tags).
48
+ const HINT_INDENT = ' '.repeat(TTY ? 5 : 10);
49
+
50
+ export function hintLine(text, stream = process.stdout) {
51
+ stream.write(`${HINT_INDENT}${c.gray}↳ ${text}${c.reset}\n`);
52
+ }
53
+
54
+ // Print a one-line message plus aligned dim hint line(s) below it. A hint is
55
+ // the tired-user safety net: it tells you what to type next. Accepts a single
56
+ // string or an array (one ↳ line each); empty omits them.
49
57
  function withHint(sym, label, hint, stream = process.stdout) {
50
58
  stream.write(` ${sym} ${label}\n`);
51
- if (hint) stream.write(` ${c.gray}↳ ${hint}${c.reset}\n`);
59
+ const hints = Array.isArray(hint) ? hint : (hint ? [hint] : []);
60
+ for (const h of hints) hintLine(h, stream);
52
61
  }
53
62
 
54
63
  export function ok(label, detail = '') {
@@ -63,11 +72,11 @@ export function warn(label, hint = '') {
63
72
  withHint(SYM_WARN, label, hint);
64
73
  }
65
74
 
66
- // Print a failure with a hint and exit with the given code. The hint is
67
- // the difference between a frustrated user and a fixed user always
68
- // supply one when you can, and default it to `claude-rpc doctor` when
69
- // you have no better idea.
70
- export function fail(label, { hint = 'run `claude-rpc doctor` for a full diagnostic', code = EX_USER_ERROR } = {}) {
75
+ // Print a failure with an optional hint and exit with the given code. Hints
76
+ // must be contextual: point at `claude-rpc doctor` only for local wiring or
77
+ // state problems it actually diagnoses for usage errors, remote rejections,
78
+ // and network failures, give a directly useful hint or none at all.
79
+ export function fail(label, { hint = '', code = EX_USER_ERROR } = {}) {
71
80
  withHint(SYM_FAIL, label, hint, process.stderr);
72
81
  process.exit(code);
73
82
  }
@@ -93,7 +102,5 @@ export function check(label, status, detail = '', hint = '') {
93
102
  else sym = SYM_INFO;
94
103
  const tail = detail ? ` ${c.dim}${detail}${c.reset}` : '';
95
104
  process.stdout.write(` ${sym} ${label}${tail}\n`);
96
- if (hint && status !== 'pass') {
97
- process.stdout.write(` ${c.gray}↳ ${hint}${c.reset}\n`);
98
- }
105
+ if (hint && status !== 'pass') hintLine(hint);
99
106
  }
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.14.0';
14
+ const BAKED = '0.15.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {