claude-rpc 0.15.0 → 0.15.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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",
package/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { readState } from './state.js';
15
15
  import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } from './format.js';
16
16
  import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
17
17
  import { runHookCli } from './hook.js';
18
- import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, 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,7 +999,7 @@ 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
  }
@@ -1018,7 +1018,12 @@ function squadAuth() {
1018
1018
  const cfg = loadConfig();
1019
1019
  const endpoint = (cfg.community?.endpoint || '').replace(/\/+$/, '');
1020
1020
  const instanceId = cfg.community?.instanceId;
1021
- if (!endpoint) fail('no community endpoint configured', { code: EX_BAD_STATE });
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
+ }
1022
1027
  if (!instanceId) {
1023
1028
  fail('squads need an identity', {
1024
1029
  hint: 'run `claude-rpc profile set --handle <name> && claude-rpc profile on` first',
@@ -1044,9 +1049,9 @@ function squadPageUrl(id) { return `https://claude-rpc.vercel.app/squad/${id}`;
1044
1049
 
1045
1050
  function printSquadInvite(squad) {
1046
1051
  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}`);
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}`);
1050
1055
  console.log('');
1051
1056
  console.log(` ${c.dim}send your crew this:${c.reset}`);
1052
1057
  console.log(` join my Claude Code squad "${squad.name}" — npx claude-rpc@latest setup, then:`);
@@ -1083,19 +1088,28 @@ async function doSquadCmd(argv) {
1083
1088
  if (sub === 'status' || sub === '') return squadStatus(ctx);
1084
1089
  if (sub === 'create') {
1085
1090
  const name = argv.slice(1).join(' ').trim();
1086
- if (!name) return fail('usage: claude-rpc squad create <name>', { code: EX_USER_ERROR });
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
+ }
1087
1095
  const r = await ctx.post('/squad/create', { name });
1088
1096
  if (r.status !== 200) return fail(`create failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1089
1097
  return printSquadInvite(r.json.squad);
1090
1098
  }
1091
1099
  if (sub === 'join') {
1092
1100
  const code = (argv[1] || '').trim();
1093
- if (!code) return fail('usage: claude-rpc squad join SQ-XXXXXX', { code: EX_USER_ERROR });
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
+ }
1094
1105
  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 });
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
+ }
1096
1110
  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}`);
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}`);
1099
1113
  return;
1100
1114
  }
1101
1115
  if (sub === 'leave') {
@@ -1114,7 +1128,7 @@ async function doSquadCmd(argv) {
1114
1128
  }
1115
1129
  const r = await ctx.post('/squad/leave', { squadId: target.id });
1116
1130
  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}` : ''}`);
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}` : ''}`);
1118
1132
  return;
1119
1133
  }
1120
1134
  fail(`unknown squad subcommand: ${sub}`, {
@@ -1147,14 +1161,15 @@ async function doLink(argv) {
1147
1161
  }
1148
1162
  const r = await ctx.post('/pair/claim', { code });
1149
1163
  if (r.status !== 200) {
1150
- return fail(`link failed: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
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 });
1151
1166
  }
1152
1167
  // Mirror the verified identity locally so `profile status` agrees.
1153
1168
  const userCfg = readJson(CONFIG_PATH, {});
1154
1169
  userCfg.profile = { ...(userCfg.profile || {}), githubUser: r.json.githubUser, verified: true };
1155
1170
  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}`);
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}`);
1158
1173
  }
1159
1174
 
1160
1175
  // ── Community totals ─────────────────────────────────────────────────────
