claude-code-discord-presence 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +21 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/assets/claude-code.png +0 -0
- package/assets/claude-liquid-dark.png +0 -0
- package/assets/claude-liquid-light.png +0 -0
- package/assets/claude-stats-dark.png +0 -0
- package/assets/claude-stats-light.png +0 -0
- package/assets/claude-usage-stats.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/assets/usage-stats.png +0 -0
- package/dist/appearance/theme-assets.js +13 -0
- package/dist/appearance/theme-watcher.js +188 -0
- package/dist/claude/app-liveness.js +82 -0
- package/dist/claude/cost.js +54 -0
- package/dist/claude/default-selection.js +268 -0
- package/dist/claude/desktop-focus.js +191 -0
- package/dist/claude/limits.js +90 -0
- package/dist/claude/monthly-usage.js +279 -0
- package/dist/claude/oauth-discovery.js +82 -0
- package/dist/claude/paths.js +13 -0
- package/dist/claude/plan-info.js +102 -0
- package/dist/claude/session-store.js +617 -0
- package/dist/claude/tool-labels.js +55 -0
- package/dist/claude/transcript.js +150 -0
- package/dist/claude/usage-poller.js +243 -0
- package/dist/cli.js +137 -0
- package/dist/config.js +96 -0
- package/dist/discord/presence-builder.js +295 -0
- package/dist/discord/rpc-client.js +224 -0
- package/dist/index.js +160 -0
- package/dist/remote/tunnel-manager.js +83 -0
- package/dist/server/http-server.js +89 -0
- package/dist/types.js +1 -0
- package/dist/util/autostart.js +73 -0
- package/dist/util/logger.js +74 -0
- package/dist/util/process-liveness.js +231 -0
- package/dist/util/process-scan-watcher.js +196 -0
- package/package.json +73 -0
- package/pricing.json +47 -0
- package/scripts/hook.mjs +32 -0
- package/scripts/install-autostart.mjs +50 -0
- package/scripts/install-remote.mjs +53 -0
- package/scripts/remove-autostart.mjs +35 -0
- package/scripts/remove-autostart.ps1 +7 -0
- package/scripts/run-hidden.vbs +7 -0
- package/scripts/run-service.ps1 +44 -0
- package/scripts/setup-autostart.ps1 +32 -0
- package/scripts/setup-claude.mjs +132 -0
- package/scripts/setup-remote.mjs +99 -0
- package/scripts/statusline.mjs +59 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const projectDir = dirname(scriptsDir);
|
|
9
|
+
const configuredDir = process.env.CLAUDE_CONFIG_DIR?.split(",")[0]?.trim();
|
|
10
|
+
const expandHome = (path) => path === "~" ? homedir()
|
|
11
|
+
: path.startsWith("~/") || path.startsWith("~\\") ? resolve(homedir(), path.slice(2))
|
|
12
|
+
: resolve(path);
|
|
13
|
+
const claudeDir = configuredDir ? expandHome(configuredDir) : join(homedir(), ".claude");
|
|
14
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
15
|
+
const installDir = join(claudeDir, "discord-presence");
|
|
16
|
+
const backupDir = join(claudeDir, "backups", "claude-code-presence");
|
|
17
|
+
const hookTarget = join(installDir, "hook.mjs");
|
|
18
|
+
const statuslineTarget = join(installDir, "statusline.mjs");
|
|
19
|
+
const events = [
|
|
20
|
+
"SessionStart",
|
|
21
|
+
"UserPromptSubmit",
|
|
22
|
+
"PreToolUse",
|
|
23
|
+
"PostToolUse",
|
|
24
|
+
"PostToolUseFailure",
|
|
25
|
+
"Stop",
|
|
26
|
+
"SubagentStart",
|
|
27
|
+
"SubagentStop",
|
|
28
|
+
"Notification",
|
|
29
|
+
"SessionEnd",
|
|
30
|
+
];
|
|
31
|
+
const matcherEvents = new Set(["PreToolUse", "PostToolUse", "PostToolUseFailure"]);
|
|
32
|
+
|
|
33
|
+
function readPort() {
|
|
34
|
+
const fromEnvironment = Number(process.env.PORT);
|
|
35
|
+
if (Number.isInteger(fromEnvironment) && fromEnvironment > 0 && fromEnvironment <= 65_535) {
|
|
36
|
+
return fromEnvironment;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const match = readFileSync(join(projectDir, ".env"), "utf8").match(/^PORT=(\d+)\s*$/m);
|
|
40
|
+
const port = Number(match?.[1] || 41724);
|
|
41
|
+
if (Number.isInteger(port) && port > 0 && port <= 65_535) return port;
|
|
42
|
+
} catch {}
|
|
43
|
+
return 41724;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shellCommand(executable, script) {
|
|
47
|
+
const quote = (value) => `"${value.replaceAll("\\", "/").replaceAll('"', '\\"')}"`;
|
|
48
|
+
return `${quote(executable)} ${quote(script)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isPresenceHook(hook, port) {
|
|
52
|
+
if (!hook || typeof hook !== "object") return false;
|
|
53
|
+
if (typeof hook.command === "string") {
|
|
54
|
+
return /discord-rpc-hook|discord-presence[\\/]hook\.mjs/i.test(hook.command);
|
|
55
|
+
}
|
|
56
|
+
if (typeof hook.url !== "string") return false;
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(hook.url);
|
|
59
|
+
return ["127.0.0.1", "localhost"].includes(url.hostname) &&
|
|
60
|
+
url.pathname === "/hook" && [String(port), "41724"].includes(url.port || "80");
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function retainLatestBackups(limit) {
|
|
67
|
+
const entries = await readdir(backupDir).catch(() => []);
|
|
68
|
+
const candidates = [];
|
|
69
|
+
for (const name of entries) {
|
|
70
|
+
if (!/^settings-\d+\.json$/.test(name)) continue;
|
|
71
|
+
const path = join(backupDir, name);
|
|
72
|
+
candidates.push({ path, modifiedAt: (await stat(path)).mtimeMs });
|
|
73
|
+
}
|
|
74
|
+
candidates.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
75
|
+
await Promise.all(candidates.slice(limit).map(({ path }) => rm(path, { force: true })));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const port = readPort();
|
|
79
|
+
await mkdir(installDir, { recursive: true });
|
|
80
|
+
await mkdir(backupDir, { recursive: true });
|
|
81
|
+
await copyFile(join(scriptsDir, "hook.mjs"), hookTarget);
|
|
82
|
+
await copyFile(join(scriptsDir, "statusline.mjs"), statuslineTarget);
|
|
83
|
+
await writeFile(
|
|
84
|
+
join(installDir, "config.json"),
|
|
85
|
+
`${JSON.stringify({ port, remote: false, installerVersion: 1 }, null, 2)}\n`,
|
|
86
|
+
{ mode: 0o600 },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
let settings = {};
|
|
90
|
+
if (existsSync(settingsPath)) {
|
|
91
|
+
const raw = await readFile(settingsPath, "utf8");
|
|
92
|
+
const backup = join(backupDir, `settings-${Date.now()}.json`);
|
|
93
|
+
await writeFile(backup, raw, { mode: 0o600 });
|
|
94
|
+
try {
|
|
95
|
+
settings = JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error(`Existing settings are not valid JSON. The original was backed up to ${backup}.`);
|
|
98
|
+
}
|
|
99
|
+
await retainLatestBackups(5);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hookCommand = shellCommand(process.execPath, hookTarget);
|
|
103
|
+
const statuslineCommand = shellCommand(process.execPath, statuslineTarget);
|
|
104
|
+
const existingStatusline = settings.statusLine?.command;
|
|
105
|
+
if (!existingStatusline || /discord-rpc-statusline|discord-presence[\\/]statusline\.mjs/i.test(existingStatusline)) {
|
|
106
|
+
settings.statusLine = { type: "command", command: statuslineCommand };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
|
|
110
|
+
settings.hooks = {};
|
|
111
|
+
}
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
114
|
+
const cleaned = [];
|
|
115
|
+
for (const group of groups) {
|
|
116
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
117
|
+
cleaned.push(group);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const hooks = group.hooks.filter((hook) => !isPresenceHook(hook, port));
|
|
121
|
+
if (hooks.length > 0) cleaned.push({ ...group, hooks });
|
|
122
|
+
}
|
|
123
|
+
const presence = {
|
|
124
|
+
...(matcherEvents.has(event) ? { matcher: "*" } : {}),
|
|
125
|
+
hooks: [{ type: "command", command: hookCommand, async: true, timeout: 5 }],
|
|
126
|
+
};
|
|
127
|
+
settings.hooks[event] = [...cleaned, presence];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
131
|
+
console.log(`Installed Claude Code hooks and statusline in ${installDir}`);
|
|
132
|
+
console.log(`Updated ${settingsPath}; restart active Claude Code sessions to load the changes.`);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const port = Number(process.argv[2]);
|
|
7
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Invalid remote port");
|
|
8
|
+
const hookSource = Buffer.from(process.argv[3] || "", "base64");
|
|
9
|
+
const statuslineSource = Buffer.from(process.argv[4] || "", "base64");
|
|
10
|
+
if (hookSource.length === 0 || statuslineSource.length === 0) throw new Error("Missing hook scripts");
|
|
11
|
+
|
|
12
|
+
const configuredDir = process.env.CLAUDE_CONFIG_DIR?.split(",")[0]?.trim();
|
|
13
|
+
const expandHome = (path) => path === "~" ? homedir()
|
|
14
|
+
: path.startsWith("~/") || path.startsWith("~\\") ? resolve(homedir(), path.slice(2))
|
|
15
|
+
: resolve(path);
|
|
16
|
+
const claudeDir = configuredDir ? expandHome(configuredDir) : join(homedir(), ".claude");
|
|
17
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
18
|
+
const installDir = join(claudeDir, "discord-presence");
|
|
19
|
+
const backupDir = join(claudeDir, "backups", "claude-code-presence");
|
|
20
|
+
const hookTarget = join(installDir, "hook.mjs");
|
|
21
|
+
const statuslineTarget = join(installDir, "statusline.mjs");
|
|
22
|
+
const events = [
|
|
23
|
+
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure",
|
|
24
|
+
"Stop", "SubagentStart", "SubagentStop", "Notification", "SessionEnd",
|
|
25
|
+
];
|
|
26
|
+
const matcherEvents = new Set(["PreToolUse", "PostToolUse", "PostToolUseFailure"]);
|
|
27
|
+
const quote = (value) => `"${value.replaceAll("\\", "/").replaceAll('"', '\\"')}"`;
|
|
28
|
+
const hookCommand = `${quote(process.execPath)} ${quote(hookTarget)}`;
|
|
29
|
+
const statuslineCommand = `${quote(process.execPath)} ${quote(statuslineTarget)}`;
|
|
30
|
+
|
|
31
|
+
function isPresenceHook(hook) {
|
|
32
|
+
if (!hook || typeof hook !== "object") return false;
|
|
33
|
+
if (typeof hook.command === "string") {
|
|
34
|
+
return /discord-rpc-hook|discord-presence[\\/]hook\.mjs/i.test(hook.command);
|
|
35
|
+
}
|
|
36
|
+
if (typeof hook.url !== "string") return false;
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(hook.url);
|
|
39
|
+
return ["127.0.0.1", "localhost"].includes(url.hostname) && url.pathname === "/hook";
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function retainBackups(limit) {
|
|
46
|
+
const names = await readdir(backupDir).catch(() => []);
|
|
47
|
+
const candidates = [];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (!/^settings-\d+\.json$/.test(name)) continue;
|
|
50
|
+
const path = join(backupDir, name);
|
|
51
|
+
candidates.push({ path, modifiedAt: (await stat(path)).mtimeMs });
|
|
52
|
+
}
|
|
53
|
+
candidates.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
54
|
+
await Promise.all(candidates.slice(limit).map(({ path }) => rm(path, { force: true })));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await mkdir(installDir, { recursive: true });
|
|
58
|
+
await mkdir(backupDir, { recursive: true });
|
|
59
|
+
await writeFile(hookTarget, hookSource, { mode: 0o700 });
|
|
60
|
+
await writeFile(statuslineTarget, statuslineSource, { mode: 0o700 });
|
|
61
|
+
await writeFile(
|
|
62
|
+
join(installDir, "config.json"),
|
|
63
|
+
`${JSON.stringify({ port, remote: true, installerVersion: 1 }, null, 2)}\n`,
|
|
64
|
+
{ mode: 0o600 },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
let settings = {};
|
|
68
|
+
if (existsSync(settingsPath)) {
|
|
69
|
+
const raw = await readFile(settingsPath, "utf8");
|
|
70
|
+
const backup = join(backupDir, `settings-${Date.now()}.json`);
|
|
71
|
+
await writeFile(backup, raw, { mode: 0o600 });
|
|
72
|
+
try { settings = JSON.parse(raw); }
|
|
73
|
+
catch { throw new Error(`Remote settings are invalid JSON; backup: ${backup}`); }
|
|
74
|
+
await retainBackups(5);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const existingStatusline = settings.statusLine?.command;
|
|
78
|
+
if (!existingStatusline || /discord-rpc-statusline|discord-presence[\\/]statusline\.mjs/i.test(existingStatusline)) {
|
|
79
|
+
settings.statusLine = { type: "command", command: statuslineCommand };
|
|
80
|
+
}
|
|
81
|
+
if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
|
|
82
|
+
for (const event of events) {
|
|
83
|
+
const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
84
|
+
const cleaned = [];
|
|
85
|
+
for (const group of groups) {
|
|
86
|
+
if (!group || typeof group !== "object" || !Array.isArray(group.hooks)) {
|
|
87
|
+
cleaned.push(group);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const hooks = group.hooks.filter((hook) => !isPresenceHook(hook));
|
|
91
|
+
if (hooks.length > 0) cleaned.push({ ...group, hooks });
|
|
92
|
+
}
|
|
93
|
+
settings.hooks[event] = [...cleaned, {
|
|
94
|
+
...(matcherEvents.has(event) ? { matcher: "*" } : {}),
|
|
95
|
+
hooks: [{ type: "command", command: hookCommand, async: true, timeout: 5 }],
|
|
96
|
+
}];
|
|
97
|
+
}
|
|
98
|
+
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
99
|
+
console.log(`Installed remote Claude Code presence in ${installDir}`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 512 * 1024;
|
|
6
|
+
let raw = "";
|
|
7
|
+
for await (const chunk of process.stdin) {
|
|
8
|
+
raw += chunk;
|
|
9
|
+
if (Buffer.byteLength(raw) > MAX_BYTES) process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let data = {};
|
|
13
|
+
try { data = JSON.parse(raw); } catch {}
|
|
14
|
+
|
|
15
|
+
const labels = {
|
|
16
|
+
minimal: "Minimal",
|
|
17
|
+
low: "Light",
|
|
18
|
+
medium: "Medium",
|
|
19
|
+
high: "High",
|
|
20
|
+
xhigh: "Extra High",
|
|
21
|
+
max: "Max",
|
|
22
|
+
ultra: "Ultra",
|
|
23
|
+
};
|
|
24
|
+
const model = data?.model?.display_name || "Claude";
|
|
25
|
+
const effortLevel = data?.effort?.level;
|
|
26
|
+
const effort = effortLevel ? ` (${labels[effortLevel] || effortLevel})` : "";
|
|
27
|
+
const fast = data?.fast_mode === true ? " Fast" : "";
|
|
28
|
+
const left = (used) => Math.max(0, Math.min(100, Math.round(100 - used)));
|
|
29
|
+
const limits = [];
|
|
30
|
+
if (Number.isFinite(data?.rate_limits?.five_hour?.used_percentage)) {
|
|
31
|
+
limits.push(`5h ${left(data.rate_limits.five_hour.used_percentage)}% left`);
|
|
32
|
+
}
|
|
33
|
+
if (Number.isFinite(data?.rate_limits?.seven_day?.used_percentage)) {
|
|
34
|
+
limits.push(`7d ${left(data.rate_limits.seven_day.used_percentage)}% left`);
|
|
35
|
+
}
|
|
36
|
+
process.stdout.write(`${model}${effort}${fast}${limits.length ? ` • ${limits.join(" • ")}` : ""}`);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const configPath = join(dirname(fileURLToPath(import.meta.url)), "config.json");
|
|
40
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
41
|
+
const port = Number(config.port);
|
|
42
|
+
if (Number.isInteger(port) && port > 0 && port <= 65_535) {
|
|
43
|
+
let body = raw.trim() || "{}";
|
|
44
|
+
if (config.remote === true) {
|
|
45
|
+
try {
|
|
46
|
+
const payload = JSON.parse(body);
|
|
47
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
48
|
+
body = JSON.stringify({ ...payload, remote: true });
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
await fetch(`http://127.0.0.1:${port}/statusline`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body,
|
|
56
|
+
signal: AbortSignal.timeout(1_000),
|
|
57
|
+
}).catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|