claude-rpc 0.3.10 → 0.5.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/src/daemon.js CHANGED
@@ -3,8 +3,9 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, watch, appendFileS
3
3
  import { Client } from '@xhayper/discord-rpc';
4
4
  import { readState } from './state.js';
5
5
  import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
6
- import { scan, readAggregate, findLiveSessions } from './scanner.js';
6
+ import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
7
7
  import { detectGithubUrl } from './git.js';
8
+ import { applyPrivacy } from './privacy.js';
8
9
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
9
10
 
10
11
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
@@ -82,6 +83,29 @@ function buildActivity(opts = {}) {
82
83
  // see ongoing transcript activity, not just this daemon's hook state.
83
84
  state.liveSessions = opts.liveSessions || liveSessions;
84
85
  state = applyIdle(state, config);
86
+
87
+ // Pull live session tokens from the transcript file. Claude Code's hook
88
+ // payloads don't include usage data, so state.tokens from PostToolUse
89
+ // events is always {0,0,0,0}. The transcript is the only running source
90
+ // of truth — readSessionTokens is mtime-cached, so this is cheap unless
91
+ // the session is actively writing.
92
+ if (state.cwd && state.status !== 'stale') {
93
+ const cwdLower = state.cwd.toLowerCase();
94
+ const match = (state.liveSessions || []).find(s =>
95
+ (s.cwd || '').toLowerCase() === cwdLower
96
+ );
97
+ if (match) {
98
+ const t = readSessionTokens(match.path);
99
+ if (t) state.tokens = t;
100
+ }
101
+ }
102
+
103
+ // Apply privacy AFTER token resolution but BEFORE buildVars — so the
104
+ // template inputs ({project}, {currentFile}, etc.) already reflect the
105
+ // visibility decision. Sets state._privacy so we can short-circuit to
106
+ // clearActivity when visibility=hidden.
107
+ state = applyPrivacy(state, config);
108
+
85
109
  const vars = buildVars(state, config, opts.aggregate || aggregate);
86
110
  const p = config.presence || {};
87
111
 
@@ -155,9 +179,12 @@ function buildActivity(opts = {}) {
155
179
  if (typeof config.activityType === 'number') activity.type = config.activityType;
156
180
 
157
181
  // Buttons: static configured set, optionally augmented with a per-project
158
- // GitHub button when the current cwd has a github origin.
182
+ // GitHub button when the current cwd has a github origin. Privacy mode
183
+ // suppresses the GitHub button entirely (else clicking it leaks the
184
+ // project name we're trying to hide).
185
+ const isPrivacyConstrained = state._privacy && state._privacy.visibility !== 'public';
159
186
  const buttons = Array.isArray(p.buttons) ? p.buttons.slice() : [];
160
- const gh = state.status !== 'stale' ? detectGithubUrl(state.cwd) : null;
187
+ const gh = (!isPrivacyConstrained && state.status !== 'stale') ? detectGithubUrl(state.cwd) : null;
161
188
  if (gh && !buttons.some((b) => /github\.com/i.test(b.url || ''))) {
162
189
  buttons.unshift({ label: 'View on GitHub →', url: gh });
163
190
  }
@@ -179,9 +206,14 @@ async function pushPresence() {
179
206
  let resolved = readState();
180
207
  resolved.liveSessions = liveSessions;
181
208
  resolved = applyIdle(resolved, config);
209
+ // Privacy can convert any state into a "hidden" verdict — give it the
210
+ // same treatment as hideWhenStale: a single clearActivity, deduped via
211
+ // lastPayloadHash so we don't spam the IPC.
212
+ resolved = applyPrivacy(resolved, config);
182
213
 
183
214
  const hideWhenStale = config.hideWhenStale !== false;
184
- if (resolved.status === 'stale' && hideWhenStale) {
215
+ const privacyHidden = resolved._privacy?.visibility === 'hidden';
216
+ if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden) {
185
217
  const stamp = 'cleared';
186
218
  if (lastPayloadHash === stamp) return;
187
219
  lastPayloadHash = stamp;
@@ -189,7 +221,8 @@ async function pushPresence() {
189
221
  // elapsed timer rather than counting from a previous session.
190
222
  effectiveSessionStart = null;
191
223
  await client.user.clearActivity();
192
- log('Presence cleared (stale — Claude Code not running)');
224
+ const reason = privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
225
+ log(`Presence cleared (${reason})`);
193
226
  return;
194
227
  }
195
228
 
package/src/doctor.js ADDED
@@ -0,0 +1,396 @@
1
+ // `claude-rpc doctor` — one-shot diagnostic.
2
+ //
3
+ // Checks every common failure path users have hit in support and prints a
4
+ // colored pass/fail/warn checklist with one-line fix hints. Self-contained:
5
+ // no Discord IPC connection (just a brief probe), no side effects, safe to
6
+ // run repeatedly. Exit code 0 when everything passes, 1 when any check fails.
7
+
8
+ import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
9
+ import { join, basename } from 'node:path';
10
+ import {
11
+ IS_PACKAGED, IS_NPM_INSTALL, IS_INSTALLED,
12
+ CONFIG_PATH, CANONICAL_EXE, USER_CONFIG_DIR,
13
+ STATE_PATH, PID_PATH, LOG_PATH,
14
+ AGGREGATE_PATH, SCAN_CACHE_PATH,
15
+ CLAUDE_HOME, CLAUDE_PROJECTS, CLAUDE_SETTINGS,
16
+ } from './paths.js';
17
+ import { findLiveSessions } from './scanner.js';
18
+ import { resolveVisibility, listPrivateCwds } from './privacy.js';
19
+
20
+ const TTY = process.stdout.isTTY && !process.env.NO_COLOR;
21
+ const c = {
22
+ reset: TTY ? '\x1b[0m' : '',
23
+ dim: TTY ? '\x1b[2m' : '',
24
+ bold: TTY ? '\x1b[1m' : '',
25
+ red: TTY ? '\x1b[31m' : '',
26
+ green: TTY ? '\x1b[32m' : '',
27
+ yellow: TTY ? '\x1b[33m' : '',
28
+ cyan: TTY ? '\x1b[36m' : '',
29
+ gray: TTY ? '\x1b[90m' : '',
30
+ };
31
+
32
+ const SYM_OK = TTY ? `${c.green}✓${c.reset}` : '[ok] ';
33
+ const SYM_FAIL = TTY ? `${c.red}✗${c.reset}` : '[fail]';
34
+ const SYM_WARN = TTY ? `${c.yellow}!${c.reset}` : '[warn]';
35
+ const SYM_INFO = TTY ? `${c.cyan}·${c.reset}` : '[info]';
36
+
37
+ const counters = { pass: 0, fail: 0, warn: 0 };
38
+
39
+ function check(label, status, detail = '', hint = '') {
40
+ let sym;
41
+ if (status === 'pass') { sym = SYM_OK; counters.pass++; }
42
+ else if (status === 'fail') { sym = SYM_FAIL; counters.fail++; }
43
+ else if (status === 'warn') { sym = SYM_WARN; counters.warn++; }
44
+ else { sym = SYM_INFO; }
45
+ const tail = detail ? ` ${c.dim}${detail}${c.reset}` : '';
46
+ console.log(` ${sym} ${label}${tail}`);
47
+ if (hint && status !== 'pass') {
48
+ console.log(` ${c.gray}↳ ${hint}${c.reset}`);
49
+ }
50
+ }
51
+
52
+ function section(title) {
53
+ console.log(`\n${c.bold}${title}${c.reset}`);
54
+ }
55
+
56
+ // ── individual checks ────────────────────────────────────────────────────
57
+
58
+ function checkNodeVersion() {
59
+ const major = Number((process.versions.node || '').split('.')[0] || 0);
60
+ if (major >= 18) {
61
+ check('Node.js version', 'pass', `${process.versions.node}`);
62
+ } else {
63
+ check('Node.js version', 'fail', `${process.versions.node} (need ≥18)`,
64
+ 'install a newer Node from https://nodejs.org');
65
+ }
66
+ }
67
+
68
+ function checkMode() {
69
+ let mode, detail;
70
+ if (IS_PACKAGED) {
71
+ mode = 'pass';
72
+ detail = `packaged exe at ${process.execPath}`;
73
+ } else if (IS_NPM_INSTALL) {
74
+ mode = 'pass';
75
+ detail = 'npm install (global or local node_modules)';
76
+ } else {
77
+ mode = 'pass';
78
+ detail = 'dev source (cloned repo, no node_modules wrapper)';
79
+ }
80
+ check('execution mode', mode, detail);
81
+ }
82
+
83
+ function checkConfig() {
84
+ if (!existsSync(CONFIG_PATH)) {
85
+ check('config.json present', 'fail', CONFIG_PATH,
86
+ 'run `claude-rpc setup` to seed a default config');
87
+ return null;
88
+ }
89
+ let cfg;
90
+ try {
91
+ cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
92
+ } catch (e) {
93
+ check('config.json present', 'fail', `parse error: ${e.message}`,
94
+ `open ${CONFIG_PATH} and fix the JSON syntax (or delete it and re-run setup)`);
95
+ return null;
96
+ }
97
+ check('config.json present', 'pass', CONFIG_PATH);
98
+
99
+ if (!cfg.clientId || cfg.clientId === '1234567890123456789') {
100
+ check('discord clientId set', 'fail', cfg.clientId || '(empty)',
101
+ `paste your discord application ID into ${CONFIG_PATH}`);
102
+ } else if (!/^\d{17,21}$/.test(String(cfg.clientId))) {
103
+ check('discord clientId set', 'warn', `${cfg.clientId} doesn't look like a snowflake`,
104
+ 'discord application IDs are 17–21 digits');
105
+ } else {
106
+ check('discord clientId set', 'pass', String(cfg.clientId));
107
+ }
108
+
109
+ const hasByStatus = !!cfg.presence?.byStatus;
110
+ const hasRotation = Array.isArray(cfg.presence?.rotation) && cfg.presence.rotation.length > 0;
111
+ if (hasByStatus) {
112
+ check('presence schema', 'pass', 'byStatus block present (v0.3.6+ shape)');
113
+ } else if (hasRotation) {
114
+ check('presence schema', 'warn', 'legacy rotation only — no byStatus block',
115
+ 'run `claude-rpc setup` again to migrate config into the byStatus shape');
116
+ } else {
117
+ check('presence schema', 'warn', 'no presence templates configured',
118
+ 'either rotation or byStatus is needed for the card to render');
119
+ }
120
+ return cfg;
121
+ }
122
+
123
+ function checkClaudeHome() {
124
+ if (!existsSync(CLAUDE_HOME)) {
125
+ check('~/.claude exists', 'fail', CLAUDE_HOME,
126
+ 'install claude code first — https://claude.com/claude-code');
127
+ return false;
128
+ }
129
+ check('~/.claude exists', 'pass', CLAUDE_HOME);
130
+ return true;
131
+ }
132
+
133
+ function checkClaudeProjects() {
134
+ if (!existsSync(CLAUDE_PROJECTS)) {
135
+ check('~/.claude/projects exists', 'warn', 'no transcripts on disk yet',
136
+ 'open claude code and prompt once — the directory is created lazily');
137
+ return 0;
138
+ }
139
+ let count = 0;
140
+ try {
141
+ for (const proj of readdirSync(CLAUDE_PROJECTS)) {
142
+ for (const f of readdirSync(join(CLAUDE_PROJECTS, proj))) {
143
+ if (f.endsWith('.jsonl')) count++;
144
+ }
145
+ }
146
+ } catch {}
147
+ check('claude transcripts visible', count > 0 ? 'pass' : 'warn',
148
+ `${count} .jsonl ${count === 1 ? 'file' : 'files'}`,
149
+ count === 0 ? 'open claude code and send a prompt — transcripts appear immediately' : '');
150
+ return count;
151
+ }
152
+
153
+ const HOOK_EVENTS = [
154
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
155
+ 'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
156
+ ];
157
+
158
+ function isOurHookCommand(cmd) {
159
+ if (!cmd) return false;
160
+ return /claude-rpc/i.test(cmd) || /hook\.js/i.test(cmd);
161
+ }
162
+
163
+ function checkHooks() {
164
+ if (!existsSync(CLAUDE_SETTINGS)) {
165
+ check('hooks registered', 'fail', `${CLAUDE_SETTINGS} missing`,
166
+ 'run `claude-rpc setup` to register hooks');
167
+ return;
168
+ }
169
+ let settings;
170
+ try {
171
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf8'));
172
+ } catch (e) {
173
+ check('hooks registered', 'fail', `parse error: ${e.message}`,
174
+ `open ${CLAUDE_SETTINGS} and fix the JSON syntax`);
175
+ return;
176
+ }
177
+ const missing = [];
178
+ const stale = [];
179
+ for (const event of HOOK_EVENTS) {
180
+ const bucket = settings.hooks?.[event];
181
+ if (!Array.isArray(bucket) || bucket.length === 0) { missing.push(event); continue; }
182
+ const ours = bucket.flatMap((e) => e.hooks || []).find((h) => isOurHookCommand(h.command));
183
+ if (!ours) { missing.push(event); continue; }
184
+ if (IS_PACKAGED && !ours.command.includes(CANONICAL_EXE)) stale.push({ event, cmd: ours.command });
185
+ if (IS_NPM_INSTALL && !/\bclaude-rpc\b\s+hook\b/.test(ours.command)) stale.push({ event, cmd: ours.command });
186
+ }
187
+ if (missing.length === 0 && stale.length === 0) {
188
+ check(`hooks registered (${HOOK_EVENTS.length}/${HOOK_EVENTS.length})`, 'pass',
189
+ 'all events wired against the current binary');
190
+ } else if (missing.length === HOOK_EVENTS.length) {
191
+ check('hooks registered', 'fail', 'no claude-rpc hooks found',
192
+ 'run `claude-rpc setup` to register hooks');
193
+ } else if (missing.length > 0) {
194
+ check('hooks registered', 'warn', `missing: ${missing.join(', ')}`,
195
+ 'run `claude-rpc setup` to add the missing events');
196
+ } else if (stale.length > 0) {
197
+ check('hooks registered', 'warn',
198
+ `${stale.length} pointing at an old binary path`,
199
+ 'run `claude-rpc setup` to refresh hook commands against the current binary');
200
+ }
201
+ }
202
+
203
+ function checkCanonicalExe() {
204
+ if (!IS_PACKAGED) {
205
+ check('canonical exe', 'pass', `not applicable (${IS_NPM_INSTALL ? 'npm' : 'dev'} mode)`);
206
+ return;
207
+ }
208
+ if (existsSync(CANONICAL_EXE)) {
209
+ let size = '';
210
+ try { size = `${(statSync(CANONICAL_EXE).size / 1024 / 1024).toFixed(1)} MB`; } catch {}
211
+ check('canonical exe installed', 'pass', `${CANONICAL_EXE} (${size})`);
212
+ } else {
213
+ check('canonical exe installed', 'fail', `missing: ${CANONICAL_EXE}`,
214
+ 'run `claude-rpc setup` to copy this binary to the canonical location');
215
+ }
216
+ }
217
+
218
+ function isAlive(pid) {
219
+ try { process.kill(pid, 0); return true; } catch { return false; }
220
+ }
221
+
222
+ function checkDaemon() {
223
+ if (!existsSync(PID_PATH)) {
224
+ check('daemon running', 'warn', 'no pid file',
225
+ 'run `claude-rpc start` to launch the daemon');
226
+ return false;
227
+ }
228
+ const pid = Number(readFileSync(PID_PATH, 'utf8'));
229
+ if (!pid || !isAlive(pid)) {
230
+ check('daemon running', 'fail', `stale pid file (${pid})`,
231
+ 'run `claude-rpc start` — old daemon died without cleaning up');
232
+ return false;
233
+ }
234
+ check('daemon running', 'pass', `pid ${pid}`);
235
+ return true;
236
+ }
237
+
238
+ function checkDaemonLog() {
239
+ if (!existsSync(LOG_PATH)) {
240
+ check('daemon log', 'warn', 'no log file yet',
241
+ 'daemon will create this on first start');
242
+ return;
243
+ }
244
+ let st;
245
+ try { st = statSync(LOG_PATH); } catch {
246
+ check('daemon log', 'warn', 'unreadable');
247
+ return;
248
+ }
249
+ const ageMin = (Date.now() - st.mtimeMs) / 60_000;
250
+ const sizeKB = (st.size / 1024).toFixed(1);
251
+ // Look for "Discord RPC connected" in the tail to confirm Discord IPC.
252
+ let connected = false;
253
+ try {
254
+ const tail = readFileSync(LOG_PATH, 'utf8').split('\n').slice(-50).join('\n');
255
+ connected = /Discord RPC connected/i.test(tail);
256
+ } catch {}
257
+ if (connected) {
258
+ check('discord IPC connection', 'pass',
259
+ `${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
260
+ } else {
261
+ check('discord IPC connection', 'warn',
262
+ `log shows no recent "connected" line`,
263
+ 'is the discord desktop client running? rpc only works via desktop, not browser');
264
+ }
265
+ }
266
+
267
+ function checkState() {
268
+ if (!existsSync(STATE_PATH)) {
269
+ check('state.json', 'warn', 'not present', 'created by the first hook event');
270
+ return;
271
+ }
272
+ let state;
273
+ try { state = JSON.parse(readFileSync(STATE_PATH, 'utf8')); }
274
+ catch (e) {
275
+ check('state.json', 'fail', `parse error: ${e.message}`,
276
+ `delete ${STATE_PATH} and let the next hook event recreate it`);
277
+ return;
278
+ }
279
+ const ageMin = state.lastActivity
280
+ ? (Date.now() - state.lastActivity) / 60_000
281
+ : Infinity;
282
+ const ageLabel = ageMin === Infinity ? 'never' : `${ageMin.toFixed(1)} min ago`;
283
+ check('state.json fresh', 'pass', `status=${state.status} · last activity ${ageLabel}`);
284
+ }
285
+
286
+ function checkAggregate() {
287
+ if (!existsSync(AGGREGATE_PATH)) {
288
+ check('aggregate built', 'warn', 'never scanned',
289
+ 'run `claude-rpc scan` to build lifetime stats from your transcripts');
290
+ return;
291
+ }
292
+ try {
293
+ const agg = JSON.parse(readFileSync(AGGREGATE_PATH, 'utf8'));
294
+ const hours = ((agg.activeMs || 0) / 3_600_000).toFixed(1);
295
+ const ageMin = (Date.now() - statSync(AGGREGATE_PATH).mtimeMs) / 60_000;
296
+ check('aggregate built', 'pass',
297
+ `${agg.sessions || 0} sessions · ${hours}h · refreshed ${ageMin.toFixed(0)} min ago`);
298
+ } catch (e) {
299
+ check('aggregate built', 'fail', `parse error: ${e.message}`,
300
+ 'run `claude-rpc rescan` to rebuild the aggregate from scratch');
301
+ }
302
+ }
303
+
304
+ function checkPrivacy(cfg) {
305
+ try {
306
+ const cwd = process.cwd();
307
+ const { visibility, projectName, reason } = resolveVisibility(cwd, cfg || {});
308
+ const listed = listPrivateCwds();
309
+ const detail = projectName
310
+ ? `${visibility} · alias=${projectName} · ${reason}`
311
+ : `${visibility} · ${reason}`;
312
+ const status = visibility === 'hidden' ? 'warn'
313
+ : visibility === 'name-only' ? 'warn'
314
+ : 'pass';
315
+ check('current directory visibility', status, detail);
316
+ if (listed.length) {
317
+ check('private-list entries', 'pass', `${listed.length} ${listed.length === 1 ? 'path' : 'paths'} marked private`);
318
+ } else {
319
+ check('private-list entries', 'pass', 'none');
320
+ }
321
+ } catch (e) {
322
+ check('privacy check', 'warn', `lookup failed: ${e.message}`);
323
+ }
324
+ }
325
+
326
+ function checkLiveSessions() {
327
+ try {
328
+ const live = findLiveSessions({ thresholdMs: 90_000 });
329
+ if (live.length === 0) {
330
+ check('live sessions', 'pass',
331
+ 'none — claude code isn\'t actively writing a transcript right now');
332
+ } else {
333
+ const names = live.slice(0, 3).map((s) => `${s.project}(${s.ageSec}s)`).join(', ');
334
+ check('live sessions', 'pass', `${live.length} active: ${names}`);
335
+ }
336
+ } catch (e) {
337
+ check('live sessions', 'warn', `lookup failed: ${e.message}`);
338
+ }
339
+ }
340
+
341
+ function checkDataDir() {
342
+ if (!existsSync(USER_CONFIG_DIR)) {
343
+ check('user config dir', 'warn', `${USER_CONFIG_DIR} missing`,
344
+ 'run `claude-rpc setup` — this is created automatically');
345
+ return;
346
+ }
347
+ check('user config dir', 'pass', USER_CONFIG_DIR);
348
+ }
349
+
350
+ // ── public entry point ──────────────────────────────────────────────────
351
+
352
+ export function runDoctor() {
353
+ console.log(`${c.bold}${c.cyan}claude-rpc doctor${c.reset} ${c.dim}— diagnostic checklist${c.reset}`);
354
+
355
+ section('Runtime');
356
+ checkNodeVersion();
357
+ checkMode();
358
+ checkCanonicalExe();
359
+
360
+ section('Config');
361
+ checkDataDir();
362
+ const cfg = checkConfig();
363
+
364
+ section('Claude Code');
365
+ if (checkClaudeHome()) {
366
+ checkClaudeProjects();
367
+ checkHooks();
368
+ }
369
+
370
+ section('Daemon');
371
+ checkDaemon();
372
+ checkDaemonLog();
373
+ checkState();
374
+
375
+ section('Data');
376
+ checkAggregate();
377
+ checkLiveSessions();
378
+
379
+ section('Privacy');
380
+ checkPrivacy(cfg);
381
+
382
+ // Summary
383
+ const { pass, fail, warn } = counters;
384
+ console.log('');
385
+ console.log(` ${c.bold}Summary:${c.reset} ${c.green}${pass} pass${c.reset}` +
386
+ (warn ? ` ${c.yellow}${warn} warn${c.reset}` : '') +
387
+ (fail ? ` ${c.red}${fail} fail${c.reset}` : ''));
388
+ if (fail === 0 && warn === 0) {
389
+ console.log(` ${c.dim}everything looks good. if presence still isn't showing, restart discord.${c.reset}`);
390
+ } else if (fail === 0) {
391
+ console.log(` ${c.dim}no failures — warnings are usually fixed by running \`claude-rpc setup\`.${c.reset}`);
392
+ }
393
+ console.log('');
394
+
395
+ return fail === 0 ? 0 : 1;
396
+ }