claude-rpc 0.12.1 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -8
- package/package.json +12 -3
- package/src/calendar.js +1 -1
- package/src/card.js +1 -1
- package/src/cli.js +260 -15
- package/src/community.js +89 -0
- package/src/daemon.js +78 -28
- package/src/default-config.js +23 -1
- package/src/doctor.js +36 -14
- package/src/format.js +11 -5
- package/src/git.js +1 -1
- package/src/hook.js +56 -13
- package/src/install.js +58 -22
- package/src/leaderboard.js +0 -0
- package/src/mcp.js +20 -4
- package/src/notify.js +33 -6
- package/src/nudge.js +87 -0
- package/src/paths.js +7 -0
- package/src/profile.js +1 -1
- package/src/scanner.js +119 -26
- package/src/server/assets/dashboard.client.js +1 -1
- package/src/server/assets/wrapped.client.js +26 -5
- package/src/session-card.js +1 -1
- package/src/state.js +102 -10
- package/src/version.js +1 -1
package/src/daemon.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
3
4
|
import { Client } from '@xhayper/discord-rpc';
|
|
4
5
|
import { readState } from './state.js';
|
|
5
6
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
@@ -135,7 +136,13 @@ function pickFrames(p, status) {
|
|
|
135
136
|
return { frames: [{ details: p.details, state: p.state }], largeImageTextTpl: null };
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
|
|
139
|
+
// Resolve the raw state file into the final presence state: idle/stale, shipped
|
|
140
|
+
// and trigger overlays, live-session token enrichment, and the privacy verdict.
|
|
141
|
+
// Run ONCE per tick (in pushPresence) and reused by buildActivity, so the
|
|
142
|
+
// clear-vs-push decision and the rendered frame are guaranteed to agree —
|
|
143
|
+
// previously this chain ran twice and only buildActivity applied the trigger
|
|
144
|
+
// overlay, so the two could diverge.
|
|
145
|
+
function resolvePresence(opts = {}) {
|
|
139
146
|
let state = opts.state || readState();
|
|
140
147
|
// Attach live sessions BEFORE applyIdle so the stale/idle decision can
|
|
141
148
|
// see ongoing transcript activity, not just this daemon's hook state.
|
|
@@ -168,6 +175,13 @@ function buildActivity(opts = {}) {
|
|
|
168
175
|
// visibility decision. Sets state._privacy so we can short-circuit to
|
|
169
176
|
// clearActivity when visibility=hidden.
|
|
170
177
|
state = applyPrivacy(state, config);
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildActivity(opts = {}) {
|
|
182
|
+
// opts.resolved is an already-resolved state (the common path, from
|
|
183
|
+
// pushPresence). Fall back to resolving here for any standalone caller.
|
|
184
|
+
const state = opts.resolved || resolvePresence(opts);
|
|
171
185
|
|
|
172
186
|
const vars = buildVars(state, config, opts.aggregate || aggregate);
|
|
173
187
|
const p = config.presence || {};
|
|
@@ -302,17 +316,10 @@ function fireStatusSideEffects(resolved) {
|
|
|
302
316
|
async function pushPresence() {
|
|
303
317
|
if (!connected || !client?.user) return;
|
|
304
318
|
try {
|
|
305
|
-
// Resolve state
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
resolved.liveSessions = liveSessions;
|
|
310
|
-
resolved = applyIdle(resolved, config);
|
|
311
|
-
resolved = applyShipped(resolved, config);
|
|
312
|
-
// Privacy can convert any state into a "hidden" verdict — give it the
|
|
313
|
-
// same treatment as hideWhenStale: a single clearActivity, deduped via
|
|
314
|
-
// lastPayloadHash so we don't spam the IPC.
|
|
315
|
-
resolved = applyPrivacy(resolved, config);
|
|
319
|
+
// Resolve state ONCE — this same object decides clear-vs-push, drives the
|
|
320
|
+
// status side-effects, AND is rendered by buildActivity, so there's no way
|
|
321
|
+
// for the decision and the frame to disagree.
|
|
322
|
+
const resolved = resolvePresence();
|
|
316
323
|
|
|
317
324
|
// Outbound side-effects on a status TRANSITION (fire once per change):
|
|
318
325
|
// a desktop notification when Claude needs you, and an opt-in webhook POST.
|
|
@@ -333,7 +340,7 @@ async function pushPresence() {
|
|
|
333
340
|
return;
|
|
334
341
|
}
|
|
335
342
|
|
|
336
|
-
const activity = buildActivity({
|
|
343
|
+
const activity = buildActivity({ resolved });
|
|
337
344
|
const hash = JSON.stringify(activity);
|
|
338
345
|
if (hash === lastPayloadHash) return;
|
|
339
346
|
lastPayloadHash = hash;
|
|
@@ -412,12 +419,43 @@ function watchFiles() {
|
|
|
412
419
|
stateTimer = setTimeout(pushPresence, 250);
|
|
413
420
|
});
|
|
414
421
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
422
|
+
// config.json may not exist yet on a fresh install where the daemon starts
|
|
423
|
+
// before `setup` seeds it (or if the user deletes it). watch() on a missing
|
|
424
|
+
// path throws ENOENT synchronously, which would crash startup — exactly the
|
|
425
|
+
// failure loadConfig was hardened against. Guard it, and if the file is
|
|
426
|
+
// absent, poll for its creation and attach the watcher once it lands.
|
|
427
|
+
const watchConfig = () => {
|
|
428
|
+
watch(CONFIG_PATH, () => {
|
|
429
|
+
log('Config changed — reloading');
|
|
430
|
+
config = loadConfigWithLog();
|
|
431
|
+
lastPayloadHash = '';
|
|
432
|
+
pushPresence();
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
if (existsSync(CONFIG_PATH)) {
|
|
436
|
+
watchConfig();
|
|
437
|
+
} else {
|
|
438
|
+
let configTimer = null;
|
|
439
|
+
const dir = dirname(CONFIG_PATH);
|
|
440
|
+
if (existsSync(dir)) {
|
|
441
|
+
const dirWatcher = watch(dir, () => {
|
|
442
|
+
if (!existsSync(CONFIG_PATH)) return;
|
|
443
|
+
clearTimeout(configTimer);
|
|
444
|
+
configTimer = setTimeout(() => {
|
|
445
|
+
try {
|
|
446
|
+
dirWatcher.close();
|
|
447
|
+
} catch {
|
|
448
|
+
/* already closed */
|
|
449
|
+
}
|
|
450
|
+
log('Config appeared — reloading');
|
|
451
|
+
config = loadConfigWithLog();
|
|
452
|
+
lastPayloadHash = '';
|
|
453
|
+
watchConfig();
|
|
454
|
+
pushPresence();
|
|
455
|
+
}, 250);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
421
459
|
if (existsSync(AGGREGATE_PATH)) {
|
|
422
460
|
let aggTimer = null;
|
|
423
461
|
watch(AGGREGATE_PATH, () => {
|
|
@@ -573,18 +611,30 @@ setInterval(() => {
|
|
|
573
611
|
// pile up locally until the next successful flush. Cadence is config-
|
|
574
612
|
// driven (`community.flushIntervalMin`, default 30 min).
|
|
575
613
|
async function runCommunityFlush() {
|
|
576
|
-
|
|
614
|
+
// Both flushes self-guard (return {ok:false, reason:'disabled'} when their
|
|
615
|
+
// opt-in is off), so run whichever is enabled. The profile flush is
|
|
616
|
+
// independent of community totals but reuses the same anonymous instanceId.
|
|
617
|
+
if (!config.community?.enabled && !config.profile?.enabled) return;
|
|
577
618
|
try {
|
|
578
|
-
const { flushCommunity } = await import('./community.js');
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
619
|
+
const { flushCommunity, flushProfile } = await import('./community.js');
|
|
620
|
+
if (config.community?.enabled) {
|
|
621
|
+
const result = await flushCommunity(config);
|
|
622
|
+
if (result.ok && result.delta) {
|
|
623
|
+
log(`community: flushed +${result.delta.sessions} sessions, +${result.delta.tokens} tokens`);
|
|
624
|
+
} else if (!result.ok && result.reason !== 'rate-limited' && result.reason !== 'no-delta') {
|
|
625
|
+
log(`community: ${result.reason}${result.error ? ' (' + result.error + ')' : ''}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (config.profile?.enabled) {
|
|
629
|
+
const pr = await flushProfile(config);
|
|
630
|
+
if (pr.ok && pr.delta) {
|
|
631
|
+
log(`profile: published @${config.profile.handle} (+${pr.delta.tokens} tokens)`);
|
|
632
|
+
} else if (!pr.ok && pr.reason !== 'rate-limited' && pr.reason !== 'disabled') {
|
|
633
|
+
log(`profile: ${pr.reason}${pr.error ? ' (' + pr.error + ')' : ''}`);
|
|
634
|
+
}
|
|
585
635
|
}
|
|
586
636
|
} catch (e) {
|
|
587
|
-
log('community flush threw:', e.message);
|
|
637
|
+
log('community/profile flush threw:', e.message);
|
|
588
638
|
}
|
|
589
639
|
}
|
|
590
640
|
const communityFlushMs = Math.max(60_000, (config.community?.flushIntervalMin || 30) * 60 * 1000);
|
package/src/default-config.js
CHANGED
|
@@ -81,6 +81,14 @@ export const DEFAULT_CONFIG = {
|
|
|
81
81
|
filename: "claude.svg",
|
|
82
82
|
public: true,
|
|
83
83
|
},
|
|
84
|
+
// Share nudges (v0.13). After you cross a genuine milestone (a streak
|
|
85
|
+
// record, a round number of sessions/hours), CLI commands like `today`
|
|
86
|
+
// print a single one-liner suggesting how to share it. Conservative by
|
|
87
|
+
// design — only the biggest NEW milestone, shown once. Set enabled:false
|
|
88
|
+
// to silence entirely. See src/nudge.js.
|
|
89
|
+
nudges: {
|
|
90
|
+
enabled: true,
|
|
91
|
+
},
|
|
84
92
|
// Community totals. On by default for fresh installs — `setup` mints an
|
|
85
93
|
// anonymous instanceId (UUID v4) into the freshly-seeded config so the
|
|
86
94
|
// daemon starts batching deltas immediately. Existing users upgrading
|
|
@@ -96,6 +104,15 @@ export const DEFAULT_CONFIG = {
|
|
|
96
104
|
endpoint: "https://claude-rpc-totals.claude-rpc.workers.dev",
|
|
97
105
|
flushIntervalMin: 30,
|
|
98
106
|
},
|
|
107
|
+
// Public leaderboard / profile (opt-in, off by default). When enabled with a
|
|
108
|
+
// handle, the daemon flush also publishes your display identity + validated
|
|
109
|
+
// usage deltas to the board. Link a GitHub user to earn the verified ✓.
|
|
110
|
+
profile: {
|
|
111
|
+
enabled: false,
|
|
112
|
+
handle: null,
|
|
113
|
+
displayName: null,
|
|
114
|
+
githubUser: null,
|
|
115
|
+
},
|
|
99
116
|
showElapsed: true,
|
|
100
117
|
activityType: 0,
|
|
101
118
|
statusAssets: {
|
|
@@ -172,7 +189,12 @@ export const DEFAULT_CONFIG = {
|
|
|
172
189
|
},
|
|
173
190
|
|
|
174
191
|
buttons: [
|
|
175
|
-
|
|
192
|
+
// The card others see in Discord is the project's main distribution
|
|
193
|
+
// surface — make the button a real call-to-action, not a bare repo link.
|
|
194
|
+
// ?ref=discord lets the landing page attribute installs that originate
|
|
195
|
+
// from a presence card. (When the cwd is a github repo the daemon also
|
|
196
|
+
// prepends a "View on GitHub →" button, so both can show.)
|
|
197
|
+
{ label: "Get claude-rpc →", url: "https://claude-rpc.vercel.app/?ref=discord" },
|
|
176
198
|
],
|
|
177
199
|
},
|
|
178
200
|
statusIcons: {
|
package/src/doctor.js
CHANGED
|
@@ -20,15 +20,35 @@ import { c, check as uiCheck } from './ui.js';
|
|
|
20
20
|
|
|
21
21
|
const counters = { pass: 0, fail: 0, warn: 0 };
|
|
22
22
|
|
|
23
|
+
// Structured, machine-readable record of every non-passing check that has a
|
|
24
|
+
// known repair, so `doctor --fix` can apply ONLY what's actually broken
|
|
25
|
+
// (targeted) instead of blindly re-running the whole setup. Each fixable check
|
|
26
|
+
// passes a `fixKind` — the CLI maps those to concrete repair actions.
|
|
27
|
+
// 'setup' — re-seed/migrate config + re-wire hooks (runInstall)
|
|
28
|
+
// 'daemon' — (re)start the daemon / clear a stale pid
|
|
29
|
+
// 'rescan' — rebuild the aggregate from transcripts
|
|
30
|
+
// 'discord' — not auto-fixable (user must open Discord desktop); advice only
|
|
31
|
+
let findings = [];
|
|
32
|
+
|
|
23
33
|
// Thin wrapper around the shared ui.check so we can keep counters local
|
|
24
34
|
// to this module without exporting a stateful version from ui.js.
|
|
25
|
-
function check(label, status, detail = '', hint = '') {
|
|
35
|
+
function check(label, status, detail = '', hint = '', fixKind = null) {
|
|
26
36
|
if (status === 'pass') counters.pass++;
|
|
27
37
|
else if (status === 'fail') counters.fail++;
|
|
28
38
|
else if (status === 'warn') counters.warn++;
|
|
39
|
+
if (fixKind && status !== 'pass') findings.push({ label, status, fixKind });
|
|
29
40
|
uiCheck(label, status, detail, hint);
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
// Deduped, ordered list of repairs the last runDoctor() identified. Consumed by
|
|
44
|
+
// the CLI's `--fix` path. Order matters: config/hooks before daemon (the daemon
|
|
45
|
+
// must restart to pick up rewired hooks), aggregate last.
|
|
46
|
+
export function fixPlan() {
|
|
47
|
+
const order = ['setup', 'rescan', 'daemon', 'discord'];
|
|
48
|
+
const kinds = new Set(findings.filter((f) => f.fixKind).map((f) => f.fixKind));
|
|
49
|
+
return order.filter((k) => kinds.has(k));
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
function section(title) {
|
|
33
53
|
console.log(`\n${c.bold}${title}${c.reset}`);
|
|
34
54
|
}
|
|
@@ -63,7 +83,7 @@ function checkMode() {
|
|
|
63
83
|
function checkConfig() {
|
|
64
84
|
if (!existsSync(CONFIG_PATH)) {
|
|
65
85
|
check('config.json present', 'fail', CONFIG_PATH,
|
|
66
|
-
'run `claude-rpc setup` to seed a default config');
|
|
86
|
+
'run `claude-rpc setup` to seed a default config', 'setup');
|
|
67
87
|
return null;
|
|
68
88
|
}
|
|
69
89
|
let cfg;
|
|
@@ -92,10 +112,10 @@ function checkConfig() {
|
|
|
92
112
|
check('presence schema', 'pass', 'byStatus block present (v0.3.6+ shape)');
|
|
93
113
|
} else if (hasRotation) {
|
|
94
114
|
check('presence schema', 'warn', 'legacy rotation only — no byStatus block',
|
|
95
|
-
'run `claude-rpc setup` again to migrate config into the byStatus shape');
|
|
115
|
+
'run `claude-rpc setup` again to migrate config into the byStatus shape', 'setup');
|
|
96
116
|
} else {
|
|
97
117
|
check('presence schema', 'warn', 'no presence templates configured',
|
|
98
|
-
'either rotation or byStatus is needed for the card to render');
|
|
118
|
+
'either rotation or byStatus is needed for the card to render', 'setup');
|
|
99
119
|
}
|
|
100
120
|
return cfg;
|
|
101
121
|
}
|
|
@@ -169,14 +189,14 @@ function checkHooks() {
|
|
|
169
189
|
'all events wired against the current binary');
|
|
170
190
|
} else if (missing.length === HOOK_EVENTS.length) {
|
|
171
191
|
check('hooks registered', 'fail', 'no claude-rpc hooks found',
|
|
172
|
-
'run `claude-rpc setup` to register hooks');
|
|
192
|
+
'run `claude-rpc setup` to register hooks', 'setup');
|
|
173
193
|
} else if (missing.length > 0) {
|
|
174
194
|
check('hooks registered', 'warn', `missing: ${missing.join(', ')}`,
|
|
175
|
-
'run `claude-rpc setup` to add the missing events');
|
|
195
|
+
'run `claude-rpc setup` to add the missing events', 'setup');
|
|
176
196
|
} else if (stale.length > 0) {
|
|
177
197
|
check('hooks registered', 'warn',
|
|
178
198
|
`${stale.length} pointing at an old binary path`,
|
|
179
|
-
'run `claude-rpc setup` to refresh hook commands against the current binary');
|
|
199
|
+
'run `claude-rpc setup` to refresh hook commands against the current binary', 'setup');
|
|
180
200
|
}
|
|
181
201
|
}
|
|
182
202
|
|
|
@@ -191,7 +211,7 @@ function checkCanonicalExe() {
|
|
|
191
211
|
check('canonical exe installed', 'pass', `${CANONICAL_EXE} (${size})`);
|
|
192
212
|
} else {
|
|
193
213
|
check('canonical exe installed', 'fail', `missing: ${CANONICAL_EXE}`,
|
|
194
|
-
'run `claude-rpc setup` to copy this binary to the canonical location');
|
|
214
|
+
'run `claude-rpc setup` to copy this binary to the canonical location', 'setup');
|
|
195
215
|
}
|
|
196
216
|
}
|
|
197
217
|
|
|
@@ -202,13 +222,13 @@ function isAlive(pid) {
|
|
|
202
222
|
function checkDaemon() {
|
|
203
223
|
if (!existsSync(PID_PATH)) {
|
|
204
224
|
check('daemon running', 'warn', 'no pid file',
|
|
205
|
-
'run `claude-rpc start` to launch the daemon');
|
|
225
|
+
'run `claude-rpc start` to launch the daemon', 'daemon');
|
|
206
226
|
return false;
|
|
207
227
|
}
|
|
208
228
|
const pid = Number(readFileSync(PID_PATH, 'utf8'));
|
|
209
229
|
if (!pid || !isAlive(pid)) {
|
|
210
230
|
check('daemon running', 'fail', `stale pid file (${pid})`,
|
|
211
|
-
'run `claude-rpc start` — old daemon died without cleaning up');
|
|
231
|
+
'run `claude-rpc start` — old daemon died without cleaning up', 'daemon');
|
|
212
232
|
return false;
|
|
213
233
|
}
|
|
214
234
|
check('daemon running', 'pass', `pid ${pid}`);
|
|
@@ -248,7 +268,7 @@ function checkDaemonLog() {
|
|
|
248
268
|
`connected · ${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
|
|
249
269
|
} else if (ipc === 'down') {
|
|
250
270
|
check('discord IPC connection', 'warn', 'daemon is reconnecting to Discord',
|
|
251
|
-
'is the discord desktop client running? rpc only works via desktop, not browser');
|
|
271
|
+
'is the discord desktop client running? rpc only works via desktop, not browser', 'discord');
|
|
252
272
|
} else {
|
|
253
273
|
check('discord IPC connection', 'warn', 'no connection activity in the log yet',
|
|
254
274
|
'start the daemon with discord desktop running');
|
|
@@ -277,7 +297,7 @@ function checkState() {
|
|
|
277
297
|
function checkAggregate() {
|
|
278
298
|
if (!existsSync(AGGREGATE_PATH)) {
|
|
279
299
|
check('aggregate built', 'warn', 'never scanned',
|
|
280
|
-
'run `claude-rpc scan` to build lifetime stats from your transcripts');
|
|
300
|
+
'run `claude-rpc scan` to build lifetime stats from your transcripts', 'rescan');
|
|
281
301
|
return;
|
|
282
302
|
}
|
|
283
303
|
try {
|
|
@@ -288,7 +308,7 @@ function checkAggregate() {
|
|
|
288
308
|
`${agg.sessions || 0} sessions · ${hours}h · refreshed ${ageMin.toFixed(0)} min ago`);
|
|
289
309
|
} catch (e) {
|
|
290
310
|
check('aggregate built', 'fail', `parse error: ${e.message}`,
|
|
291
|
-
'run `claude-rpc rescan` to rebuild the aggregate from scratch');
|
|
311
|
+
'run `claude-rpc rescan` to rebuild the aggregate from scratch', 'rescan');
|
|
292
312
|
}
|
|
293
313
|
}
|
|
294
314
|
|
|
@@ -332,7 +352,7 @@ function checkLiveSessions() {
|
|
|
332
352
|
function checkDataDir() {
|
|
333
353
|
if (!existsSync(USER_CONFIG_DIR)) {
|
|
334
354
|
check('user config dir', 'warn', `${USER_CONFIG_DIR} missing`,
|
|
335
|
-
'run `claude-rpc setup` — this is created automatically');
|
|
355
|
+
'run `claude-rpc setup` — this is created automatically', 'setup');
|
|
336
356
|
return;
|
|
337
357
|
}
|
|
338
358
|
check('user config dir', 'pass', USER_CONFIG_DIR);
|
|
@@ -341,6 +361,8 @@ function checkDataDir() {
|
|
|
341
361
|
// ── public entry point ──────────────────────────────────────────────────
|
|
342
362
|
|
|
343
363
|
export function runDoctor() {
|
|
364
|
+
counters.pass = 0; counters.fail = 0; counters.warn = 0;
|
|
365
|
+
findings = [];
|
|
344
366
|
console.log(`${c.bold}${c.cyan}claude-rpc doctor${c.reset} ${c.dim}— diagnostic checklist${c.reset}`);
|
|
345
367
|
|
|
346
368
|
section('Runtime');
|
package/src/format.js
CHANGED
|
@@ -107,9 +107,13 @@ function humanTool(name) {
|
|
|
107
107
|
return name;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// "
|
|
111
|
-
//
|
|
112
|
-
// "
|
|
110
|
+
// Given a real path, the basename is exact: "/home/alice/my-app" → "my-app".
|
|
111
|
+
// Given a Claude Code project *slug* (path with separators replaced by '-')
|
|
112
|
+
// we can only best-effort the last segment: "C--Users-simmo-Downloads-CLAUDE"
|
|
113
|
+
// → "CLAUDE". A hyphenated name ("my-app") can't be recovered from a bare slug
|
|
114
|
+
// because '-' also encodes the separators — but callers pass the real cwd
|
|
115
|
+
// wherever they have it (live sessions, aggregate keys), so the slug branch is
|
|
116
|
+
// a rare fallback for transcripts whose cwd we never saw.
|
|
113
117
|
function humanProject(slugOrPath) {
|
|
114
118
|
if (!slugOrPath) return '';
|
|
115
119
|
const raw = String(slugOrPath);
|
|
@@ -122,8 +126,10 @@ function humanProject(slugOrPath) {
|
|
|
122
126
|
|| raw.startsWith('-tmp-')
|
|
123
127
|
|| raw.startsWith('-var-')
|
|
124
128
|
|| raw.startsWith('-opt-')) {
|
|
125
|
-
// Path-style slug —
|
|
126
|
-
|
|
129
|
+
// Path-style slug — strip a leading Windows drive letter ("C--") and take
|
|
130
|
+
// the last segment. (The old code filtered out every segment equal to 'C',
|
|
131
|
+
// which wrongly dropped a real path/project segment literally named "C".)
|
|
132
|
+
const parts = raw.replace(/^[A-Za-z]--/, '').split('-').filter(Boolean);
|
|
127
133
|
name = parts[parts.length - 1] || raw;
|
|
128
134
|
} else {
|
|
129
135
|
name = raw;
|
package/src/git.js
CHANGED
|
@@ -31,7 +31,7 @@ function readGitInfo(cwd) {
|
|
|
31
31
|
// origin URL → github URL + repo name.
|
|
32
32
|
try {
|
|
33
33
|
const cfg = readFileSync(join(gitDir, 'config'), 'utf8');
|
|
34
|
-
const m = cfg.match(/\[remote\s+"origin"\][
|
|
34
|
+
const m = cfg.match(/\[remote\s+"origin"\][^[]*?url\s*=\s*([^\r\n]+)/i);
|
|
35
35
|
if (m) {
|
|
36
36
|
const raw = m[1].trim();
|
|
37
37
|
const ssh = raw.match(/^git@github\.com:([^\s]+?)(?:\.git)?$/i);
|
package/src/hook.js
CHANGED
|
@@ -5,22 +5,65 @@ import { updateState, resetState, pushUnique, shortFile } from './state.js';
|
|
|
5
5
|
import { detectLastCommitSubject, detectGitBranch } from './git.js';
|
|
6
6
|
import { EVENTS_LOG_PATH } from './paths.js';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
];
|
|
8
|
+
// Precedence when a command ships more than one way (`git commit && git push`
|
|
9
|
+
// → push). Highest first.
|
|
10
|
+
const SHIP_PRECEDENCE = ['push', 'commit', 'pr', 'issue', 'tag'];
|
|
11
|
+
|
|
12
|
+
// Tokenize one command segment the way a shell roughly would for our purposes:
|
|
13
|
+
// strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
|
|
14
|
+
// from the leading binary (/usr/bin/git → git), lowercase it.
|
|
15
|
+
function tokenizeSegment(seg) {
|
|
16
|
+
const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
|
|
17
|
+
let toks = stripped.split(/\s+/).filter(Boolean);
|
|
18
|
+
while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
|
|
19
|
+
if (toks.length) {
|
|
20
|
+
const slash = toks[0].lastIndexOf('/');
|
|
21
|
+
if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
|
|
22
|
+
toks[0] = toks[0].toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
return toks;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// First real git subcommand, skipping global flags and their values
|
|
28
|
+
// (`git -C /repo -c k=v push` → push).
|
|
29
|
+
function gitSubcommand(args) {
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
const a = args[i];
|
|
32
|
+
if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
|
|
33
|
+
if (a.startsWith('-')) continue;
|
|
34
|
+
return a.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function shipKindForSegment(seg) {
|
|
40
|
+
const toks = tokenizeSegment(seg);
|
|
41
|
+
if (!toks.length) return null;
|
|
42
|
+
if (toks[0] === 'git') {
|
|
43
|
+
const sub = gitSubcommand(toks.slice(1));
|
|
44
|
+
if (sub === 'push') return 'push';
|
|
45
|
+
if (sub === 'commit') return 'commit';
|
|
46
|
+
} else if (toks[0] === 'gh') {
|
|
47
|
+
if (toks[1] === 'pr' && toks[2] === 'create') return 'pr';
|
|
48
|
+
if (toks[1] === 'issue' && toks[2] === 'create') return 'issue';
|
|
49
|
+
if (toks[1] === 'release' && toks[2] === 'create') return 'tag';
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
19
53
|
|
|
20
54
|
// Return the "shipped" kind for a shell command, or null. Exported for tests.
|
|
55
|
+
// Splits on shell separators and only classifies a segment whose *actual*
|
|
56
|
+
// leading command is git/gh — so a quoted mention ("git push later" inside an
|
|
57
|
+
// echo or a commit message) no longer false-fires. Tolerates env prefixes,
|
|
58
|
+
// sudo/time, chained commands, and git global flags.
|
|
21
59
|
export function classifyShip(cmd) {
|
|
22
|
-
const
|
|
23
|
-
|
|
60
|
+
const segments = String(cmd || '').split(/[;&|\n]+/);
|
|
61
|
+
const found = new Set();
|
|
62
|
+
for (const seg of segments) {
|
|
63
|
+
const k = shipKindForSegment(seg);
|
|
64
|
+
if (k) found.add(k);
|
|
65
|
+
}
|
|
66
|
+
for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
|
|
24
67
|
return null;
|
|
25
68
|
}
|
|
26
69
|
|
package/src/install.js
CHANGED
|
@@ -12,10 +12,11 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
13
|
import {
|
|
14
14
|
CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR, ROOT,
|
|
15
|
-
HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL,
|
|
15
|
+
HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL, IS_NPX,
|
|
16
16
|
CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME,
|
|
17
17
|
} from './paths.js';
|
|
18
18
|
import { DEFAULT_CONFIG } from './default-config.js';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
19
20
|
|
|
20
21
|
const STARTUP_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
21
22
|
const STARTUP_VALUE = 'ClaudeRPC';
|
|
@@ -329,20 +330,32 @@ export function migrateConfig({ silent = false } = {}) {
|
|
|
329
330
|
added.push('community (preserved-off)');
|
|
330
331
|
}
|
|
331
332
|
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// default never reaches upgraders just by
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
333
|
+
// Button defaults have moved twice: the Claude Code website (pre-v0.8.1) →
|
|
334
|
+
// the project repo (v0.8.1) → a landing-page call-to-action (v0.13). Existing
|
|
335
|
+
// configs carry their own `buttons` array, which fully REPLACES the default
|
|
336
|
+
// (arrays don't deep-merge), so a new default never reaches upgraders just by
|
|
337
|
+
// bumping the package. Upgrade any button that's still a verbatim shipped
|
|
338
|
+
// default to the current CTA; as a safety net, also repoint a button that's
|
|
339
|
+
// merely been relabeled but still aims at the long-dead claude.com URL.
|
|
340
|
+
// Anything a user fully customized (their own label AND url) is left alone.
|
|
341
|
+
const NEW_BTN = DEFAULT_CONFIG.presence?.buttons?.[0];
|
|
342
|
+
const SHIPPED_DEFAULT_BTNS = [
|
|
343
|
+
{ label: 'Claude Code', url: 'https://claude.com/claude-code' },
|
|
344
|
+
{ label: 'Claude Code', url: 'https://github.com/rar-file/claude-rpc' },
|
|
345
|
+
];
|
|
346
|
+
if (NEW_BTN && Array.isArray(cfg.presence?.buttons)) {
|
|
341
347
|
let changed = false;
|
|
342
348
|
for (const b of cfg.presence.buttons) {
|
|
343
|
-
if (b
|
|
349
|
+
if (!b) continue;
|
|
350
|
+
const isShippedDefault = SHIPPED_DEFAULT_BTNS.some((d) => d.label === b.label && d.url === b.url);
|
|
351
|
+
const alreadyCurrent = b.label === NEW_BTN.label && b.url === NEW_BTN.url;
|
|
352
|
+
if (isShippedDefault && !alreadyCurrent) {
|
|
353
|
+
b.label = NEW_BTN.label; b.url = NEW_BTN.url; changed = true;
|
|
354
|
+
} else if (b.url === 'https://claude.com/claude-code') {
|
|
355
|
+
b.url = NEW_BTN.url; changed = true; // dead link, keep their custom label
|
|
356
|
+
}
|
|
344
357
|
}
|
|
345
|
-
if (changed) added.push('presence.buttons[]
|
|
358
|
+
if (changed) added.push('presence.buttons[] → CTA');
|
|
346
359
|
}
|
|
347
360
|
|
|
348
361
|
if (added.length === 0) {
|
|
@@ -397,7 +410,32 @@ function verifyHookPipe(exePath) {
|
|
|
397
410
|
return { ok: true, detail: 'SessionStart round-trip succeeded' };
|
|
398
411
|
}
|
|
399
412
|
|
|
413
|
+
// `npx claude-rpc setup` runs from npm's throwaway _npx cache, so the
|
|
414
|
+
// `claude-rpc` bin the hooks resolve through PATH disappears the moment npx
|
|
415
|
+
// exits. Promote to a real global install first, then the rest of setup wires
|
|
416
|
+
// hooks to the now-persistent global bin exactly like a normal npm install.
|
|
417
|
+
// Best-effort + loud: a failed -g (perms, offline) returns false so the caller
|
|
418
|
+
// can stop with the manual command rather than wire a dead hook.
|
|
419
|
+
function promoteNpxToGlobal() {
|
|
420
|
+
console.log(' npx is one-off — installing claude-rpc globally so hooks survive…');
|
|
421
|
+
const r = spawnSync('npm', ['install', '-g', `claude-rpc@${VERSION}`], {
|
|
422
|
+
stdio: 'inherit',
|
|
423
|
+
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
424
|
+
});
|
|
425
|
+
return !r.error && r.status === 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
400
428
|
export async function install({ exePath, withStartup = true } = {}) {
|
|
429
|
+
if (IS_NPX) {
|
|
430
|
+
if (!promoteNpxToGlobal()) {
|
|
431
|
+
console.error('\n ✗ Global install failed. Run this once, then you\'re set:');
|
|
432
|
+
console.error(' npm install -g claude-rpc && claude-rpc setup');
|
|
433
|
+
const err = new Error('npx self-install failed');
|
|
434
|
+
err.code = 3; // system error (see exit-code contract)
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
console.log(' ✓ claude-rpc installed globally\n');
|
|
438
|
+
}
|
|
401
439
|
if (process.platform !== 'win32' && withStartup) {
|
|
402
440
|
console.warn('Note: startup registration only works on Windows; other steps still run.');
|
|
403
441
|
}
|
|
@@ -428,19 +466,17 @@ export async function install({ exePath, withStartup = true } = {}) {
|
|
|
428
466
|
}
|
|
429
467
|
|
|
430
468
|
console.log('\nDone.');
|
|
431
|
-
console.log(`
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
469
|
+
console.log(`Config: ${CONFIG_PATH}`);
|
|
470
|
+
console.log(` (a working Discord app is bundled — edit clientId only to use your own)`);
|
|
471
|
+
// setup auto-starts the daemon (see cli.js), so we point at management +
|
|
472
|
+
// verification rather than a start command. The packaged exe manages the
|
|
473
|
+
// daemon via its own subcommands; the npm/dev bin uses `claude-rpc …`.
|
|
435
474
|
if (IS_PACKAGED) {
|
|
436
|
-
console.log(
|
|
437
|
-
} else if (IS_NPM_INSTALL) {
|
|
438
|
-
console.log(` claude-rpc start`);
|
|
475
|
+
console.log(`\nManage the daemon: "${target}" daemon (start) · check with claude-rpc doctor`);
|
|
439
476
|
} else {
|
|
440
|
-
console.log(
|
|
441
|
-
console.log(`
|
|
477
|
+
console.log(`\nManage the daemon: claude-rpc start | stop | status`);
|
|
478
|
+
console.log(`Verify wiring: claude-rpc doctor`);
|
|
442
479
|
}
|
|
443
|
-
console.log(`\nThen: \`claude-rpc doctor\` to verify everything is wired.`);
|
|
444
480
|
}
|
|
445
481
|
|
|
446
482
|
export async function uninstall() {
|
|
Binary file
|
package/src/mcp.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The tool HANDLERS are pure functions of an aggregate, so they're unit-tested
|
|
8
8
|
// without standing up the transport.
|
|
9
9
|
|
|
10
|
-
import { readAggregate } from './scanner.js';
|
|
10
|
+
import { readAggregate, dayKey } from './scanner.js';
|
|
11
11
|
import { buildVars } from './format.js';
|
|
12
12
|
import { readState } from './state.js';
|
|
13
13
|
import { loadConfig } from './config.js';
|
|
@@ -44,7 +44,10 @@ export const TOOLS = {
|
|
|
44
44
|
get_today: {
|
|
45
45
|
description: "Today's Claude Code activity: active hours, prompts, tool calls, tokens, estimated cost.",
|
|
46
46
|
handler(agg) {
|
|
47
|
-
|
|
47
|
+
// Key by LOCAL date via the same dayKey the scanner uses to WRITE byDay
|
|
48
|
+
// (and format.js uses to read it). A UTC slice here silently surfaced the
|
|
49
|
+
// wrong/empty bucket for anyone not on UTC once local and UTC dates split.
|
|
50
|
+
const today = (agg.byDay || {})[dayKey(Date.now())] || {};
|
|
48
51
|
const tokens = (today.inputTokens || 0) + (today.outputTokens || 0) + (today.cacheReadTokens || 0) + (today.cacheWriteTokens || 0);
|
|
49
52
|
return [
|
|
50
53
|
`Active time: ${fmtH(today.activeMs)}`,
|
|
@@ -86,7 +89,13 @@ export function toolList() {
|
|
|
86
89
|
}));
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Dispatch a tools/call by name. Reads a fresh aggregate per call (cheap).
|
|
94
|
+
* @param {string} name - Tool name (a key of TOOLS).
|
|
95
|
+
* @param {() => object} [getAgg] - Aggregate provider; defaults to readAggregate (injectable for tests).
|
|
96
|
+
* @returns {string} The tool's text result.
|
|
97
|
+
* @throws {Error} If the tool name is unknown.
|
|
98
|
+
*/
|
|
90
99
|
export function callTool(name, getAgg = readAggregate) {
|
|
91
100
|
const t = TOOLS[name];
|
|
92
101
|
if (!t) throw new Error(`unknown tool: ${name}`);
|
|
@@ -94,7 +103,14 @@ export function callTool(name, getAgg = readAggregate) {
|
|
|
94
103
|
return t.handler(agg);
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
|
|
106
|
+
/**
|
|
107
|
+
* stdio JSON-RPC transport (newline-delimited). Wires the MCP protocol methods
|
|
108
|
+
* (initialize, tools/list, tools/call, ping) to the tool handlers.
|
|
109
|
+
* @param {{input?: NodeJS.ReadableStream, output?: {write: (s: string) => void}}} [io]
|
|
110
|
+
* Streams to read requests from / write responses to. Defaults to process stdio;
|
|
111
|
+
* injectable for tests.
|
|
112
|
+
* @returns {void}
|
|
113
|
+
*/
|
|
98
114
|
export function runMcpServer({ input = process.stdin, output = process.stdout } = {}) {
|
|
99
115
|
let buf = '';
|
|
100
116
|
const send = (msg) => output.write(JSON.stringify(msg) + '\n');
|