@@ -1201,7 +1216,7 @@ async function communityOn() {
1201
1216
  const cfg = loadConfig();
1202
1217
  const community = cfg.community || {};
1203
1218
  if (community.enabled) {
1204
- console.log(`${c.green}✓${c.reset} community totals are already enabled`);
1219
+ console.log(` ${c.green}✓${c.reset} community totals are already enabled`);
1205
1220
  return;
1206
1221
  }
1207
1222
  console.log('');
@@ -1246,12 +1261,12 @@ async function communityOn() {
1246
1261
  function communityOff() {
1247
1262
  const userCfg = readJson(CONFIG_PATH, {});
1248
1263
  if (!userCfg.community?.enabled) {
1249
- console.log(`${c.dim}community totals are already off.${c.reset}`);
1264
+ console.log(` ${c.cyan}·${c.reset} community totals are already off`);
1250
1265
  return;
1251
1266
  }
1252
1267
  userCfg.community = { ...userCfg.community, enabled: false };
1253
1268
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1254
- 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}`);
1255
1270
  }
1256
1271
 
1257
1272
  async function communityReport() {
@@ -1263,11 +1278,11 @@ async function communityReport() {
1263
1278
  const result = await flushCommunity(cfg);
1264
1279
  console.log('');
1265
1280
  if (result.ok && result.delta) {
1266
- 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}`);
1267
1282
  } else if (result.ok) {
1268
- console.log(` ${c.dim}${result.reason}${c.reset}`);
1283
+ console.log(` ${c.cyan}·${c.reset} ${c.dim}${result.reason}${c.reset}`);
1269
1284
  } else {
1270
- 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}`);
1271
1286
  }
1272
1287
  console.log('');
1273
1288
  }
@@ -1283,6 +1298,32 @@ function readFlag(argv, name) {
1283
1298
  return eq ? eq.slice(name.length + 3) : undefined;
1284
1299
  }
1285
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
+
1286
1327
  function profileStatus() {
1287
1328
  const p = (loadConfig().profile) || {};
1288
1329
  const handleOk = lb.isValidHandle(p.handle);
@@ -1307,23 +1348,27 @@ function profileStatus() {
1307
1348
  // Setup checklist — same shape every time, so the user always sees where
1308
1349
  // they are and the exact next command. This is the screen the daemon's
1309
1350
  // 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
- ];
1351
+ const steps = profileSteps(p);
1315
1352
  if (steps.every((s) => s.done)) {
1316
- 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}`);
1317
1354
  } else {
1318
1355
  const nextIdx = steps.findIndex((s) => !s.done);
1319
- box('next steps', steps.map((s, i) => {
1356
+ const lines = steps.map((s, i) => {
1320
1357
  const mark = s.done ? `${c.green}✓${c.reset}` : (i === nextIdx ? `${c.yellow}○${c.reset}` : `${c.dim}○${c.reset}`);
1321
1358
  const label = s.done ? `${c.dim}${s.label}${c.reset}` : `${c.bold}${s.label}${c.reset}`;
1322
1359
  const tail = s.done
1323
1360
  ? `${c.dim}${s.note || 'done'}${c.reset}`
1324
1361
  : `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
1325
1362
  return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
1326
- }));
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);
1327
1372
  }
1328
1373
  console.log('');
1329
1374
  }
@@ -1356,8 +1401,14 @@ function profileSet(argv) {
1356
1401
 
1357
1402
  userCfg.profile = next;
1358
1403
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1359
- console.log(`${c.green}✓${c.reset} profile saved`);
1360
- 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();
1361
1412
  }
1362
1413
 
1363
1414
  function profileEnable(on) {
@@ -1382,10 +1433,11 @@ function profileEnable(on) {
1382
1433
  }
1383
1434
  userCfg.profile = next;
1384
1435
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1385
- console.log(`${c.green}✓${c.reset} leaderboard publishing ${on ? 'enabled' : 'disabled'}`);
1386
1436
  if (on) {
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}`);
1388
- profileStatus();
1437
+ console.log(` ${c.green}✓${c.reset} publishing enabled ${c.dim} board syncs on the next flush, or now: ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}`);
1438
+ profileNextStep();
1439
+ } else {
1440
+ console.log(` ${c.green}✓${c.reset} leaderboard publishing disabled`);
1389
1441
  }
1390
1442
  }
1391
1443
 
@@ -1399,12 +1451,12 @@ async function profilePublish() {
1399
1451
  });
1400
1452
  }
1401
1453
  const { flushProfile } = await import('./community.js');
1402
- 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}`);
1403
1455
  const r = await flushProfile(cfg);
1404
1456
  if (r.ok) {
1405
- 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}`);
1406
1458
  } else if (r.reason === 'rate-limited') {
1407
- 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`);
1408
1460
  } else {
1409
1461
  return fail(`publish failed: ${r.reason}${r.error ? ' (' + r.error + ')' : ''}`, { code: EX_SYS_ERROR });
1410
1462
  }
@@ -1421,13 +1473,18 @@ async function profileVerify() {
1421
1473
  // and publishing that gist already requires gh auth, so the account is
1422
1474
  // known by the time it matters.
1423
1475
  if (!profile.githubUser) {
1424
- 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}`);
1425
1477
  }
1426
1478
  if (!community.instanceId) {
1427
1479
  return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
1428
1480
  }
1429
1481
  const endpoint = (community.endpoint || '').replace(/\/+$/, '');
1430
- 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
+ }
1431
1488
 
