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/git.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Cheap, cached lookups into a project's .git directory.
|
|
2
|
+
//
|
|
3
|
+
// Everything here reads `.git/config` or `.git/HEAD` directly — no shell-out,
|
|
4
|
+
// no spawn, no recursion up parent dirs. The daemon calls these on every
|
|
5
|
+
// presence push, so each result is cached per-cwd with a short TTL.
|
|
6
|
+
//
|
|
7
|
+
// The detached-HEAD case (HEAD contains a raw SHA, not `ref: refs/heads/...`)
|
|
8
|
+
// returns an empty branch — template `requires` will hide the branch frame.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
const cache = new Map(); // cwd → { ts, github, branch, repo }
|
|
15
|
+
|
|
16
|
+
function fresh(entry) {
|
|
17
|
+
return entry && (Date.now() - entry.ts) < TTL_MS;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readGitInfo(cwd) {
|
|
21
|
+
const out = { github: null, branch: '', repo: '' };
|
|
22
|
+
if (!cwd) return out;
|
|
23
|
+
|
|
24
|
+
// Repo name fallback is always the cwd basename — overwritten below if we
|
|
25
|
+
// find a github origin.
|
|
26
|
+
out.repo = basename(cwd) || '';
|
|
27
|
+
|
|
28
|
+
const gitDir = join(cwd, '.git');
|
|
29
|
+
if (!existsSync(gitDir)) return out;
|
|
30
|
+
|
|
31
|
+
// origin URL → github URL + repo name.
|
|
32
|
+
try {
|
|
33
|
+
const cfg = readFileSync(join(gitDir, 'config'), 'utf8');
|
|
34
|
+
const m = cfg.match(/\[remote\s+"origin"\][^\[]*?url\s*=\s*([^\r\n]+)/i);
|
|
35
|
+
if (m) {
|
|
36
|
+
const raw = m[1].trim();
|
|
37
|
+
const ssh = raw.match(/^git@github\.com:([^\s]+?)(?:\.git)?$/i);
|
|
38
|
+
if (ssh) {
|
|
39
|
+
out.github = `https://github.com/${ssh[1]}`;
|
|
40
|
+
out.repo = basename(ssh[1]);
|
|
41
|
+
} else if (/^https?:\/\/github\.com\//i.test(raw)) {
|
|
42
|
+
out.github = raw.replace(/\.git$/i, '');
|
|
43
|
+
out.repo = basename(out.github);
|
|
44
|
+
} else {
|
|
45
|
+
// Non-github remote — still pull the repo name out of the URL.
|
|
46
|
+
const tail = raw.replace(/\.git$/i, '').replace(/[\\/]+$/, '');
|
|
47
|
+
const leaf = tail.split(/[\\/:]/).filter(Boolean).pop();
|
|
48
|
+
if (leaf) out.repo = leaf;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
|
|
53
|
+
// HEAD → branch (or empty when detached).
|
|
54
|
+
try {
|
|
55
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
56
|
+
const ref = head.match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
57
|
+
if (ref) out.branch = ref[1].trim();
|
|
58
|
+
} catch {}
|
|
59
|
+
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function lookup(cwd) {
|
|
64
|
+
if (!cwd) return { github: null, branch: '', repo: '' };
|
|
65
|
+
const cached = cache.get(cwd);
|
|
66
|
+
if (fresh(cached)) return cached;
|
|
67
|
+
const info = readGitInfo(cwd);
|
|
68
|
+
cache.set(cwd, { ts: Date.now(), ...info });
|
|
69
|
+
return info;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function detectGithubUrl(cwd) { return lookup(cwd).github; }
|
|
73
|
+
export function detectGitBranch(cwd) { return lookup(cwd).branch; }
|
|
74
|
+
export function detectGitRepo(cwd) { return lookup(cwd).repo; }
|
package/src/hook.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { updateState, resetState, pushUnique, shortFile } from './state.js';
|
|
5
|
+
import { EVENTS_LOG_PATH } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const EVENTS_LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function appendEvent(entry) {
|
|
10
|
+
try {
|
|
11
|
+
mkdirSync(dirname(EVENTS_LOG_PATH), { recursive: true });
|
|
12
|
+
if (existsSync(EVENTS_LOG_PATH)) {
|
|
13
|
+
const st = statSync(EVENTS_LOG_PATH);
|
|
14
|
+
if (st.size > EVENTS_LOG_ROTATE_BYTES) {
|
|
15
|
+
renameSync(EVENTS_LOG_PATH, EVENTS_LOG_PATH + '.1');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
appendFileSync(EVENTS_LOG_PATH, JSON.stringify(entry) + '\n');
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readStdin() {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(0, 'utf8');
|
|
25
|
+
} catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseInput() {
|
|
31
|
+
const raw = readStdin();
|
|
32
|
+
if (!raw.trim()) return {};
|
|
33
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle a single hook event. Exported so the bundled exe can dispatch
|
|
37
|
+
// directly (avoids spawning a child process per hook).
|
|
38
|
+
export function processHookEvent(event, input = {}) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
|
|
41
|
+
function setActivity(patch) {
|
|
42
|
+
updateState((s) => {
|
|
43
|
+
Object.assign(s, patch);
|
|
44
|
+
s.lastActivity = now;
|
|
45
|
+
// Any hook firing means Claude Code is alive — clear the closed flag
|
|
46
|
+
// in case a prior SessionEnd from a sibling session set it.
|
|
47
|
+
s.claudeClosed = false;
|
|
48
|
+
if (!s.sessionStart) s.sessionStart = now;
|
|
49
|
+
return s;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
switch (event) {
|
|
54
|
+
case 'SessionStart': {
|
|
55
|
+
resetState({
|
|
56
|
+
cwd: input.cwd || process.cwd(),
|
|
57
|
+
model: input.model?.id || input.model || 'claude',
|
|
58
|
+
status: 'idle',
|
|
59
|
+
});
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'UserPromptSubmit': {
|
|
63
|
+
updateState((s) => {
|
|
64
|
+
s.messages += 1;
|
|
65
|
+
s.lastUserPrompt = now;
|
|
66
|
+
s.lastActivity = now;
|
|
67
|
+
s.status = 'thinking';
|
|
68
|
+
s.claudeClosed = false;
|
|
69
|
+
if (!s.sessionStart) s.sessionStart = now;
|
|
70
|
+
if (input.cwd) s.cwd = input.cwd;
|
|
71
|
+
return s;
|
|
72
|
+
});
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'PreToolUse': {
|
|
76
|
+
const toolName = input.tool_name || input.toolName || 'tool';
|
|
77
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
78
|
+
const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
|
|
79
|
+
updateState((s) => {
|
|
80
|
+
s.tools += 1;
|
|
81
|
+
s.toolBreakdown[toolName] = (s.toolBreakdown[toolName] || 0) + 1;
|
|
82
|
+
s.currentTool = toolName;
|
|
83
|
+
s.currentFile = shortFile(file);
|
|
84
|
+
s.status = 'working';
|
|
85
|
+
s.lastActivity = now;
|
|
86
|
+
s.claudeClosed = false;
|
|
87
|
+
if (!s.sessionStart) s.sessionStart = now;
|
|
88
|
+
if (file && (toolName === 'Read' || toolName === 'NotebookEdit')) {
|
|
89
|
+
s.filesOpened = pushUnique(s.filesOpened, file);
|
|
90
|
+
if (toolName === 'Read') s.filesRead = pushUnique(s.filesRead, file);
|
|
91
|
+
}
|
|
92
|
+
return s;
|
|
93
|
+
});
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case 'PostToolUse': {
|
|
97
|
+
const toolName = input.tool_name || input.toolName || '';
|
|
98
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
99
|
+
const file = toolInput.file_path || toolInput.path || null;
|
|
100
|
+
updateState((s) => {
|
|
101
|
+
s.currentTool = null;
|
|
102
|
+
s.lastActivity = now;
|
|
103
|
+
s.claudeClosed = false;
|
|
104
|
+
if (!s.sessionStart) s.sessionStart = now;
|
|
105
|
+
if (file && (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit')) {
|
|
106
|
+
s.filesEdited = pushUnique(s.filesEdited, file);
|
|
107
|
+
s.filesOpened = pushUnique(s.filesOpened, file);
|
|
108
|
+
}
|
|
109
|
+
const usage = input.tool_response?.usage || input.usage;
|
|
110
|
+
if (usage) {
|
|
111
|
+
s.tokens.input += usage.input_tokens || 0;
|
|
112
|
+
s.tokens.output += usage.output_tokens || 0;
|
|
113
|
+
s.tokens.cacheRead += usage.cache_read_input_tokens || 0;
|
|
114
|
+
s.tokens.cacheWrite += usage.cache_creation_input_tokens || 0;
|
|
115
|
+
}
|
|
116
|
+
return s;
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'Notification': {
|
|
121
|
+
updateState((s) => {
|
|
122
|
+
s.status = 'notification';
|
|
123
|
+
s.lastNotification = now;
|
|
124
|
+
s.lastActivity = now;
|
|
125
|
+
s.claudeClosed = false;
|
|
126
|
+
s.currentTool = null;
|
|
127
|
+
s.currentFile = null;
|
|
128
|
+
if (!s.sessionStart) s.sessionStart = now;
|
|
129
|
+
return s;
|
|
130
|
+
});
|
|
131
|
+
appendEvent({ type: 'notification', ts: now, cwd: input.cwd || null });
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'SessionEnd': {
|
|
135
|
+
// Authoritative "Claude Code is gone" signal — don't wait on the
|
|
136
|
+
// staleSessionMin timeout. applyIdle short-circuits to stale when it
|
|
137
|
+
// sees claudeClosed=true. Any subsequent hook from another live
|
|
138
|
+
// session will flip the flag back to false.
|
|
139
|
+
updateState((s) => {
|
|
140
|
+
s.status = 'stale';
|
|
141
|
+
s.claudeClosed = true;
|
|
142
|
+
s.currentTool = null;
|
|
143
|
+
s.currentFile = null;
|
|
144
|
+
s.lastActivity = now;
|
|
145
|
+
return s;
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'Stop':
|
|
150
|
+
case 'SubagentStop':
|
|
151
|
+
default: {
|
|
152
|
+
setActivity({ status: 'idle', currentTool: null, currentFile: null });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Stdin-driven CLI form: read JSON event payload from stdin, dispatch, ack.
|
|
158
|
+
export function runHookCli(event) {
|
|
159
|
+
processHookEvent(event, parseInput());
|
|
160
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Run directly when invoked as `node src/hook.js <event>`. Detection is based
|
|
164
|
+
// on whether argv[1] ends in this filename — when imported (e.g. by cli.js),
|
|
165
|
+
// argv[1] won't match.
|
|
166
|
+
const argv1 = (process.argv[1] || '').replace(/\\/g, '/').toLowerCase();
|
|
167
|
+
if (argv1.endsWith('/src/hook.js') || argv1.endsWith('/hook.js')) {
|
|
168
|
+
runHookCli(process.argv[2] || 'unknown');
|
|
169
|
+
}
|
package/src/insights.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Generate 3–5 short, contextual insight lines from an aggregate.json snapshot.
|
|
2
|
+
// Used by `claude-rpc insights`, the web `/api/insights` route, and the TUI.
|
|
3
|
+
// Pure functions — no I/O, no globals beyond Date.
|
|
4
|
+
|
|
5
|
+
import { dayKey, weekKey } from './scanner.js';
|
|
6
|
+
import { fmtCost } from './pricing.js';
|
|
7
|
+
|
|
8
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
9
|
+
|
|
10
|
+
function fmtHours(ms) {
|
|
11
|
+
if (!ms || ms < 0) return '0h';
|
|
12
|
+
const hours = ms / 3_600_000;
|
|
13
|
+
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
|
14
|
+
if (hours < 10) return `${hours.toFixed(1)}h`;
|
|
15
|
+
return `${Math.round(hours)}h`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtNum(n) {
|
|
19
|
+
if (!n) return '0';
|
|
20
|
+
if (n < 1000) return `${n}`;
|
|
21
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
22
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pct(n) {
|
|
26
|
+
const sign = n >= 0 ? '+' : '−';
|
|
27
|
+
return `${sign}${Math.round(Math.abs(n) * 100)}%`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Sum activeMs across the last `days` days (inclusive of today).
|
|
31
|
+
function windowActive(byDay, days, offset = 0) {
|
|
32
|
+
let total = 0;
|
|
33
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
34
|
+
for (let i = offset; i < offset + days; i++) {
|
|
35
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
36
|
+
total += (byDay?.[dayKey(d.getTime())] || {}).activeMs || 0;
|
|
37
|
+
}
|
|
38
|
+
return total;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function generateInsights(aggregate, opts = {}) {
|
|
42
|
+
const a = aggregate || {};
|
|
43
|
+
const out = [];
|
|
44
|
+
|
|
45
|
+
if (!a.byDay || !Object.keys(a.byDay).length) {
|
|
46
|
+
return ['Not enough data yet — keep coding and check back tomorrow.'];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 1. Trend: last 7 days vs the 7 before it.
|
|
50
|
+
const last7 = windowActive(a.byDay, 7, 0);
|
|
51
|
+
const prev7 = windowActive(a.byDay, 7, 7);
|
|
52
|
+
if (last7 || prev7) {
|
|
53
|
+
if (prev7 > 0) {
|
|
54
|
+
const delta = (last7 - prev7) / prev7;
|
|
55
|
+
if (Math.abs(delta) >= 0.10) {
|
|
56
|
+
const dir = delta > 0 ? 'above' : 'below';
|
|
57
|
+
out.push(`You're ${pct(delta)} ${dir} last week — ${fmtHours(last7)} vs ${fmtHours(prev7)}.`);
|
|
58
|
+
} else {
|
|
59
|
+
out.push(`Steady week — ${fmtHours(last7)} active, on par with the previous 7 days.`);
|
|
60
|
+
}
|
|
61
|
+
} else if (last7 > 0) {
|
|
62
|
+
out.push(`Fresh momentum — ${fmtHours(last7)} active this past week.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Peak weekday.
|
|
67
|
+
if (a.byWeekday && Object.keys(a.byWeekday).length) {
|
|
68
|
+
let best = null;
|
|
69
|
+
for (const [wd, data] of Object.entries(a.byWeekday)) {
|
|
70
|
+
if (!best || data.activeMs > best.ms) best = { wd: Number(wd), ms: data.activeMs };
|
|
71
|
+
}
|
|
72
|
+
if (best && best.ms > 0) {
|
|
73
|
+
out.push(`Peak weekday is ${WEEKDAYS[best.wd]} — ${fmtHours(best.ms)} all-time.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Cost pace (month-to-date forecast).
|
|
78
|
+
if (a.estimatedCost && a.byDay) {
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
81
|
+
let mtd = 0;
|
|
82
|
+
for (const [k, day] of Object.entries(a.byDay)) {
|
|
83
|
+
if (k.startsWith(yearMonth)) mtd += day.cost || 0;
|
|
84
|
+
}
|
|
85
|
+
if (mtd > 0) {
|
|
86
|
+
const daysIn = now.getDate();
|
|
87
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
88
|
+
const forecast = (mtd / daysIn) * daysInMonth;
|
|
89
|
+
out.push(`Month-to-date estimate: ${fmtCost(mtd)} — pace projects ${fmtCost(forecast)} for the month.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 4. Top hotspot.
|
|
94
|
+
if (a.topEditedFiles && a.topEditedFiles.length) {
|
|
95
|
+
const top = a.topEditedFiles[0];
|
|
96
|
+
const name = top.path.split(/[\\/]/).filter(Boolean).pop();
|
|
97
|
+
out.push(`Hotspot: ${name} with ${fmtNum(top.count)} edits.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 5. Streak milestone tease.
|
|
101
|
+
if (a.streak >= 3) {
|
|
102
|
+
const targets = [7, 14, 30, 60, 100, 365];
|
|
103
|
+
const next = targets.find((t) => t > a.streak);
|
|
104
|
+
if (next) {
|
|
105
|
+
const remaining = next - a.streak;
|
|
106
|
+
out.push(`${a.streak}-day streak — ${remaining} ${remaining === 1 ? 'day' : 'days'} to ${next}.`);
|
|
107
|
+
} else {
|
|
108
|
+
out.push(`${a.streak}-day streak — beyond every milestone we track. Incredible.`);
|
|
109
|
+
}
|
|
110
|
+
} else if (a.longestStreak >= 7) {
|
|
111
|
+
out.push(`Longest streak so far: ${a.longestStreak} days. Today could start the next one.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 6. Top language / framework.
|
|
115
|
+
if (a.languages) {
|
|
116
|
+
const top = Object.entries(a.languages).sort((x, y) => y[1].edits - x[1].edits)[0];
|
|
117
|
+
if (top) {
|
|
118
|
+
out.push(`Most edits land in ${top[0]} — ${fmtNum(top[1].edits)} across ${fmtNum(top[1].files)} files.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 7. Subagent usage (where it differs interestingly from defaults).
|
|
123
|
+
if (a.subagents && Object.keys(a.subagents).length) {
|
|
124
|
+
const top = Object.entries(a.subagents).sort((x, y) => y[1] - x[1])[0];
|
|
125
|
+
if (top && top[1] >= 3) {
|
|
126
|
+
out.push(`Favorite subagent: ${top[0]} — invoked ${top[1]} times.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 8. Notification frequency (only when high).
|
|
131
|
+
if (a.notifications && a.notifications > 20) {
|
|
132
|
+
out.push(`${a.notifications} notifications — Claude has paused for you that many times.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Trim to a tidy 5 — biggest signals first (sort is already roughly priority).
|
|
136
|
+
const limit = opts.limit ?? 5;
|
|
137
|
+
return out.slice(0, limit);
|
|
138
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// One-shot installer logic invoked by the bundled exe.
|
|
2
|
+
// Seeds %APPDATA%\claude-rpc\config.json, points Claude Code's hooks at the
|
|
3
|
+
// exe, and registers a Windows startup entry so the daemon comes up on login.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
existsSync, mkdirSync, readFileSync, writeFileSync,
|
|
7
|
+
copyFileSync, chmodSync, renameSync, statSync,
|
|
8
|
+
readdirSync, unlinkSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import {
|
|
13
|
+
CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR,
|
|
14
|
+
HOOK_SCRIPT, IS_PACKAGED,
|
|
15
|
+
CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME,
|
|
16
|
+
} from './paths.js';
|
|
17
|
+
import { DEFAULT_CONFIG } from './default-config.js';
|
|
18
|
+
|
|
19
|
+
const STARTUP_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
20
|
+
const STARTUP_VALUE = 'ClaudeRPC';
|
|
21
|
+
|
|
22
|
+
const EVENTS = [
|
|
23
|
+
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
24
|
+
'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function readJson(p, fb) {
|
|
28
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
29
|
+
catch { return fb; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeJson(p, d) {
|
|
33
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
34
|
+
writeFileSync(p, JSON.stringify(d, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isOurHookCommand(cmd) {
|
|
38
|
+
if (!cmd) return false;
|
|
39
|
+
return /claude-rpc/i.test(cmd) || /hook\.js/i.test(cmd);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function installHooks(exePath) {
|
|
43
|
+
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
44
|
+
settings.hooks = settings.hooks || {};
|
|
45
|
+
// Packaged: `"<exe>" hook <event>`. Dev: `node "<src/hook.js>" <event>`.
|
|
46
|
+
const cmdFor = IS_PACKAGED
|
|
47
|
+
? (event) => `"${exePath}" hook ${event}`
|
|
48
|
+
: (event) => `node "${HOOK_SCRIPT.replace(/\\/g, '/')}" ${event}`;
|
|
49
|
+
|
|
50
|
+
for (const event of EVENTS) {
|
|
51
|
+
const bucket = settings.hooks[event] = settings.hooks[event] || [];
|
|
52
|
+
const wanted = cmdFor(event);
|
|
53
|
+
const existingEntry = bucket.find((b) =>
|
|
54
|
+
Array.isArray(b.hooks) && b.hooks.some((h) => isOurHookCommand(h.command))
|
|
55
|
+
);
|
|
56
|
+
if (existingEntry) {
|
|
57
|
+
existingEntry.hooks = existingEntry.hooks.map((h) =>
|
|
58
|
+
isOurHookCommand(h.command) ? { ...h, command: wanted } : h
|
|
59
|
+
);
|
|
60
|
+
} else {
|
|
61
|
+
bucket.push({ matcher: '', hooks: [{ type: 'command', command: wanted }] });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
writeJson(CLAUDE_SETTINGS, settings);
|
|
65
|
+
console.log(` hooks → ${CLAUDE_SETTINGS}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function uninstallHooks() {
|
|
69
|
+
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
70
|
+
if (!settings.hooks) return;
|
|
71
|
+
for (const event of EVENTS) {
|
|
72
|
+
const bucket = settings.hooks[event];
|
|
73
|
+
if (!Array.isArray(bucket)) continue;
|
|
74
|
+
settings.hooks[event] = bucket
|
|
75
|
+
.map((entry) => ({ ...entry, hooks: (entry.hooks || []).filter((h) => !isOurHookCommand(h.command)) }))
|
|
76
|
+
.filter((entry) => (entry.hooks || []).length > 0);
|
|
77
|
+
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
78
|
+
}
|
|
79
|
+
writeJson(CLAUDE_SETTINGS, settings);
|
|
80
|
+
console.log(` hooks removed from ${CLAUDE_SETTINGS}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function regCommand(args) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const proc = spawn('reg', args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
86
|
+
let err = '';
|
|
87
|
+
proc.stderr.on('data', (d) => err += d.toString());
|
|
88
|
+
proc.on('error', reject);
|
|
89
|
+
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(err || `reg.exe exit ${code}`)));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function addStartupEntry(exePath) {
|
|
94
|
+
await regCommand([
|
|
95
|
+
'add', STARTUP_KEY,
|
|
96
|
+
'/v', STARTUP_VALUE,
|
|
97
|
+
'/t', 'REG_SZ',
|
|
98
|
+
'/d', `"${exePath}" daemon`,
|
|
99
|
+
'/f',
|
|
100
|
+
]);
|
|
101
|
+
console.log(` startup → HKCU\\...\\Run\\${STARTUP_VALUE}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function removeStartupEntry() {
|
|
105
|
+
try {
|
|
106
|
+
await regCommand(['delete', STARTUP_KEY, '/v', STARTUP_VALUE, '/f']);
|
|
107
|
+
console.log(` startup entry removed`);
|
|
108
|
+
} catch {
|
|
109
|
+
// Already absent — fine.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function samePath(a, b) {
|
|
114
|
+
if (!a || !b) return false;
|
|
115
|
+
try {
|
|
116
|
+
const ra = resolve(a);
|
|
117
|
+
const rb = resolve(b);
|
|
118
|
+
return process.platform === 'win32'
|
|
119
|
+
? ra.toLowerCase() === rb.toLowerCase()
|
|
120
|
+
: ra === rb;
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Best-effort sweep of stale `.old-<ts>` siblings left behind by a prior
|
|
125
|
+
// rename-out-of-the-way during an in-place exe replacement.
|
|
126
|
+
function sweepStaleCanonicalBackups() {
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(CANONICAL_INSTALL_DIR)) return;
|
|
129
|
+
const prefix = CANONICAL_EXE_NAME + '.old-';
|
|
130
|
+
for (const name of readdirSync(CANONICAL_INSTALL_DIR)) {
|
|
131
|
+
if (name.startsWith(prefix)) {
|
|
132
|
+
try { unlinkSync(join(CANONICAL_INSTALL_DIR, name)); } catch {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Copy the running binary into CANONICAL_EXE if it's not already there.
|
|
139
|
+
// Returns the path that hook entries should point at — canonical on success,
|
|
140
|
+
// the original path as a fallback. Only meaningful in packaged mode.
|
|
141
|
+
export function ensureCanonicalExe(currentExe) {
|
|
142
|
+
if (!IS_PACKAGED) return currentExe;
|
|
143
|
+
if (samePath(currentExe, CANONICAL_EXE)) return CANONICAL_EXE;
|
|
144
|
+
mkdirSync(CANONICAL_INSTALL_DIR, { recursive: true });
|
|
145
|
+
|
|
146
|
+
// Skip the copy when canonical already exists AND matches the source —
|
|
147
|
+
// avoids a needless overwrite (and the Windows running-file gymnastics it
|
|
148
|
+
// can trigger) on repeated `setup` runs from the same launch point.
|
|
149
|
+
if (existsSync(CANONICAL_EXE)) {
|
|
150
|
+
try {
|
|
151
|
+
const src = statSync(currentExe);
|
|
152
|
+
const dst = statSync(CANONICAL_EXE);
|
|
153
|
+
if (src.size === dst.size && Math.abs(src.mtimeMs - dst.mtimeMs) < 2000) {
|
|
154
|
+
console.log(` exe already installed → ${CANONICAL_EXE}`);
|
|
155
|
+
return CANONICAL_EXE;
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Windows won't let you overwrite a currently-executing file. If
|
|
162
|
+
// canonical is the running daemon, move it aside first — that succeeds
|
|
163
|
+
// even while the file handle is open, and Windows will delete the
|
|
164
|
+
// renamed copy when the process exits.
|
|
165
|
+
if (process.platform === 'win32' && existsSync(CANONICAL_EXE)) {
|
|
166
|
+
try { renameSync(CANONICAL_EXE, CANONICAL_EXE + '.old-' + Date.now()); }
|
|
167
|
+
catch {}
|
|
168
|
+
}
|
|
169
|
+
copyFileSync(currentExe, CANONICAL_EXE);
|
|
170
|
+
if (process.platform !== 'win32') chmodSync(CANONICAL_EXE, 0o755);
|
|
171
|
+
console.log(` exe installed → ${CANONICAL_EXE}`);
|
|
172
|
+
console.log(` (the copy at ${currentExe} can be safely deleted)`);
|
|
173
|
+
sweepStaleCanonicalBackups();
|
|
174
|
+
return CANONICAL_EXE;
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.warn(` ! failed to copy exe to ${CANONICAL_EXE}: ${e.message}`);
|
|
177
|
+
console.warn(` falling back to ${currentExe} — manual updates that change`);
|
|
178
|
+
console.warn(` the exe path may require running 'setup' again.`);
|
|
179
|
+
return currentExe;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function seedConfig() {
|
|
184
|
+
if (existsSync(CONFIG_PATH)) {
|
|
185
|
+
console.log(` config exists → ${CONFIG_PATH}`);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
mkdirSync(USER_CONFIG_DIR, { recursive: true });
|
|
189
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
190
|
+
console.log(` config seeded → ${CONFIG_PATH}`);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Non-destructive merge of any new top-level keys / presence blocks the
|
|
195
|
+
// shipped DEFAULT_CONFIG has but the user's existing file doesn't.
|
|
196
|
+
//
|
|
197
|
+
// Runs every time install/setup or the packaged default launcher fires,
|
|
198
|
+
// so an upgraded exe pulls in new shape (e.g. v0.3.6's presence.byStatus)
|
|
199
|
+
// without clobbering the user's customizations. Anything the user already
|
|
200
|
+
// has — including a pre-existing byStatus, custom rotation array, custom
|
|
201
|
+
// appName etc. — is left untouched.
|
|
202
|
+
export function migrateConfig() {
|
|
203
|
+
if (!existsSync(CONFIG_PATH)) return false;
|
|
204
|
+
let cfg;
|
|
205
|
+
try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
|
|
206
|
+
catch (e) {
|
|
207
|
+
console.warn(` ! could not read config for migration: ${e.message}`);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (!cfg || typeof cfg !== 'object') return false;
|
|
211
|
+
|
|
212
|
+
const added = [];
|
|
213
|
+
|
|
214
|
+
// appName (introduced as a template var in v0.3.5).
|
|
215
|
+
if (!cfg.appName && DEFAULT_CONFIG.appName) {
|
|
216
|
+
cfg.appName = DEFAULT_CONFIG.appName;
|
|
217
|
+
added.push('appName');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// presence.byStatus (introduced in v0.3.6) — the headline upgrade.
|
|
221
|
+
// We only seed it when entirely absent. If a user has already started
|
|
222
|
+
// editing their own byStatus, we leave it alone.
|
|
223
|
+
cfg.presence = cfg.presence || {};
|
|
224
|
+
if (!cfg.presence.byStatus && DEFAULT_CONFIG.presence?.byStatus) {
|
|
225
|
+
cfg.presence.byStatus = JSON.parse(JSON.stringify(DEFAULT_CONFIG.presence.byStatus));
|
|
226
|
+
added.push('presence.byStatus');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Refresh the lifetime tooltip when the user is on the very old default
|
|
230
|
+
// ("…{daysSinceFirstLabel}") so they pick up the new streak-aware copy
|
|
231
|
+
// without us touching anything they've customized.
|
|
232
|
+
const OLD_LIT = '{modelPretty} · {allHours} on Claude · {daysSinceFirstLabel}';
|
|
233
|
+
if (cfg.presence.largeImageText === OLD_LIT && DEFAULT_CONFIG.presence?.largeImageText) {
|
|
234
|
+
cfg.presence.largeImageText = DEFAULT_CONFIG.presence.largeImageText;
|
|
235
|
+
added.push('presence.largeImageText');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (added.length === 0) {
|
|
239
|
+
console.log(` config up to date → ${CONFIG_PATH}`);
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
243
|
+
console.log(` config migrated → ${CONFIG_PATH}`);
|
|
244
|
+
console.log(` added: ${added.join(', ')}`);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function install({ exePath, withStartup = true } = {}) {
|
|
249
|
+
if (process.platform !== 'win32' && withStartup) {
|
|
250
|
+
console.warn('Note: startup registration only works on Windows; other steps still run.');
|
|
251
|
+
}
|
|
252
|
+
const incoming = exePath || process.execPath;
|
|
253
|
+
// Canonicalize first so hook + startup entries point at a stable location,
|
|
254
|
+
// not at the temp/Downloads path the user happened to launch from.
|
|
255
|
+
const target = ensureCanonicalExe(incoming);
|
|
256
|
+
console.log('Installing Claude RPC…');
|
|
257
|
+
// Order matters: seed creates the file if missing, then migrate fills in
|
|
258
|
+
// any blocks new exe versions added (e.g. presence.byStatus from v0.3.6).
|
|
259
|
+
seedConfig();
|
|
260
|
+
migrateConfig();
|
|
261
|
+
installHooks(target);
|
|
262
|
+
if (withStartup && process.platform === 'win32') {
|
|
263
|
+
try { await addStartupEntry(target); }
|
|
264
|
+
catch (e) { console.warn(` startup entry failed: ${e.message}`); }
|
|
265
|
+
}
|
|
266
|
+
console.log('\nDone.');
|
|
267
|
+
console.log(`Edit ${CONFIG_PATH} to set your Discord clientId, then either reboot or run:`);
|
|
268
|
+
console.log(` "${target}" daemon`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function uninstall() {
|
|
272
|
+
console.log('Uninstalling Claude RPC…');
|
|
273
|
+
uninstallHooks();
|
|
274
|
+
if (process.platform === 'win32') await removeStartupEntry();
|
|
275
|
+
console.log('\nDone. (Config at %APPDATA%\\claude-rpc\\ left intact — delete manually if you want.)');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function isInstalled() {
|
|
279
|
+
return IS_PACKAGED && existsSync(CONFIG_PATH);
|
|
280
|
+
}
|