claude-rpc 0.3.11 → 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
@@ -5,6 +5,7 @@ import { readState } from './state.js';
5
5
  import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
6
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 });
@@ -99,6 +100,12 @@ function buildActivity(opts = {}) {
99
100
  }
100
101
  }
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
+
102
109
  const vars = buildVars(state, config, opts.aggregate || aggregate);
103
110
  const p = config.presence || {};
104
111
 
@@ -172,9 +179,12 @@ function buildActivity(opts = {}) {
172
179
  if (typeof config.activityType === 'number') activity.type = config.activityType;
173
180
 
174
181
  // Buttons: static configured set, optionally augmented with a per-project
175
- // 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';
176
186
  const buttons = Array.isArray(p.buttons) ? p.buttons.slice() : [];
177
- const gh = state.status !== 'stale' ? detectGithubUrl(state.cwd) : null;
187
+ const gh = (!isPrivacyConstrained && state.status !== 'stale') ? detectGithubUrl(state.cwd) : null;
178
188
  if (gh && !buttons.some((b) => /github\.com/i.test(b.url || ''))) {
179
189
  buttons.unshift({ label: 'View on GitHub →', url: gh });
180
190
  }
@@ -196,9 +206,14 @@ async function pushPresence() {
196
206
  let resolved = readState();
197
207
  resolved.liveSessions = liveSessions;
198
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);
199
213
 
200
214
  const hideWhenStale = config.hideWhenStale !== false;
201
- if (resolved.status === 'stale' && hideWhenStale) {
215
+ const privacyHidden = resolved._privacy?.visibility === 'hidden';
216
+ if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden) {
202
217
  const stamp = 'cleared';
203
218
  if (lastPayloadHash === stamp) return;
204
219
  lastPayloadHash = stamp;
@@ -206,7 +221,8 @@ async function pushPresence() {
206
221
  // elapsed timer rather than counting from a previous session.
207
222
  effectiveSessionStart = null;
208
223
  await client.user.clearActivity();
209
- 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})`);
210
226
  return;
211
227
  }
212
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
+ }
package/src/privacy.js ADDED
@@ -0,0 +1,231 @@
1
+ // Privacy mode — controls what the Discord card shows about a given cwd.
2
+ // Three layers, highest-priority first:
3
+ //
4
+ // 1. Per-project ./.claude-rpc.json file in the project root.
5
+ // { "visibility": "hidden|name-only|public", "projectName": "alias" }
6
+ // Shortcut: { "private": true } == { "visibility": "hidden" }
7
+ // 2. Runtime list at ~/.claude-rpc/private-list.json, toggled by
8
+ // `claude-rpc private` / `claude-rpc public`.
9
+ // 3. Auto-detection of GitHub private repos via the `gh` CLI when
10
+ // installed. Best-effort, silently skips when gh isn't available
11
+ // or auth isn't set up. Cached per-cwd with 5min TTL.
12
+ //
13
+ // "Visibility" levels:
14
+ // public everything as-is (default)
15
+ // name-only project name kept, but currentFile / currentTool / files
16
+ // arrays cleared so the card doesn't leak paths
17
+ // hidden cwd cleared entirely; daemon then short-circuits to
18
+ // clearActivity (same effect as hideWhenStale)
19
+ //
20
+ // Aggregates (scanner) and the local TUI/web dashboards are NEVER affected
21
+ // by these flags. Privacy is a one-way valve from local state → Discord.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { join, basename, dirname, resolve as resolvePath } from 'node:path';
26
+ import { DATA_DIR } from './paths.js';
27
+
28
+ const PRIVATE_LIST_PATH = join(DATA_DIR, 'private-list.json');
29
+ const TTL_MS = 5 * 60 * 1000;
30
+
31
+ const projectFileCache = new Map(); // cwd → { ts, value | null }
32
+ const ghPrivateCache = new Map(); // cwd → { ts, value: bool | null }
33
+
34
+ // ── Per-project .claude-rpc.json ────────────────────────────────────────
35
+
36
+ // Walk up from `cwd` looking for a .claude-rpc.json. Stops at the first
37
+ // match or at a .git directory (the project root). Lets a subdirectory
38
+ // inherit the parent's privacy config.
39
+ function findProjectFile(cwd) {
40
+ if (!cwd) return null;
41
+ let dir = cwd;
42
+ while (true) {
43
+ const candidate = join(dir, '.claude-rpc.json');
44
+ if (existsSync(candidate)) return candidate;
45
+ if (existsSync(join(dir, '.git'))) return null;
46
+ const parent = dirname(dir);
47
+ if (parent === dir) return null;
48
+ dir = parent;
49
+ }
50
+ }
51
+
52
+ function readProjectConfig(cwd) {
53
+ if (!cwd) return null;
54
+ const cached = projectFileCache.get(cwd);
55
+ if (cached && Date.now() - cached.ts < TTL_MS) return cached.value;
56
+ let value = null;
57
+ const path = findProjectFile(cwd);
58
+ if (path) {
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
61
+ if (parsed && typeof parsed === 'object') value = parsed;
62
+ } catch { /* broken JSON ≡ no override */ }
63
+ }
64
+ projectFileCache.set(cwd, { ts: Date.now(), value });
65
+ return value;
66
+ }
67
+
68
+ function normalizeProjectConfig(cfg) {
69
+ if (!cfg) return null;
70
+ let visibility = cfg.visibility;
71
+ if (cfg.private === true && !visibility) visibility = 'hidden';
72
+ if (!['public', 'name-only', 'hidden'].includes(visibility)) visibility = null;
73
+ return {
74
+ visibility,
75
+ projectName: typeof cfg.projectName === 'string' ? cfg.projectName : null,
76
+ };
77
+ }
78
+
79
+ // ── Runtime private-list ────────────────────────────────────────────────
80
+
81
+ function readPrivateList() {
82
+ if (!existsSync(PRIVATE_LIST_PATH)) return { paths: [] };
83
+ try {
84
+ const v = JSON.parse(readFileSync(PRIVATE_LIST_PATH, 'utf8'));
85
+ if (Array.isArray(v?.paths)) return v;
86
+ } catch {}
87
+ return { paths: [] };
88
+ }
89
+
90
+ function writePrivateList(list) {
91
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
92
+ writeFileSync(PRIVATE_LIST_PATH, JSON.stringify(list, null, 2));
93
+ }
94
+
95
+ export function addPrivateCwd(cwd) {
96
+ const list = readPrivateList();
97
+ const abs = resolvePath(cwd);
98
+ if (!list.paths.includes(abs)) {
99
+ list.paths.push(abs);
100
+ writePrivateList(list);
101
+ }
102
+ return list.paths;
103
+ }
104
+
105
+ export function removePrivateCwd(cwd) {
106
+ const list = readPrivateList();
107
+ const abs = resolvePath(cwd);
108
+ list.paths = list.paths.filter((p) => p !== abs);
109
+ writePrivateList(list);
110
+ return list.paths;
111
+ }
112
+
113
+ export function listPrivateCwds() {
114
+ return readPrivateList().paths;
115
+ }
116
+
117
+ function isInPrivateList(cwd) {
118
+ if (!cwd) return false;
119
+ const abs = resolvePath(cwd);
120
+ return readPrivateList().paths.some(
121
+ (p) => abs === p || abs.startsWith(p + '/') || abs.startsWith(p + '\\')
122
+ );
123
+ }
124
+
125
+ // ── GitHub-private detection (best-effort, gh CLI) ──────────────────────
126
+
127
+ function detectGithubPrivate(cwd) {
128
+ if (!cwd) return null;
129
+ const cached = ghPrivateCache.get(cwd);
130
+ if (cached && Date.now() - cached.ts < TTL_MS) return cached.value;
131
+ let value = null;
132
+ try {
133
+ const out = execFileSync(
134
+ 'gh',
135
+ ['repo', 'view', '--json', 'isPrivate', '-q', '.isPrivate'],
136
+ { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 }
137
+ ).toString().trim();
138
+ if (out === 'true') value = true;
139
+ else if (out === 'false') value = false;
140
+ } catch { /* gh missing, not auth'd, not a repo, timeout — unknown */ }
141
+ ghPrivateCache.set(cwd, { ts: Date.now(), value });
142
+ return value;
143
+ }
144
+
145
+ // ── Resolution + application ────────────────────────────────────────────
146
+
147
+ // Resolve effective visibility for a cwd.
148
+ // Returns: { visibility, projectName, reason }
149
+ export function resolveVisibility(cwd, config = {}) {
150
+ const proj = normalizeProjectConfig(readProjectConfig(cwd));
151
+ if (proj?.visibility) {
152
+ return { visibility: proj.visibility, projectName: proj.projectName, reason: '.claude-rpc.json' };
153
+ }
154
+ if (isInPrivateList(cwd)) {
155
+ return { visibility: 'hidden', projectName: proj?.projectName ?? null, reason: 'private-list' };
156
+ }
157
+ const patterns = config?.privacy?.patterns || [];
158
+ if (patterns.length && cwd) {
159
+ const leaf = basename(cwd);
160
+ for (const p of patterns) {
161
+ if (matchesPattern(leaf, p)) {
162
+ const mode = config?.privacy?.mode || 'hidden';
163
+ return { visibility: mode, projectName: proj?.projectName ?? null, reason: `config pattern '${p}'` };
164
+ }
165
+ }
166
+ }
167
+ if (config?.privacy?.autoDetectGithubPrivate !== false) {
168
+ const isPrivate = detectGithubPrivate(cwd);
169
+ if (isPrivate === true) {
170
+ const mode = config?.privacy?.githubPrivateMode || 'hidden';
171
+ return { visibility: mode, projectName: proj?.projectName ?? null, reason: 'github private repo' };
172
+ }
173
+ }
174
+ return { visibility: 'public', projectName: proj?.projectName ?? null, reason: 'default' };
175
+ }
176
+
177
+ // Glob-lite. '*' matches any run; otherwise plain text. Case-insensitive.
178
+ function matchesPattern(name, pattern) {
179
+ if (pattern === name) return true;
180
+ if (!pattern.includes('*')) return false;
181
+ const re = new RegExp('^' + pattern.split('*').map(escapeRe).join('.*') + '$', 'i');
182
+ return re.test(name);
183
+ }
184
+ function escapeRe(s) { return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); }
185
+
186
+ // Apply privacy to a post-applyIdle state. Pure function — no IO.
187
+ export function applyPrivacy(state, config = {}) {
188
+ if (!state || state.status === 'stale') return state;
189
+ const { visibility, projectName } = resolveVisibility(state.cwd || '', config);
190
+
191
+ if (visibility === 'public') {
192
+ if (projectName) {
193
+ return { ...state, cwd: joinCwdAlias(state.cwd, projectName), _privacy: { visibility, alias: true } };
194
+ }
195
+ return state;
196
+ }
197
+
198
+ if (visibility === 'name-only') {
199
+ return {
200
+ ...state,
201
+ cwd: projectName ? joinCwdAlias(state.cwd, projectName) : state.cwd,
202
+ currentTool: null,
203
+ currentFile: null,
204
+ filesEdited: [],
205
+ filesRead: [],
206
+ filesOpened: [],
207
+ _privacy: { visibility, alias: !!projectName },
208
+ };
209
+ }
210
+
211
+ // hidden
212
+ return {
213
+ ...state,
214
+ cwd: '',
215
+ currentTool: null,
216
+ currentFile: null,
217
+ filesEdited: [],
218
+ filesRead: [],
219
+ filesOpened: [],
220
+ _privacy: { visibility, alias: false },
221
+ };
222
+ }
223
+
224
+ function joinCwdAlias(cwd, alias) {
225
+ if (!cwd) return alias;
226
+ const sep = cwd.includes('\\') ? '\\' : '/';
227
+ const parts = cwd.split(/[\\/]/).filter(Boolean);
228
+ if (!parts.length) return alias;
229
+ parts[parts.length - 1] = alias;
230
+ return (cwd.startsWith('/') ? '/' : '') + parts.join(sep);
231
+ }