1432
1489
  const post = async (path, body) => {
1433
1490
  const res = await fetch(endpoint + path, {
@@ -1445,13 +1502,13 @@ async function profileVerify() {
1445
1502
  const { flushProfile } = await import('./community.js');
1446
1503
  await flushProfile(cfg);
1447
1504
  }
1448
- console.log(`${c.dim}requesting a verification token…${c.reset}`);
1505
+ console.log(` ${c.dim}requesting a verification token…${c.reset}`);
1449
1506
  const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser || null });
1450
1507
  if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
1451
1508
  const token = start.json.token;
1452
1509
 
1453
1510
  const { publishGistFile } = await import('./gist.js');
1454
- console.log(`${c.dim}publishing a public proof gist…${c.reset}`);
1511
+ console.log(` ${c.dim}publishing a public proof gist…${c.reset}`);
1455
1512
  const gist = await publishGistFile({
1456
1513
  svg: `claude-rpc leaderboard verification\n${token}\n`,
1457
1514
  filename: 'claude-rpc-verify.txt',
@@ -1462,7 +1519,7 @@ async function profileVerify() {
1462
1519
  // Hand the worker the gist ID so it fetches that gist directly (no
1463
1520
  // gist-list lag) and reads the real owner — instant, and the owner becomes
1464
1521
  // the verified identity regardless of what --github was set to.
1465
- console.log(`${c.dim}confirming with the server…${c.reset}`);
1522
+ console.log(` ${c.dim}confirming with the server…${c.reset}`);
1466
1523
  const check = await post('/verify/check', { instanceId: community.instanceId, gistId: gist.id });
1467
1524
  if (check.json?.verified) {
1468
1525
  const who = check.json.githubUser || gist.owner || profile.githubUser;
@@ -1471,13 +1528,13 @@ async function profileVerify() {
1471
1528
  const userCfg = readJson(CONFIG_PATH, {});
1472
1529
  userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
1473
1530
  writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1474
- 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`);
1475
1532
  if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
1476
- 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}`);
1477
1534
  }
1478
1535
  } else {
1479
- console.log(`${c.yellow}!${c.reset} not confirmed: ${check.json?.error || check.status}`);
1480
- 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}`);
1481
1538
  }
1482
1539
  } catch (e) {
1483
1540
  return fail(`verification failed: ${e.message}`, {
@@ -1514,7 +1571,8 @@ async function doCommunity(argv) {
1514
1571
 
1515
1572
  function tailLog() {
1516
1573
  if (!existsSync(LOG_PATH)) {
1517
- 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}`);
1518
1576
  return;
1519
1577
  }
1520
1578
  // Print the last ~30 lines, then follow.
@@ -1646,18 +1704,19 @@ function help() {
1646
1704
  ['tail', 'Tail the daemon log file'],
1647
1705
  ['daemon', 'Run daemon in foreground (debug)'],
1648
1706
  ];
1707
+ const colW = cmds.reduce((m, [name]) => Math.max(m, name.length), 0);
1649
1708
  console.log('');
1650
1709
  console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}— Discord Rich Presence for Claude Code${c.reset}`);
1651
1710
  console.log('');
1652
1711
  console.log(` ${c.dim}Commands:${c.reset}`);
1653
1712
  for (const [name, desc] of cmds) {
1654
- console.log(` ${c.cyan}${name.padEnd(10)}${c.reset} ${desc}`);
1713
+ console.log(` ${c.cyan}${name.padEnd(colW)}${c.reset} ${desc}`);
1655
1714
  }
1656
1715
  console.log('');
1657
1716
  console.log(` ${c.dim}First-time setup:${c.reset}`);
1658
- console.log(` 1. Set ${c.cyan}clientId${c.reset} in ${c.cyan}config.json${c.reset} to your Discord app id.`);
1659
- 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}.`);
1660
- 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}.`);
1661
1720
  console.log('');
1662
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.`);
1663
1722
  console.log('');
@@ -1686,8 +1745,11 @@ const packagedDefault = IS_PACKAGED && !cmd;
1686
1745
  // startup, install = with) but in practice users expect one command
1687
1746
  // to do everything. Non-Windows: addStartupEntry is a no-op + warning.
1688
1747
  case 'setup':
