claude-rpc 0.15.3 → 0.15.5
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 +1 -1
- package/src/cli.js +145 -57
- package/src/install.js +1 -0
- package/src/nudge.js +25 -0
- package/src/tui.js +9 -6
- package/src/ui.js +62 -0
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { runHookCli } from './hook.js';
|
|
|
18
18
|
import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand, setupOutro } from './install.js';
|
|
19
19
|
import { startTui } from './tui.js';
|
|
20
20
|
import { generateInsights } from './insights.js';
|
|
21
|
-
import { maybeNudge } from './nudge.js';
|
|
21
|
+
import { maybeNudge, pickTodayMilestone } from './nudge.js';
|
|
22
22
|
import { badgeSvg } from './badge.js';
|
|
23
23
|
import { fmtCost } from './pricing.js';
|
|
24
24
|
import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
|
|
@@ -26,7 +26,7 @@ import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
|
|
|
26
26
|
import { loadConfig, hasUserConfig } from './config.js';
|
|
27
27
|
import * as lb from './leaderboard.js';
|
|
28
28
|
import { VERSION } from './version.js';
|
|
29
|
-
import { fail, tailLines, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
29
|
+
import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
30
30
|
import { randomUUID } from 'node:crypto';
|
|
31
31
|
import { createInterface } from 'node:readline';
|
|
32
32
|
import { basename } from 'node:path';
|
|
@@ -144,24 +144,24 @@ function shortPath(p) {
|
|
|
144
144
|
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
// 24-bar histogram of hour-of-day activity
|
|
147
|
+
// 24-bar histogram of hour-of-day activity, heat-graded per hour — the
|
|
148
|
+
// peak hour literally glows (bold rust) instead of relying on a color swap.
|
|
148
149
|
function renderHourHistogram(byHour, opts = {}) {
|
|
149
150
|
const heightChars = ' ▁▂▃▄▅▆▇█';
|
|
150
151
|
let max = 0;
|
|
151
152
|
for (let h = 0; h < 24; h++) max = Math.max(max, byHour?.[h]?.activeMs || 0);
|
|
152
153
|
if (max <= 0) return [' (no hourly data yet)'];
|
|
153
|
-
const
|
|
154
|
+
const colored = [];
|
|
154
155
|
for (let h = 0; h < 24; h++) {
|
|
155
156
|
const ms = byHour?.[h]?.activeMs || 0;
|
|
156
157
|
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
157
|
-
|
|
158
|
+
const ch = heightChars[idx];
|
|
159
|
+
colored.push(h === opts.peakHour ? `${c.bold}${heat(1)}${ch}${c.reset}` : `${heat(ms / max)}${ch}${c.reset}`);
|
|
158
160
|
}
|
|
159
|
-
const peakH = opts.peakHour ?? bars.findIndex((b) => b === heightChars[Math.min(8, ...bars.map((_, i) => i).filter(() => true))]);
|
|
160
|
-
const colored = bars.map((ch, h) => h === opts.peakHour ? `${c.magenta}${c.bold}${ch}${c.reset}` : `${c.green}${ch}${c.reset}`).join('');
|
|
161
161
|
// Hour labels under every 3rd hour.
|
|
162
162
|
const labels = '00 03 06 09 12 15 18 21 ';
|
|
163
163
|
return [
|
|
164
|
-
` ${colored}`,
|
|
164
|
+
` ${colored.join('')}`,
|
|
165
165
|
` ${c.dim}${labels}${c.reset}`,
|
|
166
166
|
];
|
|
167
167
|
}
|
|
@@ -196,13 +196,14 @@ function renderHeatmap(byDay, days = 91) {
|
|
|
196
196
|
}
|
|
197
197
|
const cols = Math.ceil(cells.length / 7);
|
|
198
198
|
|
|
199
|
-
// Quantize: 0 / >0 / >15m / >1h / >3h
|
|
199
|
+
// Quantize: 0 / >0 / >15m / >1h / >3h — mapped onto the shared heat ramp
|
|
200
|
+
// so the heatmap, bars, and histograms all speak the same temperature.
|
|
200
201
|
const shade = (ms) => {
|
|
201
202
|
if (ms <= 0) return `${c.dim}·${c.reset}`;
|
|
202
|
-
if (ms < 15 * 60_000) return `${
|
|
203
|
-
if (ms < 60 * 60_000) return `${
|
|
204
|
-
if (ms < 3 * 3600_000) return `${
|
|
205
|
-
return `${c.
|
|
203
|
+
if (ms < 15 * 60_000) return `${heat(0.2)}▪${c.reset}`;
|
|
204
|
+
if (ms < 60 * 60_000) return `${heat(0.5)}▪${c.reset}`;
|
|
205
|
+
if (ms < 3 * 3600_000) return `${heat(0.8)}▪${c.reset}`;
|
|
206
|
+
return `${c.bold}${heat(1)}▪${c.reset}`;
|
|
206
207
|
};
|
|
207
208
|
|
|
208
209
|
// Month labels along the top (where the month changes within the visible window).
|
|
@@ -234,7 +235,7 @@ function renderHeatmap(byDay, days = 91) {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
// Footer legend.
|
|
237
|
-
const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${
|
|
238
|
+
const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${heat(0.2)}▪${c.reset} ${heat(0.5)}▪${c.reset} ${heat(0.8)}▪${c.reset} ${c.bold}${heat(1)}▪${c.reset} ${c.dim}more${c.reset}`;
|
|
238
239
|
lines.push('');
|
|
239
240
|
lines.push(legend);
|
|
240
241
|
return lines;
|
|
@@ -244,11 +245,69 @@ function pair(label, value, valueColor = c.cyan) {
|
|
|
244
245
|
return `${c.dim}${label.padEnd(14)}${c.reset} ${valueColor}${value}${c.reset}`;
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
// ASCII bar for a value relative to max.
|
|
248
|
+
// ASCII bar for a value relative to max. Fill color is heat-graded by the
|
|
249
|
+
// ratio (calm green → amber → rust), so intensity reads at a glance; with
|
|
250
|
+
// colors off it degrades to the same monochrome bar as before.
|
|
248
251
|
function bar(val, max, width = 22) {
|
|
249
252
|
if (!max || max <= 0) return '';
|
|
250
253
|
const filled = Math.max(0, Math.min(width, Math.round((val / max) * width)));
|
|
251
|
-
return `${c.magenta}${'█'.repeat(filled)}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
|
|
254
|
+
return `${heat(val / max) || c.magenta}${'█'.repeat(filled)}${c.reset}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The last n calendar days of byDay entries (oldest → today), null where a
|
|
258
|
+
// day has no activity. Date-stepped rather than ms arithmetic so DST shifts
|
|
259
|
+
// can't skip or duplicate a day.
|
|
260
|
+
function lastDays(byDay, n) {
|
|
261
|
+
const out = [];
|
|
262
|
+
const d = new Date();
|
|
263
|
+
d.setHours(12, 0, 0, 0); // noon — immune to DST midnight edges
|
|
264
|
+
d.setDate(d.getDate() - (n - 1));
|
|
265
|
+
for (let i = 0; i < n; i++) {
|
|
266
|
+
out.push(byDay?.[dayKey(d.getTime())] || null);
|
|
267
|
+
d.setDate(d.getDate() + 1);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const dayTokens = (d) => d
|
|
273
|
+
? (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0)
|
|
274
|
+
: 0;
|
|
275
|
+
|
|
276
|
+
// The `today` box, shared by `claude-rpc today` and `claude-rpc status`.
|
|
277
|
+
// Each headline metric carries its own context — a ▲/▼ against the trailing
|
|
278
|
+
// 7-day average (today excluded), a percentile callout on standout days, the
|
|
279
|
+
// day's cost, and a 14-day sparkline — so the numbers answer "is that a lot?"
|
|
280
|
+
// instead of making you remember.
|
|
281
|
+
function todayBoxLines(vars, aggregate) {
|
|
282
|
+
const byDay = aggregate?.byDay || {};
|
|
283
|
+
const series = lastDays(byDay, 15);
|
|
284
|
+
const today = series.at(-1);
|
|
285
|
+
const prior7 = series.slice(-8, -1);
|
|
286
|
+
const avg = (xs) => xs.reduce((a, b) => a + b, 0) / (xs.length || 1);
|
|
287
|
+
|
|
288
|
+
const dActive = fmtDelta(today?.activeMs || 0, avg(prior7.map((d) => d?.activeMs || 0)), { vs: 'vs 7-day avg' });
|
|
289
|
+
const dPrompts = fmtDelta(today?.userMessages || 0, avg(prior7.map((d) => d?.userMessages || 0)));
|
|
290
|
+
const dTokens = fmtDelta(dayTokens(today), avg(prior7.map(dayTokens)));
|
|
291
|
+
const pctLabel = topPercentile(Object.values(byDay).map((d) => d?.activeMs || 0), today?.activeMs || 0);
|
|
292
|
+
|
|
293
|
+
const lines = [
|
|
294
|
+
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}${dActive ? ` ${dActive}` : ''}${pctLabel ? ` ${c.magenta}· ${pctLabel}${c.reset}` : ''}`, ''),
|
|
295
|
+
pair('prompts', `${c.yellow}${vars.todayPrompts}${c.reset}${dPrompts ? ` ${dPrompts}` : ''}`, ''),
|
|
296
|
+
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
297
|
+
pair('sessions', String(vars.todaySessions || 0)),
|
|
298
|
+
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}${dTokens ? ` ${dTokens}` : ''}`, ''),
|
|
299
|
+
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
300
|
+
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
301
|
+
];
|
|
302
|
+
if (today?.cost) {
|
|
303
|
+
lines.push(pair('cost', `${c.green}≈${fmtCost(today.cost)}${c.reset} ${c.dim}+${(today.linesAdded || 0).toLocaleString()} lines today${c.reset}`, ''));
|
|
304
|
+
}
|
|
305
|
+
const spark = sparkline(series.slice(1).map((d) => d?.activeMs || 0));
|
|
306
|
+
if (spark) {
|
|
307
|
+
lines.push('');
|
|
308
|
+
lines.push(pair('last 14d', `${spark} ${c.dim}← today${c.reset}`, ''));
|
|
309
|
+
}
|
|
310
|
+
return lines;
|
|
252
311
|
}
|
|
253
312
|
|
|
254
313
|
function showStatus() {
|
|
@@ -295,15 +354,7 @@ function showStatus() {
|
|
|
295
354
|
}
|
|
296
355
|
|
|
297
356
|
if (aggregate) {
|
|
298
|
-
box('today',
|
|
299
|
-
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
|
|
300
|
-
pair('prompts', String(vars.todayPrompts), c.yellow),
|
|
301
|
-
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
302
|
-
pair('sessions', String(vars.todaySessions || 0)),
|
|
303
|
-
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
304
|
-
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
305
|
-
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
306
|
-
]);
|
|
357
|
+
box('today', todayBoxLines(vars, aggregate));
|
|
307
358
|
console.log('');
|
|
308
359
|
|
|
309
360
|
box('streak', [
|
|
@@ -446,17 +497,15 @@ function showToday() {
|
|
|
446
497
|
console.log(` ${c.bold}${c.magenta}◆ Today${c.reset} ${c.dim}— ${new Date().toLocaleDateString()}${c.reset}`);
|
|
447
498
|
console.log('');
|
|
448
499
|
|
|
449
|
-
box('today',
|
|
450
|
-
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
|
|
451
|
-
pair('prompts', String(vars.todayPrompts), c.yellow),
|
|
452
|
-
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
453
|
-
pair('sessions', String(vars.todaySessions || 0)),
|
|
454
|
-
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
455
|
-
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
456
|
-
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
457
|
-
]);
|
|
500
|
+
box('today', todayBoxLines(vars, aggregate));
|
|
458
501
|
console.log('');
|
|
459
502
|
|
|
503
|
+
// One quiet celebration line on milestone days (token round numbers
|
|
504
|
+
// crossed today, day-N anniversaries). The share nudge below owns streak
|
|
505
|
+
// records and round session/hour counts — no overlap.
|
|
506
|
+
const milestone = pickTodayMilestone(aggregate, dayTokens(lastDays(aggregate?.byDay, 1)[0]));
|
|
507
|
+
if (milestone) console.log(` ${c.magenta}✶${c.reset} ${c.bold}${milestone}${c.reset}\n`);
|
|
508
|
+
|
|
460
509
|
if (aggregate?.byHour && Object.keys(aggregate.byHour).length) {
|
|
461
510
|
box('when you code · hour of day', renderHourHistogram(aggregate.byHour, { peakHour: vars.peakHourNum }), 40);
|
|
462
511
|
console.log('');
|
|
@@ -481,12 +530,23 @@ function showWeek() {
|
|
|
481
530
|
console.log(` ${c.bold}${c.magenta}◆ This week${c.reset} ${c.dim}— ${weekKey(Date.now())}${c.reset}`);
|
|
482
531
|
console.log('');
|
|
483
532
|
|
|
533
|
+
// Week-over-week context: this week so far vs the whole of last week.
|
|
534
|
+
// Early in the week "▼" is expected — the vs label keeps that honest.
|
|
535
|
+
const prevRef = new Date();
|
|
536
|
+
prevRef.setHours(12, 0, 0, 0); // noon — immune to DST midnight edges
|
|
537
|
+
prevRef.setDate(prevRef.getDate() - 7);
|
|
538
|
+
const wkNow = aggregate?.byWeek?.[weekKey(Date.now())];
|
|
539
|
+
const wkPrev = aggregate?.byWeek?.[weekKey(prevRef.getTime())];
|
|
540
|
+
const dWkActive = fmtDelta(wkNow?.activeMs || 0, wkPrev?.activeMs || 0, { vs: 'vs last week' });
|
|
541
|
+
const dWkPrompts = fmtDelta(wkNow?.userMessages || 0, wkPrev?.userMessages || 0);
|
|
542
|
+
const dWkTokens = fmtDelta(dayTokens(wkNow), dayTokens(wkPrev));
|
|
543
|
+
|
|
484
544
|
box('this week', [
|
|
485
|
-
pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}`, ''),
|
|
486
|
-
pair('prompts',
|
|
545
|
+
pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}${dWkActive ? ` ${dWkActive}` : ''}`, ''),
|
|
546
|
+
pair('prompts', `${c.yellow}${vars.weekPrompts}${c.reset}${dWkPrompts ? ` ${dWkPrompts}` : ''}`, ''),
|
|
487
547
|
pair('tool calls', vars.weekToolsFmt, c.yellow),
|
|
488
548
|
pair('sessions', String(vars.weekSessions || 0)),
|
|
489
|
-
pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
549
|
+
pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}${dWkTokens ? ` ${dWkTokens}` : ''}`, ''),
|
|
490
550
|
]);
|
|
491
551
|
console.log('');
|
|
492
552
|
|
|
@@ -513,7 +573,8 @@ function showWeek() {
|
|
|
513
573
|
const h = ms / 3_600_000;
|
|
514
574
|
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
515
575
|
const prefix = isToday ? `${c.bold}` : '';
|
|
516
|
-
|
|
576
|
+
const peak = ms === maxMs && ms > 0 ? ` ${c.bold}${heat(1)}◆${c.reset}` : '';
|
|
577
|
+
return `${prefix}${label.padEnd(12)}${c.reset} ${bar(ms, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset}${peak}${isToday ? ` ${c.dim}← today${c.reset}` : ''}`;
|
|
517
578
|
});
|
|
518
579
|
box('this week · daily breakdown', lines);
|
|
519
580
|
console.log('');
|
|
@@ -1137,22 +1198,49 @@ async function doSquadCmd(argv) {
|
|
|
1137
1198
|
});
|
|
1138
1199
|
}
|
|
1139
1200
|
|
|
1140
|
-
// ── Link (
|
|
1201
|
+
// ── Link (one profile across machines) ───────────────────────────────────
|
|
1141
1202
|
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1203
|
+
// Two-sided by design — one verb owns the whole story:
|
|
1204
|
+
// claude-rpc link on your MAIN (verified) machine → mints a
|
|
1205
|
+
// one-time code, no browser needed
|
|
1206
|
+
// claude-rpc link <code> on the NEW machine → claims it, merging this
|
|
1207
|
+
// install into the same leaderboard identity
|
|
1208
|
+
// The browser fallback lives at claude-rpc.vercel.app/link (log in with
|
|
1209
|
+
// GitHub → same code) for when the other machine isn't handy. Claiming
|
|
1210
|
+
// verifies the profile (✓) and unlocks managing squads from the browser.
|
|
1211
|
+
|
|
1212
|
+
const LINK_PAGE = 'https://claude-rpc.vercel.app/link';
|
|
1213
|
+
|
|
1214
|
+
// Mint side: this machine asks the worker for a code. The worker only obliges
|
|
1215
|
+
// when this install's canonical profile is verified — the ✓ a claim grants
|
|
1216
|
+
// has to root in an already-proven identity.
|
|
1217
|
+
async function linkMint(ctx) {
|
|
1218
|
+
const r = await ctx.post('/pair/start', {});
|
|
1219
|
+
if (r.status === 403) {
|
|
1220
|
+
return fail('link codes come from a verified machine — this one isn\'t yet', {
|
|
1221
|
+
hint: `verify here first (claude-rpc profile verify), or mint in the browser: ${LINK_PAGE}`,
|
|
1222
|
+
code: EX_BAD_STATE,
|
|
1153
1223
|
});
|
|
1154
1224
|
}
|
|
1225
|
+
if (r.status !== 200 || !r.json?.code) {
|
|
1226
|
+
return fail(`could not mint a link code: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
|
|
1227
|
+
}
|
|
1228
|
+
const mins = Math.round((r.json.expiresInSec || 600) / 60);
|
|
1229
|
+
console.log('');
|
|
1230
|
+
console.log(` ${c.green}✓${c.reset} link code: ${c.cyan}${c.bold}${r.json.code}${c.reset} ${c.dim}(expires in ${mins} min)${c.reset}`);
|
|
1231
|
+
console.log('');
|
|
1232
|
+
console.log(` ${c.dim}on the new machine:${c.reset}`);
|
|
1233
|
+
console.log(` npx claude-rpc@latest setup`);
|
|
1234
|
+
console.log(` ${c.cyan}claude-rpc link ${r.json.code}${c.reset}`);
|
|
1235
|
+
console.log('');
|
|
1236
|
+
console.log(` ${c.dim}one leaderboard profile — stats from every linked machine count as one${c.reset}`);
|
|
1237
|
+
console.log('');
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async function doLink(argv) {
|
|
1155
1241
|
const ctx = squadAuth();
|
|
1242
|
+
const code = (argv[0] || '').trim();
|
|
1243
|
+
if (!code) return linkMint(ctx);
|
|
1156
1244
|
// Make sure the profile row exists server-side before claiming — same
|
|
1157
1245
|
// pre-publish profileVerify does, so link works on a fresh `profile on`.
|
|
1158
1246
|
if (lb.profileIsPublishable(ctx.cfg.profile || {})) {
|
|
@@ -1162,7 +1250,7 @@ async function doLink(argv) {
|
|
|
1162
1250
|
const r = await ctx.post('/pair/claim', { code });
|
|
1163
1251
|
if (r.status !== 200) {
|
|
1164
1252
|
return fail(`link failed: ${r.json?.error || r.status}`,
|
|
1165
|
-
{ hint:
|
|
1253
|
+
{ hint: `get a fresh code: run \`claude-rpc link\` on your main machine, or ${LINK_PAGE}`, code: EX_SYS_ERROR });
|
|
1166
1254
|
}
|
|
1167
1255
|
// Mirror the verified identity locally so `profile status` agrees.
|
|
1168
1256
|
const userCfg = readJson(CONFIG_PATH, {});
|
|
@@ -1174,7 +1262,7 @@ async function doLink(argv) {
|
|
|
1174
1262
|
// canonical handle, one board row across all your machines.
|
|
1175
1263
|
console.log(` ${c.green}✓${c.reset} this machine now merges into ${c.cyan}@${r.json.handle}${c.reset} ${c.dim}— stats from all your machines count as one${c.reset}`);
|
|
1176
1264
|
}
|
|
1177
|
-
console.log(` ${c.dim}
|
|
1265
|
+
console.log(` ${c.dim}started in a browser tab? it picks the link up automatically${c.reset}`);
|
|
1178
1266
|
}
|
|
1179
1267
|
|
|
1180
1268
|
// ── Community totals ─────────────────────────────────────────────────────
|
|
@@ -1323,7 +1411,7 @@ function profileNextStep() {
|
|
|
1323
1411
|
if (!next) {
|
|
1324
1412
|
console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}${c.reset}`);
|
|
1325
1413
|
} else if (next.key === 'verify') {
|
|
1326
|
-
console.log(` ${c.dim}→ next:
|
|
1414
|
+
console.log(` ${c.dim}→ next: run${c.reset} ${c.cyan}claude-rpc link${c.reset} ${c.dim}on a machine that's already verified, then${c.reset} ${c.cyan}claude-rpc link <code>${c.reset} ${c.dim}here — first machine? ${LINK_PAGE}${c.reset}`);
|
|
1327
1415
|
} else {
|
|
1328
1416
|
console.log(` ${c.dim}→ next:${c.reset} ${c.cyan}${next.cmd}${c.reset} ${c.dim}(${next.label})${c.reset}`);
|
|
1329
1417
|
}
|
|
@@ -1366,12 +1454,12 @@ function profileStatus() {
|
|
|
1366
1454
|
: `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
|
|
1367
1455
|
return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
|
|
1368
1456
|
});
|
|
1369
|
-
//
|
|
1457
|
+
// Link codes are the primary verify path; the gist dance stays available
|
|
1370
1458
|
// for terminals with no browser nearby.
|
|
1371
1459
|
if (!steps[2].done) {
|
|
1372
1460
|
lines.push('');
|
|
1373
|
-
lines.push(`${c.dim}the code comes from${c.reset} ${c.cyan}
|
|
1374
|
-
lines.push(`${c.dim}
|
|
1461
|
+
lines.push(`${c.dim}the code comes from${c.reset} ${c.cyan}claude-rpc link${c.reset} ${c.dim}on a machine you already verified${c.reset}`);
|
|
1462
|
+
lines.push(`${c.dim}first machine? log in at${c.reset} ${c.cyan}${LINK_PAGE}${c.reset} ${c.dim}— or no browser:${c.reset} ${c.cyan}claude-rpc profile verify${c.reset}`);
|
|
1375
1463
|
}
|
|
1376
1464
|
box('next steps', lines);
|
|
1377
1465
|
}
|
|
@@ -1704,7 +1792,7 @@ function help() {
|
|
|
1704
1792
|
['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
|
|
1705
1793
|
['profile', 'Public leaderboard identity (status|set|on|off|publish|verify)'],
|
|
1706
1794
|
['squad', 'Private mini-leaderboards with friends (create|join|leave|status)'],
|
|
1707
|
-
['link', '
|
|
1795
|
+
['link', 'Link machines into one profile (mints a code; `link <code>` claims it)'],
|
|
1708
1796
|
['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
|
|
1709
1797
|
['tail', 'Tail the daemon log file'],
|
|
1710
1798
|
['daemon', 'Run daemon in foreground (debug)'],
|
package/src/install.js
CHANGED
|
@@ -570,6 +570,7 @@ export function setupOutro(target, changed = true) {
|
|
|
570
570
|
if (IS_PACKAGED) point('start daemon', `"${target}" daemon`, 'also runs automatically at login');
|
|
571
571
|
else point('manage daemon', 'claude-rpc start · stop · status');
|
|
572
572
|
point('config', CONFIG_PATH, 'a working Discord app is bundled — set clientId only to use your own');
|
|
573
|
+
point('other machine?', 'claude-rpc link', 'run it there, claim the code here — one leaderboard profile');
|
|
573
574
|
console.log('');
|
|
574
575
|
}
|
|
575
576
|
|
package/src/nudge.js
CHANGED
|
@@ -63,6 +63,31 @@ export function pickShareNudge(agg) {
|
|
|
63
63
|
return out[0];
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// A quiet, local celebration line for `claude-rpc today`. Complements the
|
|
67
|
+
// share nudges above — which own streak records and round session/hour
|
|
68
|
+
// counts — without overlapping them: this detects lifetime-token round
|
|
69
|
+
// numbers CROSSED TODAY (no state file needed — the crossing happened today
|
|
70
|
+
// iff total ≥ mark and total − todayTokens < mark) and round "day N"
|
|
71
|
+
// anniversaries (which are only true on the day itself). Returns one string
|
|
72
|
+
// or null; the caller styles it.
|
|
73
|
+
const TOKEN_MARKS = [1e9, 5e9, 1e10, 2.5e10, 5e10, 1e11, 2.5e11, 5e11, 1e12];
|
|
74
|
+
const DAY_MARKS = new Set([50, 100, 200, 365, 500, 730, 1000]);
|
|
75
|
+
const fmtTok = (n) => n >= 1e12 ? `${n / 1e12}T` : n >= 1e9 ? `${n / 1e9}B` : `${n / 1e6}M`;
|
|
76
|
+
|
|
77
|
+
export function pickTodayMilestone(agg, todayTokens = 0) {
|
|
78
|
+
if (!agg || typeof agg !== 'object') return null;
|
|
79
|
+
const total = (agg.inputTokens || 0) + (agg.outputTokens || 0)
|
|
80
|
+
+ (agg.cacheReadTokens || 0) + (agg.cacheWriteTokens || 0);
|
|
81
|
+
for (let i = TOKEN_MARKS.length - 1; i >= 0; i--) {
|
|
82
|
+
const mark = TOKEN_MARKS[i];
|
|
83
|
+
if (total >= mark && total - todayTokens < mark) {
|
|
84
|
+
return `crossed ${fmtTok(mark)} lifetime tokens today`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (DAY_MARKS.has(agg.daysSinceFirst || 0)) return `day ${agg.daysSinceFirst} with claude`;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
function readLastKey(path = NUDGE_STATE) {
|
|
67
92
|
try { return JSON.parse(readFileSync(path, 'utf8')).key || null; }
|
|
68
93
|
catch { return null; }
|
package/src/tui.js
CHANGED
|
@@ -13,6 +13,7 @@ import { loadConfig } from './config.js';
|
|
|
13
13
|
import { PID_PATH } from './paths.js';
|
|
14
14
|
import { fmtCost } from './pricing.js';
|
|
15
15
|
import { generateInsights } from './insights.js';
|
|
16
|
+
import { heat } from './ui.js';
|
|
16
17
|
|
|
17
18
|
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
18
19
|
const ESC = '\x1b[';
|
|
@@ -74,10 +75,11 @@ function width() { return Math.max(50, Math.min(120, process.stdout.columns ||
|
|
|
74
75
|
function height() { return Math.max(20, process.stdout.rows || 24); }
|
|
75
76
|
|
|
76
77
|
function rule(w) { return C.gray + '─'.repeat(w - 4) + C.reset; }
|
|
78
|
+
// Heat-graded fill (same ramp as the CLI views) — intensity reads at a glance.
|
|
77
79
|
function bar(value, max, w = 16) {
|
|
78
80
|
if (!max || max <= 0) return ''.padEnd(w);
|
|
79
81
|
const filled = Math.max(0, Math.min(w, Math.round((value / max) * w)));
|
|
80
|
-
return '█'.repeat(filled) + ' '.repeat(w - filled);
|
|
82
|
+
return `${heat(value / max) || C.magenta}${'█'.repeat(filled)}${C.reset}` + ' '.repeat(w - filled);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// Pad a (possibly ANSI-colored) line with spaces so its VISIBLE width hits n.
|
|
@@ -166,7 +168,7 @@ function tabToday(_, data) {
|
|
|
166
168
|
const ms = agg.byHour?.[h]?.activeMs || 0;
|
|
167
169
|
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
168
170
|
const ch = heightChars[idx];
|
|
169
|
-
bars.push(h === v.peakHourNum ? `${C.
|
|
171
|
+
bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
|
|
170
172
|
}
|
|
171
173
|
out.push(bars.join(''));
|
|
172
174
|
out.push(`${C.dim}00 03 06 09 12 15 18 21${C.reset}`);
|
|
@@ -212,7 +214,8 @@ function tabWeek(_, data) {
|
|
|
212
214
|
const h = ms / 3_600_000;
|
|
213
215
|
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
214
216
|
const prefix = isToday ? C.bold : '';
|
|
215
|
-
|
|
217
|
+
const peak = ms === maxMs && ms > 0 ? ` ${C.bold}${heat(1)}◆${C.reset}` : '';
|
|
218
|
+
out.push(`${prefix}${label.padEnd(11)}${C.reset} ${bar(ms, maxMs, 18)} ${C.cyan}${hStr.padStart(5)}${C.reset}${peak}${isToday ? ` ${C.dim}← today${C.reset}` : ''}`);
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
}
|
|
@@ -267,7 +270,7 @@ function tabLifetime(_, data) {
|
|
|
267
270
|
const h = p.activeMs / 3_600_000;
|
|
268
271
|
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
269
272
|
const pretty = humanProject(name).slice(0, 18).padEnd(20);
|
|
270
|
-
out.push(`${pretty} ${
|
|
273
|
+
out.push(`${pretty} ${bar(p.activeMs, maxMs, 16)} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
|
|
271
274
|
}
|
|
272
275
|
}
|
|
273
276
|
return out;
|
|
@@ -291,7 +294,7 @@ function tabCost(_, data) {
|
|
|
291
294
|
const max = byModel[0][1];
|
|
292
295
|
for (const [m, val] of byModel) {
|
|
293
296
|
const pretty = String(m).padEnd(20);
|
|
294
|
-
out.push(`${pretty} ${
|
|
297
|
+
out.push(`${pretty} ${bar(val, max, 18)} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
|
|
295
298
|
}
|
|
296
299
|
}
|
|
297
300
|
|
|
@@ -330,7 +333,7 @@ function tabCode(_, data) {
|
|
|
330
333
|
const max = langs[0][1].edits || 1;
|
|
331
334
|
for (const [name, l] of langs) {
|
|
332
335
|
const pretty = name.slice(0, 18).padEnd(20);
|
|
333
|
-
out.push(`${pretty} ${
|
|
336
|
+
out.push(`${pretty} ${bar(l.edits, max, 18)} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
|
|
334
337
|
}
|
|
335
338
|
}
|
|
336
339
|
|
package/src/ui.js
CHANGED
|
@@ -81,6 +81,68 @@ export function fail(label, { hint = '', code = EX_USER_ERROR } = {}) {
|
|
|
81
81
|
process.exit(code);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// ── Heat / sparkline / comparison primitives ──────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Shared by the focused views (today / week / status / TUI). All of these
|
|
87
|
+
// degrade to plain glyphs when colors are off, so piped output stays clean.
|
|
88
|
+
|
|
89
|
+
// Intensity 0..1 → a 256-color ramp matching the site palette: calm green
|
|
90
|
+
// for light activity, amber for solid, rust for hot. `tty` is overridable so
|
|
91
|
+
// the ramp is unit-testable in a non-TTY test runner.
|
|
92
|
+
const HEAT_RAMP = [
|
|
93
|
+
[0.25, '\x1b[38;5;65m'], // sage — barely warm
|
|
94
|
+
[0.45, '\x1b[38;5;71m'], // green — steady
|
|
95
|
+
[0.65, '\x1b[38;5;178m'], // amber — solid
|
|
96
|
+
[0.85, '\x1b[38;5;208m'], // orange — heavy
|
|
97
|
+
[Infinity, '\x1b[38;5;166m'], // rust — peak
|
|
98
|
+
];
|
|
99
|
+
export function heat(t, { tty = TTY } = {}) {
|
|
100
|
+
if (!tty) return '';
|
|
101
|
+
if (!(t > 0)) return c.dim;
|
|
102
|
+
for (const [ceil, color] of HEAT_RAMP) if (t < ceil) return color;
|
|
103
|
+
return HEAT_RAMP.at(-1)[1];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Heat-colored sparkline of a numeric series (▁▂▃▄▅▆▇█), scaled to its own
|
|
107
|
+
// max. Zero/empty input → ''. With colors off this is just the glyphs.
|
|
108
|
+
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█';
|
|
109
|
+
export function sparkline(values, { tty = TTY } = {}) {
|
|
110
|
+
const max = Math.max(0, ...values.map((v) => v || 0));
|
|
111
|
+
if (!(max > 0)) return '';
|
|
112
|
+
const out = values.map((raw) => {
|
|
113
|
+
const v = raw || 0;
|
|
114
|
+
const idx = v > 0 ? Math.max(1, Math.min(8, Math.round((v / max) * 8))) : 0;
|
|
115
|
+
return `${heat(v / max, { tty })}${SPARK_CHARS[idx]}`;
|
|
116
|
+
}).join('');
|
|
117
|
+
return out + (tty ? c.reset : '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// "▲ +18% vs 7-day avg" — current vs a baseline, colored by direction.
|
|
121
|
+
// A quiet day isn't a failure, so down renders gray, not red. Returns ''
|
|
122
|
+
// when the baseline is too small to compare against (fresh installs).
|
|
123
|
+
export function fmtDelta(current, baseline, { vs = '' } = {}) {
|
|
124
|
+
if (!(baseline > 0)) return '';
|
|
125
|
+
const pct = Math.round(((current || 0) - baseline) / baseline * 100);
|
|
126
|
+
const tail = vs ? ` ${c.dim}${vs}${c.reset}` : '';
|
|
127
|
+
if (pct === 0) return `${c.dim}≈ ${vs || 'usual'}${c.reset}`;
|
|
128
|
+
const up = pct > 0;
|
|
129
|
+
const shown = Math.min(Math.abs(pct), 999); // a 40×-average day reads "+999%", not noise
|
|
130
|
+
return `${up ? c.green : c.gray}${up ? '▲' : '▼'} ${up ? '+' : '−'}${shown}%${c.reset}${tail}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Percentile callout for a standout value among its history ("top 10% day").
|
|
134
|
+
// Quiet unless there's real history to rank against and the value is high —
|
|
135
|
+
// a callout on every middling day would train the eye to skip it.
|
|
136
|
+
export function topPercentile(values, v, { min = 14 } = {}) {
|
|
137
|
+
const past = values.filter((x) => x > 0);
|
|
138
|
+
if (past.length < min || !(v > 0)) return '';
|
|
139
|
+
const rank = past.filter((x) => x <= v).length / past.length;
|
|
140
|
+
if (rank >= 1) return 'best day yet';
|
|
141
|
+
if (rank >= 0.9) return 'top 10% day';
|
|
142
|
+
if (rank >= 0.75) return 'top 25% day';
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
84
146
|
// Return the last n lines of a log file's raw text, trimming the trailing
|
|
85
147
|
// empty element that split('\n') produces when the file ends with a newline.
|
|
86
148
|
// When the file lacks a trailing newline the last element is the last real
|