claude-rpc 0.3.10 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/badge.js +38 -10
- package/src/card.js +344 -0
- package/src/cli.js +147 -1
- package/src/daemon.js +38 -5
- package/src/doctor.js +396 -0
- package/src/privacy.js +231 -0
- package/src/scanner.js +101 -5
- package/src/server/api.js +172 -0
- package/src/server/index.js +98 -0
- package/src/{server.js → server/page.js} +18 -325
- package/src/server/routes.js +63 -0
- package/src/server/sse.js +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-rpc",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
20
|
-
|
|
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 · ${
|
|
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 · ${
|
|
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 · ${
|
|
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 · ${
|
|
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 · ${
|
|
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, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"');
|
|
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;
|