1689
- case 'install':
1690
- await runInstall({ exePath: EXE_PATH || process.execPath });
1748
+ case 'install': {
1749
+ // runInstall prints the phased checklist (or a one-line "already set
1750
+ // up" on clean re-runs); the daemon row lands after it, then setupOutro
1751
+ // closes the screen — only when something actually changed.
1752
+ const { target, changed } = await runInstall({ exePath: EXE_PATH || process.execPath });
1691
1753
  // Slimmer first run: bring the daemon up now so the card appears
1692
1754
  // immediately, instead of making the user run a separate `start`.
1693
1755
  // Best-effort — a start hiccup must never make `setup` look failed.
@@ -1696,22 +1758,29 @@ const packagedDefault = IS_PACKAGED && !cmd;
1696
1758
  // Our own tree is npm's throwaway _npx cache; launch from the global
1697
1759
  // install setup just promoted to, via the PATH-resolved bin.
1698
1760
  if (!daemonPid()) {
1699
- spawn('claude-rpc', ['daemon'], {
1761
+ const child = spawn('claude-rpc', ['daemon'], {
1700
1762
  detached: true, stdio: 'ignore', windowsHide: true,
1701
1763
  shell: process.platform === 'win32',
1702
- }).unref();
1703
- console.log(`${c.green}✓${c.reset} Daemon launched ${c.dim}logs: ${LOG_PATH}${c.reset}`);
1764
+ });
1765
+ child.unref();
1766
+ console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}log ${shortPath(LOG_PATH)}${c.reset}`);
1767
+ } else {
1768
+ console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonPid()}${c.reset}`);
1704
1769
  }
1705
1770
  } else {
1706
1771
  startDaemon();
1707
1772
  }
1708
1773
  } catch (e) {
1709
- console.log(`${c.yellow}!${c.reset} Couldn't auto-start the daemon: ${e.message}`);
1710
- console.log(` ${c.dim}↳ run \`claude-rpc start\` when you're ready${c.reset}`);
1774
+ console.log(` ${c.yellow}!${c.reset} ${'daemon start'.padEnd(16)}${c.dim}couldn't auto-start: ${e.message}${c.reset}`);
1775
+ console.log(` ${c.gray}↳ run \`claude-rpc start\` when you're ready${c.reset}`);
1711
1776
  }
1777
+ setupOutro(target, changed);
1712
1778
  break;
1779
+ }
1713
1780
  case 'uninstall': await runUninstall(); break;
1714
- case 'upgrade-config': migrateConfig(); break;
1781
+ case 'upgrade-config':
1782
+ if (!migrateConfig()) console.log(` ${c.green}✓${c.reset} config already current — nothing to migrate`);
1783
+ break;
1715
1784
  case 'start': startDaemon(); break;
1716
1785
  case 'stop': stopDaemon(); break;
1717
1786
  case 'restart': restartDaemon(); break;
@@ -1776,19 +1845,19 @@ const packagedDefault = IS_PACKAGED && !cmd;
1776
1845
  try {
1777
1846
  if (kind === 'setup') {
1778
1847
  await runInstall({ exePath: EXE_PATH || process.execPath });
1779
- console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1848
+ console.log(` ${c.green}✓${c.reset} config + hooks repaired`);
1780
1849
  } else if (kind === 'rescan') {
1781
1850
  doScan(true);
1782
- console.log(` ${c.green}✓${c.reset} aggregate rebuilt from transcripts`);
1851
+ console.log(` ${c.green}✓${c.reset} aggregate rebuilt from transcripts`);
1783
1852
  } else if (kind === 'daemon') {
1784
1853
  restartDaemon();
1785
1854
  restarted = true;
1786
- console.log(` ${c.green}✓${c.reset} daemon (re)starting`);
1855
+ console.log(` ${c.green}✓${c.reset} daemon (re)starting`);
1787
1856
  } else if (kind === 'discord') {
1788
- 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.`);
1857
+ 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.`);
1789
1858
  }
1790
1859
  } catch (e) {
1791
- console.log(` ${c.red}✗${c.reset} ${kind} step failed: ${e.message}`);
1860
+ console.log(` ${c.red}✗${c.reset} ${kind} step failed: ${e.message}`);
1792
1861
  }
1793
1862
  }
1794
1863
  // A 'setup' rewire only takes effect once the daemon reloads, so ensure a
