claude-rpc 0.20.4 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  [![Node 18+](https://img.shields.io/badge/node-%3E%3D18-43853d.svg?logo=node.js&logoColor=white)](https://nodejs.org)
26
26
  [![Claude Code](https://img.shields.io/badge/Claude%20Code-hooks-d97757.svg)](https://claude.com/claude-code)
27
27
  [![Discord RPC](https://img.shields.io/badge/Discord-RPC-5865F2.svg?logo=discord&logoColor=white)](https://discord.com/developers/docs/topics/rpc)
28
- [![Release](https://img.shields.io/github/v/release/rar-file/claude-rpc?color=4c1)](https://github.com/rar-file/claude-rpc/releases/latest)
28
+ [![Release](https://img.shields.io/npm/v/claude-rpc?color=4c1&label=release&logo=npm&logoColor=white)](https://github.com/rar-file/claude-rpc/releases/latest)
29
29
  [![npm downloads](https://img.shields.io/npm/dm/claude-rpc?color=cb3837&logo=npm&logoColor=white&label=downloads)](https://www.npmjs.com/package/claude-rpc)
30
30
 
31
31
  </div>
@@ -139,7 +139,22 @@ The web dashboard pushes updates via SSE; the TUI refreshes on a 3-second tick.
139
139
 
140
140
  ### beyond your machine
141
141
 
142
- Shields-style badges and a poster-style summary card you can paste into a README or a Discord message:
142
+ Shields-style badges and a poster-style summary card you can paste into a README or a Discord message. The fastest path is one command:
143
+
144
+ ```sh
145
+ claude-rpc readme # prints paste-ready README badge markdown
146
+ claude-rpc readme --raw | pbcopy # straight to your clipboard
147
+ ```
148
+
149
+ **Live badges, paste once.** With a public profile (`claude-rpc profile set --handle <you> && claude-rpc profile on`), your stats are served as always-current badges from the community worker — no `gh`, no gist, nothing to re-run:
150
+
151
+ ```md
152
+ [![Claude Code hours](https://claude-rpc-totals.claude-rpc.workers.dev/badge/<you>.svg?metric=hours)](https://claude-rpc.vercel.app/?ref=badge)
153
+ ```
154
+
155
+ `metric=` is one of `tokens · sessions · hours · streak` (optional `&label=` to retitle). Your profile page at `/u/<you>` has a one-click "copy" for the same block. The badge refreshes itself as the daemon flushes your profile (~every 30 min).
156
+
157
+ Prefer to render locally? `badge`/`card`/`calendar`/`github-stat` all write SVG, and `--gist` self-hosts a live one:
143
158
 
144
159
  ```sh
145
160
  claude-rpc badge --metric hours --range 7d --out claude-hours.svg
@@ -258,6 +273,17 @@ Frames have a `requires` field; the daemon skips a frame when any of its require
258
273
 
259
274
  The full default config is in [`src/default-config.js`](src/default-config.js) — that's the canonical list of every key. ~140 template variables are available; `claude-rpc vars` is the source of truth.
260
275
 
276
+ ## claude code plugin
277
+
278
+ Prefer to install from inside Claude Code? There's a [plugin](plugin/) for that:
279
+
280
+ ```text
281
+ /plugin marketplace add rar-file/claude-rpc
282
+ /plugin install claude-rpc@claude-rpc
283
+ ```
284
+
285
+ It's a thin bootstrapper — on the first session it just runs `npx claude-rpc@latest setup` for you (the same install as above), then stays out of the way. macOS / Linux / WSL; on Windows use the portable exe. Nothing extra is added to your sessions — the plugin is a single `SessionStart` hook with no model-context cost.
286
+
261
287
  ## commands
262
288
 
263
289
  `claude-rpc --help` lists them all — and after `setup` you rarely need any.
@@ -286,6 +312,7 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
286
312
  | `github-stat` | Embeddable profile stat card (`--handle` `--out` `--gist`) |
287
313
  | `calendar` | Year activity heatmap SVG (`--out` `--gist`) |
288
314
  | `session-card` | Recap card for the current session (`--out`) |
315
+ | `readme` | Paste-ready README badge markdown for your profile (`--raw` to pipe) |
289
316
  | `statusline` | One-line status for tmux/shell prompts (`--template`) |
290
317
  | `mcp install` | Wire the stats MCP server into Claude Code (one command) |
291
318
  | `mcp` | Run the MCP server (stdio) for Claude Code |
package/SECURITY.md CHANGED
@@ -16,7 +16,7 @@ fetch-and-execute anywhere in `src/`.
16
16
 
17
17
  | Behavior | Where | Scope | Reversible? |
18
18
  | --- | --- | --- | --- |
19
- | Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal, all platforms) | `HKCU` Run key (no admin) + a detached daemon (re)launched when you start a Claude Code session | Yes — `claude-rpc uninstall` / `removeStartupEntry`; disable self-heal with `autostart:false` |
19
+ | Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key, macOS LaunchAgent, Linux `systemd --user`) + `selfHealOnUpdate` (installs it on update); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal) | Per-user login autostart (no admin/root/system service) + a detached daemon (re)launched when you start a Claude Code session | Yes — `claude-rpc uninstall` / `removeStartupEntry`; disable every auto-start path with `autostart:false` |
20
20
  | Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
21
21
  | Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
22
22
  | Local subprocess | `reg.exe`, `wscript`, the daemon itself (`node`/packaged exe — via the Run key and the SessionStart self-heal), `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
@@ -25,9 +25,10 @@ No credential access beyond the read-only Claude Code OAuth-token read for usage
25
25
  polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
26
26
  transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
27
27
 
28
- ## 1. Startup persistence (Windows Run key)
28
+ ## 1. Startup persistence (login autostart, all platforms)
29
29
 
30
- **Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`.
30
+ **Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`; the
31
+ on-update install path lives in `selfHealOnUpdate`.
31
32
 
32
33
  On Windows, `setup` writes one value:
33
34
 
@@ -47,16 +48,38 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
47
48
  not at wherever you happened to run the installer from — see
48
49
  `ensureCanonicalExe`.
49
50
 
50
- Non-Windows platforms get **no** Run-key/login registration (`install()` warns
51
- and skips it). Instead — on every platform, Windows included — the
52
- `SessionStart` hook **self-heals** the daemon: if none is running when you start
53
- a Claude Code session, the hook spawns one (detached, windowless), so presence
54
- is restored after a reboot, crash, or OS sleep exactly when you next use Claude.
55
- It is the same long-lived daemon launched the same way (`src/ensure-daemon.js` →
51
+ **macOS and Linux** now get login autostart too, at per-user parity with Windows:
52
+
53
+ ```
54
+ macOS ~/Library/LaunchAgents/com.claude-rpc.daemon.plist (launchctl, RunAtLoad)
55
+ Linux ~/.config/systemd/user/claude-rpc.service (systemctl --user enable)
56
+ ```
57
+
58
+ - **Scope:** per-user only — a user LaunchAgent / `systemctl --user` unit, never
59
+ a root/system daemon, LaunchDaemon, or system-wide service. No admin, no `sudo`.
60
+ - **Reverse it:** `claude-rpc uninstall` removes the plist/unit and unloads it
61
+ (`removeStartupEntry`); or delete the file and `launchctl unload` /
62
+ `systemctl --user disable` by hand.
63
+
64
+ **A login-autostart entry can appear on UPDATE, not only at `setup`.** Because
65
+ npm runs no install script (see intro), an `npm update` reaches you through the
66
+ daemon: the first time it starts after an update, `selfHealOnUpdate` re-wires
67
+ hooks, migrates config, **and installs the login-autostart entry above** so the
68
+ daemon comes up at login on the new version. This is the one place a
69
+ background-start entry is created without an explicit `setup`. It is gated by
70
+ `autostart` (default on) — set `autostart:false` to opt out of every auto-start
71
+ path — and is best-effort: if `launchctl`/`systemctl`/`reg` fails, nothing
72
+ breaks (the session self-heal below still covers startup).
73
+
74
+ Underneath all of this, on every platform, the `SessionStart` hook also
75
+ **self-heals** the daemon: if none is running when you start a Claude Code
76
+ session, the hook spawns one (detached, windowless), so presence is restored
77
+ after a reboot, crash, or OS sleep exactly when you next use Claude. It is the
78
+ same long-lived daemon launched the same way (`src/ensure-daemon.js` →
56
79
  `spawnDaemonDetached`), a no-op when a daemon is already running, and
57
80
  cooldown-guarded against spawn storms; the daemon's atomic single-instance claim
58
- reaps any duplicate. Disable it with `autostart:false` in config (then manage
59
- the daemon yourself via `claude-rpc start` / `stop`).
81
+ reaps any duplicate. Disable everything with `autostart:false` in config (then
82
+ manage the daemon yourself via `claude-rpc start` / `stop`).
60
83
 
61
84
  ## 2. Hook injection into Claude Code
62
85
 
@@ -256,6 +279,10 @@ untrusted or remote input into a shell:
256
279
 
257
280
  - `reg.exe add/delete` — the Windows Run key (`src/install.js`).
258
281
  - `wscript.exe` — runs the generated windowless startup shim (`src/install.js`).
282
+ - `launchctl load/unload` — the macOS LaunchAgent (`src/install.js`); args are
283
+ the fixed plist path.
284
+ - `systemctl --user enable/disable/daemon-reload` — the Linux user service
285
+ (`src/install.js`); args are the fixed unit name.
259
286
  - `chcp.com 65001` — set the console to UTF-8 on Windows TTYs (`src/cli.js`).
260
287
  - `git` — read last commit subject / branch for the "just shipped" card
261
288
  (`src/git.js`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.20.4",
3
+ "version": "0.22.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1057,6 +1057,67 @@ async function doSessionCard(argv) {
1057
1057
  } else process.stdout.write(svg);
1058
1058
  }
1059
1059
 
1060
+ // `claude-rpc readme` — paste-ready markdown for putting your live Claude Code
1061
+ // stats in a README. Pure printer: reads config + the worker endpoint, never
1062
+ // hits the network. The live badges track your published profile and refresh
1063
+ // themselves (no gist, no `gh`, no re-running). `--raw` prints just the
1064
+ // markdown so you can pipe it straight to a clipboard tool.
1065
+ function doReadme(argv) {
1066
+ const raw = argv.includes('--raw') || argv.includes('--plain');
1067
+ const cfg = loadConfig();
1068
+ const endpoint = (cfg.community?.endpoint || 'https://claude-rpc-totals.claude-rpc.workers.dev').replace(/\/+$/, '');
1069
+ const site = 'https://claude-rpc.vercel.app';
1070
+ const profile = cfg.profile || {};
1071
+ const handle = profile.handle;
1072
+ const live = !!profile.enabled && lb.isValidHandle(handle);
1073
+ const metrics = [
1074
+ ['hours', 'Claude Code hours'],
1075
+ ['streak', 'Claude Code streak'],
1076
+ ['tokens', 'Claude Code tokens'],
1077
+ ];
1078
+ const liveMd = (h) => metrics
1079
+ .map(([m, alt]) => `[![${alt}](${endpoint}/badge/${h}.svg?metric=${m})](${site}/?ref=badge)`)
1080
+ .join('\n');
1081
+
1082
+ // Raw mode: just the markdown, for `claude-rpc readme --raw | pbcopy`.
1083
+ if (raw) {
1084
+ process.stdout.write(live
1085
+ ? liveMd(handle) + '\n'
1086
+ : '<!-- live badges need a public profile: claude-rpc profile set --handle <name> && claude-rpc profile on -->\n');
1087
+ return;
1088
+ }
1089
+
1090
+ console.log('');
1091
+ console.log(` ${c.bold}${c.magenta}◆ README badges${c.reset}`);
1092
+ console.log('');
1093
+
1094
+ if (live) {
1095
+ console.log(` ${c.dim}Live badges for ${c.reset}${c.cyan}@${handle}${c.reset}${c.dim} — paste anywhere; they refresh as you work:${c.reset}`);
1096
+ console.log('');
1097
+ for (const line of liveMd(handle).split('\n')) console.log(` ${line}`);
1098
+ console.log('');
1099
+ console.log(` ${c.dim}Pick any metric:${c.reset} ${c.cyan}tokens · sessions · hours · streak${c.reset}`);
1100
+ console.log(` ${c.dim}One-click copy + shareable page:${c.reset} ${c.cyan}${site}/u/${handle}${c.reset}`);
1101
+ console.log(` ${c.dim}Straight to clipboard:${c.reset} ${c.cyan}claude-rpc readme --raw${c.reset}${c.dim} | pbcopy${c.reset}`);
1102
+ } else {
1103
+ console.log(` ${c.dim}A live, always-current badge needs a public profile. Turn one on:${c.reset}`);
1104
+ console.log(` ${c.cyan}claude-rpc profile set --handle <name> && claude-rpc profile on${c.reset}`);
1105
+ console.log('');
1106
+ console.log(` ${c.dim}Then your badges live here (self-refreshing — paste once):${c.reset}`);
1107
+ console.log(` ${c.cyan}${endpoint}/badge/<handle>.svg?metric=hours${c.reset}`);
1108
+ }
1109
+
1110
+ console.log('');
1111
+ console.log(` ${c.dim}── self-host on a GitHub gist instead (works without a profile) ──${c.reset}`);
1112
+ console.log(` ${c.cyan}claude-rpc badge --metric hours --gist${c.reset} ${c.dim}# prints README markdown${c.reset}`);
1113
+ console.log('');
1114
+ console.log(` ${c.dim}Shareable cards:${c.reset}`);
1115
+ console.log(` ${c.cyan}claude-rpc card --range year --out year.svg${c.reset} ${c.dim}# poster${c.reset}`);
1116
+ console.log(` ${c.cyan}claude-rpc calendar --gist${c.reset} ${c.dim}# live year heatmap${c.reset}`);
1117
+ console.log(` ${c.cyan}claude-rpc github-stat --gist${c.reset} ${c.dim}# profile stat card${c.reset}`);
1118
+ console.log('');
1119
+ }
1120
+
1060
1121
  // MCP server — expose stats to Claude Code over stdio. Long-running; never
1061
1122
  // writes to stdout except JSON-RPC frames.
1062
1123
  async function doMcp() {
@@ -1942,6 +2003,7 @@ function help() {
1942
2003
  ['statusline', 'One-line status for tmux/shell prompts (--template)'],
1943
2004
  ['calendar', 'Year activity heatmap SVG (--out --gist)'],
1944
2005
  ['session-card', 'Recap card for the current session (--out)'],
2006
+ ['readme', 'Paste-ready README badges for your profile (--raw to pipe)'],
1945
2007
  ['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
1946
2008
  ['mcp uninstall', 'Remove the stats MCP server from Claude Code'],
1947
2009
  ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
@@ -2097,6 +2159,7 @@ process.on('unhandledRejection', (e) => {
2097
2159
  case 'statusline': doStatusline(process.argv.slice(3)); break;
2098
2160
  case 'calendar': await doCalendar(process.argv.slice(3)); break;
2099
2161
  case 'session-card': await doSessionCard(process.argv.slice(3)); break;
2162
+ case 'readme': doReadme(process.argv.slice(3)); break;
2100
2163
  case 'mcp': {
2101
2164
  const sub = process.argv[3];
2102
2165
  if (sub === 'install') { doMcpInstall(process.argv.slice(4)); break; }
package/src/daemon.js CHANGED
@@ -10,7 +10,8 @@ import { detectGithubUrl } from './git.js';
10
10
  import { applyPrivacy } from './privacy.js';
11
11
  import { pauseUntil } from './pause.js';
12
12
  import { loadConfig } from './config.js';
13
- import { migrateConfig } from './install.js';
13
+ import { selfHealOnUpdate } from './install.js';
14
+ import { VERSION } from './version.js';
14
15
  import { desktopNotify, postWebhook, shouldWebhook, shouldNotify, sanitizeLabel } from './notify.js';
15
16
  import { humanProject } from './format.js';
16
17
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
@@ -79,12 +80,20 @@ function loadConfigWithLog() {
79
80
  // move. Idempotent: only writes when something actually changes, so steady-state
80
81
  // restarts are a no-op and can't loop the config watcher. Best-effort — a
81
82
  // migration failure must never stop the daemon from starting.
83
+ // Self-heal on update — when this daemon is a newer version than the last one
84
+ // that ran, re-wire hooks (their command/path drifts across versions) and
85
+ // migrate config to the current shape, then stamp the version. This is how an
86
+ // `npm update` reaches users who never re-run setup: there is no install script
87
+ // (by design), so the daemon — itself brought up by the SessionStart self-heal —
88
+ // carries the update forward. A since-0.1.0 install converges to the current
89
+ // hooks + config the first time it's used after updating.
82
90
  try {
83
- if (migrateConfig({ silent: true })) {
84
- log('config.json migrated to current defaults on startup');
91
+ const healed = selfHealOnUpdate({ silent: true });
92
+ if (healed.changed) {
93
+ log(`self-heal on update: ${healed.from || 'fresh'} → ${VERSION} (hooks ${healed.rewired ? 're-wired' : 'already current'}, config migrated${healed.autostart ? ', login autostart ensured' : ''})`);
85
94
  }
86
95
  } catch (e) {
87
- log('startup config migration failed (continuing):', e?.message || String(e));
96
+ log('startup self-heal failed (continuing):', e?.message || String(e));
88
97
  }
89
98
 
90
99
  let config = loadConfigWithLog();
package/src/install.js CHANGED
@@ -8,12 +8,14 @@ import {
8
8
  readdirSync, unlinkSync,
9
9
  } from 'node:fs';
10
10
  import { dirname, join, resolve } from 'node:path';
11
+ import { homedir } from 'node:os';
11
12
  import { spawn, spawnSync } from 'node:child_process';
12
13
  import { randomUUID } from 'node:crypto';
13
14
  import {
14
15
  CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR, ROOT,
15
16
  HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL, IS_NPX,
16
- CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME,
17
+ CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME, VERSION_STAMP,
18
+ DAEMON_SCRIPT,
17
19
  } from './paths.js';
18
20
  import { DEFAULT_CONFIG } from './default-config.js';
19
21
  import { VERSION } from './version.js';
@@ -83,7 +85,7 @@ export function isOurHook(h) {
83
85
  return !!h && (h._claudeRpc === true || isOurHookCommand(h.command));
84
86
  }
85
87
 
86
- export function installHooks(exePath) {
88
+ export function installHooks(exePath, { silent = false } = {}) {
87
89
  const settings = readJson(CLAUDE_SETTINGS, {});
88
90
  const before = JSON.stringify(settings.hooks || {});
89
91
  settings.hooks = settings.hooks || {};
@@ -118,11 +120,11 @@ export function installHooks(exePath) {
118
120
  }
119
121
  }
120
122
  if (JSON.stringify(settings.hooks) === before) {
121
- noop(`hooks wired (${EVENTS.length} events)`);
123
+ if (!silent) noop(`hooks wired (${EVENTS.length} events)`);
122
124
  return false;
123
125
  }
124
126
  writeJson(CLAUDE_SETTINGS, settings);
125
- dirtyStep(SYM_OK, 'hooks wired', `${EVENTS.length} events → ${CLAUDE_SETTINGS}`);
127
+ if (!silent) dirtyStep(SYM_OK, 'hooks wired', `${EVENTS.length} events → ${CLAUDE_SETTINGS}`);
126
128
  return true;
127
129
  }
128
130
 
@@ -153,7 +155,129 @@ function regCommand(args) {
153
155
 
154
156
  const STARTUP_VBS = join(CANONICAL_INSTALL_DIR, 'claude-rpc-daemon.vbs');
155
157
 
158
+ // macOS LaunchAgent + Linux systemd --user paths/labels.
159
+ const LAUNCHD_LABEL = 'com.claude-rpc.daemon';
160
+ const LAUNCHD_PLIST = join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
161
+ const SYSTEMD_UNIT_NAME = 'claude-rpc.service';
162
+ const SYSTEMD_UNIT = join(homedir(), '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
163
+
164
+ // The daemon launch command, shared by every autostart mechanism so login/boot
165
+ // starts the daemon exactly like `start` and the hook do:
166
+ // packaged → "<exe>" daemon ; npm/dev → "<abs node>" "<abs daemon.js>"
167
+ // (absolute node, like the hooks — login shells under nvm have no node on PATH).
168
+ function daemonLaunch(exePath) {
169
+ if (IS_PACKAGED) return { exe: exePath, args: ['daemon'] };
170
+ return { exe: process.execPath, args: [DAEMON_SCRIPT] };
171
+ }
172
+
173
+ function xmlEscape(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
174
+ function systemctlUser(args) {
175
+ try { return spawnSync('systemctl', ['--user', ...args], { stdio: 'ignore' }); }
176
+ catch { return { status: 1 }; }
177
+ }
178
+
179
+ // Pure file-content builders — exported so the generated plist/unit are
180
+ // unit-testable without touching launchctl/systemctl or the real filesystem.
181
+ export function launchdPlist({ exe, args }) {
182
+ const progArgs = [exe, ...args].map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
183
+ return [
184
+ '<?xml version="1.0" encoding="UTF-8"?>',
185
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
186
+ '<plist version="1.0">',
187
+ '<dict>',
188
+ ` <key>Label</key><string>${LAUNCHD_LABEL}</string>`,
189
+ ' <key>ProgramArguments</key>',
190
+ ' <array>',
191
+ progArgs,
192
+ ' </array>',
193
+ ' <key>RunAtLoad</key><true/>',
194
+ // KeepAlive false: start at login, but don't fight a manual `claude-rpc stop`.
195
+ ' <key>KeepAlive</key><false/>',
196
+ ' <key>ProcessType</key><string>Background</string>',
197
+ '</dict>',
198
+ '</plist>',
199
+ '',
200
+ ].join('\n');
201
+ }
202
+
203
+ export function systemdUnit({ exe, args }) {
204
+ const execStart = [exe, ...args].map((a) => `"${a}"`).join(' '); // quote each token (paths with spaces)
205
+ return [
206
+ '[Unit]',
207
+ 'Description=claude-rpc — Discord Rich Presence for Claude Code',
208
+ 'After=default.target',
209
+ '',
210
+ '[Service]',
211
+ 'Type=simple',
212
+ `ExecStart=${execStart}`,
213
+ // on-failure (not always): a clean exit when another daemon already owns the
214
+ // instance must NOT be restarted, and neither should a `claude-rpc stop`.
215
+ 'Restart=on-failure',
216
+ 'RestartSec=5',
217
+ '',
218
+ '[Install]',
219
+ 'WantedBy=default.target',
220
+ '',
221
+ ].join('\n');
222
+ }
223
+
224
+ // ── macOS: launchd LaunchAgent (per-user, starts at login) ────────────────────
225
+ function addStartupEntryMac(exePath) {
226
+ const plist = launchdPlist(daemonLaunch(exePath));
227
+ try {
228
+ mkdirSync(dirname(LAUNCHD_PLIST), { recursive: true });
229
+ writeFileSync(LAUNCHD_PLIST, plist);
230
+ } catch (e) {
231
+ step(SYM_WARN, 'startup entry', `couldn't write LaunchAgent (${e.message}) — the session self-heal still starts the daemon`, console.warn);
232
+ return;
233
+ }
234
+ // Load so it's active now and at every login. `load -w` is deprecated but
235
+ // portable across the macOS versions we target; a failure is non-fatal — the
236
+ // plist alone starts it next login, and SessionStart self-heal covers the gap.
237
+ try { spawnSync('launchctl', ['unload', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch { /* not loaded yet */ }
238
+ try { spawnSync('launchctl', ['load', '-w', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch { /* deferred to next login */ }
239
+ dirtyStep(SYM_OK, 'startup entry', `LaunchAgent ${LAUNCHD_LABEL} — daemon starts at login`);
240
+ }
241
+
242
+ function removeStartupEntryMac() {
243
+ try { spawnSync('launchctl', ['unload', '-w', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch { /* not loaded */ }
244
+ try { if (existsSync(LAUNCHD_PLIST)) { unlinkSync(LAUNCHD_PLIST); step(SYM_OK, 'startup entry', 'removed (LaunchAgent)'); } }
245
+ catch { /* already gone */ }
246
+ }
247
+
248
+ // ── Linux: systemd --user service (starts at login) ───────────────────────────
249
+ function addStartupEntryLinux(exePath) {
250
+ const unit = systemdUnit(daemonLaunch(exePath));
251
+ try {
252
+ mkdirSync(dirname(SYSTEMD_UNIT), { recursive: true });
253
+ writeFileSync(SYSTEMD_UNIT, unit);
254
+ } catch (e) {
255
+ step(SYM_WARN, 'startup entry', `couldn't write systemd unit (${e.message}) — the session self-heal still starts the daemon`, console.warn);
256
+ return;
257
+ }
258
+ // Enable + start now. Non-fatal if there's no systemd --user session (some
259
+ // containers / minimal WSL): the SessionStart self-heal still brings it up.
260
+ systemctlUser(['daemon-reload']);
261
+ const r = systemctlUser(['enable', '--now', SYSTEMD_UNIT_NAME]);
262
+ if (r && r.status === 0) dirtyStep(SYM_OK, 'startup entry', `systemd --user ${SYSTEMD_UNIT_NAME} — daemon starts at login`);
263
+ else dirtyStep(SYM_INFO, 'startup entry', `systemd unit written — enable with: systemctl --user enable --now ${SYSTEMD_UNIT_NAME}`);
264
+ }
265
+
266
+ function removeStartupEntryLinux() {
267
+ systemctlUser(['disable', '--now', SYSTEMD_UNIT_NAME]);
268
+ try { if (existsSync(SYSTEMD_UNIT)) { unlinkSync(SYSTEMD_UNIT); step(SYM_OK, 'startup entry', 'removed (systemd unit)'); } }
269
+ catch { /* already gone */ }
270
+ systemctlUser(['daemon-reload']);
271
+ }
272
+
156
273
  export async function addStartupEntry(exePath) {
274
+ if (process.platform === 'darwin') return addStartupEntryMac(exePath);
275
+ if (process.platform === 'linux') return addStartupEntryLinux(exePath);
276
+ if (process.platform !== 'win32') {
277
+ if (runDirty) step(SYM_INFO, 'startup entry', `skipped — no login-autostart for ${process.platform}`);
278
+ return;
279
+ }
280
+ // ── Windows: HKCU Run-key + windowless .vbs shim ──
157
281
  // The packaged exe is a console-subsystem node.exe, so a bare Run-key entry
158
282
  // (`"<exe>" daemon`) makes Explorer pop a console window at every login that
159
283
  // persists for the daemon's whole (weeks-long) life — closing it kills the
@@ -178,6 +302,9 @@ export async function addStartupEntry(exePath) {
178
302
  }
179
303
 
180
304
  export async function removeStartupEntry() {
305
+ if (process.platform === 'darwin') return removeStartupEntryMac();
306
+ if (process.platform === 'linux') return removeStartupEntryLinux();
307
+ if (process.platform !== 'win32') return;
181
308
  try {
182
309
  await regCommand(['delete', STARTUP_KEY, '/v', STARTUP_VALUE, '/f']);
183
310
  step(SYM_OK, 'startup entry', 'removed');
@@ -557,6 +684,43 @@ function warnIfStale() {
557
684
  } catch { /* offline or npm missing — a version check must never block setup */ }
558
685
  }
559
686
 
687
+ // Self-heal an install across an update. There is NO npm install script (by
688
+ // design — `npm install` must run nothing), so an `npm update` swaps the files
689
+ // but re-wires nothing: a long-lived user keeps stale hook commands and old
690
+ // config and never gets the new behaviour. The daemon — itself brought up by the
691
+ // SessionStart self-heal — calls this on startup: when the stamped version
692
+ // differs from the running one, re-wire the hooks (their command/path drifts
693
+ // across versions) and migrate config to the current shape, then stamp the
694
+ // version. Best-effort and quiet by default; never throws into the caller.
695
+ // Returns { changed, from, rewired }.
696
+ export function selfHealOnUpdate({ exePath = null, silent = true } = {}) {
697
+ let from = null;
698
+ try { from = (readFileSync(VERSION_STAMP, 'utf8') || '').trim() || null; } catch { /* first run / unreadable — treat as needing a heal */ }
699
+ if (from === VERSION) return { changed: false, from, rewired: false };
700
+ const exe = exePath || ((IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath);
701
+ let rewired = false;
702
+ try { rewired = installHooks(exe, { silent }); } catch { /* best-effort — a hook re-wire must never block daemon startup */ }
703
+ try { migrateConfig({ silent }); } catch { /* best-effort */ }
704
+ // Carry the login autostart forward too — an update should leave the daemon
705
+ // coming up at login, not only on the next Claude Code session — unless the
706
+ // user opted out with autostart:false. Best-effort + fire-and-forget; a
707
+ // failure just falls back to the SessionStart self-heal. In the detached
708
+ // daemon, addStartupEntry's console output lands in ignored stdio. This is
709
+ // disclosed in SECURITY.md (a login-autostart service can appear on update).
710
+ let autostart = false;
711
+ try {
712
+ if (readJson(CONFIG_PATH, {}).autostart !== false) {
713
+ autostart = true;
714
+ Promise.resolve(addStartupEntry(exe)).catch(() => {});
715
+ }
716
+ } catch { /* best-effort */ }
717
+ try {
718
+ mkdirSync(dirname(VERSION_STAMP), { recursive: true });
719
+ writeFileSync(VERSION_STAMP, VERSION + '\n');
720
+ } catch { /* unwritable — we'll just self-heal again next start, which is harmless (idempotent) */ }
721
+ return { changed: true, from, rewired, autostart };
722
+ }
723
+
560
724
  export async function install({ exePath, withStartup = true } = {}) {
561
725
  resetRun();
562
726
  console.log('');
@@ -604,12 +768,11 @@ export async function install({ exePath, withStartup = true } = {}) {
604
768
  // row lands under this heading; setupOutro() then closes the screen.
605
769
  phase('daemon');
606
770
  if (withStartup) {
607
- if (process.platform === 'win32') {
608
- try { await addStartupEntry(target); }
609
- catch (e) { step(SYM_WARN, 'startup entry', `failed: ${e.message}`, console.warn); }
610
- } else if (runDirty) {
611
- step(SYM_INFO, 'startup entry', 'skippedlogin autostart is Windows-only');
612
- }
771
+ // Cross-platform login autostart: Windows Run-key, macOS LaunchAgent, Linux
772
+ // systemd --user. Best-effort — a failure leaves the SessionStart self-heal
773
+ // as the (already reliable) fallback, so setup never fails on this.
774
+ try { await addStartupEntry(target); }
775
+ catch (e) { step(SYM_WARN, 'startup entry', `failed: ${e.message} session self-heal still covers it`, console.warn); }
613
776
  }
614
777
  // Nothing changed: the checklist above stayed silent, so say so in one line.
615
778
  if (!runDirty && probe.ok) {
@@ -640,7 +803,7 @@ export async function uninstall() {
640
803
  console.log(` ${c.bold}${c.magenta}◆ claude-rpc uninstall${c.reset}`);
641
804
  console.log('');
642
805
  uninstallHooks();
643
- if (process.platform === 'win32') await removeStartupEntry();
806
+ await removeStartupEntry();
644
807
  console.log('');
645
808
  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}`);
646
809
  console.log('');
package/src/paths.js CHANGED
@@ -91,6 +91,10 @@ export const DATA_DIR = join(homedir(), '.claude-rpc');
91
91
  export const AGGREGATE_PATH = join(DATA_DIR, 'aggregate.json');
92
92
  export const SCAN_CACHE_PATH = join(DATA_DIR, 'scan-cache.json');
93
93
  export const EVENTS_LOG_PATH = join(DATA_DIR, 'events.jsonl');
94
+ // Last claude-rpc version that ran the on-update self-heal (hook re-wire + config
95
+ // migrate). Persistent (under ~/.claude-rpc) so an update is detected across
96
+ // reboots — see install.selfHealOnUpdate.
97
+ export const VERSION_STAMP = join(DATA_DIR, 'version');
94
98
  export const CLAUDE_HOME = join(homedir(), '.claude');
95
99
  export const CLAUDE_PROJECTS = join(CLAUDE_HOME, 'projects');
96
100
  export const CLAUDE_SETTINGS = join(CLAUDE_HOME, 'settings.json');
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.20.4';
14
+ const BAKED = '0.22.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {