claude-code-discord-status 0.1.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 +128 -0
- package/dist/cli.js +505 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/index.js +614 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/mcp/index.js +108 -0
- package/dist/mcp/index.js.map +1 -0
- package/package.json +59 -0
- package/src/hooks/claude-hook.sh +141 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruno
|
|
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,128 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/hero-banner.svg" alt="claude-code-discord-status" width="900" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/claude-code-discord-status"><img src="https://img.shields.io/npm/v/claude-code-discord-status?color=5865f2&style=flat-square" alt="npm version" /></a>
|
|
7
|
+
<a href="https://github.com/BrunoJurkovic/claude-code-discord-status/actions"><img src="https://img.shields.io/github/actions/workflow/status/BrunoJurkovic/claude-code-discord-status/ci.yml?style=flat-square" alt="CI" /></a>
|
|
8
|
+
<a href="https://github.com/BrunoJurkovic/claude-code-discord-status/blob/main/LICENSE"><img src="https://img.shields.io/github/license/BrunoJurkovic/claude-code-discord-status?style=flat-square" alt="License" /></a>
|
|
9
|
+
<img src="https://img.shields.io/node/v/claude-code-discord-status?style=flat-square" alt="Node version" />
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
Show what Claude Code is doing as a live Discord Rich Presence card.<br/>
|
|
14
|
+
Hooks into Claude Code's lifecycle events and updates your Discord status in real time.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Preview
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="./assets/card-single.svg" alt="Single session card" width="340" />
|
|
23
|
+
|
|
24
|
+
<img src="./assets/card-multi.svg" alt="Multi-session card" width="340" />
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<b>Single session</b> — shows current action + project name · <b>Multiple sessions</b> — quirky messages + aggregate stats
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Live activity updates** — Your Discord card reflects what Claude is doing right now (editing, searching, running commands, thinking)
|
|
34
|
+
- **MCP-powered custom messages** — Claude can set its own status via `set_discord_status`, with a 30-second priority window over hook updates
|
|
35
|
+
- **Multi-session support** — Running multiple Claude Code instances? The card escalates with quirky messages and aggregated stats
|
|
36
|
+
- **Activity mode detection** — Dominant activity type (coding, terminal, searching, thinking) changes the card icon
|
|
37
|
+
- **Rotating tooltips** — Hidden easter eggs on hover, rotating every 5 minutes
|
|
38
|
+
- **Auto-reconnect** — Daemon handles Discord RPC disconnects gracefully
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### Prerequisites
|
|
43
|
+
|
|
44
|
+
- Node.js >= 18
|
|
45
|
+
- [jq](https://jqlang.github.io/jq/) (`brew install jq` / `apt install jq`)
|
|
46
|
+
- Discord desktop app running
|
|
47
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
|
|
48
|
+
|
|
49
|
+
### Setup
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx claude-code-discord-status setup
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This will:
|
|
56
|
+
|
|
57
|
+
1. Create a config at `~/.claude-discord-status/config.json`
|
|
58
|
+
2. Register the MCP server with Claude Code
|
|
59
|
+
3. Add lifecycle hooks to `~/.claude/settings.json`
|
|
60
|
+
4. Start the daemon in the background
|
|
61
|
+
|
|
62
|
+
That's it. Your Discord status updates automatically whenever you use Claude Code.
|
|
63
|
+
|
|
64
|
+
## How It Works
|
|
65
|
+
|
|
66
|
+
<p align="center">
|
|
67
|
+
<img src="./assets/architecture.svg" alt="Architecture diagram" width="800" />
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
Three components work together:
|
|
71
|
+
|
|
72
|
+
1. **Hooks** — Bash scripts fired by Claude Code lifecycle events (session start/end, tool use, prompt submit). They POST updates to the daemon's HTTP API.
|
|
73
|
+
2. **MCP Server** — An MCP tool (`set_discord_status`) that Claude can call to set a custom, contextual status message — these take priority for 30 seconds.
|
|
74
|
+
3. **Daemon** — Background process that holds the Discord RPC connection, tracks all sessions, resolves what to show, and pushes it to Discord.
|
|
75
|
+
|
|
76
|
+
> See [docs/architecture.md](./docs/architecture.md) for the full deep dive.
|
|
77
|
+
|
|
78
|
+
## CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx claude-code-discord-status setup # Interactive setup wizard
|
|
82
|
+
npx claude-code-discord-status status # Check daemon status and active sessions
|
|
83
|
+
npx claude-code-discord-status start -d # Start daemon in background
|
|
84
|
+
npx claude-code-discord-status stop # Stop the daemon
|
|
85
|
+
npx claude-code-discord-status uninstall # Remove everything
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
Config file: `~/.claude-discord-status/config.json`
|
|
91
|
+
|
|
92
|
+
| Key | Env Override | Default | Description |
|
|
93
|
+
| --- | --- | --- | --- |
|
|
94
|
+
| `discordClientId` | `CLAUDE_DISCORD_CLIENT_ID` | `1472915568930848829` | Discord Application Client ID |
|
|
95
|
+
| `daemonPort` | `CLAUDE_DISCORD_PORT` | `19452` | Local HTTP server port |
|
|
96
|
+
|
|
97
|
+
The default client ID works out of the box — it's a public app identifier, not a secret.
|
|
98
|
+
|
|
99
|
+
> See [docs/setup.md](./docs/setup.md) for all config options, timeouts, and how to use a custom Discord application.
|
|
100
|
+
|
|
101
|
+
## Multi-Session Fun
|
|
102
|
+
|
|
103
|
+
When you're running multiple Claude Code sessions, the card gets quirky:
|
|
104
|
+
|
|
105
|
+
- **2 sessions** — _"Dual-wielding codebases"_, _"Pair programming with myself"_
|
|
106
|
+
- **3 sessions** — _"Triple threat detected"_, _"Three-ring circus"_
|
|
107
|
+
- **4 sessions** — _"4 parallel universes deep"_, _"One for each brain cell"_
|
|
108
|
+
- **5+ sessions** — _"Send help (5 projects)"_, _"Gone feral (6 projects)"_
|
|
109
|
+
|
|
110
|
+
Plus aggregate stats like `23 edits · 8 cmds · 2h 15m deep` and rotating hover tooltips like _"Technically I'm one Claude in a trenchcoat"_.
|
|
111
|
+
|
|
112
|
+
> See [docs/multi-session.md](./docs/multi-session.md) for the full message pool and how the resolver works.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
git clone https://github.com/BrunoJurkovic/claude-code-discord-status.git
|
|
118
|
+
cd claude-code-discord-status
|
|
119
|
+
npm install
|
|
120
|
+
npm run build
|
|
121
|
+
npm test
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync, mkdirSync, unlinkSync } from "fs";
|
|
5
|
+
import { spawn, execSync } from "child_process";
|
|
6
|
+
import { join as join2, dirname, resolve } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
9
|
+
|
|
10
|
+
// src/shared/constants.ts
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
var DEFAULT_PORT = 19452;
|
|
14
|
+
var CONFIG_DIR = join(homedir(), ".claude-discord-status");
|
|
15
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
16
|
+
var PID_FILE = join(CONFIG_DIR, "daemon.pid");
|
|
17
|
+
var LOG_FILE = join(CONFIG_DIR, "daemon.log");
|
|
18
|
+
var DEFAULT_DISCORD_CLIENT_ID = "1472915568930848829";
|
|
19
|
+
var STALE_CHECK_INTERVAL = 3e4;
|
|
20
|
+
var IDLE_TIMEOUT = 6e5;
|
|
21
|
+
var REMOVE_TIMEOUT = 18e5;
|
|
22
|
+
|
|
23
|
+
// src/shared/config.ts
|
|
24
|
+
import { readFileSync, existsSync } from "fs";
|
|
25
|
+
function loadConfig() {
|
|
26
|
+
let fileConfig = {};
|
|
27
|
+
if (existsSync(CONFIG_FILE)) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
30
|
+
fileConfig = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
discordClientId: process.env.CLAUDE_DISCORD_CLIENT_ID ?? fileConfig.discordClientId ?? DEFAULT_DISCORD_CLIENT_ID,
|
|
36
|
+
daemonPort: process.env.CLAUDE_DISCORD_PORT ? parseInt(process.env.CLAUDE_DISCORD_PORT, 10) : fileConfig.daemonPort ?? DEFAULT_PORT,
|
|
37
|
+
staleCheckInterval: fileConfig.staleCheckInterval ?? STALE_CHECK_INTERVAL,
|
|
38
|
+
idleTimeout: fileConfig.idleTimeout ?? IDLE_TIMEOUT,
|
|
39
|
+
removeTimeout: fileConfig.removeTimeout ?? REMOVE_TIMEOUT
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/cli-utils.ts
|
|
44
|
+
var green = (s) => `\x1B[32m${s}\x1B[39m`;
|
|
45
|
+
var yellow = (s) => `\x1B[33m${s}\x1B[39m`;
|
|
46
|
+
var dim = (s) => `\x1B[2m${s}\x1B[22m`;
|
|
47
|
+
function formatDuration(ms) {
|
|
48
|
+
const seconds = Math.floor(ms / 1e3);
|
|
49
|
+
if (seconds < 60) return `${seconds}s`;
|
|
50
|
+
const minutes = Math.floor(seconds / 60);
|
|
51
|
+
const remainingSeconds = seconds % 60;
|
|
52
|
+
if (minutes < 60) {
|
|
53
|
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
54
|
+
}
|
|
55
|
+
const hours = Math.floor(minutes / 60);
|
|
56
|
+
const remainingMinutes = minutes % 60;
|
|
57
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
58
|
+
}
|
|
59
|
+
function statusBadge(status) {
|
|
60
|
+
if (status === "active") return green("active");
|
|
61
|
+
if (status === "idle") return yellow("idle");
|
|
62
|
+
return dim(status);
|
|
63
|
+
}
|
|
64
|
+
function connectionBadge(connected) {
|
|
65
|
+
return connected ? green("Connected") : yellow("Connecting...");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/cli.ts
|
|
69
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
70
|
+
var __dirname = dirname(__filename);
|
|
71
|
+
var args = process.argv.slice(2);
|
|
72
|
+
var command = args[0];
|
|
73
|
+
function getDaemonPid() {
|
|
74
|
+
try {
|
|
75
|
+
if (existsSync2(PID_FILE)) {
|
|
76
|
+
const pid = parseInt(readFileSync2(PID_FILE, "utf-8").trim(), 10);
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
return pid;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(PID_FILE);
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
async function checkHealth() {
|
|
89
|
+
const config = loadConfig();
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`http://127.0.0.1:${config.daemonPort}/health`);
|
|
92
|
+
if (res.ok) {
|
|
93
|
+
return await res.json();
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
async function startDaemon(background) {
|
|
100
|
+
p.intro("claude-discord-status");
|
|
101
|
+
const existing = getDaemonPid();
|
|
102
|
+
if (existing) {
|
|
103
|
+
p.log.warn(`Daemon is already running (PID ${existing})`);
|
|
104
|
+
p.outro();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const daemonPath = resolve(__dirname, "daemon", "index.js");
|
|
108
|
+
if (!existsSync2(daemonPath)) {
|
|
109
|
+
p.log.error(`Daemon entry point not found at ${daemonPath}`);
|
|
110
|
+
p.log.info("Run `npm run build` first.");
|
|
111
|
+
p.outro();
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
if (background) {
|
|
115
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
116
|
+
const { openSync } = await import("fs");
|
|
117
|
+
const logFd = openSync(LOG_FILE, "a");
|
|
118
|
+
const child = spawn("node", [daemonPath], {
|
|
119
|
+
detached: true,
|
|
120
|
+
stdio: ["ignore", logFd, logFd],
|
|
121
|
+
env: { ...process.env }
|
|
122
|
+
});
|
|
123
|
+
child.unref();
|
|
124
|
+
p.log.success(`Daemon started in background (PID ${child.pid})`);
|
|
125
|
+
p.log.info(`Log file: ${LOG_FILE}`);
|
|
126
|
+
p.outro();
|
|
127
|
+
} else {
|
|
128
|
+
p.log.info("Starting daemon in foreground...");
|
|
129
|
+
p.outro();
|
|
130
|
+
const child = spawn("node", [daemonPath], {
|
|
131
|
+
stdio: "inherit",
|
|
132
|
+
env: { ...process.env }
|
|
133
|
+
});
|
|
134
|
+
child.on("exit", (code) => {
|
|
135
|
+
process.exit(code ?? 0);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function stopDaemon() {
|
|
140
|
+
p.intro("claude-discord-status");
|
|
141
|
+
const pid = getDaemonPid();
|
|
142
|
+
if (!pid) {
|
|
143
|
+
p.log.info("Daemon is not running.");
|
|
144
|
+
p.outro();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, "SIGTERM");
|
|
149
|
+
p.log.success(`Daemon stopped (PID ${pid})`);
|
|
150
|
+
} catch {
|
|
151
|
+
p.log.info("Daemon process not found, cleaning up PID file.");
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(PID_FILE);
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
p.outro();
|
|
158
|
+
}
|
|
159
|
+
async function showStatus() {
|
|
160
|
+
p.intro("claude-discord-status");
|
|
161
|
+
const pid = getDaemonPid();
|
|
162
|
+
const health = await checkHealth();
|
|
163
|
+
if (!pid && !health) {
|
|
164
|
+
p.log.info("Daemon is not running.");
|
|
165
|
+
p.outro();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const lines = [];
|
|
169
|
+
lines.push(`PID ${pid ?? "unknown"}`);
|
|
170
|
+
if (health) {
|
|
171
|
+
lines.push(`Discord ${connectionBadge(health.connected)}`);
|
|
172
|
+
lines.push(`Sessions ${health.sessions} active`);
|
|
173
|
+
lines.push(`Uptime ${formatDuration(health.uptime * 1e3)}`);
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(`Health Could not reach daemon`);
|
|
176
|
+
}
|
|
177
|
+
p.note(lines.join("\n"), "Daemon Status");
|
|
178
|
+
const config = loadConfig();
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch(`http://127.0.0.1:${config.daemonPort}/sessions`);
|
|
181
|
+
if (res.ok) {
|
|
182
|
+
const sessions = await res.json();
|
|
183
|
+
if (sessions.length > 0) {
|
|
184
|
+
for (const s of sessions) {
|
|
185
|
+
const elapsed = s.startedAt ? formatDuration(Date.now() - new Date(s.startedAt).getTime()) : "";
|
|
186
|
+
const badge = statusBadge(s.status);
|
|
187
|
+
p.log.step(`${s.projectName}
|
|
188
|
+
${s.details} \u2014 ${badge}${elapsed ? ` \u2014 ${elapsed}` : ""}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
p.outro();
|
|
195
|
+
}
|
|
196
|
+
async function setup() {
|
|
197
|
+
p.intro("claude-discord-status");
|
|
198
|
+
const nodeVersion = process.versions.node;
|
|
199
|
+
const nodeMajor = parseInt(nodeVersion.split(".")[0], 10);
|
|
200
|
+
if (nodeMajor < 18) {
|
|
201
|
+
p.log.error(`Node.js >= 18 required (found ${nodeVersion})`);
|
|
202
|
+
p.outro();
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
let jqVersion = "";
|
|
206
|
+
try {
|
|
207
|
+
jqVersion = execSync("jq --version", { stdio: "pipe" }).toString().trim();
|
|
208
|
+
} catch {
|
|
209
|
+
p.log.error("jq is required but not found.");
|
|
210
|
+
p.log.info(" macOS: brew install jq");
|
|
211
|
+
p.log.info(" Ubuntu: sudo apt install jq");
|
|
212
|
+
p.outro();
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
p.log.success(`Node.js ${nodeVersion}`);
|
|
216
|
+
p.log.success(`jq ${jqVersion}`);
|
|
217
|
+
let resolvedClientId = DEFAULT_DISCORD_CLIENT_ID;
|
|
218
|
+
const existingConfig = existsSync2(CONFIG_FILE);
|
|
219
|
+
if (existingConfig) {
|
|
220
|
+
try {
|
|
221
|
+
const current = JSON.parse(readFileSync2(CONFIG_FILE, "utf-8"));
|
|
222
|
+
if (current.discordClientId) {
|
|
223
|
+
resolvedClientId = current.discordClientId;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const useCustomApp = await p.confirm({
|
|
229
|
+
message: 'Use a custom Discord app? (default shows as "Claude Code")',
|
|
230
|
+
initialValue: false
|
|
231
|
+
});
|
|
232
|
+
if (p.isCancel(useCustomApp)) {
|
|
233
|
+
p.cancel("Setup cancelled.");
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
236
|
+
if (useCustomApp) {
|
|
237
|
+
const clientId = await p.text({
|
|
238
|
+
message: "Discord Client ID",
|
|
239
|
+
placeholder: DEFAULT_DISCORD_CLIENT_ID,
|
|
240
|
+
validate: (value = "") => {
|
|
241
|
+
if (!value.trim()) return "Client ID is required";
|
|
242
|
+
if (!/^\d+$/.test(value.trim())) return "Client ID must be numeric";
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
if (p.isCancel(clientId)) {
|
|
246
|
+
p.cancel("Setup cancelled.");
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
resolvedClientId = clientId.trim();
|
|
250
|
+
}
|
|
251
|
+
if (resolvedClientId === DEFAULT_DISCORD_CLIENT_ID) {
|
|
252
|
+
p.log.info("Using default Client ID");
|
|
253
|
+
} else {
|
|
254
|
+
p.log.info(`Using custom Client ID: ${resolvedClientId}`);
|
|
255
|
+
}
|
|
256
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
257
|
+
const config = {
|
|
258
|
+
discordClientId: resolvedClientId,
|
|
259
|
+
daemonPort: DEFAULT_PORT
|
|
260
|
+
};
|
|
261
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
262
|
+
p.log.success(`Config written to ${CONFIG_FILE}`);
|
|
263
|
+
let hasClaude = false;
|
|
264
|
+
try {
|
|
265
|
+
execSync("which claude", { stdio: "pipe" });
|
|
266
|
+
hasClaude = true;
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
const mcpPath = resolve(__dirname, "mcp", "index.js");
|
|
270
|
+
if (hasClaude) {
|
|
271
|
+
try {
|
|
272
|
+
execSync(`claude mcp add --transport stdio --scope user discord-status -- node ${mcpPath}`, {
|
|
273
|
+
stdio: "pipe"
|
|
274
|
+
});
|
|
275
|
+
p.log.success("MCP server registered");
|
|
276
|
+
} catch {
|
|
277
|
+
p.log.warn("Could not register MCP server automatically.");
|
|
278
|
+
p.log.info(
|
|
279
|
+
` Run: claude mcp add --transport stdio --scope user discord-status -- node ${mcpPath}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
p.log.warn("claude CLI not found \u2014 skipping MCP registration.");
|
|
284
|
+
p.log.info(
|
|
285
|
+
` Run: claude mcp add --transport stdio --scope user discord-status -- node ${mcpPath}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const hookScriptPath = resolve(__dirname, "..", "src", "hooks", "claude-hook.sh");
|
|
289
|
+
const hookCommand = existsSync2(hookScriptPath) ? hookScriptPath : "claude-hook.sh";
|
|
290
|
+
const claudeSettingsPath = join2(
|
|
291
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "~",
|
|
292
|
+
".claude",
|
|
293
|
+
"settings.json"
|
|
294
|
+
);
|
|
295
|
+
const hookConfig = createHookConfig(hookCommand);
|
|
296
|
+
try {
|
|
297
|
+
let existingSettings = {};
|
|
298
|
+
if (existsSync2(claudeSettingsPath)) {
|
|
299
|
+
existingSettings = JSON.parse(readFileSync2(claudeSettingsPath, "utf-8"));
|
|
300
|
+
}
|
|
301
|
+
const existingHooks = existingSettings.hooks ?? {};
|
|
302
|
+
const newHooks = hookConfig.hooks;
|
|
303
|
+
let hooksAdded = 0;
|
|
304
|
+
let hooksSkipped = 0;
|
|
305
|
+
for (const [event, entries] of Object.entries(newHooks)) {
|
|
306
|
+
if (!existingHooks[event]) {
|
|
307
|
+
existingHooks[event] = [];
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
const entryStr = JSON.stringify(entry);
|
|
311
|
+
const alreadyExists = existingHooks[event].some((e) => JSON.stringify(e) === entryStr);
|
|
312
|
+
if (!alreadyExists) {
|
|
313
|
+
existingHooks[event].push(entry);
|
|
314
|
+
hooksAdded++;
|
|
315
|
+
} else {
|
|
316
|
+
hooksSkipped++;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
existingSettings.hooks = existingHooks;
|
|
321
|
+
mkdirSync(dirname(claudeSettingsPath), { recursive: true });
|
|
322
|
+
writeFileSync(claudeSettingsPath, JSON.stringify(existingSettings, null, 2), "utf-8");
|
|
323
|
+
if (hooksAdded > 0 && hooksSkipped > 0) {
|
|
324
|
+
p.log.success(`Hooks configured (${hooksAdded} added, ${hooksSkipped} already present)`);
|
|
325
|
+
} else if (hooksAdded > 0) {
|
|
326
|
+
p.log.success(`Hooks configured (${hooksAdded} lifecycle events)`);
|
|
327
|
+
} else {
|
|
328
|
+
p.log.success("Hooks already configured (no changes)");
|
|
329
|
+
}
|
|
330
|
+
} catch (err) {
|
|
331
|
+
p.log.warn(`Could not configure hooks: ${err.message}`);
|
|
332
|
+
p.log.info(` Manually add hooks to ${claudeSettingsPath}`);
|
|
333
|
+
}
|
|
334
|
+
const existingPid = getDaemonPid();
|
|
335
|
+
if (existingPid) {
|
|
336
|
+
p.log.success(`Daemon already running (PID ${existingPid})`);
|
|
337
|
+
} else {
|
|
338
|
+
const daemonPath = resolve(__dirname, "daemon", "index.js");
|
|
339
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
340
|
+
const { openSync } = await import("fs");
|
|
341
|
+
const logFd = openSync(LOG_FILE, "a");
|
|
342
|
+
const child = spawn("node", [daemonPath], {
|
|
343
|
+
detached: true,
|
|
344
|
+
stdio: ["ignore", logFd, logFd],
|
|
345
|
+
env: { ...process.env }
|
|
346
|
+
});
|
|
347
|
+
child.unref();
|
|
348
|
+
p.log.success(`Daemon started (PID ${child.pid})`);
|
|
349
|
+
}
|
|
350
|
+
const s = p.spinner();
|
|
351
|
+
s.start("Verifying Discord connection...");
|
|
352
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
353
|
+
const health = await checkHealth();
|
|
354
|
+
if (health) {
|
|
355
|
+
if (health.connected) {
|
|
356
|
+
s.stop("Discord connected");
|
|
357
|
+
} else {
|
|
358
|
+
s.stop("Discord is connecting (open Discord if not running)");
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
s.stop("Could not reach daemon \u2014 check logs");
|
|
362
|
+
p.log.info(` cat ${LOG_FILE}`);
|
|
363
|
+
}
|
|
364
|
+
p.note(
|
|
365
|
+
'Open Discord and check your profile \u2014 you\nshould see "Using Claude Code" as activity.',
|
|
366
|
+
"Next steps"
|
|
367
|
+
);
|
|
368
|
+
p.outro("Setup complete!");
|
|
369
|
+
}
|
|
370
|
+
function createHookConfig(hookCommand) {
|
|
371
|
+
const syncHook = {
|
|
372
|
+
matcher: "",
|
|
373
|
+
hooks: [
|
|
374
|
+
{
|
|
375
|
+
type: "command",
|
|
376
|
+
command: hookCommand,
|
|
377
|
+
timeout: 5
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
};
|
|
381
|
+
const asyncHook = (matcher) => ({
|
|
382
|
+
...matcher ? { matcher } : {},
|
|
383
|
+
hooks: [
|
|
384
|
+
{
|
|
385
|
+
type: "command",
|
|
386
|
+
command: hookCommand,
|
|
387
|
+
timeout: 5,
|
|
388
|
+
async: true
|
|
389
|
+
}
|
|
390
|
+
]
|
|
391
|
+
});
|
|
392
|
+
return {
|
|
393
|
+
hooks: {
|
|
394
|
+
SessionStart: [syncHook],
|
|
395
|
+
UserPromptSubmit: [asyncHook()],
|
|
396
|
+
PreToolUse: [asyncHook("Write|Edit|Bash|Read|Grep|Glob|WebSearch|WebFetch|Task")],
|
|
397
|
+
Stop: [asyncHook()],
|
|
398
|
+
Notification: [asyncHook()],
|
|
399
|
+
SessionEnd: [asyncHook()]
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function uninstall() {
|
|
404
|
+
p.intro("claude-discord-status");
|
|
405
|
+
const shouldContinue = await p.confirm({
|
|
406
|
+
message: "This will remove all hooks, MCP registration, and config. Continue?",
|
|
407
|
+
initialValue: false
|
|
408
|
+
});
|
|
409
|
+
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
|
410
|
+
p.cancel("Uninstall cancelled.");
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
const pid = getDaemonPid();
|
|
414
|
+
if (pid) {
|
|
415
|
+
try {
|
|
416
|
+
process.kill(pid, "SIGTERM");
|
|
417
|
+
p.log.success(`Daemon stopped (PID ${pid})`);
|
|
418
|
+
} catch {
|
|
419
|
+
p.log.info("Daemon process not found, cleaning up PID file.");
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
unlinkSync(PID_FILE);
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
p.log.info("Daemon was not running");
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
execSync("claude mcp remove discord-status", { stdio: "pipe" });
|
|
430
|
+
p.log.success("MCP server removed");
|
|
431
|
+
} catch {
|
|
432
|
+
p.log.warn("Could not remove MCP server (may not have been registered)");
|
|
433
|
+
}
|
|
434
|
+
const claudeSettingsPath = join2(
|
|
435
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "~",
|
|
436
|
+
".claude",
|
|
437
|
+
"settings.json"
|
|
438
|
+
);
|
|
439
|
+
try {
|
|
440
|
+
if (existsSync2(claudeSettingsPath)) {
|
|
441
|
+
const settings = JSON.parse(readFileSync2(claudeSettingsPath, "utf-8"));
|
|
442
|
+
if (settings.hooks) {
|
|
443
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
444
|
+
settings.hooks[event] = settings.hooks[event].filter((entry) => {
|
|
445
|
+
const str = JSON.stringify(entry);
|
|
446
|
+
return !str.includes("claude-hook.sh");
|
|
447
|
+
});
|
|
448
|
+
if (settings.hooks[event].length === 0) {
|
|
449
|
+
delete settings.hooks[event];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
453
|
+
delete settings.hooks;
|
|
454
|
+
}
|
|
455
|
+
writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
456
|
+
p.log.success("Hooks removed");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
p.log.warn("Could not clean up hooks");
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const { rmSync } = await import("fs");
|
|
464
|
+
rmSync(CONFIG_DIR, { recursive: true, force: true });
|
|
465
|
+
p.log.success("Config removed");
|
|
466
|
+
} catch {
|
|
467
|
+
p.log.warn("Could not remove config directory");
|
|
468
|
+
}
|
|
469
|
+
p.outro("Uninstall complete.");
|
|
470
|
+
}
|
|
471
|
+
function showHelp() {
|
|
472
|
+
p.intro("claude-discord-status");
|
|
473
|
+
p.note(
|
|
474
|
+
[
|
|
475
|
+
"setup Interactive setup",
|
|
476
|
+
"start [-d] Start the daemon (-d for background)",
|
|
477
|
+
"stop Stop the daemon",
|
|
478
|
+
"status Show daemon status and sessions",
|
|
479
|
+
"uninstall Remove all hooks, MCP, and config"
|
|
480
|
+
].join("\n"),
|
|
481
|
+
"Commands"
|
|
482
|
+
);
|
|
483
|
+
p.outro("Discord Rich Presence for Claude Code");
|
|
484
|
+
}
|
|
485
|
+
switch (command) {
|
|
486
|
+
case "start":
|
|
487
|
+
await startDaemon(args.includes("-d") || args.includes("--daemon"));
|
|
488
|
+
break;
|
|
489
|
+
case "stop":
|
|
490
|
+
await stopDaemon();
|
|
491
|
+
break;
|
|
492
|
+
case "status":
|
|
493
|
+
await showStatus();
|
|
494
|
+
break;
|
|
495
|
+
case "setup":
|
|
496
|
+
await setup();
|
|
497
|
+
break;
|
|
498
|
+
case "uninstall":
|
|
499
|
+
await uninstall();
|
|
500
|
+
break;
|
|
501
|
+
default:
|
|
502
|
+
showHelp();
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
//# sourceMappingURL=cli.js.map
|