@@ -1805,8 +1874,9 @@ const packagedDefault = IS_PACKAGED && !cmd;
1805
1874
  default: {
1806
1875
  if (packagedDefault) {
1807
1876
  if (!isInstalled()) {
1808
- await runInstall({ exePath: EXE_PATH || process.execPath });
1877
+ const { target } = await runInstall({ exePath: EXE_PATH || process.execPath });
1809
1878
  startDaemon();
1879
+ setupOutro(target);
1810
1880
  } else {
1811
1881
  // Self-heal an existing install. Two real failure modes this fixes:
1812
1882
  //
@@ -1824,13 +1894,14 @@ const packagedDefault = IS_PACKAGED && !cmd;
1824
1894
  // Refresh hooks against the canonical exe, migrate config blocks,
1825
1895
  // wipe state, restart daemon. Anything the user has customized in
1826
1896
  // config.json is preserved (migrateConfig is non-destructive).
1827
- console.log('Claude RPC is installed. Refreshing…');
1897
+ console.log('');
1898
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}— already installed; refreshing hooks + config${c.reset}`);
1828
1899
  try {
1829
1900
  const target = ensureCanonicalExe(process.execPath);
1830
1901
  migrateConfig();
1831
1902
  installHooks(target);
1832
1903
  } catch (e) {
1833
- console.warn(`refresh skipped: ${e.message}`);
1904
+ console.warn(` ${c.yellow}!${c.reset} ${'refresh skipped'.padEnd(16)}${c.dim}${e.message}${c.reset}`);
1834
1905
  }
1835
1906
  const wasRunning = stopDaemon({ quiet: true });
1836
1907
  try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch { /* state.json locked or already gone — next hook will recreate it */ }
@@ -1847,11 +1918,16 @@ const packagedDefault = IS_PACKAGED && !cmd;
1847
1918
  // click with no args" install-and-start flow.
1848
1919
  overview();
1849
1920
  } else {
1850
- // Version in the error line is deliberate: the #1 cause of "unknown
1921
+ // Version in the hint is deliberate: the #1 cause of "unknown
1851
1922
  // command" in the wild is a stale global install resolving instead of
1852
1923
  // 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 });
1924
+ fail(`unknown command: ${cmd}`, {
1925
+ hint: [
1926
+ 'run `claude-rpc --help` for the full command list',
1927
+ `this install is v${VERSION} — if the docs mention \`${cmd}\`, update first: npm install -g claude-rpc@latest`,
1928
+ ],
1929
+ code: EX_USER_ERROR,
1930
+ });
1855
1931
  }
1856
1932
  }
1857
1933
  }
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,41 @@ 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
+ //
30
+ // Loud when something changes, near-silent when nothing does: a re-run where
31
+ // everything is already in place collapses to ONE summary line instead of
32
+ // re-printing the checklist. State-changing steps print rows (flushing their
33
+ // pending phase header) and mark the run dirty; confirmations record a
34
+ // `noop()` fact for the summary. Failures always print.
35
+ const LABEL_W = 16;
36
+ let pendingPhase = null;
37
+ let runDirty = false;
38
+ let noopFacts = [];
39
+
40
+ function resetRun() { pendingPhase = null; runDirty = false; noopFacts = []; }
41
+ function phase(title) { pendingPhase = title; }
42
+ function step(sym, label, detail = '', log = console.log) {
43
+ if (pendingPhase) {
44
+ console.log(`\n ${c.bold}${pendingPhase}${c.reset}`);
45
+ pendingPhase = null;
46
+ }
47
+ log(` ${sym} ${label.padEnd(LABEL_W)}${detail ? `${c.dim}${detail}${c.reset}` : ''}`);
48
+ }
49
+ function dirtyStep(sym, label, detail = '', log = console.log) {
50
+ runDirty = true;
51
+ step(sym, label, detail, log);
52
+ }
53
+ function noop(fact) { noopFacts.push(fact); }
54
+
24
55
  const EVENTS = [
25
56
  'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
26
57
  'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
@@ -43,6 +74,7 @@ function isOurHookCommand(cmd) {
43
74
 
44
75
  export function installHooks(exePath) {
45
76
  const settings = readJson(CLAUDE_SETTINGS, {});
77
+ const before = JSON.stringify(settings.hooks || {});
46
78
  settings.hooks = settings.hooks || {};
47
79
  // Three modes, three shapes:
48
80
  // packaged → `"<exe>" hook <event>` (canonical exe, no node)
@@ -69,8 +101,13 @@ export function installHooks(exePath) {
69
101
  bucket.push({ matcher: '', hooks: [{ type: 'command', command: wanted }] });
70
102
  }
71
103
  }
104
+ if (JSON.stringify(settings.hooks) === before) {
105
+ noop(`hooks wired (${EVENTS.length} events)`);
106
+ return false;
107
+ }
72
108
  writeJson(CLAUDE_SETTINGS, settings);
73
- console.log(` hooks → ${CLAUDE_SETTINGS}`);
109
+ dirtyStep(SYM_OK, 'hooks wired', `${EVENTS.length} events → ${CLAUDE_SETTINGS}`);
110
+ return true;
74
111
  }
75
112
 
76
113
  export function uninstallHooks() {
@@ -85,7 +122,7 @@ export function uninstallHooks() {
85
122
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
86
123
  }
87
124
  writeJson(CLAUDE_SETTINGS, settings);
88
- console.log(` hooks removed from ${CLAUDE_SETTINGS}`);
125
+ step(SYM_OK, 'hooks removed', CLAUDE_SETTINGS);
89
126
  }
90
127
 
91
128
  function regCommand(args) {
@@ -106,13 +143,14 @@ export async function addStartupEntry(exePath) {
106
143
  '/d', `"${exePath}" daemon`,
107
144
  '/f',
108
145
  ]);
109
- console.log(` startup HKCU\\...\\Run\\${STARTUP_VALUE}`);
146
+ if (runDirty) step(SYM_OK, 'startup entry', `HKCU\\…\\Run\\${STARTUP_VALUE} — daemon starts at login`);
147
+ else noop('startup entry present');
110
148
  }
