vibecoder-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/LICENSE +21 -0
- package/README.md +87 -0
- package/assets/README.md +0 -0
- package/assets/logo.jpg +0 -0
- package/assets/profile.png +0 -0
- package/dist/chunk-2A7GKQFO.js +108 -0
- package/dist/chunk-4V354UFY.js +90 -0
- package/dist/chunk-CEWLOUQO.js +97 -0
- package/dist/chunk-FRGALQ5R.js +39 -0
- package/dist/chunk-KDLAS6ED.js +126 -0
- package/dist/chunk-LKE6H3YG.js +28 -0
- package/dist/chunk-XYXO7VLI.js +65 -0
- package/dist/claude-code-Y5EVVTMQ.js +114 -0
- package/dist/config-GLQUOMQ6.js +245 -0
- package/dist/daemon-UR7HFYY7.js +276 -0
- package/dist/install-PS6R32NO.js +45 -0
- package/dist/restart-4QW7NSWI.js +28 -0
- package/dist/status-4L4TX5GV.js +62 -0
- package/dist/stop-GI5PLZ4I.js +20 -0
- package/dist/uninstall-APXX4WCS.js +42 -0
- package/dist/vdp.js +66 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Younesfdj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/logo.jpg" alt="" width="160" height="160" align="middle" />
|
|
3
|
+
<h1>
|
|
4
|
+
Vibecoder Discord Presence
|
|
5
|
+
</h1>
|
|
6
|
+
<p><strong>Your AI coding sessions, live on Discord.</strong></p>
|
|
7
|
+
|
|
8
|
+
<img src="assets/demo.gif" alt="demo" width="480" />
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
A live Discord status for your AI coding sessions — thinking, editing, running
|
|
13
|
+
tests, as it happens. Theme it however you like (or build your own from scratch),
|
|
14
|
+
and it stays private unless you choose to share more.
|
|
15
|
+
|
|
16
|
+
## Supported tools
|
|
17
|
+
|
|
18
|
+
| Tool | Status |
|
|
19
|
+
| ----------- | ------------ |
|
|
20
|
+
| Claude Code | ✅ supported |
|
|
21
|
+
| Gemini CLI | 🔜 planned |
|
|
22
|
+
| Codex | 🔜 planned |
|
|
23
|
+
| OpenCode | 🔜 planned |
|
|
24
|
+
|
|
25
|
+
> Built on a provider model — adding a tool only changes how events are read, not
|
|
26
|
+
> the rest. PRs welcome.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm i -g vibecoder-discord-presence
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
vdp install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Open your AI coding tool with the Discord **desktop** app running — your status
|
|
41
|
+
shows up on its own. That's the whole setup.
|
|
42
|
+
|
|
43
|
+
> Needs Node 18+ and the Discord desktop app.
|
|
44
|
+
|
|
45
|
+
## Themes
|
|
46
|
+
|
|
47
|
+
Five built-ins, from privacy-safe to maximum vibes.
|
|
48
|
+
|
|
49
|
+
<p align="center">
|
|
50
|
+
<img src="assets/profile.png" alt="Discord rich presence" width="320"/>
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
`minimal` · `developer` · `focus` · `playful` · `chaos`
|
|
54
|
+
|
|
55
|
+
## Customize
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
vdp config
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pick a theme or build your own — every line, image, and button — with a live
|
|
62
|
+
preview as you go.
|
|
63
|
+
|
|
64
|
+
## Commands
|
|
65
|
+
|
|
66
|
+
| Command | What it does |
|
|
67
|
+
| -------------------------- | ---------------------------------------------- |
|
|
68
|
+
| `vdp install` | Set it up |
|
|
69
|
+
| `vdp config` | Customize the card |
|
|
70
|
+
| `vdp status` | See what's running |
|
|
71
|
+
| `vdp stop` / `vdp restart` | Control the background process |
|
|
72
|
+
| `vdp uninstall [--purge]` | Remove it (`--purge` also deletes your config) |
|
|
73
|
+
|
|
74
|
+
## Privacy
|
|
75
|
+
|
|
76
|
+
The default `minimal` theme shares nothing about your work — no project names,
|
|
77
|
+
paths, or filenames. Anything more is opt-in, and there's no telemetry.
|
|
78
|
+
|
|
79
|
+
## How it works
|
|
80
|
+
|
|
81
|
+
A small hook fires on each event and writes a marker file. A lightweight
|
|
82
|
+
background process reads it, updates Discord, and exits once you're idle — no
|
|
83
|
+
always-on daemon, no manual start/stop.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
[MIT](LICENSE) © [younesfdj](https://github.com/younesfdj)
|
package/assets/README.md
ADDED
|
File without changes
|
package/assets/logo.jpg
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configPath
|
|
3
|
+
} from "./chunk-FRGALQ5R.js";
|
|
4
|
+
|
|
5
|
+
// src/themes/index.ts
|
|
6
|
+
var REPO_URL = "https://github.com/younesfdj/vibecoder-discord-presence";
|
|
7
|
+
var THEMES = {
|
|
8
|
+
minimal: {
|
|
9
|
+
details: "Coding with Claude Code",
|
|
10
|
+
state: "",
|
|
11
|
+
largeImage: { key: "logo", text: "Claude Code" },
|
|
12
|
+
smallImage: { key: "status-{state}", text: "{activity}" },
|
|
13
|
+
timer: true,
|
|
14
|
+
buttons: [],
|
|
15
|
+
// No state line; let the compact status show the activity from details.
|
|
16
|
+
statusDisplay: "details"
|
|
17
|
+
},
|
|
18
|
+
developer: {
|
|
19
|
+
details: "Coding {project} ({branch})",
|
|
20
|
+
state: "{activity} \xB7 {model}",
|
|
21
|
+
largeImage: { key: "logo", text: "Claude Code \xB7 {model}" },
|
|
22
|
+
smallImage: { key: "status-{state}", text: "{activity}" },
|
|
23
|
+
timer: true,
|
|
24
|
+
buttons: [{ label: "\u2B50 Star on GitHub", url: REPO_URL }],
|
|
25
|
+
statusDisplay: "state"
|
|
26
|
+
},
|
|
27
|
+
focus: {
|
|
28
|
+
details: "In a deep work session \u{1F3AF}",
|
|
29
|
+
state: "Focused for {elapsed}",
|
|
30
|
+
largeImage: { key: "focus", text: "Deep work" },
|
|
31
|
+
smallImage: { key: "status-focus", text: "Focusing" },
|
|
32
|
+
timer: true,
|
|
33
|
+
buttons: [],
|
|
34
|
+
statusDisplay: "state"
|
|
35
|
+
},
|
|
36
|
+
playful: {
|
|
37
|
+
details: "\u{1F916} vibecoding with Claude",
|
|
38
|
+
state: "shipping {project} \xB7 {model}",
|
|
39
|
+
largeImage: { key: "logo", text: "vibecoder" },
|
|
40
|
+
smallImage: { key: "status-{state}", text: "{activity}" },
|
|
41
|
+
timer: true,
|
|
42
|
+
buttons: [{ label: "get vibecoder", url: REPO_URL }],
|
|
43
|
+
statusDisplay: "state"
|
|
44
|
+
},
|
|
45
|
+
chaos: {
|
|
46
|
+
details: "\u{1F680} {activity} \u2014 \u{1F4C2} {project} {branch} \u{1F4BB}\u{1F525}",
|
|
47
|
+
state: "cooking with {model} \xB7 {tokens} tokens burned\xB7 {cost} \xB7 \u{1F465} {sessionCount} \xB7 \u231B {elapsed}",
|
|
48
|
+
largeImage: { key: "logo", text: "\u2728 locked in \xB7 {model} \xB7 no thoughts only vibes \u{1F525}" },
|
|
49
|
+
smallImage: { key: "status-{state}", text: "{activity} fr fr \u{1F4AF}" },
|
|
50
|
+
timer: true,
|
|
51
|
+
buttons: [
|
|
52
|
+
{ label: "\u{1F525} join the vibe", url: REPO_URL },
|
|
53
|
+
{ label: "\u2728 star (real)", url: REPO_URL }
|
|
54
|
+
],
|
|
55
|
+
statusDisplay: "details"
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var DEFAULT_THEME = "minimal";
|
|
59
|
+
|
|
60
|
+
// src/core/config.ts
|
|
61
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
62
|
+
import { dirname } from "path";
|
|
63
|
+
var DEFAULT_CONFIG = {
|
|
64
|
+
theme: DEFAULT_THEME,
|
|
65
|
+
overrides: {}
|
|
66
|
+
};
|
|
67
|
+
var DEFAULT_CLIENT_ID = "1511730102499541123";
|
|
68
|
+
function stripBom(s) {
|
|
69
|
+
return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
|
|
70
|
+
}
|
|
71
|
+
function readUserConfig(configPath2) {
|
|
72
|
+
try {
|
|
73
|
+
const raw = stripBom(readFileSync(configPath2, "utf8"));
|
|
74
|
+
const parsed = JSON.parse(raw);
|
|
75
|
+
if (typeof parsed !== "object" || parsed === null) return DEFAULT_CONFIG;
|
|
76
|
+
return {
|
|
77
|
+
theme: typeof parsed.theme === "string" ? parsed.theme : DEFAULT_CONFIG.theme,
|
|
78
|
+
overrides: typeof parsed.overrides === "object" && parsed.overrides !== null ? parsed.overrides : {},
|
|
79
|
+
clientId: typeof parsed.clientId === "string" ? parsed.clientId : void 0
|
|
80
|
+
};
|
|
81
|
+
} catch {
|
|
82
|
+
return DEFAULT_CONFIG;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function resolveTheme(config) {
|
|
86
|
+
const base = THEMES[config.theme] ?? THEMES[DEFAULT_THEME];
|
|
87
|
+
return { ...base, ...config.overrides ?? {} };
|
|
88
|
+
}
|
|
89
|
+
function resolveClientId(config) {
|
|
90
|
+
return process.env.VDP_DISCORD_CLIENT_ID || config.clientId || DEFAULT_CLIENT_ID;
|
|
91
|
+
}
|
|
92
|
+
function saveUserConfig(config) {
|
|
93
|
+
const dest = configPath();
|
|
94
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
95
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
96
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
|
|
97
|
+
`);
|
|
98
|
+
renameSync(tmp, dest);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export {
|
|
102
|
+
THEMES,
|
|
103
|
+
DEFAULT_CONFIG,
|
|
104
|
+
readUserConfig,
|
|
105
|
+
resolveTheme,
|
|
106
|
+
resolveClientId,
|
|
107
|
+
saveUserConfig
|
|
108
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sessionsDir
|
|
3
|
+
} from "./chunk-FRGALQ5R.js";
|
|
4
|
+
|
|
5
|
+
// src/core/state.ts
|
|
6
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
var STALE_AFTER_MS = 20 * 60 * 1e3;
|
|
9
|
+
var PRUNE_AFTER_MS = 60 * 60 * 1e3;
|
|
10
|
+
function markerPath(id) {
|
|
11
|
+
return join(sessionsDir(), `${id}.json`);
|
|
12
|
+
}
|
|
13
|
+
function stripBom(s) {
|
|
14
|
+
return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
|
|
15
|
+
}
|
|
16
|
+
function writeSessionMarker(marker) {
|
|
17
|
+
const dir = sessionsDir();
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
const dest = markerPath(marker.id);
|
|
20
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
21
|
+
writeFileSync(tmp, JSON.stringify(marker));
|
|
22
|
+
renameSync(tmp, dest);
|
|
23
|
+
}
|
|
24
|
+
function readSessionMarker(id) {
|
|
25
|
+
try {
|
|
26
|
+
const raw = stripBom(readFileSync(markerPath(id), "utf8"));
|
|
27
|
+
return JSON.parse(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function updateSessionMarker(id, patch, now) {
|
|
33
|
+
const existing = readSessionMarker(id);
|
|
34
|
+
const merged = {
|
|
35
|
+
id,
|
|
36
|
+
startedAt: existing?.startedAt ?? now,
|
|
37
|
+
...existing,
|
|
38
|
+
...patch,
|
|
39
|
+
heartbeat: now
|
|
40
|
+
};
|
|
41
|
+
writeSessionMarker(merged);
|
|
42
|
+
}
|
|
43
|
+
function removeSessionMarker(id) {
|
|
44
|
+
try {
|
|
45
|
+
rmSync(markerPath(id));
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function readMarkers() {
|
|
50
|
+
let files;
|
|
51
|
+
try {
|
|
52
|
+
files = readdirSync(sessionsDir());
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const f of files) {
|
|
58
|
+
if (!f.endsWith(".json")) continue;
|
|
59
|
+
const m = readSessionMarker(f.slice(0, -5));
|
|
60
|
+
if (m) out.push(m);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function aggregate(markers, now, staleAfterMs = STALE_AFTER_MS) {
|
|
65
|
+
const live = markers.filter((m) => now - m.heartbeat <= staleAfterMs);
|
|
66
|
+
if (live.length === 0) return null;
|
|
67
|
+
const current = live.reduce((a, b) => b.heartbeat > a.heartbeat ? b : a);
|
|
68
|
+
const startedAt = live.reduce((min, m) => Math.min(min, m.startedAt), Infinity);
|
|
69
|
+
return {
|
|
70
|
+
sessionCount: live.length,
|
|
71
|
+
startedAt,
|
|
72
|
+
transcriptPath: current.transcriptPath,
|
|
73
|
+
project: current.project,
|
|
74
|
+
branch: current.branch,
|
|
75
|
+
model: current.model,
|
|
76
|
+
state: current.state,
|
|
77
|
+
activity: current.activity,
|
|
78
|
+
file: current.file,
|
|
79
|
+
tokens: current.tokens,
|
|
80
|
+
cost: current.cost
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
PRUNE_AFTER_MS,
|
|
86
|
+
updateSessionMarker,
|
|
87
|
+
removeSessionMarker,
|
|
88
|
+
readMarkers,
|
|
89
|
+
aggregate
|
|
90
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claudeDir,
|
|
3
|
+
settingsPath
|
|
4
|
+
} from "./chunk-FRGALQ5R.js";
|
|
5
|
+
|
|
6
|
+
// src/core/settings.ts
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
var HOOK_MARKER = "vdp.js";
|
|
10
|
+
var HOOK_EVENTS = [
|
|
11
|
+
{ name: "SessionStart", arg: "session-start" },
|
|
12
|
+
{ name: "UserPromptSubmit", arg: "user-prompt-submit" },
|
|
13
|
+
{ name: "PreToolUse", arg: "pre-tool-use" },
|
|
14
|
+
{ name: "Notification", arg: "notification" },
|
|
15
|
+
{ name: "Stop", arg: "stop" },
|
|
16
|
+
{ name: "SessionEnd", arg: "session-end" }
|
|
17
|
+
];
|
|
18
|
+
function stripBom(s) {
|
|
19
|
+
return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
|
|
20
|
+
}
|
|
21
|
+
async function readSettings() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = stripBom(await readFile(settingsPath(), "utf8"));
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
26
|
+
return parsed;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === "ENOENT") return {};
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function writeSettings(settings) {
|
|
33
|
+
const dest = settingsPath();
|
|
34
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
35
|
+
const tmp = `${dest}.tmp`;
|
|
36
|
+
await writeFile(tmp, `${JSON.stringify(settings, null, 2)}
|
|
37
|
+
`);
|
|
38
|
+
await rename(tmp, dest);
|
|
39
|
+
}
|
|
40
|
+
async function backupSettings(settings) {
|
|
41
|
+
if (Object.keys(settings).length === 0) return null;
|
|
42
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
43
|
+
const path = `${settingsPath()}.${ts}.bak`;
|
|
44
|
+
await mkdir(claudeDir(), { recursive: true });
|
|
45
|
+
await writeFile(path, `${JSON.stringify(settings, null, 2)}
|
|
46
|
+
`);
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
function isOurEntry(entry) {
|
|
50
|
+
if (!Array.isArray(entry.hooks)) return false;
|
|
51
|
+
return entry.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER));
|
|
52
|
+
}
|
|
53
|
+
function buildEntry(entryPath, arg) {
|
|
54
|
+
return {
|
|
55
|
+
matcher: "*",
|
|
56
|
+
hooks: [{ type: "command", command: `node "${entryPath}" hook ${arg}` }]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function mergeHooks(settings, entryPath) {
|
|
60
|
+
const hooks = { ...settings.hooks ?? {} };
|
|
61
|
+
for (const event of HOOK_EVENTS) {
|
|
62
|
+
const existing = (hooks[event.name] ?? []).filter((e) => !isOurEntry(e));
|
|
63
|
+
existing.push(buildEntry(entryPath, event.arg));
|
|
64
|
+
hooks[event.name] = existing;
|
|
65
|
+
}
|
|
66
|
+
return { ...settings, hooks };
|
|
67
|
+
}
|
|
68
|
+
function stripOurHooks(settings) {
|
|
69
|
+
const inHooks = settings.hooks;
|
|
70
|
+
if (inHooks === void 0) return { cleaned: settings, removed: 0 };
|
|
71
|
+
let removed = 0;
|
|
72
|
+
const outHooks = {};
|
|
73
|
+
for (const [event, entries] of Object.entries(inHooks)) {
|
|
74
|
+
const kept = entries.filter((e) => {
|
|
75
|
+
if (isOurEntry(e)) {
|
|
76
|
+
removed++;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
if (kept.length > 0) outHooks[event] = kept;
|
|
82
|
+
}
|
|
83
|
+
const cleaned = { ...settings };
|
|
84
|
+
if (Object.keys(outHooks).length > 0) cleaned.hooks = outHooks;
|
|
85
|
+
else delete cleaned.hooks;
|
|
86
|
+
return { cleaned, removed };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
HOOK_EVENTS,
|
|
91
|
+
readSettings,
|
|
92
|
+
writeSettings,
|
|
93
|
+
backupSettings,
|
|
94
|
+
isOurEntry,
|
|
95
|
+
mergeHooks,
|
|
96
|
+
stripOurHooks
|
|
97
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/core/paths.ts
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
function entryPath() {
|
|
6
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "vdp.js");
|
|
7
|
+
}
|
|
8
|
+
function claudeDir() {
|
|
9
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
10
|
+
}
|
|
11
|
+
function settingsPath() {
|
|
12
|
+
return path.join(claudeDir(), "settings.json");
|
|
13
|
+
}
|
|
14
|
+
function presenceDir() {
|
|
15
|
+
return path.join(claudeDir(), "discord-presence");
|
|
16
|
+
}
|
|
17
|
+
function configPath() {
|
|
18
|
+
return path.join(presenceDir(), "config.json");
|
|
19
|
+
}
|
|
20
|
+
function statePath() {
|
|
21
|
+
return path.join(presenceDir(), "state.json");
|
|
22
|
+
}
|
|
23
|
+
function lockPath() {
|
|
24
|
+
return path.join(presenceDir(), "daemon.lock");
|
|
25
|
+
}
|
|
26
|
+
function sessionsDir() {
|
|
27
|
+
return path.join(presenceDir(), "sessions");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
entryPath,
|
|
32
|
+
claudeDir,
|
|
33
|
+
settingsPath,
|
|
34
|
+
presenceDir,
|
|
35
|
+
configPath,
|
|
36
|
+
statePath,
|
|
37
|
+
lockPath,
|
|
38
|
+
sessionsDir
|
|
39
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entryPath,
|
|
3
|
+
lockPath,
|
|
4
|
+
presenceDir,
|
|
5
|
+
statePath
|
|
6
|
+
} from "./chunk-FRGALQ5R.js";
|
|
7
|
+
|
|
8
|
+
// src/core/daemon-state.ts
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
10
|
+
import { spawn } from "child_process";
|
|
11
|
+
var DAEMON_STATUS_STALE_MS = 60 * 1e3;
|
|
12
|
+
function stripBom(s) {
|
|
13
|
+
return s.charCodeAt(0) === 65279 ? s.slice(1) : s;
|
|
14
|
+
}
|
|
15
|
+
function isProcessAlive(pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return err.code === "EPERM";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function readLock() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(stripBom(readFileSync(lockPath(), "utf8")));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function acquireLock(now) {
|
|
31
|
+
mkdirSync(presenceDir(), { recursive: true });
|
|
32
|
+
const payload = JSON.stringify({ pid: process.pid, startedAt: now });
|
|
33
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(lockPath(), payload, { flag: "wx" });
|
|
36
|
+
return true;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code !== "EEXIST") return false;
|
|
39
|
+
const existing = readLock();
|
|
40
|
+
if (existing && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
rmSync(lockPath());
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function releaseLock() {
|
|
52
|
+
const existing = readLock();
|
|
53
|
+
if (existing && existing.pid === process.pid) {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(lockPath());
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeDaemonStatus(status) {
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(presenceDir(), { recursive: true });
|
|
63
|
+
const dest = statePath();
|
|
64
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
65
|
+
writeFileSync(tmp, JSON.stringify(status));
|
|
66
|
+
renameSync(tmp, dest);
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function readDaemonStatus() {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(stripBom(readFileSync(statePath(), "utf8")));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function clearDaemonStatus() {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(statePath());
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function waitForExit(pid, timeoutMs) {
|
|
84
|
+
const step = 100;
|
|
85
|
+
for (let waited = 0; waited < timeoutMs; waited += step) {
|
|
86
|
+
if (!isProcessAlive(pid)) return true;
|
|
87
|
+
await new Promise((r) => setTimeout(r, step));
|
|
88
|
+
}
|
|
89
|
+
return !isProcessAlive(pid);
|
|
90
|
+
}
|
|
91
|
+
async function stopDaemon(timeoutMs = 2e3) {
|
|
92
|
+
const lock = readLock();
|
|
93
|
+
if (!lock || !isProcessAlive(lock.pid)) return null;
|
|
94
|
+
try {
|
|
95
|
+
process.kill(lock.pid, "SIGTERM");
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
await waitForExit(lock.pid, timeoutMs);
|
|
100
|
+
return lock.pid;
|
|
101
|
+
}
|
|
102
|
+
function spawnDaemon() {
|
|
103
|
+
try {
|
|
104
|
+
const child = spawn(process.execPath, [entryPath(), "daemon"], {
|
|
105
|
+
detached: true,
|
|
106
|
+
stdio: "ignore",
|
|
107
|
+
windowsHide: true
|
|
108
|
+
// don't flash a console window on Windows
|
|
109
|
+
});
|
|
110
|
+
child.unref();
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
DAEMON_STATUS_STALE_MS,
|
|
117
|
+
isProcessAlive,
|
|
118
|
+
readLock,
|
|
119
|
+
acquireLock,
|
|
120
|
+
releaseLock,
|
|
121
|
+
writeDaemonStatus,
|
|
122
|
+
readDaemonStatus,
|
|
123
|
+
clearDaemonStatus,
|
|
124
|
+
stopDaemon,
|
|
125
|
+
spawnDaemon
|
|
126
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/ui.ts
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
var ui = {
|
|
4
|
+
/** Bold cyan heading. */
|
|
5
|
+
title: (s) => pc.bold(pc.cyan(s)),
|
|
6
|
+
/** Success text (green). */
|
|
7
|
+
ok: (s) => pc.green(s),
|
|
8
|
+
/** Error text (red). */
|
|
9
|
+
err: (s) => pc.red(s),
|
|
10
|
+
/** Warning / attention text (yellow). */
|
|
11
|
+
warn: (s) => pc.yellow(s),
|
|
12
|
+
/** Accent for values, commands, URLs (cyan). */
|
|
13
|
+
accent: (s) => pc.cyan(s),
|
|
14
|
+
/** Secondary / muted text (dim). */
|
|
15
|
+
dim: (s) => pc.dim(s),
|
|
16
|
+
/** Emphasis (bold). */
|
|
17
|
+
bold: (s) => pc.bold(s),
|
|
18
|
+
/** Green check glyph. */
|
|
19
|
+
check: pc.green("\u2713"),
|
|
20
|
+
/** Red cross glyph. */
|
|
21
|
+
cross: pc.red("\u2717"),
|
|
22
|
+
/** Muted bullet glyph. */
|
|
23
|
+
bullet: pc.dim("\u2022")
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
ui
|
|
28
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/core/presence.ts
|
|
2
|
+
function formatElapsed(ms) {
|
|
3
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
4
|
+
const h = Math.floor(total / 3600);
|
|
5
|
+
const m = Math.floor(total % 3600 / 60);
|
|
6
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
7
|
+
if (m > 0) return `${m}m`;
|
|
8
|
+
return `${total}s`;
|
|
9
|
+
}
|
|
10
|
+
function formatTokens(n) {
|
|
11
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
12
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
|
13
|
+
return String(n);
|
|
14
|
+
}
|
|
15
|
+
function buildValues(state, now) {
|
|
16
|
+
return {
|
|
17
|
+
project: state.project ?? "",
|
|
18
|
+
branch: state.branch ?? "",
|
|
19
|
+
model: state.model ?? "",
|
|
20
|
+
activity: state.activity ?? "",
|
|
21
|
+
file: state.file ?? "",
|
|
22
|
+
tokens: state.tokens != null ? formatTokens(state.tokens) : "",
|
|
23
|
+
cost: state.cost != null ? `$${state.cost.toFixed(2)}` : "",
|
|
24
|
+
elapsed: formatElapsed(now - state.startedAt),
|
|
25
|
+
sessionCount: String(state.sessionCount),
|
|
26
|
+
// Drives the `status-{state}` badge; also available as a text token.
|
|
27
|
+
state: state.state ?? "idle"
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function tidy(s) {
|
|
31
|
+
return s.replace(/\(\s*\)/g, "").replace(/\[\s*\]/g, "").replace(/\s+/g, " ").replace(/·(\s*·)+/g, "\xB7").replace(/^\s*·\s*/, "").replace(/\s*·\s*$/, "").trim();
|
|
32
|
+
}
|
|
33
|
+
function interpolate(template, values) {
|
|
34
|
+
return tidy(template.replace(/\{(\w+)\}/g, (_, key) => values[key] ?? ""));
|
|
35
|
+
}
|
|
36
|
+
function renderPresence(theme, state, now) {
|
|
37
|
+
const values = buildValues(state, now);
|
|
38
|
+
const payload = {};
|
|
39
|
+
const details = interpolate(theme.details, values);
|
|
40
|
+
if (details) payload.details = details;
|
|
41
|
+
const stateLine = interpolate(theme.state, values);
|
|
42
|
+
if (stateLine) payload.state = stateLine;
|
|
43
|
+
const largeKey = interpolate(theme.largeImage.key, values);
|
|
44
|
+
if (largeKey) {
|
|
45
|
+
payload.largeImageKey = largeKey;
|
|
46
|
+
const largeText = interpolate(theme.largeImage.text, values);
|
|
47
|
+
if (largeText) payload.largeImageText = largeText;
|
|
48
|
+
}
|
|
49
|
+
const smallKey = interpolate(theme.smallImage.key, values);
|
|
50
|
+
if (smallKey) {
|
|
51
|
+
payload.smallImageKey = smallKey;
|
|
52
|
+
const smallText = interpolate(theme.smallImage.text, values);
|
|
53
|
+
if (smallText) payload.smallImageText = smallText;
|
|
54
|
+
}
|
|
55
|
+
if (theme.timer) payload.startTimestamp = state.startedAt;
|
|
56
|
+
if (theme.statusDisplay === "state" && payload.state) payload.statusDisplayType = 1;
|
|
57
|
+
else if (theme.statusDisplay === "details" && payload.details) payload.statusDisplayType = 2;
|
|
58
|
+
const buttons = theme.buttons.map((b) => ({ label: interpolate(b.label, values), url: b.url })).filter((b) => b.label && b.url).slice(0, 2);
|
|
59
|
+
if (buttons.length > 0) payload.buttons = buttons;
|
|
60
|
+
return payload;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
renderPresence
|
|
65
|
+
};
|