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/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
- function buildActivity(opts = {}) {
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 once so we can decide whether to push or clear.
306
- // Mirrors buildActivity's first two lines kept here so we don't
307
- // have to round-trip through buildActivity just to learn the status.
308
- let resolved = readState();
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({ state: resolved });
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
- watch(CONFIG_PATH, () => {
416
- log('Config changed reloading');
417
- config = loadConfigWithLog();
418
- lastPayloadHash = '';
419
- pushPresence();
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
- if (!config.community?.enabled) return;
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
- const result = await flushCommunity(config);
580
- if (result.ok && result.delta) {
581
- log(`community: flushed +${result.delta.sessions} sessions, +${result.delta.tokens} tokens`);
582
- } else if (!result.ok && result.reason !== 'rate-limited' && result.reason !== 'no-delta') {
583
- // Don't spam the log for routine "nothing to send" cases.
584
- log(`community: ${result.reason}${result.error ? ' (' + result.error + ')' : ''}`);
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);
@@ -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
- { label: "Claude Code", url: "https://github.com/rar-file/claude-rpc" },
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
- // "C--Users-simmo-Downloads-CLAUDE" → "CLAUDE"
111
- // "-home-alice-projects-my-app" → "my-app"
112
- // "archive-2026-04-25T185311Z" → "archive"
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 — take the last segment.
126
- const parts = raw.split('-').filter((p) => p && p !== 'C');
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"\][^\[]*?url\s*=\s*([^\r\n]+)/i);
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
- // Bash invocations we treat as "just shipped", classified into a kind for
9
- // state.justShippedKind. Tolerates extra args, leading env vars, and chained
10
- // commands ("git add . && git commit -m …"). Order matters: push outranks
11
- // commit when a command does both (`git commit && git push`).
12
- const SHIP_PATTERNS = [
13
- { kind: 'push', re: /(?:^|[;&|]|\s)git\s+push(?:\s|$)/ },
14
- { kind: 'commit', re: /(?:^|[;&|]|\s)git\s+commit(?:\s|$)/ },
15
- { kind: 'pr', re: /(?:^|[;&|]|\s)gh\s+pr\s+create(?:\s|$)/ },
16
- { kind: 'issue', re: /(?:^|[;&|]|\s)gh\s+issue\s+create(?:\s|$)/ },
17
- { kind: 'tag', re: /(?:^|[;&|]|\s)gh\s+release\s+create(?:\s|$)/ },
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 s = String(cmd || '');
23
- for (const p of SHIP_PATTERNS) if (p.re.test(s)) return p.kind;
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
- // v0.8.1: the default presence button moved from the Claude Code website
333
- // to the project repo. Existing configs carry their own `buttons` array,
334
- // which fully REPLACES the default (arrays don't deep-merge) so the new
335
- // default never reaches upgraders just by bumping the package. Rewrite
336
- // ONLY a button still pointing at the verbatim old default URL; anything a
337
- // user has customized (label or url) is left untouched.
338
- const OLD_BTN_URL = 'https://claude.com/claude-code';
339
- const NEW_BTN_URL = DEFAULT_CONFIG.presence?.buttons?.[0]?.url;
340
- if (NEW_BTN_URL && Array.isArray(cfg.presence?.buttons)) {
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 && b.url === OLD_BTN_URL) { b.url = NEW_BTN_URL; changed = true; }
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[].urlrepo');
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(`Edit ${CONFIG_PATH} to set your Discord clientId, then run:`);
432
- // Per-mode "start" instructionspackaged exe takes a daemon subcommand,
433
- // the npm bin shim handles `start` as a subcommand of itself, and dev
434
- // mode runs the daemon script directly through node.
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(` "${target}" daemon`);
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(` node "${join(ROOT, 'src', 'daemon.js').replace(/\\/g, '/')}"`);
441
- console.log(` # or: claude-rpc start (if you've run \`npm link\`)`);
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
- const today = (agg.byDay || {})[new Date().toISOString().slice(0, 10)] || {};
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
- // Dispatch a tools/call by name. Reads a fresh aggregate per call (cheap).
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
- // ── stdio JSON-RPC transport (newline-delimited) ──────────────────────────
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');