111
149
 
112
150
  export async function removeStartupEntry() {
113
151
  try {
114
152
  await regCommand(['delete', STARTUP_KEY, '/v', STARTUP_VALUE, '/f']);
115
- console.log(` startup entry removed`);
153
+ step(SYM_OK, 'startup entry', 'removed');
116
154
  } catch {
117
155
  // Already absent — fine.
118
156
  }
@@ -159,7 +197,7 @@ export function ensureCanonicalExe(currentExe) {
159
197
  const src = statSync(currentExe);
160
198
  const dst = statSync(CANONICAL_EXE);
161
199
  if (src.size === dst.size && Math.abs(src.mtimeMs - dst.mtimeMs) < 2000) {
162
- console.log(` exe already installed → ${CANONICAL_EXE}`);
200
+ noop('exe current');
163
201
  return CANONICAL_EXE;
164
202
  }
165
203
  } catch { /* stat failed — fall through to copy attempt */ }
@@ -176,14 +214,13 @@ export function ensureCanonicalExe(currentExe) {
176
214
  }
177
215
  copyFileSync(currentExe, CANONICAL_EXE);
178
216
  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)`);
217
+ dirtyStep(SYM_OK, 'exe installed', CANONICAL_EXE);
218
+ step(SYM_INFO, 'original copy', `${currentExe} safe to delete`);
181
219
  sweepStaleCanonicalBackups();
182
220
  return CANONICAL_EXE;
183
221
  } 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.`);
222
+ step(SYM_WARN, 'exe copy failed', `${CANONICAL_EXE}: ${e.message}`, console.warn);
223
+ hintLine(`falling back to ${currentExe} — if that file moves, run \`claude-rpc setup\` again`, process.stderr);
187
224
  return currentExe;
188
225
  }
189
226
  }
@@ -199,17 +236,17 @@ export function seedConfig() {
199
236
  if (!existsSync(CONFIG_PATH) && existsSync(legacyPath)) {
200
237
  mkdirSync(USER_CONFIG_DIR, { recursive: true });
201
238
  copyFileSync(legacyPath, CONFIG_PATH);
202
- console.log(` config migrated → ${CONFIG_PATH}`);
203
- console.log(` (was: ${legacyPath} — safe to delete on next 'npm update')`);
239
+ dirtyStep(SYM_OK, 'config migrated', CONFIG_PATH);
240
+ step(SYM_INFO, 'legacy copy', `${legacyPath} — safe to delete on the next npm update`);
204
241
  return false;
205
242
  }
206
243
  } catch (e) {
207
- console.warn(` ! legacy-config migration skipped: ${e.message}`);
244
+ step(SYM_WARN, 'config legacy', `migration skipped: ${e.message}`, console.warn);
208
245
  }
209
246
  }
210
247
 
211
248
  if (existsSync(CONFIG_PATH)) {
212
- console.log(` config exists → ${CONFIG_PATH}`);
249
+ noop('config current');
213
250
  return false;
214
251
  }
215
252
  mkdirSync(USER_CONFIG_DIR, { recursive: true });
@@ -221,9 +258,9 @@ export function seedConfig() {
221
258
  seeded.community.instanceId = randomUUID();
222
259
  }
223
260
  writeFileSync(CONFIG_PATH, JSON.stringify(seeded, null, 2));
