claude-rpc 0.3.8
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/LICENSE +21 -0
- package/README.md +300 -0
- package/bin/claude-rpc.js +2 -0
- package/config.example.json +67 -0
- package/package.json +53 -0
- package/src/badge.js +144 -0
- package/src/cli.js +765 -0
- package/src/daemon.js +324 -0
- package/src/default-config.js +91 -0
- package/src/format.js +657 -0
- package/src/git.js +74 -0
- package/src/hook.js +169 -0
- package/src/insights.js +138 -0
- package/src/install.js +280 -0
- package/src/languages.js +114 -0
- package/src/paths.js +59 -0
- package/src/pricing.js +73 -0
- package/src/scanner.js +721 -0
- package/src/server.js +1584 -0
- package/src/state.js +73 -0
- package/src/tui.js +420 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, watchFile, unlinkSync } from 'node:fs';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
|
|
6
|
+
// Force the console code page to UTF-8 (65001) on Windows so Unicode box
|
|
7
|
+
// drawing, block elements, and other chars render correctly. Default cmd.exe
|
|
8
|
+
// code page on Win10 is 437/850, which displays many of our chars as `?`.
|
|
9
|
+
// Hook events (no TTY) skip this — they don't print anything user-visible.
|
|
10
|
+
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
11
|
+
try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch {}
|
|
12
|
+
}
|
|
13
|
+
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, EXE_PATH, CANONICAL_EXE } from './paths.js';
|
|
14
|
+
import { readState } from './state.js';
|
|
15
|
+
import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses } from './format.js';
|
|
16
|
+
import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
|
|
17
|
+
import { runHookCli } from './hook.js';
|
|
18
|
+
import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe } from './install.js';
|
|
19
|
+
import { startTui } from './tui.js';
|
|
20
|
+
import { generateInsights } from './insights.js';
|
|
21
|
+
import { badgeSvg } from './badge.js';
|
|
22
|
+
import { fmtCost } from './pricing.js';
|
|
23
|
+
import { basename } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const cmd = process.argv[2];
|
|
26
|
+
|
|
27
|
+
// ── ANSI styling (degrades gracefully) ────────────────────────────────────────
|
|
28
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
29
|
+
const c = {
|
|
30
|
+
reset: tty ? '\x1b[0m' : '',
|
|
31
|
+
dim: tty ? '\x1b[2m' : '',
|
|
32
|
+
bold: tty ? '\x1b[1m' : '',
|
|
33
|
+
cyan: tty ? '\x1b[36m' : '',
|
|
34
|
+
green: tty ? '\x1b[32m' : '',
|
|
35
|
+
yellow: tty ? '\x1b[33m' : '',
|
|
36
|
+
red: tty ? '\x1b[31m' : '',
|
|
37
|
+
magenta: tty ? '\x1b[35m' : '',
|
|
38
|
+
blue: tty ? '\x1b[34m' : '',
|
|
39
|
+
gray: tty ? '\x1b[90m' : '',
|
|
40
|
+
};
|
|
41
|
+
const ansiRe = /\x1b\[[0-9;]*m/g;
|
|
42
|
+
const visibleLen = (s) => String(s).replace(ansiRe, '').length;
|
|
43
|
+
|
|
44
|
+
function readJson(path, fallback) {
|
|
45
|
+
if (!existsSync(path)) return fallback;
|
|
46
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAlive(pid) {
|
|
50
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function daemonPid() {
|
|
54
|
+
if (!existsSync(PID_PATH)) return null;
|
|
55
|
+
const pid = Number(readFileSync(PID_PATH, 'utf8'));
|
|
56
|
+
return pid && isAlive(pid) ? pid : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function startDaemon({ quiet = false } = {}) {
|
|
60
|
+
const pid = daemonPid();
|
|
61
|
+
if (pid) {
|
|
62
|
+
if (!quiet) console.log(`${c.yellow}!${c.reset} Daemon already running (pid ${pid}). Run 'stop' first to restart.`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// In packaged mode the "daemon script" is the exe itself with a subcommand;
|
|
66
|
+
// in dev mode it's the src/daemon.js path passed to node. Prefer the
|
|
67
|
+
// canonical exe when it exists so we don't keep the user's Downloads copy
|
|
68
|
+
// locked open — the canonical install is the long-lived path.
|
|
69
|
+
const exe = (IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath;
|
|
70
|
+
const args = IS_PACKAGED ? ['daemon'] : [DAEMON_SCRIPT];
|
|
71
|
+
const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
72
|
+
child.unref();
|
|
73
|
+
if (!quiet) console.log(`${c.green}✓${c.reset} Daemon launched (pid ${c.cyan}${child.pid}${c.reset}) ${c.dim}logs: ${LOG_PATH}${c.reset}`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stopDaemon({ quiet = false } = {}) {
|
|
78
|
+
const pid = daemonPid();
|
|
79
|
+
if (!pid) { if (!quiet) console.log('Daemon not running.'); return false; }
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 'SIGTERM');
|
|
82
|
+
if (!quiet) console.log(`${c.green}✓${c.reset} Sent SIGTERM to pid ${c.cyan}${pid}${c.reset}`);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (!quiet) console.log(`${c.red}✗${c.reset} Failed to stop: ${e.message}`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function restartDaemon() {
|
|
91
|
+
if (stopDaemon({ quiet: true })) {
|
|
92
|
+
// wait briefly for the OS to release the pid file, then spawn fresh
|
|
93
|
+
setTimeout(() => startDaemon(), 600);
|
|
94
|
+
} else {
|
|
95
|
+
startDaemon();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Box drawing — auto-widens to fit longest line ────────────────────────────
|
|
100
|
+
function box(title, lines, minWidth = 64) {
|
|
101
|
+
const longest = lines.reduce((m, l) => Math.max(m, visibleLen(l)), 0);
|
|
102
|
+
const termWidth = process.stdout.columns || 100;
|
|
103
|
+
const maxAllowed = Math.max(40, termWidth - 2);
|
|
104
|
+
const width = Math.min(maxAllowed, Math.max(minWidth, longest + 4, title.length + 8));
|
|
105
|
+
const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width - 4 - title.length))}┐${c.reset}`;
|
|
106
|
+
const bottom = `${c.gray}└${'─'.repeat(width - 2)}┘${c.reset}`;
|
|
107
|
+
console.log(top);
|
|
108
|
+
for (const raw of lines) {
|
|
109
|
+
const truncated = truncateAnsi(raw, width - 4);
|
|
110
|
+
const pad = Math.max(1, width - 2 - visibleLen(truncated));
|
|
111
|
+
console.log(`${c.gray}│${c.reset} ${truncated}${' '.repeat(pad - 1)}${c.gray}│${c.reset}`);
|
|
112
|
+
}
|
|
113
|
+
console.log(bottom);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Truncate a string to `max` visible chars, keeping ANSI codes intact.
|
|
117
|
+
function truncateAnsi(str, max) {
|
|
118
|
+
if (visibleLen(str) <= max) return str;
|
|
119
|
+
let out = '';
|
|
120
|
+
let visible = 0;
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < str.length && visible < max - 1) {
|
|
123
|
+
if (str[i] === '\x1b' && str[i + 1] === '[') {
|
|
124
|
+
const end = str.indexOf('m', i);
|
|
125
|
+
if (end !== -1) { out += str.slice(i, end + 1); i = end + 1; continue; }
|
|
126
|
+
}
|
|
127
|
+
out += str[i]; visible++; i++;
|
|
128
|
+
}
|
|
129
|
+
return out + '…' + c.reset;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function shortPath(p) {
|
|
133
|
+
if (!p) return '';
|
|
134
|
+
const home = process.env.USERPROFILE || process.env.HOME || '';
|
|
135
|
+
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 24-bar histogram of hour-of-day activity.
|
|
139
|
+
function renderHourHistogram(byHour, opts = {}) {
|
|
140
|
+
const heightChars = ' ▁▂▃▄▅▆▇█';
|
|
141
|
+
let max = 0;
|
|
142
|
+
for (let h = 0; h < 24; h++) max = Math.max(max, byHour?.[h]?.activeMs || 0);
|
|
143
|
+
if (max <= 0) return [' (no hourly data yet)'];
|
|
144
|
+
const bars = [];
|
|
145
|
+
for (let h = 0; h < 24; h++) {
|
|
146
|
+
const ms = byHour?.[h]?.activeMs || 0;
|
|
147
|
+
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
148
|
+
bars.push(heightChars[idx]);
|
|
149
|
+
}
|
|
150
|
+
const peakH = opts.peakHour ?? bars.findIndex((b) => b === heightChars[Math.min(8, ...bars.map((_, i) => i).filter(() => true))]);
|
|
151
|
+
const colored = bars.map((ch, h) => h === opts.peakHour ? `${c.magenta}${c.bold}${ch}${c.reset}` : `${c.green}${ch}${c.reset}`).join('');
|
|
152
|
+
// Hour labels under every 3rd hour.
|
|
153
|
+
const labels = '00 03 06 09 12 15 18 21 ';
|
|
154
|
+
return [
|
|
155
|
+
` ${colored}`,
|
|
156
|
+
` ${c.dim}${labels}${c.reset}`,
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Bar chart for an array of [label, value] entries.
|
|
161
|
+
function renderBars(rows, opts = {}) {
|
|
162
|
+
if (!rows.length) return [' (none yet)'];
|
|
163
|
+
const max = rows[0][1];
|
|
164
|
+
const labelWidth = opts.labelWidth || 20;
|
|
165
|
+
return rows.map(([label, val]) => {
|
|
166
|
+
const shown = label.length > labelWidth ? label.slice(0, labelWidth - 1) + '…' : label;
|
|
167
|
+
return `${shown.padEnd(labelWidth)} ${bar(val, max)} ${c.cyan}${typeof val === 'number' ? val.toLocaleString() : val}${c.reset}`;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// GitHub-style heatmap of last N days. Cells colored by activeMs intensity.
|
|
172
|
+
function renderHeatmap(byDay, days = 91) {
|
|
173
|
+
const today = new Date();
|
|
174
|
+
today.setHours(0, 0, 0, 0);
|
|
175
|
+
// Walk back N-1 days and snap to the previous Sunday so columns align.
|
|
176
|
+
const start = new Date(today);
|
|
177
|
+
start.setDate(start.getDate() - (days - 1));
|
|
178
|
+
while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
|
|
179
|
+
|
|
180
|
+
// Collect cells column-by-column (week = column, weekday = row).
|
|
181
|
+
const cells = [];
|
|
182
|
+
const cursor = new Date(start);
|
|
183
|
+
while (cursor <= today) {
|
|
184
|
+
const k = dayKey(cursor.getTime());
|
|
185
|
+
cells.push({ key: k, ms: byDay?.[k]?.activeMs || 0, future: cursor > today });
|
|
186
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
187
|
+
}
|
|
188
|
+
const cols = Math.ceil(cells.length / 7);
|
|
189
|
+
|
|
190
|
+
// Quantize: 0 / >0 / >15m / >1h / >3h
|
|
191
|
+
const shade = (ms) => {
|
|
192
|
+
if (ms <= 0) return `${c.dim}·${c.reset}`;
|
|
193
|
+
if (ms < 15 * 60_000) return `${c.gray}▪${c.reset}`;
|
|
194
|
+
if (ms < 60 * 60_000) return `${c.green}▪${c.reset}`;
|
|
195
|
+
if (ms < 3 * 3600_000) return `${c.green}${c.bold}▪${c.reset}`;
|
|
196
|
+
return `${c.magenta}${c.bold}▪${c.reset}`;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Month labels along the top (where the month changes within the visible window).
|
|
200
|
+
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
201
|
+
const labelRow = new Array(cols).fill(' ');
|
|
202
|
+
let lastMonth = -1;
|
|
203
|
+
for (let col = 0; col < cols; col++) {
|
|
204
|
+
const first = cells[col * 7];
|
|
205
|
+
if (!first) continue;
|
|
206
|
+
const d = new Date(first.key + 'T00:00:00');
|
|
207
|
+
if (d.getMonth() !== lastMonth) {
|
|
208
|
+
labelRow[col] = months[d.getMonth()];
|
|
209
|
+
lastMonth = d.getMonth();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Build header (3-letter months stretched across columns).
|
|
213
|
+
const header = labelRow.map((s) => (s + ' ').slice(0, 2)).join(' ');
|
|
214
|
+
|
|
215
|
+
const dayLabels = [' ', 'M', ' ', 'W', ' ', 'F', ' '];
|
|
216
|
+
const lines = [` ${c.dim}${header}${c.reset}`];
|
|
217
|
+
for (let row = 0; row < 7; row++) {
|
|
218
|
+
let line = ` ${c.dim}${dayLabels[row]}${c.reset} `;
|
|
219
|
+
for (let col = 0; col < cols; col++) {
|
|
220
|
+
const cell = cells[col * 7 + row];
|
|
221
|
+
if (!cell || cell.future) line += ' ';
|
|
222
|
+
else line += shade(cell.ms) + ' ';
|
|
223
|
+
}
|
|
224
|
+
lines.push(line);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Footer legend.
|
|
228
|
+
const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${c.gray}▪${c.reset} ${c.green}▪${c.reset} ${c.green}${c.bold}▪${c.reset} ${c.magenta}${c.bold}▪${c.reset} ${c.dim}more${c.reset}`;
|
|
229
|
+
lines.push('');
|
|
230
|
+
lines.push(legend);
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pair(label, value, valueColor = c.cyan) {
|
|
235
|
+
return `${c.dim}${label.padEnd(14)}${c.reset} ${valueColor}${value}${c.reset}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ASCII bar for a value relative to max.
|
|
239
|
+
function bar(val, max, width = 22) {
|
|
240
|
+
if (!max || max <= 0) return '';
|
|
241
|
+
const filled = Math.max(0, Math.min(width, Math.round((val / max) * width)));
|
|
242
|
+
return `${c.magenta}${'█'.repeat(filled)}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function showStatus() {
|
|
246
|
+
const state = readState();
|
|
247
|
+
const aggregate = readAggregate();
|
|
248
|
+
const config = readJson(CONFIG_PATH, {});
|
|
249
|
+
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
250
|
+
state.liveSessions = live;
|
|
251
|
+
const vars = buildVars(state, config, aggregate);
|
|
252
|
+
const pid = daemonPid();
|
|
253
|
+
|
|
254
|
+
console.log('');
|
|
255
|
+
console.log(` ${c.bold}${c.magenta}◆ Claude RPC${c.reset} ${c.dim}— Discord Rich Presence for Claude Code${c.reset}`);
|
|
256
|
+
console.log('');
|
|
257
|
+
|
|
258
|
+
box('daemon', [
|
|
259
|
+
pair('status', pid ? `${c.green}● running${c.reset} pid ${pid}` : `${c.red}○ not running${c.reset}`, ''),
|
|
260
|
+
pair('client', config.clientId || '—'),
|
|
261
|
+
pair('config', shortPath(CONFIG_PATH), c.gray),
|
|
262
|
+
pair('state', shortPath(STATE_PATH), c.gray),
|
|
263
|
+
pair('log', shortPath(LOG_PATH), c.gray),
|
|
264
|
+
]);
|
|
265
|
+
console.log('');
|
|
266
|
+
|
|
267
|
+
box('current session', [
|
|
268
|
+
pair('status', vars.statusVerbose, statusColor(vars.status)),
|
|
269
|
+
pair('project', vars.project),
|
|
270
|
+
pair('model', vars.modelPretty),
|
|
271
|
+
pair('duration', vars.duration),
|
|
272
|
+
pair('messages', String(vars.messages), c.yellow),
|
|
273
|
+
pair('tool calls', String(vars.tools), c.yellow),
|
|
274
|
+
pair('files', `${vars.filesOpened} opened · ${vars.filesEdited} edited · ${vars.filesRead} read`),
|
|
275
|
+
pair('tokens', `${c.bold}${vars.tokensFmt}${c.reset} ${c.dim}(${vars.inputTokens} in · ${vars.outputTokens} out · ${vars.cacheTokens} cache)${c.reset}`),
|
|
276
|
+
]);
|
|
277
|
+
console.log('');
|
|
278
|
+
|
|
279
|
+
if (live.length) {
|
|
280
|
+
const lines = live.slice(0, 6).map((s) => {
|
|
281
|
+
const proj = humanProject(s.cwd || s.project);
|
|
282
|
+
return `${c.cyan}${(proj || '—').padEnd(20)}${c.reset} ${c.dim}modified ${s.ageSec}s ago${c.reset}`;
|
|
283
|
+
});
|
|
284
|
+
box(`live sessions (${live.length})`, lines);
|
|
285
|
+
console.log('');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (aggregate) {
|
|
289
|
+
box('today', [
|
|
290
|
+
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
|
|
291
|
+
pair('prompts', String(vars.todayPrompts), c.yellow),
|
|
292
|
+
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
293
|
+
pair('sessions', String(vars.todaySessions || 0)),
|
|
294
|
+
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
295
|
+
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
296
|
+
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
297
|
+
]);
|
|
298
|
+
console.log('');
|
|
299
|
+
|
|
300
|
+
box('streak', [
|
|
301
|
+
pair('current', `${c.bold}${c.magenta}${vars.streak}${c.reset} ${c.dim}days${c.reset}`, ''),
|
|
302
|
+
pair('longest', `${vars.longestStreak} ${c.dim}days${c.reset}`, c.cyan),
|
|
303
|
+
pair('day no.', String(vars.daysSinceFirst), c.cyan),
|
|
304
|
+
pair('best day', vars.bestDayDate ? `${c.bold}${vars.bestDayHours}${c.reset} ${c.dim}on ${vars.bestDayDate} · ${vars.bestDayPrompts} prompts${c.reset}` : '—', ''),
|
|
305
|
+
]);
|
|
306
|
+
console.log('');
|
|
307
|
+
|
|
308
|
+
box('all-time on claude', [
|
|
309
|
+
pair('active', `${c.bold}${c.green}${vars.allHours}${c.reset} ${c.dim}(${vars.allWallHours} wall clock)${c.reset}`, ''),
|
|
310
|
+
pair('sessions', `${vars.allSessions} ${c.dim}(+${vars.allSubagentRuns} subagent runs)${c.reset}`, c.yellow),
|
|
311
|
+
pair('prompts', vars.allMessages.toLocaleString(), c.yellow),
|
|
312
|
+
pair('tool calls', vars.allTools.toLocaleString(), c.yellow),
|
|
313
|
+
pair('files', vars.allFiles.toLocaleString()),
|
|
314
|
+
pair('tokens', `${c.bold}${c.magenta}${vars.allTokensFmt}${c.reset} ${c.dim}grand total — in + out + cache${c.reset}`, ''),
|
|
315
|
+
pair(' input', vars.allInputTokens, c.gray),
|
|
316
|
+
pair(' output', vars.allOutputTokens, c.gray),
|
|
317
|
+
pair(' cache read', vars.allCacheReadTokens, c.gray),
|
|
318
|
+
pair(' cache write', vars.allCacheWriteTokens, c.gray),
|
|
319
|
+
pair(' billable', `${vars.allBillableFmt} ${c.dim}(in + out + cache writes)${c.reset}`, c.dim),
|
|
320
|
+
]);
|
|
321
|
+
console.log('');
|
|
322
|
+
|
|
323
|
+
// 13-week heatmap of activity (rounded up to the nearest Sunday).
|
|
324
|
+
if (aggregate.byDay && Object.keys(aggregate.byDay).length) {
|
|
325
|
+
box('activity · last 13 weeks', renderHeatmap(aggregate.byDay, 91), 56);
|
|
326
|
+
console.log('');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Hour-of-day histogram (when do you code?).
|
|
330
|
+
if (aggregate.byHour && Object.keys(aggregate.byHour).length) {
|
|
331
|
+
box('when you code · hour of day', renderHourHistogram(aggregate.byHour, { peakHour: vars.peakHourNum }), 40);
|
|
332
|
+
console.log('');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Top edited files.
|
|
336
|
+
if (aggregate.topEditedFiles && aggregate.topEditedFiles.length) {
|
|
337
|
+
const top = aggregate.topEditedFiles.slice(0, 8).map((e) => [basename(e.path), e.count]);
|
|
338
|
+
box('most-edited files', renderBars(top, { labelWidth: 22 }));
|
|
339
|
+
console.log('');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Top tools as a tiny bar chart.
|
|
343
|
+
const tools = Object.entries(aggregate.toolBreakdown || {})
|
|
344
|
+
.sort((a, b) => b[1] - a[1])
|
|
345
|
+
.slice(0, 8);
|
|
346
|
+
if (tools.length) {
|
|
347
|
+
const max = tools[0][1];
|
|
348
|
+
const lines = tools.map(([name, count]) => {
|
|
349
|
+
const pretty = humanTool(name).slice(0, 18);
|
|
350
|
+
return `${pretty.padEnd(20)} ${bar(count, max)} ${c.cyan}${count.toLocaleString()}${c.reset}`;
|
|
351
|
+
});
|
|
352
|
+
box('top tools', lines);
|
|
353
|
+
console.log('');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Top projects.
|
|
357
|
+
const projects = aggregate.projects || {};
|
|
358
|
+
const top = Object.entries(projects)
|
|
359
|
+
.sort((a, b) => b[1].activeMs - a[1].activeMs)
|
|
360
|
+
.slice(0, 6);
|
|
361
|
+
if (top.length) {
|
|
362
|
+
const maxMs = top[0][1].activeMs;
|
|
363
|
+
const lines = top.map(([name, p]) => {
|
|
364
|
+
const h = (p.activeMs / 3_600_000);
|
|
365
|
+
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
366
|
+
return `${humanProject(name).slice(0, 20).padEnd(22)} ${bar(p.activeMs, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset} ${c.dim}${p.sessions} sess · ${p.userMessages} prompts${c.reset}`;
|
|
367
|
+
});
|
|
368
|
+
box('top projects by active time', lines, 72);
|
|
369
|
+
console.log('');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Code churn — lines added / removed.
|
|
373
|
+
if (aggregate.linesAdded || aggregate.linesRemoved) {
|
|
374
|
+
box('code churn', [
|
|
375
|
+
pair('added', `${c.green}+${aggregate.linesAdded.toLocaleString()}${c.reset} lines`, ''),
|
|
376
|
+
pair('removed', `${c.red}−${aggregate.linesRemoved.toLocaleString()}${c.reset} lines`, ''),
|
|
377
|
+
pair('net', `${c.bold}${vars.linesNetFmt}${c.reset}`, ''),
|
|
378
|
+
pair('today', `${c.green}+${aggregate.byDay?.[dayKey(Date.now())]?.linesAdded || 0}${c.reset} added`, ''),
|
|
379
|
+
]);
|
|
380
|
+
console.log('');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Top languages by edits.
|
|
384
|
+
const langs = Object.entries(aggregate.languages || {})
|
|
385
|
+
.sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0))
|
|
386
|
+
.slice(0, 6);
|
|
387
|
+
if (langs.length) {
|
|
388
|
+
const max = langs[0][1].edits || 1;
|
|
389
|
+
const lines = langs.map(([name, l]) => `${name.slice(0, 22).padEnd(24)} ${bar(l.edits, max)} ${c.cyan}${l.edits.toLocaleString().padStart(6)}${c.reset} ${c.dim}${l.files} files${c.reset}`);
|
|
390
|
+
box('languages · by edits', lines);
|
|
391
|
+
console.log('');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Top bash commands.
|
|
395
|
+
const bashes = Object.entries(aggregate.bashCommands || {})
|
|
396
|
+
.sort((a, b) => b[1] - a[1])
|
|
397
|
+
.slice(0, 8);
|
|
398
|
+
if (bashes.length) {
|
|
399
|
+
const max = bashes[0][1];
|
|
400
|
+
const lines = bashes.map(([name, n]) => `${name.padEnd(20)} ${bar(n, max)} ${c.cyan}${n.toLocaleString()}${c.reset}`);
|
|
401
|
+
box('top bash commands', lines);
|
|
402
|
+
console.log('');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Cost.
|
|
406
|
+
if (aggregate.estimatedCost) {
|
|
407
|
+
const byModel = Object.entries(aggregate.costByModel || {}).sort((a, b) => b[1] - a[1]);
|
|
408
|
+
const max = byModel[0] ? byModel[0][1] : 1;
|
|
409
|
+
const lines = [
|
|
410
|
+
pair('all-time', `${c.bold}${c.green}${fmtCost(aggregate.estimatedCost)}${c.reset} ${c.dim}approximate${c.reset}`, ''),
|
|
411
|
+
...byModel.map(([m, v]) => `${m.padEnd(20)} ${bar(v, max)} ${c.cyan}${fmtCost(v).padStart(8)}${c.reset}`),
|
|
412
|
+
];
|
|
413
|
+
box('estimated cost', lines);
|
|
414
|
+
console.log('');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Insights.
|
|
418
|
+
const insights = generateInsights(aggregate, { limit: 4 });
|
|
419
|
+
if (insights.length) {
|
|
420
|
+
box('insights', insights.map((s) => `${c.dim}→${c.reset} ${s}`));
|
|
421
|
+
console.log('');
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
console.log(` ${c.dim}No aggregate yet — run ${c.reset}${c.cyan}claude-rpc scan${c.reset}`);
|
|
425
|
+
console.log('');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function showToday() {
|
|
430
|
+
const state = readState();
|
|
431
|
+
const aggregate = readAggregate();
|
|
432
|
+
const config = readJson(CONFIG_PATH, {});
|
|
433
|
+
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
434
|
+
const vars = buildVars(state, config, aggregate);
|
|
435
|
+
|
|
436
|
+
console.log('');
|
|
437
|
+
console.log(` ${c.bold}${c.magenta}◆ Today${c.reset} ${c.dim}— ${new Date().toLocaleDateString()}${c.reset}`);
|
|
438
|
+
console.log('');
|
|
439
|
+
|
|
440
|
+
box('today', [
|
|
441
|
+
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
|
|
442
|
+
pair('prompts', String(vars.todayPrompts), c.yellow),
|
|
443
|
+
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
444
|
+
pair('sessions', String(vars.todaySessions || 0)),
|
|
445
|
+
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
446
|
+
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
447
|
+
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
448
|
+
]);
|
|
449
|
+
console.log('');
|
|
450
|
+
|
|
451
|
+
if (aggregate?.byHour && Object.keys(aggregate.byHour).length) {
|
|
452
|
+
box('when you code · hour of day', renderHourHistogram(aggregate.byHour, { peakHour: vars.peakHourNum }), 40);
|
|
453
|
+
console.log('');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function showWeek() {
|
|
458
|
+
const state = readState();
|
|
459
|
+
const aggregate = readAggregate();
|
|
460
|
+
const config = readJson(CONFIG_PATH, {});
|
|
461
|
+
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
462
|
+
const vars = buildVars(state, config, aggregate);
|
|
463
|
+
|
|
464
|
+
console.log('');
|
|
465
|
+
console.log(` ${c.bold}${c.magenta}◆ This week${c.reset} ${c.dim}— ${weekKey(Date.now())}${c.reset}`);
|
|
466
|
+
console.log('');
|
|
467
|
+
|
|
468
|
+
box('this week', [
|
|
469
|
+
pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}`, ''),
|
|
470
|
+
pair('prompts', String(vars.weekPrompts), c.yellow),
|
|
471
|
+
pair('tool calls', vars.weekToolsFmt, c.yellow),
|
|
472
|
+
pair('sessions', String(vars.weekSessions || 0)),
|
|
473
|
+
pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
474
|
+
]);
|
|
475
|
+
console.log('');
|
|
476
|
+
|
|
477
|
+
// Current ISO week (Mon → Sun). Future days show "—".
|
|
478
|
+
if (aggregate?.byDay) {
|
|
479
|
+
const now = new Date();
|
|
480
|
+
now.setHours(0, 0, 0, 0);
|
|
481
|
+
const monday = new Date(now);
|
|
482
|
+
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
483
|
+
const days = [];
|
|
484
|
+
for (let i = 0; i < 7; i++) {
|
|
485
|
+
const d = new Date(monday);
|
|
486
|
+
d.setDate(d.getDate() + i);
|
|
487
|
+
const k = dayKey(d.getTime());
|
|
488
|
+
const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
489
|
+
const ms = aggregate.byDay[k]?.activeMs || 0;
|
|
490
|
+
const isFuture = d > now;
|
|
491
|
+
const isToday = k === dayKey(now.getTime());
|
|
492
|
+
days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
|
|
493
|
+
}
|
|
494
|
+
const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
|
|
495
|
+
const lines = days.map(({ label, ms, isFuture, isToday }) => {
|
|
496
|
+
if (isFuture) return `${c.dim}${label.padEnd(12)} ${'·'.repeat(22)} —${c.reset}`;
|
|
497
|
+
const h = ms / 3_600_000;
|
|
498
|
+
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
499
|
+
const prefix = isToday ? `${c.bold}` : '';
|
|
500
|
+
return `${prefix}${label.padEnd(12)}${c.reset} ${bar(ms, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset}${isToday ? ` ${c.dim}← today${c.reset}` : ''}`;
|
|
501
|
+
});
|
|
502
|
+
box('this week · daily breakdown', lines);
|
|
503
|
+
console.log('');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function statusColor(status) {
|
|
508
|
+
switch (status) {
|
|
509
|
+
case 'working': return c.green;
|
|
510
|
+
case 'thinking': return c.yellow;
|
|
511
|
+
case 'idle': return c.gray;
|
|
512
|
+
case 'stale': return c.dim;
|
|
513
|
+
default: return c.cyan;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function showPreview() {
|
|
518
|
+
let state = readState();
|
|
519
|
+
const aggregate = readAggregate();
|
|
520
|
+
const config = readJson(CONFIG_PATH, {});
|
|
521
|
+
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
522
|
+
state.liveSessions = live;
|
|
523
|
+
state = applyIdle(state, config);
|
|
524
|
+
const vars = buildVars(state, config, aggregate);
|
|
525
|
+
const p = config.presence || {};
|
|
526
|
+
const frames = (Array.isArray(p.rotation) ? p.rotation : [{ details: p.details, state: p.state }]);
|
|
527
|
+
|
|
528
|
+
console.log('');
|
|
529
|
+
console.log(` ${c.bold}${c.magenta}◆ Presence preview${c.reset} ${c.dim}— how Discord renders each rotation frame${c.reset}`);
|
|
530
|
+
console.log('');
|
|
531
|
+
|
|
532
|
+
const largeText = p.largeImageText ? fillTemplate(p.largeImageText, vars) : '';
|
|
533
|
+
const smallText = p.smallImageText ? fillTemplate(p.smallImageText, vars) : '';
|
|
534
|
+
console.log(` ${c.dim}large image:${c.reset} ${c.cyan}${p.largeImageKey || '—'}${c.reset} ${c.dim}· tooltip:${c.reset} ${largeText}`);
|
|
535
|
+
const smallKey = fillTemplate(p.smallImageKey || '{statusIcon}', vars);
|
|
536
|
+
const smallHidden = !smallKey || smallKey === 'stale' || smallKey.startsWith('{');
|
|
537
|
+
console.log(` ${c.dim}small image:${c.reset} ${smallHidden ? c.dim + '(hidden)' + c.reset : c.cyan + smallKey + c.reset} ${c.dim}· tooltip:${c.reset} ${smallText}`);
|
|
538
|
+
console.log('');
|
|
539
|
+
|
|
540
|
+
frames.forEach((frame, i) => {
|
|
541
|
+
const passes = framePasses(frame, vars);
|
|
542
|
+
const reqs = frame.requires ? (Array.isArray(frame.requires) ? frame.requires : [frame.requires]) : [];
|
|
543
|
+
const tag = passes
|
|
544
|
+
? `${c.green}● live${c.reset}`
|
|
545
|
+
: `${c.dim}○ skipped (requires ${reqs.join(', ')})${c.reset}`;
|
|
546
|
+
const details = fillTemplate(frame.details || '', vars);
|
|
547
|
+
const stateLine = fillTemplate(frame.state || '', vars);
|
|
548
|
+
console.log(` ${c.bold}${String(i + 1).padStart(2)}.${c.reset} ${tag}`);
|
|
549
|
+
console.log(` ${passes ? c.cyan : c.dim}${details || '—'}${c.reset}`);
|
|
550
|
+
console.log(` ${passes ? '' : c.dim}${stateLine || '—'}${c.reset}`);
|
|
551
|
+
console.log('');
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Emit the autocomplete payload the dashboard needs as JSON, without the
|
|
556
|
+
// dashboard having to inline-eval ESM source. Output shape matches the
|
|
557
|
+
// previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
|
|
558
|
+
function dumpVars() {
|
|
559
|
+
let state = readState();
|
|
560
|
+
const config = readJson(CONFIG_PATH, {});
|
|
561
|
+
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
562
|
+
state = applyIdle(state, config);
|
|
563
|
+
const live = buildVars(state, config, readAggregate() || {});
|
|
564
|
+
process.stdout.write(JSON.stringify({ vars: Object.keys(live).sort(), live }));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function doScan(force = false) {
|
|
568
|
+
console.log(`${c.dim}Scanning ~/.claude/projects${c.reset}`, force ? '(force re-parse)' : '(incremental)…');
|
|
569
|
+
const t0 = Date.now();
|
|
570
|
+
let lastReport = 0;
|
|
571
|
+
const result = scan({
|
|
572
|
+
force,
|
|
573
|
+
onProgress: ({ scanned, total }) => {
|
|
574
|
+
if (Date.now() - lastReport > 500) {
|
|
575
|
+
process.stdout.write(`\r parsed ${scanned}/${total}…`);
|
|
576
|
+
lastReport = Date.now();
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
process.stdout.write('\n');
|
|
581
|
+
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)`);
|
|
582
|
+
console.log(`${c.dim}Aggregate written to ${AGGREGATE_PATH}${c.reset}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function showInsights() {
|
|
586
|
+
const aggregate = readAggregate();
|
|
587
|
+
const insights = generateInsights(aggregate, { limit: 6 });
|
|
588
|
+
console.log('');
|
|
589
|
+
console.log(` ${c.bold}${c.magenta}◆ Insights${c.reset}`);
|
|
590
|
+
console.log('');
|
|
591
|
+
for (const line of insights) console.log(` ${c.dim}→${c.reset} ${line}`);
|
|
592
|
+
console.log('');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function parseBadgeArgs(argv) {
|
|
596
|
+
const out = { metric: 'hours', range: '7d', out: '' };
|
|
597
|
+
for (let i = 0; i < argv.length; i++) {
|
|
598
|
+
const a = argv[i];
|
|
599
|
+
if (a === '--metric' || a === '-m') out.metric = argv[++i];
|
|
600
|
+
else if (a === '--range' || a === '-r') out.range = argv[++i];
|
|
601
|
+
else if (a === '--out' || a === '-o') out.out = argv[++i];
|
|
602
|
+
else if (a === '--label' || a === '-l') out.label = argv[++i];
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function doBadge(argv) {
|
|
608
|
+
const opts = parseBadgeArgs(argv);
|
|
609
|
+
const aggregate = readAggregate();
|
|
610
|
+
if (!aggregate) {
|
|
611
|
+
console.error(`${c.yellow}No aggregate yet. Run ${c.cyan}claude-rpc scan${c.reset} first.`);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
const svg = badgeSvg({ aggregate, metric: opts.metric, range: opts.range, label: opts.label });
|
|
615
|
+
if (opts.out) {
|
|
616
|
+
writeFileSync(opts.out, svg);
|
|
617
|
+
console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${opts.out}${c.reset} (${svg.length} bytes)`);
|
|
618
|
+
} else {
|
|
619
|
+
process.stdout.write(svg);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function tailLog() {
|
|
624
|
+
if (!existsSync(LOG_PATH)) {
|
|
625
|
+
console.log(`${c.yellow}No log yet at ${LOG_PATH}${c.reset}`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// Print the last ~30 lines, then follow.
|
|
629
|
+
const raw = readFileSync(LOG_PATH, 'utf8').split('\n');
|
|
630
|
+
const tail = raw.slice(-31, -1);
|
|
631
|
+
for (const line of tail) process.stdout.write(line + '\n');
|
|
632
|
+
let lastSize = readFileSync(LOG_PATH).length;
|
|
633
|
+
console.log(`${c.dim}-- tailing ${LOG_PATH} (Ctrl-C to stop) --${c.reset}`);
|
|
634
|
+
watchFile(LOG_PATH, { interval: 500 }, () => {
|
|
635
|
+
try {
|
|
636
|
+
const buf = readFileSync(LOG_PATH);
|
|
637
|
+
if (buf.length > lastSize) {
|
|
638
|
+
process.stdout.write(buf.slice(lastSize).toString('utf8'));
|
|
639
|
+
lastSize = buf.length;
|
|
640
|
+
} else if (buf.length < lastSize) {
|
|
641
|
+
// file rotated
|
|
642
|
+
lastSize = buf.length;
|
|
643
|
+
}
|
|
644
|
+
} catch {}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function help() {
|
|
649
|
+
const cmds = [
|
|
650
|
+
['setup', 'Install Claude Code hooks (~/.claude/settings.json)'],
|
|
651
|
+
['uninstall', 'Remove Claude Code hooks'],
|
|
652
|
+
['start', 'Start the Discord RPC daemon (detached)'],
|
|
653
|
+
['stop', 'Stop the daemon'],
|
|
654
|
+
['restart', 'Stop then start the daemon'],
|
|
655
|
+
['status', 'Print current session + all-time stats'],
|
|
656
|
+
['today', 'Focus view: today\'s stats + 24h activity histogram'],
|
|
657
|
+
['week', 'Focus view: this week, daily breakdown'],
|
|
658
|
+
['serve', 'Open a live web dashboard in your browser'],
|
|
659
|
+
['preview', 'Show how each rotation frame renders right now'],
|
|
660
|
+
['scan', 'Incrementally scan ~/.claude/projects for all-time totals'],
|
|
661
|
+
['rescan', 'Force re-parse every transcript (ignores cache)'],
|
|
662
|
+
['insights', 'Auto-generated insights from your history'],
|
|
663
|
+
['badge', 'Render a Shields-style SVG (--metric --range --out)'],
|
|
664
|
+
['tail', 'Tail the daemon log file'],
|
|
665
|
+
['daemon', 'Run daemon in foreground (debug)'],
|
|
666
|
+
];
|
|
667
|
+
console.log('');
|
|
668
|
+
console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}— Discord Rich Presence for Claude Code${c.reset}`);
|
|
669
|
+
console.log('');
|
|
670
|
+
console.log(` ${c.dim}Commands:${c.reset}`);
|
|
671
|
+
for (const [name, desc] of cmds) {
|
|
672
|
+
console.log(` ${c.cyan}${name.padEnd(10)}${c.reset} ${desc}`);
|
|
673
|
+
}
|
|
674
|
+
console.log('');
|
|
675
|
+
console.log(` ${c.dim}First-time setup:${c.reset}`);
|
|
676
|
+
console.log(` 1. Set ${c.cyan}clientId${c.reset} in ${c.cyan}config.json${c.reset} to your Discord app id.`);
|
|
677
|
+
console.log(` 2. (Optional) Upload art under Rich Presence → Art Assets: ${c.cyan}claude${c.reset}, ${c.cyan}working${c.reset}, ${c.cyan}idle${c.reset}, ${c.cyan}thinking${c.reset}.`);
|
|
678
|
+
console.log(` 3. ${c.cyan}npm install${c.reset} && ${c.cyan}claude-rpc setup${c.reset} && ${c.cyan}claude-rpc start${c.reset}.`);
|
|
679
|
+
console.log('');
|
|
680
|
+
console.log(` ${c.dim}Tip: ${c.reset}edit ${c.cyan}config.json${c.reset} to customize rotation frames. Run ${c.cyan}claude-rpc preview${c.reset} to see the result without Discord.`);
|
|
681
|
+
console.log('');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Packaged exe: `claude-rpc.exe` with no args → first-run install + start.
|
|
685
|
+
// `claude-rpc.exe hook PreToolUse` → handle hook.
|
|
686
|
+
// Dev mode keeps the original `help` fallback so behavior is unchanged.
|
|
687
|
+
const packagedDefault = IS_PACKAGED && !cmd;
|
|
688
|
+
|
|
689
|
+
// Wrapped in an async IIFE so the same source compiles cleanly under both
|
|
690
|
+
// ESM (dev) and CommonJS (esbuild → SEA bundle) — CJS doesn't allow
|
|
691
|
+
// top-level await.
|
|
692
|
+
(async () => {
|
|
693
|
+
switch (cmd) {
|
|
694
|
+
case 'setup': await runInstall({ exePath: EXE_PATH || process.execPath, withStartup: false }); break;
|
|
695
|
+
case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
|
|
696
|
+
case 'uninstall': await runUninstall(); break;
|
|
697
|
+
case 'start': startDaemon(); break;
|
|
698
|
+
case 'stop': stopDaemon(); break;
|
|
699
|
+
case 'restart': restartDaemon(); break;
|
|
700
|
+
case 'status': {
|
|
701
|
+
const dumpFlag = process.argv.slice(3).some((a) => a === '--dump' || a === '-d');
|
|
702
|
+
if (dumpFlag || !process.stdout.isTTY) showStatus();
|
|
703
|
+
else startTui();
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case 'dump': showStatus(); break;
|
|
707
|
+
case 'today': showToday(); break;
|
|
708
|
+
case 'week': showWeek(); break;
|
|
709
|
+
case 'serve': await import('./server.js'); break;
|
|
710
|
+
case 'preview': showPreview(); break;
|
|
711
|
+
case 'vars': dumpVars(); break;
|
|
712
|
+
case 'scan': doScan(false); break;
|
|
713
|
+
case 'rescan': doScan(true); break;
|
|
714
|
+
case 'insights': showInsights(); break;
|
|
715
|
+
case 'badge': doBadge(process.argv.slice(3)); break;
|
|
716
|
+
case 'tail':
|
|
717
|
+
case 'logs':
|
|
718
|
+
case 'log': tailLog(); break;
|
|
719
|
+
case 'hook': runHookCli(process.argv[3] || 'unknown'); break;
|
|
720
|
+
case 'daemon': await import('./daemon.js'); break;
|
|
721
|
+
default: {
|
|
722
|
+
if (packagedDefault) {
|
|
723
|
+
if (!isInstalled()) {
|
|
724
|
+
await runInstall({ exePath: EXE_PATH || process.execPath });
|
|
725
|
+
startDaemon();
|
|
726
|
+
} else {
|
|
727
|
+
// Self-heal an existing install. Two real failure modes this fixes:
|
|
728
|
+
//
|
|
729
|
+
// 1. Hook entries in ~/.claude/settings.json can still point at
|
|
730
|
+
// the user's original launch directory (e.g. ~/Downloads).
|
|
731
|
+
// If that exe is gone or out-of-date, SessionEnd never reaches
|
|
732
|
+
// the *current* daemon — close detection silently breaks.
|
|
733
|
+
//
|
|
734
|
+
// 2. state.json can be pinned at status='idle' from a long-ago
|
|
735
|
+
// session. applyIdle treats idle as the resting state and
|
|
736
|
+
// won't transition out of it until the next hook fires; with
|
|
737
|
+
// hooks broken (see #1) the daemon happily renders idle-with-
|
|
738
|
+
// real-aggregate-data forever.
|
|
739
|
+
//
|
|
740
|
+
// Refresh hooks against the canonical exe, migrate config blocks,
|
|
741
|
+
// wipe state, restart daemon. Anything the user has customized in
|
|
742
|
+
// config.json is preserved (migrateConfig is non-destructive).
|
|
743
|
+
console.log('Claude RPC is installed. Refreshing…');
|
|
744
|
+
try {
|
|
745
|
+
const target = ensureCanonicalExe(process.execPath);
|
|
746
|
+
migrateConfig();
|
|
747
|
+
installHooks(target);
|
|
748
|
+
} catch (e) {
|
|
749
|
+
console.warn(`refresh skipped: ${e.message}`);
|
|
750
|
+
}
|
|
751
|
+
const wasRunning = stopDaemon({ quiet: true });
|
|
752
|
+
try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch {}
|
|
753
|
+
if (wasRunning) {
|
|
754
|
+
// Brief wait for the OS to release the pid file before we spawn.
|
|
755
|
+
setTimeout(() => startDaemon(), 700);
|
|
756
|
+
} else {
|
|
757
|
+
startDaemon();
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
help();
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
})();
|