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/package.json +3 -2
- package/src/badge.js +38 -10
- package/src/card.js +344 -0
- package/src/cli.js +147 -1
- package/src/daemon.js +38 -5
- package/src/doctor.js +396 -0
- package/src/privacy.js +231 -0
- package/src/scanner.js +101 -5
- package/src/server/api.js +172 -0
- package/src/server/index.js +98 -0
- package/src/{server.js → server/page.js} +18 -325
- package/src/server/routes.js +63 -0
- package/src/server/sse.js +32 -0
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|