224
- console.log(` config seeded → ${CONFIG_PATH}`);
261
+ dirtyStep(SYM_OK, 'config seeded', CONFIG_PATH);
225
262
  if (seeded.community?.enabled && seeded.community.instanceId) {
226
- console.log(` community totals on by default opt out with \`claude-rpc community off\``);
263
+ step(SYM_INFO, 'community', `anonymous totals on by default · opt out: ${c.reset}${c.cyan}claude-rpc community off`);
227
264
  }
228
265
  return true;
229
266
  }
@@ -270,7 +307,7 @@ export function migrateConfig({ silent = false } = {}) {
270
307
  let cfg;
271
308
  try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
272
309
  catch (e) {
273
- if (!silent) console.warn(` ! could not read config for migration: ${e.message}`);
310
+ if (!silent) step(SYM_WARN, 'config migration', `could not read config: ${e.message}`, console.warn);
274
311
  return false;
275
312
  }
276
313
  if (!cfg || typeof cfg !== 'object') return false;
@@ -358,15 +395,9 @@ export function migrateConfig({ silent = false } = {}) {
358
395
  if (changed) added.push('presence.buttons[] → CTA');
359
396
  }
360
397
 
361
- if (added.length === 0) {
362
- if (!silent) console.log(` config up to date → ${CONFIG_PATH}`);
363
- return false;
364
- }
398
+ if (added.length === 0) return false;
365
399
  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
- }
400
+ if (!silent) dirtyStep(SYM_OK, 'config migrated', `added: ${added.join(', ')}`);
370
401
  return true;
371
402
  }
372
403
 
@@ -417,12 +448,29 @@ function verifyHookPipe(exePath) {
417
448
  // Best-effort + loud: a failed -g (perms, offline) returns false so the caller
418
449
  // can stop with the manual command rather than wire a dead hook.
419
450
  function promoteNpxToGlobal() {
420
- console.log(' npx is one-off installing claude-rpc globally so hooks survive…');
451
+ // Already promoted on a previous run? The PATH-resolved bin answers fast.
452
+ try {
453
+ const v = spawnSync('claude-rpc', ['--version'], {
454
+ encoding: 'utf8', timeout: 4000, windowsHide: true,
455
+ shell: process.platform === 'win32',
456
+ });
457
+ if ((v.stdout || '').trim() === `claude-rpc ${VERSION}`) {
458
+ noop('global install current');
459
+ return true;
460
+ }
461
+ } catch { /* not installed yet — promote below */ }
421
462
  const r = spawnSync('npm', ['install', '-g', `claude-rpc@${VERSION}`], {
422
- stdio: 'inherit',
463
+ encoding: 'utf8',
423
464
  shell: process.platform === 'win32', // npm is npm.cmd on Windows
424
465
  });
425
- return !r.error && r.status === 0;
466
+ if (r.error || r.status !== 0) {
467
+ // The piped npm chatter only matters when it failed.
468
+ if (r.stdout) process.stderr.write(r.stdout);
469
+ if (r.stderr) process.stderr.write(r.stderr);
470
+ return false;
471
+ }
472
+ dirtyStep(SYM_OK, 'installed globally', `claude-rpc@${VERSION} — hooks survive npx's throwaway cache`);
473
+ return true;
426
474
  }
427
475
 
428
476
  // Best-effort registry check. npx serves stale cached copies without
@@ -442,72 +490,98 @@ function warnIfStale() {
442
490
  const [l, v] = [num(latest), num(VERSION)];
443
491
  const newer = l[0] !== v[0] ? l[0] > v[0] : l[1] !== v[1] ? l[1] > v[1] : l[2] > v[2];
444
492
  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`);
493
+ step(SYM_WARN, 'newer version', `v${latest} is published but this is v${VERSION} — npx may have served a stale cache`, console.warn);
494
+ hintLine('for the newest version, stop here and re-run: npx claude-rpc@latest setup', process.stderr);
447
495
  }
448
496
  } catch { /* offline or npm missing — a version check must never block setup */ }
449
497
  }
450
498
 
451
499
  export async function install({ exePath, withStartup = true } = {}) {
500
+ resetRun();
501
+ console.log('');
502
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc setup${c.reset} ${c.dim}v${VERSION}${c.reset}`);
452
503
  warnIfStale();
453
504
  if (IS_NPX) {
454
505
  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');
506
+ console.error('');
507
+ step(SYM_FAIL, 'global install', 'failed', console.error);
508
+ hintLine('run this once, then you\'re set: npm install -g claude-rpc && claude-rpc setup', process.stderr);
457
509
  const err = new Error('npx self-install failed');
458
510
  err.code = 3; // system error (see exit-code contract)
459
511
  throw err;
460
512
  }
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.');
513
+ step(SYM_OK, 'global install', `claude-rpc@${VERSION}`);
465
514
  }
