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,150 @@
|
|
|
1
|
+
import { open, stat, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createLogger } from "../util/logger.js";
|
|
4
|
+
import { claudeConfigDir } from "./paths.js";
|
|
5
|
+
const log = createLogger("transcript");
|
|
6
|
+
const TAIL_BYTES = 262144;
|
|
7
|
+
const PROJECTS_DIR = join(claudeConfigDir(), "projects");
|
|
8
|
+
export async function findTranscriptPath(sessionId) {
|
|
9
|
+
try {
|
|
10
|
+
for (const dir of await readdir(PROJECTS_DIR)) {
|
|
11
|
+
const candidate = join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
|
|
12
|
+
try {
|
|
13
|
+
await stat(candidate);
|
|
14
|
+
return candidate;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* not in this project dir */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* ignore */
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function num(value) {
|
|
27
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
28
|
+
}
|
|
29
|
+
export async function readSessionStats(path) {
|
|
30
|
+
try {
|
|
31
|
+
const text = await readFile(path, "utf8");
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
34
|
+
const usageByModel = {};
|
|
35
|
+
let lastModel;
|
|
36
|
+
for (const line of text.split("\n")) {
|
|
37
|
+
if (line.charCodeAt(0) !== 123)
|
|
38
|
+
continue;
|
|
39
|
+
let obj;
|
|
40
|
+
try {
|
|
41
|
+
obj = JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (obj.type !== "assistant" || obj.isSidechain === true)
|
|
47
|
+
continue;
|
|
48
|
+
const msg = obj.message;
|
|
49
|
+
const id = msg?.id;
|
|
50
|
+
const model = msg?.model;
|
|
51
|
+
const u = msg?.usage;
|
|
52
|
+
if (typeof id !== "string" || typeof model !== "string" || !u || seen.has(id))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
lastModel = model;
|
|
56
|
+
const bucket = (usageByModel[model] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
57
|
+
const input = num(u.input_tokens);
|
|
58
|
+
const output = num(u.output_tokens);
|
|
59
|
+
const cacheRead = num(u.cache_read_input_tokens);
|
|
60
|
+
const cacheWrite = num(u.cache_creation_input_tokens);
|
|
61
|
+
usage.input += input;
|
|
62
|
+
usage.output += output;
|
|
63
|
+
usage.cacheRead += cacheRead;
|
|
64
|
+
usage.cacheWrite += cacheWrite;
|
|
65
|
+
bucket.input += input;
|
|
66
|
+
bucket.output += output;
|
|
67
|
+
bucket.cacheRead += cacheRead;
|
|
68
|
+
bucket.cacheWrite += cacheWrite;
|
|
69
|
+
}
|
|
70
|
+
if (seen.size === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
const stats = { usage, usageByModel };
|
|
73
|
+
if (lastModel)
|
|
74
|
+
stats.model = lastModel;
|
|
75
|
+
return stats;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
log.debug(`session stats read failed: ${err.message}`);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function modelCommand(content) {
|
|
83
|
+
const text = typeof content === "string"
|
|
84
|
+
? content
|
|
85
|
+
: Array.isArray(content)
|
|
86
|
+
? content
|
|
87
|
+
.map((item) => item && typeof item === "object" && "text" in item ? item.text : undefined)
|
|
88
|
+
.filter((item) => typeof item === "string")
|
|
89
|
+
.join("\n")
|
|
90
|
+
: "";
|
|
91
|
+
const args = text.match(/<command-name>\/model<\/command-name>[\s\S]*?<command-args>([^<]+)<\/command-args>/i)?.[1]
|
|
92
|
+
?? text.match(/<local-command-stdout>\s*Set model to\s+([^<\s]+)[\s\S]*?<\/local-command-stdout>/i)?.[1];
|
|
93
|
+
const model = args?.trim();
|
|
94
|
+
return model?.startsWith("claude-") ? model : undefined;
|
|
95
|
+
}
|
|
96
|
+
export async function readModelFromTranscript(path) {
|
|
97
|
+
let handle;
|
|
98
|
+
try {
|
|
99
|
+
const info = await stat(path);
|
|
100
|
+
if (info.size === 0)
|
|
101
|
+
return {};
|
|
102
|
+
const length = Math.min(info.size, TAIL_BYTES);
|
|
103
|
+
handle = await open(path, "r");
|
|
104
|
+
const buffer = Buffer.alloc(length);
|
|
105
|
+
await handle.read(buffer, 0, length, info.size - length);
|
|
106
|
+
const text = buffer.toString("utf8");
|
|
107
|
+
const found = {};
|
|
108
|
+
for (const line of text.split("\n")) {
|
|
109
|
+
if (line.charCodeAt(0) !== 123)
|
|
110
|
+
continue;
|
|
111
|
+
let obj;
|
|
112
|
+
try {
|
|
113
|
+
obj = JSON.parse(line);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const fallback = obj.type === "system" && obj.subtype === "model_refusal_fallback"
|
|
119
|
+
? obj.fallbackModel
|
|
120
|
+
: undefined;
|
|
121
|
+
const command = obj.type === "user" && obj.isSidechain !== true
|
|
122
|
+
? modelCommand(obj.message?.content)
|
|
123
|
+
: undefined;
|
|
124
|
+
const messageModel = obj.message?.model;
|
|
125
|
+
const model = typeof fallback === "string" && fallback.startsWith("claude-")
|
|
126
|
+
? fallback
|
|
127
|
+
: command
|
|
128
|
+
?? (typeof messageModel === "string" && messageModel.startsWith("claude-") ? messageModel : undefined);
|
|
129
|
+
if (!model)
|
|
130
|
+
continue;
|
|
131
|
+
found.any = model;
|
|
132
|
+
if (obj.isSidechain === true)
|
|
133
|
+
continue;
|
|
134
|
+
found.main = model;
|
|
135
|
+
const timestamp = typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : Number.NaN;
|
|
136
|
+
found.mainAt = Number.isFinite(timestamp) ? timestamp : info.mtimeMs;
|
|
137
|
+
found.mainEvent = [obj.timestamp, obj.uuid, obj.requestId, obj.subtype, model]
|
|
138
|
+
.filter((value) => typeof value === "string" && value !== "")
|
|
139
|
+
.join(":");
|
|
140
|
+
}
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
log.debug(`transcript read failed: ${err.message}`);
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
await handle?.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { limitsFromUsage } from "./limits.js";
|
|
5
|
+
import { readOauthCredentials, saveRefreshedCredentials, CREDENTIALS_PATH } from "./plan-info.js";
|
|
6
|
+
import { discoverOauthConfig } from "./oauth-discovery.js";
|
|
7
|
+
import { createLogger } from "../util/logger.js";
|
|
8
|
+
const log = createLogger("usage");
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
11
|
+
const MAX_BACKOFF_MS = 30 * 60 * 1000;
|
|
12
|
+
const EXPIRY_SLACK_MS = 60_000;
|
|
13
|
+
const EXPIRED_RETRY_MS = 5 * 60 * 1000;
|
|
14
|
+
const REFRESH_COOLDOWN_MS = 5 * 60 * 1000;
|
|
15
|
+
const FALLBACK_CLI_VERSION = "2.1.204";
|
|
16
|
+
export class UsagePoller {
|
|
17
|
+
intervalMs;
|
|
18
|
+
onUpdate;
|
|
19
|
+
timer;
|
|
20
|
+
inFlight = false;
|
|
21
|
+
stopped = false;
|
|
22
|
+
latest;
|
|
23
|
+
consecutiveFailures = 0;
|
|
24
|
+
lastRefreshAt = 0;
|
|
25
|
+
refreshing = false;
|
|
26
|
+
userAgent;
|
|
27
|
+
oauthConfig;
|
|
28
|
+
watcher;
|
|
29
|
+
watchDebounce;
|
|
30
|
+
constructor(intervalMs, onUpdate) {
|
|
31
|
+
this.intervalMs = intervalMs;
|
|
32
|
+
this.onUpdate = onUpdate;
|
|
33
|
+
}
|
|
34
|
+
getLatest() {
|
|
35
|
+
return this.latest;
|
|
36
|
+
}
|
|
37
|
+
start() {
|
|
38
|
+
this.stopped = false;
|
|
39
|
+
this.watchCredentials();
|
|
40
|
+
void this.tick();
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
this.stopped = true;
|
|
44
|
+
if (this.timer)
|
|
45
|
+
clearTimeout(this.timer);
|
|
46
|
+
if (this.watchDebounce)
|
|
47
|
+
clearTimeout(this.watchDebounce);
|
|
48
|
+
this.watcher?.close();
|
|
49
|
+
}
|
|
50
|
+
schedule(delayMs) {
|
|
51
|
+
if (this.stopped)
|
|
52
|
+
return;
|
|
53
|
+
if (this.timer)
|
|
54
|
+
clearTimeout(this.timer);
|
|
55
|
+
this.timer = setTimeout(() => void this.tick(), delayMs);
|
|
56
|
+
}
|
|
57
|
+
jitter(base) {
|
|
58
|
+
const spread = base * 0.2;
|
|
59
|
+
return base + Math.floor((0.5 - Math.abs((Date.now() % 1000) / 1000 - 0.5)) * spread);
|
|
60
|
+
}
|
|
61
|
+
watchCredentials() {
|
|
62
|
+
try {
|
|
63
|
+
this.watcher = watch(CREDENTIALS_PATH, () => {
|
|
64
|
+
if (this.watchDebounce)
|
|
65
|
+
clearTimeout(this.watchDebounce);
|
|
66
|
+
this.watchDebounce = setTimeout(() => {
|
|
67
|
+
if (this.stopped)
|
|
68
|
+
return;
|
|
69
|
+
log.debug("credentials changed; polling usage");
|
|
70
|
+
void this.tick();
|
|
71
|
+
}, 2_000);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
log.debug(`credentials watch unavailable: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async getUserAgent() {
|
|
79
|
+
if (this.userAgent)
|
|
80
|
+
return this.userAgent;
|
|
81
|
+
let version = FALLBACK_CLI_VERSION;
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execFileAsync("claude", ["-v"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
timeout: 10_000,
|
|
87
|
+
});
|
|
88
|
+
const match = stdout.match(/(\d+\.\d+\.\d+)/);
|
|
89
|
+
if (match)
|
|
90
|
+
version = match[1];
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
log.debug(`claude version probe failed: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
this.userAgent = `claude-code/${version}`;
|
|
96
|
+
return this.userAgent;
|
|
97
|
+
}
|
|
98
|
+
refreshEnabled() {
|
|
99
|
+
const raw = process.env.USAGE_TOKEN_REFRESH?.trim().toLowerCase();
|
|
100
|
+
return raw === "on" || raw === "1" || raw === "true";
|
|
101
|
+
}
|
|
102
|
+
async refreshToken() {
|
|
103
|
+
if (!this.refreshEnabled() || this.refreshing)
|
|
104
|
+
return false;
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
if (now - this.lastRefreshAt < REFRESH_COOLDOWN_MS)
|
|
107
|
+
return false;
|
|
108
|
+
this.lastRefreshAt = now;
|
|
109
|
+
this.refreshing = true;
|
|
110
|
+
try {
|
|
111
|
+
const creds = await readOauthCredentials();
|
|
112
|
+
if (!creds.refreshToken) {
|
|
113
|
+
log.warn("no refresh token available; cannot refresh access token");
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
this.oauthConfig ??= discoverOauthConfig();
|
|
117
|
+
const config = await this.oauthConfig;
|
|
118
|
+
for (const clientId of config.clientIds) {
|
|
119
|
+
for (const url of config.tokenUrls) {
|
|
120
|
+
const host = new URL(url).host;
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(url, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
grant_type: "refresh_token",
|
|
127
|
+
refresh_token: creds.refreshToken,
|
|
128
|
+
client_id: clientId,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
log.warn(`token refresh failed at ${host} (client ${clientId.slice(0, 8)}): ${res.status}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const data = (await res.json());
|
|
136
|
+
if (!data.access_token) {
|
|
137
|
+
log.warn(`token refresh response from ${host} missing access_token`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const saved = await saveRefreshedCredentials(data.access_token, data.refresh_token, data.expires_in);
|
|
141
|
+
if (saved)
|
|
142
|
+
log.info("access token refreshed and saved");
|
|
143
|
+
return saved;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
log.warn(`token refresh error at ${host}: ${err.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
this.refreshing = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
tokenExpired(expiresAt) {
|
|
157
|
+
return expiresAt !== undefined && expiresAt < Date.now() + EXPIRY_SLACK_MS;
|
|
158
|
+
}
|
|
159
|
+
async tick() {
|
|
160
|
+
if (this.inFlight || this.stopped)
|
|
161
|
+
return;
|
|
162
|
+
this.inFlight = true;
|
|
163
|
+
let nextDelay = this.intervalMs;
|
|
164
|
+
try {
|
|
165
|
+
let creds = await readOauthCredentials();
|
|
166
|
+
if (this.tokenExpired(creds.expiresAt)) {
|
|
167
|
+
if (await this.refreshToken())
|
|
168
|
+
creds = await readOauthCredentials();
|
|
169
|
+
if (this.tokenExpired(creds.expiresAt)) {
|
|
170
|
+
this.consecutiveFailures++;
|
|
171
|
+
nextDelay = EXPIRED_RETRY_MS;
|
|
172
|
+
log.warn("access token expired; waiting for a refresh before polling usage");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!creds.accessToken) {
|
|
177
|
+
log.warn("no access token available; retrying");
|
|
178
|
+
this.consecutiveFailures++;
|
|
179
|
+
nextDelay = Math.min(this.intervalMs, 60_000);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const res = await fetch(USAGE_URL, {
|
|
183
|
+
method: "GET",
|
|
184
|
+
headers: {
|
|
185
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
186
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
187
|
+
"Content-Type": "application/json",
|
|
188
|
+
"User-Agent": await this.getUserAgent(),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
if (res.status === 429) {
|
|
192
|
+
this.consecutiveFailures++;
|
|
193
|
+
const retryAfter = Number.parseInt(res.headers.get("retry-after") ?? "", 10);
|
|
194
|
+
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
|
|
195
|
+
? retryAfter * 1000
|
|
196
|
+
: Math.min(MAX_BACKOFF_MS, this.intervalMs * 2 ** this.consecutiveFailures);
|
|
197
|
+
nextDelay = this.jitter(backoff);
|
|
198
|
+
log.warn(`rate limited (429); backing off ${Math.round(nextDelay / 1000)}s`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (res.status === 401 || res.status === 403) {
|
|
202
|
+
this.consecutiveFailures++;
|
|
203
|
+
const refreshed = await this.refreshToken();
|
|
204
|
+
nextDelay = refreshed ? 10_000 : EXPIRED_RETRY_MS;
|
|
205
|
+
log.warn(`usage auth failed (${res.status}); ${refreshed ? "retrying with the refreshed token" : "waiting for a token refresh"}`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
this.consecutiveFailures++;
|
|
210
|
+
nextDelay = this.jitter(Math.min(MAX_BACKOFF_MS, this.intervalMs * 2 ** this.consecutiveFailures));
|
|
211
|
+
log.warn(`usage request failed: ${res.status}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = (await res.json());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
this.consecutiveFailures++;
|
|
220
|
+
nextDelay = 5_000;
|
|
221
|
+
log.warn("usage response parse failed; retrying shortly");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const limits = limitsFromUsage(parsed, Date.now());
|
|
225
|
+
this.consecutiveFailures = 0;
|
|
226
|
+
if (limits) {
|
|
227
|
+
this.latest = limits;
|
|
228
|
+
const scoped = limits.sevenDayScoped?.map((s) => `${s.label}=${s.usedPercentage}`).join(",") ?? "-";
|
|
229
|
+
log.debug(`usage ok: 5h used=${limits.fiveHour?.usedPercentage ?? "-"} 7d used=${limits.sevenDay?.usedPercentage ?? "-"} scoped=${scoped}`);
|
|
230
|
+
this.onUpdate(limits);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
this.consecutiveFailures++;
|
|
235
|
+
nextDelay = this.jitter(Math.min(MAX_BACKOFF_MS, this.intervalMs * 2 ** this.consecutiveFailures));
|
|
236
|
+
log.warn(`usage poll error: ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
this.inFlight = false;
|
|
240
|
+
this.schedule(nextDelay);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
const projectDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
const packageJson = JSON.parse(readFileSync(resolve(projectDir, "package.json"), "utf8"));
|
|
9
|
+
const flagEnvironment = {
|
|
10
|
+
"--application-id": "CLAUDE_DISCORD_APPLICATION_ID",
|
|
11
|
+
"--port": "PORT",
|
|
12
|
+
"--claude-config-dir": "CLAUDE_CONFIG_DIR",
|
|
13
|
+
"--desktop-sessions-dir": "CLAUDE_DESKTOP_SESSIONS_DIR",
|
|
14
|
+
"--remote-hosts": "CLAUDE_REMOTE_HOSTS",
|
|
15
|
+
"--remote-port": "CLAUDE_REMOTE_PORT",
|
|
16
|
+
"--usage-poll-interval": "USAGE_POLL_INTERVAL_S",
|
|
17
|
+
"--log-level": "RPC_LOG_LEVEL",
|
|
18
|
+
"--log-max-bytes": "RPC_LOG_MAX_BYTES",
|
|
19
|
+
};
|
|
20
|
+
function help() {
|
|
21
|
+
console.log(`Claude Code Discord Presence ${packageJson.version}
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
claude-code-presence [start] [options]
|
|
25
|
+
claude-code-presence setup [options]
|
|
26
|
+
claude-code-presence remote:setup [options]
|
|
27
|
+
claude-code-presence autostart [options]
|
|
28
|
+
claude-code-presence autostart:remove
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--env <file> Load an optional environment file
|
|
32
|
+
--application-id <id> Override the shared Discord application
|
|
33
|
+
--port <port> Local hook server port (default: 41724)
|
|
34
|
+
--claude-config-dir <path> Override Claude's configuration directory
|
|
35
|
+
--desktop-sessions-dir <p> Override Desktop session discovery, or off
|
|
36
|
+
--remote-hosts <aliases> Comma-separated SSH config aliases
|
|
37
|
+
--remote-port <port> Remote loopback tunnel port
|
|
38
|
+
--usage-poll-interval <sec> Account usage refresh interval
|
|
39
|
+
--log-level <level> debug, info, warn, error, or silent
|
|
40
|
+
--log-max-bytes <bytes> Maximum bytes per log file
|
|
41
|
+
-h, --help Show help
|
|
42
|
+
-v, --version Show version`);
|
|
43
|
+
}
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
let command = "start";
|
|
46
|
+
if (args[0] && !args[0].startsWith("-"))
|
|
47
|
+
command = args.shift();
|
|
48
|
+
let envFile;
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const flag = args[i];
|
|
51
|
+
if (flag === "--help" || flag === "-h") {
|
|
52
|
+
help();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
if (flag === "--version" || flag === "-v") {
|
|
56
|
+
console.log(packageJson.version);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
const value = args[++i];
|
|
60
|
+
if (!value)
|
|
61
|
+
throw new Error(`${flag} requires a value`);
|
|
62
|
+
if (flag === "--env")
|
|
63
|
+
envFile = resolve(value);
|
|
64
|
+
else {
|
|
65
|
+
const key = flagEnvironment[flag];
|
|
66
|
+
if (!key)
|
|
67
|
+
throw new Error(`Unknown option: ${flag}`);
|
|
68
|
+
process.env[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (command === "help") {
|
|
72
|
+
help();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
if (command === "version") {
|
|
76
|
+
console.log(packageJson.version);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
const candidateEnv = envFile ?? resolve(process.cwd(), ".env");
|
|
80
|
+
if (envFile && !existsSync(envFile))
|
|
81
|
+
throw new Error(`Environment file not found: ${envFile}`);
|
|
82
|
+
if (existsSync(candidateEnv))
|
|
83
|
+
process.loadEnvFile(candidateEnv);
|
|
84
|
+
function claudeDir() {
|
|
85
|
+
const configured = process.env.CLAUDE_CONFIG_DIR?.split(",")[0]?.trim();
|
|
86
|
+
if (!configured)
|
|
87
|
+
return join(homedir(), ".claude");
|
|
88
|
+
if (configured === "~")
|
|
89
|
+
return homedir();
|
|
90
|
+
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
|
|
91
|
+
return resolve(homedir(), configured.slice(2));
|
|
92
|
+
}
|
|
93
|
+
return resolve(configured);
|
|
94
|
+
}
|
|
95
|
+
function setupInstalled() {
|
|
96
|
+
try {
|
|
97
|
+
const config = JSON.parse(readFileSync(join(claudeDir(), "discord-presence", "config.json"), "utf8"));
|
|
98
|
+
const settings = readFileSync(join(claudeDir(), "settings.json"), "utf8");
|
|
99
|
+
return config.installerVersion === 1 && /discord-presence[\\/]hook\.mjs/i.test(settings) &&
|
|
100
|
+
/discord-presence[\\/]statusline\.mjs/i.test(settings);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function runScript(name) {
|
|
107
|
+
const result = spawnSync(process.execPath, [resolve(projectDir, "scripts", name)], {
|
|
108
|
+
stdio: "inherit",
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
env: process.env,
|
|
111
|
+
});
|
|
112
|
+
if (result.status !== 0)
|
|
113
|
+
process.exit(result.status ?? 1);
|
|
114
|
+
}
|
|
115
|
+
if (command === "setup") {
|
|
116
|
+
runScript("setup-claude.mjs");
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
if (command === "remote:setup") {
|
|
120
|
+
runScript("install-remote.mjs");
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
if (command === "autostart") {
|
|
124
|
+
if (!setupInstalled())
|
|
125
|
+
runScript("setup-claude.mjs");
|
|
126
|
+
runScript("install-autostart.mjs");
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
if (command === "autostart:remove") {
|
|
130
|
+
runScript("remove-autostart.mjs");
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
if (command !== "start")
|
|
134
|
+
throw new Error(`Unknown command: ${command}`);
|
|
135
|
+
if (!setupInstalled())
|
|
136
|
+
runScript("setup-claude.mjs");
|
|
137
|
+
await import("./index.js");
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import { defaultDesktopSessionsDir } from "./claude/desktop-focus.js";
|
|
4
|
+
const SSH_ALIAS = /^[a-z0-9](?:[a-z0-9._-]{0,252}[a-z0-9])?$/i;
|
|
5
|
+
export const DEFAULT_DISCORD_APPLICATION_ID = "1524135246633894049";
|
|
6
|
+
export function resolveRemoteHosts(value) {
|
|
7
|
+
if (!value?.trim() || value.trim().toLowerCase() === "off")
|
|
8
|
+
return [];
|
|
9
|
+
const hosts = [...new Set(value.split(",").map((host) => host.trim()).filter(Boolean))];
|
|
10
|
+
const invalid = hosts.find((host) => !SSH_ALIAS.test(host));
|
|
11
|
+
if (invalid) {
|
|
12
|
+
throw new Error(`CLAUDE_REMOTE_HOSTS contains an unsafe SSH alias: ${JSON.stringify(invalid)}`);
|
|
13
|
+
}
|
|
14
|
+
return hosts;
|
|
15
|
+
}
|
|
16
|
+
function runtimePaths() {
|
|
17
|
+
return { userHome: homedir(), cwd: process.cwd(), platform: process.platform };
|
|
18
|
+
}
|
|
19
|
+
function readInt(value, fallback) {
|
|
20
|
+
if (!value?.trim())
|
|
21
|
+
return fallback;
|
|
22
|
+
const parsed = Number.parseInt(value, 10);
|
|
23
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
24
|
+
}
|
|
25
|
+
function optional(value) {
|
|
26
|
+
const trimmed = value?.trim();
|
|
27
|
+
if (!trimmed || ["off", "none", "default"].includes(trimmed.toLowerCase()))
|
|
28
|
+
return undefined;
|
|
29
|
+
return trimmed;
|
|
30
|
+
}
|
|
31
|
+
function pathApi(runtime) {
|
|
32
|
+
return runtime.platform === "win32" ? win32 : posix;
|
|
33
|
+
}
|
|
34
|
+
function resolveUserPath(value, runtime) {
|
|
35
|
+
const paths = pathApi(runtime);
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (trimmed === "~")
|
|
38
|
+
return paths.normalize(runtime.userHome);
|
|
39
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
40
|
+
return paths.resolve(runtime.userHome, trimmed.slice(2));
|
|
41
|
+
}
|
|
42
|
+
return paths.isAbsolute(trimmed) ? paths.normalize(trimmed) : paths.resolve(runtime.cwd, trimmed);
|
|
43
|
+
}
|
|
44
|
+
export function resolvePresenceDataDir(env, runtime) {
|
|
45
|
+
const paths = pathApi(runtime);
|
|
46
|
+
const configured = env.CLAUDE_PRESENCE_DATA_DIR?.trim();
|
|
47
|
+
if (configured)
|
|
48
|
+
return resolveUserPath(configured, runtime);
|
|
49
|
+
if (runtime.platform === "win32") {
|
|
50
|
+
const base = env.LOCALAPPDATA?.trim() || paths.join(runtime.userHome, "AppData", "Local");
|
|
51
|
+
return paths.join(base, "Claude Code Discord Presence");
|
|
52
|
+
}
|
|
53
|
+
if (runtime.platform === "darwin") {
|
|
54
|
+
return paths.join(runtime.userHome, "Library", "Application Support", "Claude Code Discord Presence");
|
|
55
|
+
}
|
|
56
|
+
const state = env.XDG_STATE_HOME?.trim();
|
|
57
|
+
const base = state
|
|
58
|
+
? resolveUserPath(state, runtime)
|
|
59
|
+
: paths.join(runtime.userHome, ".local", "state");
|
|
60
|
+
return paths.join(base, "claude-code-discord-presence");
|
|
61
|
+
}
|
|
62
|
+
export function loadConfig(env = process.env, runtime = runtimePaths()) {
|
|
63
|
+
const paths = pathApi(runtime);
|
|
64
|
+
const applicationId = (env.CLAUDE_DISCORD_APPLICATION_ID ?? env.DISCORD_APPLICATION_ID)?.trim() ||
|
|
65
|
+
DEFAULT_DISCORD_APPLICATION_ID;
|
|
66
|
+
const dataDir = resolvePresenceDataDir(env, runtime);
|
|
67
|
+
const rawDesktopDir = env.CLAUDE_DESKTOP_SESSIONS_DIR?.trim();
|
|
68
|
+
const desktopSessionsDir = rawDesktopDir?.toLowerCase() === "off"
|
|
69
|
+
? undefined
|
|
70
|
+
: rawDesktopDir
|
|
71
|
+
? resolveUserPath(rawDesktopDir, runtime)
|
|
72
|
+
: defaultDesktopSessionsDir();
|
|
73
|
+
const configuredLog = env.RPC_LOG_FILE?.trim();
|
|
74
|
+
const port = readInt(env.PORT, 41724);
|
|
75
|
+
return {
|
|
76
|
+
applicationId,
|
|
77
|
+
appName: env.CLAUDE_APP_NAME?.trim() || env.APP_NAME?.trim() || "Claude Code",
|
|
78
|
+
port,
|
|
79
|
+
largeImageKey: optional(env.CLAUDE_LARGE_IMAGE_KEY ?? env.LARGE_IMAGE_KEY ?? "claude_code"),
|
|
80
|
+
largeImageKeyLight: optional(env.CLAUDE_LARGE_IMAGE_KEY_LIGHT ?? env.LARGE_IMAGE_KEY_LIGHT ?? "claude-liquid-light"),
|
|
81
|
+
largeImageKeyDark: optional(env.CLAUDE_LARGE_IMAGE_KEY_DARK ?? env.LARGE_IMAGE_KEY_DARK ?? "claude-liquid-dark"),
|
|
82
|
+
largeImageUrl: optional(env.CLAUDE_LARGE_IMAGE_URL ?? env.LARGE_IMAGE_URL),
|
|
83
|
+
smallImageKey: optional(env.CLAUDE_SMALL_IMAGE_KEY ?? env.SMALL_IMAGE_KEY ?? "claude-usage-stats"),
|
|
84
|
+
smallImageKeyLight: optional(env.CLAUDE_SMALL_IMAGE_KEY_LIGHT ?? env.SMALL_IMAGE_KEY_LIGHT ?? "claude-stats-light"),
|
|
85
|
+
smallImageKeyDark: optional(env.CLAUDE_SMALL_IMAGE_KEY_DARK ?? env.SMALL_IMAGE_KEY_DARK ?? "claude-stats-dark"),
|
|
86
|
+
smallImageUrl: optional(env.CLAUDE_SMALL_IMAGE_URL ?? env.SMALL_IMAGE_URL),
|
|
87
|
+
usagePollIntervalMs: readInt(env.USAGE_POLL_INTERVAL_S, 300) * 1000,
|
|
88
|
+
desktopSessionsDir,
|
|
89
|
+
dataDir,
|
|
90
|
+
logFile: configuredLog
|
|
91
|
+
? resolveUserPath(configuredLog, runtime)
|
|
92
|
+
: paths.join(dataDir, "claude-code-discord-presence.log"),
|
|
93
|
+
remoteHosts: resolveRemoteHosts(env.CLAUDE_REMOTE_HOSTS),
|
|
94
|
+
remotePort: readInt(env.CLAUDE_REMOTE_PORT, port),
|
|
95
|
+
};
|
|
96
|
+
}
|