claude-rpc 0.3.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.3.10",
3
+ "version": "0.5.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "build:exe": "node ./scripts/build-exe.js",
31
31
  "prep:dashboard": "node ./scripts/prep-dashboard.js",
32
32
  "dist:mac": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:mac",
33
- "dist:win": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:win"
33
+ "dist:win": "npm run build:exe && npm run prep:dashboard && npm --prefix dashboard run dist:win",
34
+ "test": "node --test test/*.test.js"
34
35
  },
35
36
  "dependencies": {
36
37
  "@xhayper/discord-rpc": "^1.2.1"
package/src/badge.js CHANGED
@@ -14,11 +14,26 @@ const COLORS = {
14
14
  files: { left: '#555', right: '#aa6' }, // olive
15
15
  };
16
16
 
17
+ // Resolve a range token to a count of days back from today.
18
+ // 'all' → entire history
19
+ // 'year' / 'y' → 365 days
20
+ // 'month' / 'mo' / 'm' → 30 days
21
+ // 'week' / 'w' → 7 days
22
+ // numeric ('30d', '7') → that many days
23
+ function rangeToDays(range) {
24
+ if (!range || range === 'all') return null; // null = unbounded
25
+ if (/^(year|1y|y|365d?)$/i.test(range)) return 365;
26
+ if (/^(month|1mo|mo|m|30d?)$/i.test(range)) return 30;
27
+ if (/^(week|1w|w|7d?)$/i.test(range)) return 7;
28
+ if (/^(day|1d|d|24h|today)$/i.test(range)) return 1;
29
+ const n = parseInt(range, 10);
30
+ return Number.isFinite(n) && n > 0 ? n : null;
31
+ }
32
+
17
33
  function pickWindow(byDay, range) {
18
34
  if (!byDay) return [];
19
- if (range === 'all') return Object.entries(byDay);
20
- const days = parseInt(range, 10);
21
- if (!Number.isFinite(days) || days <= 0) return Object.entries(byDay);
35
+ const days = rangeToDays(range);
36
+ if (days === null) return Object.entries(byDay); // 'all'
22
37
  const today = new Date(); today.setHours(0, 0, 0, 0);
23
38
  const out = [];
24
39
  for (let i = 0; i < days; i++) {
@@ -29,6 +44,20 @@ function pickWindow(byDay, range) {
29
44
  return out;
30
45
  }
31
46
 
47
+ // Pretty label for a range — used as badge subtitle.
48
+ // 'year' → 'year' '30' → '30d' 'all' → 'all-time'
49
+ function rangeLabel(range) {
50
+ if (!range || range === 'all') return 'all-time';
51
+ if (/^(year|1y|y|365d?)$/i.test(range)) return 'year';
52
+ if (/^(month|1mo|mo|m|30d?)$/i.test(range)) return 'month';
53
+ if (/^(week|1w|w|7d?)$/i.test(range)) return 'week';
54
+ if (/^(day|1d|d|24h|today)$/i.test(range)) return 'today';
55
+ const n = parseInt(range, 10);
56
+ return Number.isFinite(n) && n > 0 ? `${n}d` : range;
57
+ }
58
+
59
+ export { rangeToDays, rangeLabel, pickWindow };
60
+
32
61
  function fmtHoursLabel(ms) {
33
62
  if (!ms) return '0h';
34
63
  const h = ms / 3_600_000;
@@ -49,34 +78,33 @@ function fmtNum(n) {
49
78
  function valueFor(aggregate, metric, range) {
50
79
  const a = aggregate || {};
51
80
  const window = pickWindow(a.byDay, range);
52
-
53
- const rangeLabel = range === 'all' ? 'all-time' : range;
81
+ const rl = rangeLabel(range);
54
82
 
55
83
  switch (metric) {
56
84
  case 'hours': {
57
85
  const ms = window.reduce((s, [, d]) => s + (d.activeMs || 0), 0);
58
- return { label: `claude · ${rangeLabel}`, value: fmtHoursLabel(ms) };
86
+ return { label: `claude · ${rl}`, value: fmtHoursLabel(ms) };
59
87
  }
60
88
  case 'streak': {
61
89
  return { label: 'streak', value: `${a.streak || 0} days` };
62
90
  }
63
91
  case 'cost': {
64
92
  const cost = window.reduce((s, [, d]) => s + (d.cost || 0), 0);
65
- return { label: `claude cost · ${rangeLabel}`, value: fmtCost(cost) };
93
+ return { label: `claude cost · ${rl}`, value: fmtCost(cost) };
66
94
  }
67
95
  case 'lines': {
68
96
  const lines = window.reduce((s, [, d]) => s + (d.linesAdded || 0), 0);
69
- return { label: `lines · ${rangeLabel}`, value: fmtNum(lines) };
97
+ return { label: `lines · ${rl}`, value: fmtNum(lines) };
70
98
  }
71
99
  case 'prompts': {
72
100
  const p = window.reduce((s, [, d]) => s + (d.userMessages || 0), 0);
73
- return { label: `prompts · ${rangeLabel}`, value: fmtNum(p) };
101
+ return { label: `prompts · ${rl}`, value: fmtNum(p) };
74
102
  }
75
103
  case 'tokens': {
76
104
  const t = window.reduce((s, [, d]) =>
77
105
  s + (d.inputTokens || 0) + (d.outputTokens || 0)
78
106
  + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0), 0);
79
- return { label: `tokens · ${rangeLabel}`, value: fmtNum(t) };
107
+ return { label: `tokens · ${rl}`, value: fmtNum(t) };
80
108
  }
81
109
  case 'files': {
82
110
  return { label: 'files touched', value: fmtNum(a.uniqueFiles || 0) };
package/src/card.js ADDED
@@ -0,0 +1,344 @@
1
+ // Poster-style SVG card — designed to be screenshotted and shared.
2
+ //
3
+ // Renders an 880×540 magazine-style summary for a given range:
4
+ // year on claude / month on claude / week on claude / all-time
5
+ //
6
+ // Stats: total hours, prompts, tokens, lines added, cost, top language,
7
+ // top file, longest streak, top weekday, peak hour. Visual style matches
8
+ // the indie cream-paper landing page — same palette, same monospace +
9
+ // display-font split.
10
+ //
11
+ // Output is SVG only (no PNG dep). Modern Discord / GitHub render the SVG
12
+ // inline; for hard-copy sharing, screenshot or convert via any web tool.
13
+
14
+ import { dayKey } from './scanner.js';
15
+ import { fmtCost } from './pricing.js';
16
+ import { rangeToDays, rangeLabel, pickWindow } from './badge.js';
17
+
18
+ const W = 880;
19
+ const H = 540;
20
+
21
+ const PALETTE = {
22
+ paper: '#f4ede0',
23
+ paper2: '#ebe2d2',
24
+ paper3: '#e1d6c0',
25
+ paper4: '#d6c8ac',
26
+ ink: '#1a1611',
27
+ inkSoft:'#2d2520',
28
+ inkMute:'#5c5147',
29
+ inkFaint:'#8a7c6d',
30
+ rust: '#c2491e',
31
+ rust3: '#f08a4a',
32
+ tape: '#f2d76e',
33
+ grass: '#4a9462',
34
+ blurple:'#5865f2',
35
+ };
36
+
37
+ const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
38
+
39
+ function escapeXml(s) {
40
+ return String(s == null ? '' : s)
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;');
45
+ }
46
+
47
+ function fmtNum(n) {
48
+ if (!n) return '0';
49
+ if (n < 1000) return String(Math.round(n));
50
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
51
+ if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
52
+ return `${(n / 1_000_000_000).toFixed(2)}B`;
53
+ }
54
+
55
+ function fmtHours(ms) {
56
+ if (!ms || ms < 0) return '0h';
57
+ const h = ms / 3_600_000;
58
+ if (h < 1) return `${Math.round(h * 60)}m`;
59
+ if (h < 10) return `${h.toFixed(1)}h`;
60
+ return `${Math.round(h)}h`;
61
+ }
62
+
63
+ function rangeTitle(range) {
64
+ const rl = rangeLabel(range);
65
+ if (rl === 'all-time') return 'on claude';
66
+ return `${rl} on claude`;
67
+ }
68
+
69
+ // Roll up a windowed metric across the active range.
70
+ function rollup(aggregate, range) {
71
+ const window = pickWindow(aggregate?.byDay, range);
72
+ const out = {
73
+ activeMs: 0,
74
+ userMessages: 0,
75
+ toolCalls: 0,
76
+ tokens: 0,
77
+ linesAdded: 0,
78
+ linesRemoved: 0,
79
+ cost: 0,
80
+ sessions: 0,
81
+ days: 0,
82
+ daysActive: 0,
83
+ };
84
+ for (const [, d] of window) {
85
+ out.days += 1;
86
+ if ((d.activeMs || 0) > 0) out.daysActive += 1;
87
+ out.activeMs += d.activeMs || 0;
88
+ out.userMessages += d.userMessages || 0;
89
+ out.toolCalls += d.toolCalls || 0;
90
+ out.tokens += (d.inputTokens || 0) + (d.outputTokens || 0)
91
+ + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0);
92
+ out.linesAdded += d.linesAdded || 0;
93
+ out.linesRemoved += d.linesRemoved || 0;
94
+ out.cost += d.cost || 0;
95
+ out.sessions += d.sessions || 0;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Best weekday by active time, computed from byWeekday on the aggregate.
101
+ function topWeekday(aggregate) {
102
+ const wd = aggregate?.byWeekday || {};
103
+ let best = null;
104
+ for (const [k, v] of Object.entries(wd)) {
105
+ if (!best || (v.activeMs || 0) > (best.ms || 0)) best = { day: Number(k), ms: v.activeMs || 0 };
106
+ }
107
+ return best;
108
+ }
109
+
110
+ // Top language by edits.
111
+ function topLanguage(aggregate) {
112
+ const langs = aggregate?.languages || {};
113
+ let best = null;
114
+ for (const [name, v] of Object.entries(langs)) {
115
+ if (!best || (v.edits || 0) > (best.edits || 0)) best = { name, edits: v.edits || 0 };
116
+ }
117
+ return best;
118
+ }
119
+
120
+ // Top edited file (basename only — full path would overflow).
121
+ function topFile(aggregate) {
122
+ const list = aggregate?.topEditedFiles || [];
123
+ if (!list.length) return null;
124
+ const top = list[0];
125
+ const p = String(top.path || '').replace(/\\/g, '/');
126
+ const name = p.split('/').pop() || p;
127
+ return { name, count: top.count };
128
+ }
129
+
130
+ // Compute peak hour-of-day (24h clock).
131
+ function peakHourLabel(aggregate) {
132
+ const ph = aggregate?.peakHour;
133
+ if (!ph || ph.hour == null) return null;
134
+ const h = Number(ph.hour);
135
+ return `${String(h).padStart(2, '0')}:00`;
136
+ }
137
+
138
+ // ── SVG building blocks ─────────────────────────────────────────────────
139
+
140
+ function paperDefs() {
141
+ return `
142
+ <defs>
143
+ <pattern id="dotgrid" width="22" height="22" patternUnits="userSpaceOnUse">
144
+ <circle cx="1" cy="1" r="1" fill="${PALETTE.ink}" opacity="0.07"/>
145
+ </pattern>
146
+ <filter id="noise" x="0" y="0" width="100%" height="100%">
147
+ <feTurbulence type="fractalNoise" baseFrequency="0.95" numOctaves="2" stitchTiles="stitch"/>
148
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
149
+ </filter>
150
+ </defs>`;
151
+ }
152
+
153
+ function background() {
154
+ return `
155
+ <rect width="${W}" height="${H}" fill="${PALETTE.paper}"/>
156
+ <rect width="${W}" height="${H}" fill="url(#dotgrid)"/>
157
+ <rect width="${W}" height="${H}" filter="url(#noise)" opacity="0.6"/>`;
158
+ }
159
+
160
+ function tapeSticker(x, y, text, { rotate = -2, bg = PALETTE.tape, fg = PALETTE.ink } = {}) {
161
+ const pad = 12;
162
+ const fontSize = 14;
163
+ const w = text.length * 8.4 + pad * 2;
164
+ const h = fontSize + 12;
165
+ return `
166
+ <g transform="translate(${x} ${y}) rotate(${rotate})">
167
+ <rect x="0" y="0" width="${w}" height="${h}" fill="${bg}" stroke="${PALETTE.ink}" stroke-width="1.5"/>
168
+ <rect x="2" y="2" width="${w}" height="${h}" fill="none" stroke="${PALETTE.ink}" stroke-width="1.5" opacity="0.18"/>
169
+ <text x="${w / 2}" y="${h / 2 + 5}"
170
+ font-family="JetBrains Mono, ui-monospace, monospace"
171
+ font-size="${fontSize}" font-weight="700"
172
+ letter-spacing="1.5"
173
+ text-anchor="middle" fill="${fg}">${escapeXml(text.toUpperCase())}</text>
174
+ </g>`;
175
+ }
176
+
177
+ function statBox(x, y, w, h, { label, value, sub = '', accent = PALETTE.ink, tilt = 0 } = {}) {
178
+ // Drop-shadow style: a second rect offset by (2,2) under the main one.
179
+ return `
180
+ <g transform="translate(${x} ${y}) rotate(${tilt})">
181
+ <rect x="2" y="3" width="${w}" height="${h}" fill="${PALETTE.ink}"/>
182
+ <rect x="0" y="0" width="${w}" height="${h}" fill="${PALETTE.paper}" stroke="${PALETTE.ink}" stroke-width="1.5"/>
183
+ <text x="18" y="28"
184
+ font-family="JetBrains Mono, ui-monospace, monospace"
185
+ font-size="11" font-weight="700"
186
+ letter-spacing="2"
187
+ fill="${PALETTE.inkMute}">${escapeXml(label.toUpperCase())}</text>
188
+ <text x="18" y="${h - 24}"
189
+ font-family="Space Grotesk, Inter, system-ui, sans-serif"
190
+ font-size="34" font-weight="800"
191
+ fill="${accent}">${escapeXml(value)}</text>
192
+ ${sub ? `<text x="18" y="${h - 8}"
193
+ font-family="JetBrains Mono, ui-monospace, monospace"
194
+ font-size="11"
195
+ fill="${PALETTE.inkMute}">${escapeXml(sub)}</text>` : ''}
196
+ </g>`;
197
+ }
198
+
199
+ // Mini bar chart of daily active hours across the windowed range.
200
+ // Used as a strip across the bottom of the card.
201
+ function activityStrip(x, y, w, h, byDay, range) {
202
+ const window = pickWindow(byDay, range).reverse(); // oldest → newest
203
+ if (!window.length) return '';
204
+ const max = Math.max(1, ...window.map(([, d]) => d.activeMs || 0));
205
+ const N = window.length;
206
+ const colW = w / N;
207
+ const bw = Math.max(1, colW - 1.5);
208
+ let bars = '';
209
+ for (let i = 0; i < N; i++) {
210
+ const [, d] = window[i];
211
+ const ratio = (d.activeMs || 0) / max;
212
+ const bh = Math.max(0, ratio * h);
213
+ bars += `<rect x="${x + i * colW}" y="${y + (h - bh)}" width="${bw}" height="${bh}" fill="${PALETTE.rust}" opacity="${0.45 + ratio * 0.45}"/>`;
214
+ }
215
+ return `<g>${bars}</g>`;
216
+ }
217
+
218
+ // Heatmap-like dot row of the last N days.
219
+ function dotRow(x, y, byDay, range) {
220
+ const window = pickWindow(byDay, range).reverse();
221
+ const sz = 9;
222
+ const gap = 2;
223
+ let svg = '';
224
+ for (let i = 0; i < window.length; i++) {
225
+ const [, d] = window[i];
226
+ const ms = d.activeMs || 0;
227
+ const intensity = ms === 0 ? 0 : Math.min(1, ms / (4 * 3_600_000)); // 4h = full
228
+ const fill = intensity === 0
229
+ ? PALETTE.paper3
230
+ : intensity < 0.34 ? '#f6dccb'
231
+ : intensity < 0.67 ? PALETTE.rust3
232
+ : PALETTE.rust;
233
+ svg += `<rect x="${x + i * (sz + gap)}" y="${y}" width="${sz}" height="${sz}" fill="${fill}" stroke="${PALETTE.ink}" stroke-width="0.5"/>`;
234
+ }
235
+ return svg;
236
+ }
237
+
238
+ // ── public entry point ─────────────────────────────────────────────────
239
+
240
+ export function renderCard(aggregate, { range = 'year', generatedAt = new Date() } = {}) {
241
+ const r = rollup(aggregate, range);
242
+ const lang = topLanguage(aggregate);
243
+ const file = topFile(aggregate);
244
+ const wd = topWeekday(aggregate);
245
+ const peak = peakHourLabel(aggregate);
246
+ const streak = aggregate?.streak || 0;
247
+ const longestStreak = aggregate?.longestStreak || 0;
248
+ const linesNet = r.linesAdded - r.linesRemoved;
249
+ const allTimeHours = ((aggregate?.activeMs || 0) / 3_600_000).toFixed(1);
250
+
251
+ const subtitle = `${escapeXml(r.daysActive)} active days / ${escapeXml(rangeLabel(range))} ending ${escapeXml(generatedAt.toISOString().slice(0, 10))}`;
252
+
253
+ // Layout grid
254
+ // Title block 80 → W-80
255
+ // Hero hours card (60, 130) 380×170
256
+ // Right-column stats (470, 130) → 4 mini boxes
257
+ // Activity strip 60, 332, W-120, 50
258
+ // Footer credits 60, 480
259
+
260
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
261
+ ${paperDefs()}
262
+ ${background()}
263
+
264
+ <!-- ── title ── -->
265
+ <g transform="translate(60 60)">
266
+ <text x="0" y="0"
267
+ font-family="Space Grotesk, Inter, system-ui, sans-serif"
268
+ font-size="48" font-weight="800"
269
+ letter-spacing="-1.5"
270
+ fill="${PALETTE.ink}">${escapeXml(rangeTitle(range))}</text>
271
+ <text x="0" y="26"
272
+ font-family="JetBrains Mono, ui-monospace, monospace"
273
+ font-size="13"
274
+ fill="${PALETTE.inkMute}">${subtitle}</text>
275
+ </g>
276
+ ${tapeSticker(W - 220, 40, 'claude-rpc · v0.4', { rotate: 3 })}
277
+
278
+ <!-- ── hero hours card ── -->
279
+ <g transform="translate(60 130)">
280
+ <rect x="3" y="4" width="380" height="170" fill="${PALETTE.ink}"/>
281
+ <rect x="0" y="0" width="380" height="170" fill="${PALETTE.paper}" stroke="${PALETTE.ink}" stroke-width="2"/>
282
+ <text x="22" y="36"
283
+ font-family="JetBrains Mono, ui-monospace, monospace"
284
+ font-size="12" font-weight="700"
285
+ letter-spacing="2.5"
286
+ fill="${PALETTE.inkMute}">TIME WITH CLAUDE</text>
287
+ <text x="22" y="118"
288
+ font-family="Space Grotesk, Inter, system-ui, sans-serif"
289
+ font-size="82" font-weight="800"
290
+ letter-spacing="-3"
291
+ fill="${PALETTE.rust}">${escapeXml(fmtHours(r.activeMs))}</text>
292
+ <text x="22" y="148"
293
+ font-family="JetBrains Mono, ui-monospace, monospace"
294
+ font-size="13"
295
+ fill="${PALETTE.ink}">${escapeXml(r.daysActive)} days · ${escapeXml(fmtNum(r.sessions || 0))} sessions · streak ${escapeXml(streak)} (best ${escapeXml(longestStreak)})</text>
296
+ </g>
297
+
298
+ <!-- ── right-column stats (2x2 grid) ── -->
299
+ ${statBox(470, 130, 170, 82, { label: 'prompts', value: fmtNum(r.userMessages), accent: PALETTE.ink, tilt: -0.8 })}
300
+ ${statBox(650, 130, 170, 82, { label: 'tokens', value: fmtNum(r.tokens), accent: PALETTE.ink, tilt: 0.6 })}
301
+ ${statBox(470, 218, 170, 82, { label: 'lines', value: `${linesNet >= 0 ? '+' : '−'}${fmtNum(Math.abs(linesNet))}`, sub: `${fmtNum(r.linesAdded)} added`, accent: PALETTE.grass, tilt: 0.6 })}
302
+ ${statBox(650, 218, 170, 82, { label: 'cost', value: fmtCost(r.cost), sub: `≈ ${fmtCost(r.cost / Math.max(1, r.daysActive))}/day`, accent: PALETTE.blurple, tilt: -0.5 })}
303
+
304
+ <!-- ── activity strip ── -->
305
+ <g>
306
+ <text x="60" y="328"
307
+ font-family="JetBrains Mono, ui-monospace, monospace"
308
+ font-size="11" font-weight="700"
309
+ letter-spacing="2.5"
310
+ fill="${PALETTE.inkMute}">DAILY ACTIVITY</text>
311
+ <rect x="60" y="335" width="${W - 120}" height="62" fill="${PALETTE.paper2}" stroke="${PALETTE.ink}" stroke-width="1.5"/>
312
+ ${activityStrip(64, 337, W - 128, 58, aggregate?.byDay, range)}
313
+ </g>
314
+
315
+ <!-- ── footer stats row ── -->
316
+ <g transform="translate(60 420)">
317
+ <text x="0" y="0"
318
+ font-family="JetBrains Mono, ui-monospace, monospace"
319
+ font-size="11" font-weight="700"
320
+ letter-spacing="2.5"
321
+ fill="${PALETTE.inkMute}">HIGHLIGHTS</text>
322
+
323
+ <g transform="translate(0 16)">
324
+ <text font-family="JetBrains Mono, ui-monospace, monospace" font-size="13" fill="${PALETTE.ink}">
325
+ <tspan x="0" dy="14" fill="${PALETTE.inkMute}">top language</tspan><tspan dx="10" font-weight="700">${escapeXml(lang ? lang.name : '—')}</tspan><tspan dx="6" fill="${PALETTE.inkFaint}">${escapeXml(lang ? `(${fmtNum(lang.edits)} edits)` : '')}</tspan>
326
+ <tspan x="0" dy="20" fill="${PALETTE.inkMute}">hotspot file</tspan><tspan dx="14" font-weight="700">${escapeXml(file ? file.name : '—')}</tspan><tspan dx="6" fill="${PALETTE.inkFaint}">${escapeXml(file ? `(× ${fmtNum(file.count)})` : '')}</tspan>
327
+ <tspan x="0" dy="20" fill="${PALETTE.inkMute}">peak day</tspan><tspan dx="36" font-weight="700">${escapeXml(wd && wd.day != null ? WEEKDAY[wd.day] : '—')}</tspan><tspan dx="6" fill="${PALETTE.inkFaint}">${escapeXml(wd ? `(${fmtHours(wd.ms)})` : '')}</tspan>
328
+ <tspan x="0" dy="20" fill="${PALETTE.inkMute}">peak hour</tspan><tspan dx="30" font-weight="700">${escapeXml(peak || '—')}</tspan>
329
+ </text>
330
+ </g>
331
+ </g>
332
+
333
+ <!-- ── credits ── -->
334
+ <g transform="translate(${W - 60} ${H - 24})" text-anchor="end">
335
+ <text font-family="JetBrains Mono, ui-monospace, monospace" font-size="10"
336
+ fill="${PALETTE.inkFaint}">${escapeXml(allTimeHours)}h all-time · github.com/rar-file/claude-rpc</text>
337
+ </g>
338
+ </svg>`;
339
+ }
340
+
341
+ // Top-level convenience matching badge.js shape.
342
+ export function cardSvg({ aggregate, range = 'year' }) {
343
+ return renderCard(aggregate, { range });
344
+ }
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ import { startTui } from './tui.js';
20
20
  import { generateInsights } from './insights.js';
21
21
  import { badgeSvg } from './badge.js';
22
22
  import { fmtCost } from './pricing.js';
23
+ import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
23
24
  import { basename } from 'node:path';
24
25
 
25
26
  const cmd = process.argv[2];
@@ -579,9 +580,55 @@ function doScan(force = false) {
579
580
  });
580
581
  process.stdout.write('\n');
581
582
  console.log(`${c.green}✓${c.reset} Done in ${Date.now() - t0}ms — ${c.cyan}${result.scanned}${c.reset} parsed · ${result.skipped} cached · ${result.removed} removed (${result.total} total)`);
583
+ if (result.dirs && result.dirs.length > 1) {
584
+ console.log(`${c.dim}Scanned roots:${c.reset} ${result.dirs.join(', ')}`);
585
+ }
582
586
  console.log(`${c.dim}Aggregate written to ${AGGREGATE_PATH}${c.reset}`);
583
587
  }
584
588
 
589
+ // Backfill from any folder that has .jsonl transcripts. Useful for:
590
+ // • restoring from a backup of ~/.claude
591
+ // • merging transcripts from another machine
592
+ // • importing data from an older Claude Code install with a non-default path
593
+ //
594
+ // Walks the given path recursively, adds every .jsonl to the existing cache,
595
+ // and rebuilds the aggregate. Does NOT remove anything from the existing
596
+ // aggregate — adds only.
597
+ function doBackfill(argv) {
598
+ const path = argv[0];
599
+ if (!path) {
600
+ console.log(`${c.red}✗${c.reset} usage: claude-rpc backfill <path>`);
601
+ console.log(`${c.dim} scans the given directory recursively for .jsonl transcripts and${c.reset}`);
602
+ console.log(`${c.dim} merges them into your aggregate.${c.reset}`);
603
+ process.exit(1);
604
+ }
605
+ if (!existsSync(path)) {
606
+ console.log(`${c.red}✗${c.reset} path doesn't exist: ${path}`);
607
+ process.exit(1);
608
+ }
609
+ console.log(`${c.dim}Backfilling from${c.reset} ${c.cyan}${path}${c.reset}…`);
610
+ const t0 = Date.now();
611
+ let lastReport = 0;
612
+ // Pass `extraDirs` rather than `projectsDirs` — this way the default
613
+ // ~/.claude/projects (+ any auto-discovered alt paths) ALSO gets scanned
614
+ // and the user's existing cache for those isn't pruned.
615
+ const result = scan({
616
+ force: false,
617
+ extraDirs: [path],
618
+ onProgress: ({ scanned, total }) => {
619
+ if (Date.now() - lastReport > 500) {
620
+ process.stdout.write(`\r parsed ${scanned}/${total}…`);
621
+ lastReport = Date.now();
622
+ }
623
+ },
624
+ });
625
+ process.stdout.write('\n');
626
+ console.log(`${c.green}✓${c.reset} Done in ${Date.now() - t0}ms — ${c.cyan}${result.scanned}${c.reset} new/changed · ${result.skipped} cached`);
627
+ console.log(`${c.dim}Scanned roots:${c.reset} ${result.dirs.join(', ')}`);
628
+ const hours = ((result.aggregate.activeMs || 0) / 3_600_000).toFixed(1);
629
+ console.log(`${c.dim}Aggregate now:${c.reset} ${result.aggregate.sessions} sessions · ${hours}h · ${result.aggregate.userMessages} prompts`);
630
+ }
631
+
585
632
  function showInsights() {
586
633
  const aggregate = readAggregate();
587
634
  const insights = generateInsights(aggregate, { limit: 6 });
@@ -620,6 +667,89 @@ function doBadge(argv) {
620
667
  }
621
668
  }
622
669
 
670
+ // Poster-style SVG card. Bigger sibling of `badge` — shareable summary
671
+ // for a range (year / month / week / all-time). Output is SVG only;
672
+ // screenshot or convert to PNG offline if needed.
673
+ function parseCardArgs(argv) {
674
+ const out = { range: 'year', out: '' };
675
+ for (let i = 0; i < argv.length; i++) {
676
+ const a = argv[i];
677
+ if (a === '--range' || a === '-r') out.range = argv[++i];
678
+ else if (a === '--out' || a === '-o') out.out = argv[++i];
679
+ }
680
+ return out;
681
+ }
682
+
683
+ async function doCard(argv) {
684
+ const opts = parseCardArgs(argv);
685
+ const aggregate = readAggregate();
686
+ if (!aggregate) {
687
+ console.error(`${c.yellow}No aggregate yet. Run ${c.cyan}claude-rpc scan${c.reset} first.`);
688
+ process.exit(1);
689
+ }
690
+ const { renderCard } = await import('./card.js');
691
+ const svg = renderCard(aggregate, { range: opts.range });
692
+ if (opts.out) {
693
+ writeFileSync(opts.out, svg);
694
+ console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
695
+ console.log(`${c.dim}Tip: open in a browser, right-click → Save as PNG. Or drop straight into a Discord message — it'll render inline.${c.reset}`);
696
+ } else {
697
+ process.stdout.write(svg);
698
+ }
699
+ }
700
+
701
+ // ── Privacy commands ─────────────────────────────────────────────────────
702
+ //
703
+ // `claude-rpc private` → add current cwd to ~/.claude-rpc/private-list.json
704
+ // `claude-rpc public` → remove current cwd
705
+ // `claude-rpc privacy` → show resolved visibility for current cwd + listed paths
706
+ //
707
+ // Per-project overrides live in <project>/.claude-rpc.json and take priority
708
+ // over the runtime list. See src/privacy.js for the full resolution chain.
709
+
710
+ function loadConfigSafe() {
711
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
712
+ }
713
+
714
+ function doPrivate() {
715
+ const cwd = process.cwd();
716
+ const list = addPrivateCwd(cwd);
717
+ console.log(`${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} marked private`);
718
+ console.log(`${c.dim} ${list.length} ${list.length === 1 ? 'path' : 'paths'} in the private list. Daemon picks it up within ~5 min (cache TTL) or restart.${c.reset}`);
719
+ }
720
+
721
+ function doPublic() {
722
+ const cwd = process.cwd();
723
+ const before = listPrivateCwds().length;
724
+ const list = removePrivateCwd(cwd);
725
+ if (list.length === before) {
726
+ console.log(`${c.yellow}!${c.reset} ${c.cyan}${cwd}${c.reset} wasn't in the private list`);
727
+ } else {
728
+ console.log(`${c.green}✓${c.reset} ${c.cyan}${cwd}${c.reset} removed from the private list`);
729
+ }
730
+ }
731
+
732
+ function doPrivacy() {
733
+ const cwd = process.cwd();
734
+ const cfg = loadConfigSafe();
735
+ const { visibility, projectName, reason } = resolveVisibility(cwd, cfg);
736
+ const color = visibility === 'hidden' ? c.red : visibility === 'name-only' ? c.yellow : c.green;
737
+ console.log('');
738
+ console.log(` ${c.bold}privacy${c.reset} ${c.dim}for${c.reset} ${c.cyan}${cwd}${c.reset}`);
739
+ console.log(` ${c.dim}visibility:${c.reset} ${color}${visibility}${c.reset} ${c.dim}(${reason})${c.reset}`);
740
+ if (projectName) console.log(` ${c.dim}alias: ${c.reset} ${projectName}`);
741
+ const list = listPrivateCwds();
742
+ if (list.length) {
743
+ console.log('');
744
+ console.log(` ${c.bold}private-list${c.reset} ${c.dim}(${list.length} ${list.length === 1 ? 'path' : 'paths'})${c.reset}`);
745
+ for (const p of list) console.log(` ${p === cwd ? c.cyan + '●' + c.reset : ' '} ${p}`);
746
+ }
747
+ console.log('');
748
+ console.log(` ${c.dim}toggle: claude-rpc private / claude-rpc public${c.reset}`);
749
+ console.log(` ${c.dim}per-proj: drop a {"private": true} into .claude-rpc.json at the repo root${c.reset}`);
750
+ console.log('');
751
+ }
752
+
623
753
  function tailLog() {
624
754
  if (!existsSync(LOG_PATH)) {
625
755
  console.log(`${c.yellow}No log yet at ${LOG_PATH}${c.reset}`);
@@ -659,8 +789,14 @@ function help() {
659
789
  ['preview', 'Show how each rotation frame renders right now'],
660
790
  ['scan', 'Incrementally scan ~/.claude/projects for all-time totals'],
661
791
  ['rescan', 'Force re-parse every transcript (ignores cache)'],
792
+ ['backfill', 'Import transcripts from any folder (e.g. a backup)'],
662
793
  ['insights', 'Auto-generated insights from your history'],
663
794
  ['badge', 'Render a Shields-style SVG (--metric --range --out)'],
795
+ ['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
796
+ ['private', 'Mark the current directory as private (hide from Discord)'],
797
+ ['public', 'Un-mark the current directory'],
798
+ ['privacy', 'Show resolved visibility for the current directory'],
799
+ ['doctor', 'Run a diagnostic checklist — common-failure triage'],
664
800
  ['tail', 'Tail the daemon log file'],
665
801
  ['daemon', 'Run daemon in foreground (debug)'],
666
802
  ];
@@ -706,13 +842,23 @@ const packagedDefault = IS_PACKAGED && !cmd;
706
842
  case 'dump': showStatus(); break;
707
843
  case 'today': showToday(); break;
708
844
  case 'week': showWeek(); break;
709
- case 'serve': await import('./server.js'); break;
845
+ case 'serve': await import('./server/index.js'); break;
710
846
  case 'preview': showPreview(); break;
711
847
  case 'vars': dumpVars(); break;
712
848
  case 'scan': doScan(false); break;
713
849
  case 'rescan': doScan(true); break;
850
+ case 'backfill': doBackfill(process.argv.slice(3)); break;
714
851
  case 'insights': showInsights(); break;
715
852
  case 'badge': doBadge(process.argv.slice(3)); break;
853
+ case 'card': await doCard(process.argv.slice(3)); break;
854
+ case 'private': doPrivate(); break;
855
+ case 'public': doPublic(); break;
856
+ case 'privacy': doPrivacy(); break;
857
+ case 'doctor': {
858
+ const { runDoctor } = await import('./doctor.js');
859
+ process.exit(runDoctor());
860
+ break;
861
+ }
716
862
  case 'tail':
717
863
  case 'logs':
718
864
  case 'log': tailLog(); break;