466
515
  const incoming = exePath || process.execPath;
467
516
  // Canonicalize first so hook + startup entries point at a stable location,
468
517
  // not at the temp/Downloads path the user happened to launch from.
518
+ if (IS_PACKAGED) phase('binary');
469
519
  const target = ensureCanonicalExe(incoming);
470
- console.log('Installing Claude RPC…');
520
+
521
+ phase('config');
471
522
  // Order matters: seed creates the file if missing, then migrate fills in
472
523
  // any blocks new exe versions added (e.g. presence.byStatus from v0.3.6).
473
524
  seedConfig();
474
525
  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
526
 
527
+ phase('claude code');
528
+ installHooks(target);
481
529
  // Proof the hook pipe actually fires. A setup that returns success
482
530
  // without verification is a lie — we caught broken-hook-path bugs
483
531
  // twice during v0.3.x because no one ran a real event after install.
484
532
  const probe = verifyHookPipe(target);
485
- if (probe.ok) {
486
- console.log(` hook pipe ✓ ${probe.detail}`);
533
+ if (!probe.ok) {
534
+ step(SYM_FAIL, 'hook verify', probe.detail, console.warn);
535
+ hintLine('run `claude-rpc doctor` for a full diagnostic', process.stderr);
536
+ } else if (runDirty) {
537
+ step(SYM_OK, 'hook verified', probe.detail);
487
538
  } else {
488
- console.warn(` hook pipe ${probe.detail}`);
489
- console.warn(` ↳ run \`claude-rpc doctor\` for a full diagnostic`);
539
+ noop('hook pipe verified');
490
540
  }
491
541
 
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`);
542
+ // The CLI's setup case launches the daemon right after this returns, so its
543
+ // row lands under this heading; setupOutro() then closes the screen.
544
+ phase('daemon');
545
+ if (withStartup) {
546
+ if (process.platform === 'win32') {
547
+ try { await addStartupEntry(target); }
548
+ catch (e) { step(SYM_WARN, 'startup entry', `failed: ${e.message}`, console.warn); }
549
+ } else if (runDirty) {
550
+ step(SYM_INFO, 'startup entry', 'skipped — login autostart is Windows-only');
551
+ }
503
552
  }
553
+ // Nothing changed: the checklist above stayed silent, so say so in one line.
554
+ if (!runDirty && probe.ok) {
555
+ console.log(` ${SYM_OK} ${c.bold}already set up${c.reset} ${c.dim}${noopFacts.join(' · ')}${c.reset}`);
556
+ }
557
+ return { target, changed: runDirty };
558
+ }
559
+
560
+ // The single closing block of `claude-rpc setup` — what to do now, where the
561
+ // levers are. Printed by the CLI after the daemon launch so it always lands
562
+ // last; doctor --fix re-runs install() without it.
563
+ export function setupOutro(target, changed = true) {
564
+ if (!changed) return;
565
+ const point = (label, value, note = '') =>
566
+ console.log(` ${c.dim}→${c.reset} ${c.dim}${label.padEnd(14)}${c.reset} ${c.cyan}${value}${c.reset}${note ? ` ${c.dim}${note}${c.reset}` : ''}`);
567
+ console.log('');
568
+ console.log(` ${SYM_OK} ${c.bold}setup complete${c.reset} — open Claude Code and send a prompt; your card goes live in Discord.`);
569
+ point('verify wiring', 'claude-rpc doctor');
570
+ if (IS_PACKAGED) point('start daemon', `"${target}" daemon`, 'also runs automatically at login');
571
+ else point('manage daemon', 'claude-rpc start · stop · status');
572
+ point('config', CONFIG_PATH, 'a working Discord app is bundled — set clientId only to use your own');
573
+ console.log('');
504
574
  }
505
575
 
506
576
  export async function uninstall() {
507
- console.log('Uninstalling Claude RPC…');
577
+ console.log('');
578
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc uninstall${c.reset}`);
579
+ console.log('');
508
580
  uninstallHooks();
509
581
  if (process.platform === 'win32') await removeStartupEntry();
510
- console.log('\nDone. (Config at %APPDATA%\\claude-rpc\\ left intact — delete manually if you want.)');
582
+ console.log('');
583
+ 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}`);
584
+ console.log('');
511
585
  }
512
586
 
513
587
  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.15.0';
14
+ const BAKED = '0.15.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {