hookherald 0.3.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/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # HookHerald
2
+
3
+ A webhook relay that pushes notifications into running [Claude Code](https://claude.ai/code) sessions. Any system that can fire an HTTP POST — CI pipelines, monitoring alerts, deployment hooks, chat bots, cron jobs — can send messages directly into your Claude conversation.
4
+
5
+ ## How It Works
6
+
7
+ ```
8
+ Webhook POST ──> Router (auth + route) ──> Channel ──> Claude Code session
9
+ :9000 (MCP) <channel> notification
10
+ ```
11
+
12
+ The **router** is a central HTTP server that receives webhooks and forwards them by `project_slug` to the right session. Each Claude Code session runs a **channel** (MCP server) that auto-registers with the router. The **CLI** (`hh`) sets everything up.
13
+
14
+ Payloads are forwarded as raw JSON — send whatever you want, the agent figures it out.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g hookherald
20
+ ```
21
+
22
+ That's it. Requires Node.js >= 18.
23
+
24
+ ## Usage
25
+
26
+ ### 1. Start the router
27
+
28
+ ```bash
29
+ hh router # foreground (see logs, ctrl+c to stop)
30
+ hh router --bg # background (detach, write PID file)
31
+ hh router stop # stop background router
32
+ ```
33
+
34
+ Dashboard at `http://127.0.0.1:9000/`.
35
+
36
+ ### 2. Set up a project
37
+
38
+ ```bash
39
+ cd ~/my-project
40
+ hh init
41
+ ```
42
+
43
+ Auto-detects the project slug from `git remote origin` and writes `.mcp.json`. Merges with existing config if present.
44
+
45
+ ### 3. Start Claude Code
46
+
47
+ ```bash
48
+ claude --dangerously-load-development-channels server:webhook-channel
49
+ ```
50
+
51
+ The channel starts, registers with the router, and maintains a heartbeat. If the router restarts, the channel reconnects automatically. When the session ends, the channel cleans up after itself.
52
+
53
+ ### 4. Send webhooks
54
+
55
+ ```bash
56
+ curl -X POST http://127.0.0.1:9000/ \
57
+ -H "Content-Type: application/json" \
58
+ -H "X-Webhook-Token: dev-secret" \
59
+ -d '{"project_slug":"my-group/my-project","status":"deployed","version":"1.2.3"}'
60
+ ```
61
+
62
+ The only required field is `project_slug` for routing — everything else passes through as raw JSON.
63
+
64
+ The `X-Webhook-Token` header authenticates the request against `WEBHOOK_SECRET`. GitLab sends this natively as `X-Gitlab-Token` when you configure a webhook secret — the router accepts both headers.
65
+
66
+ ## CLI Reference
67
+
68
+ ```
69
+ hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json in current directory
70
+ hh status [--router-url <url>] Show active sessions
71
+ hh kill <slug> [--router-url <url>] Bounce a session (Claude Code respawns it)
72
+ hh router [--port <port>] [--secret <secret>] Start the webhook router
73
+ [--bg] Run in background
74
+ hh router stop Stop background router
75
+ ```
76
+
77
+ ## Docker
78
+
79
+ The router can also run via Docker. Requires `--network host` to reach channel processes on localhost:
80
+
81
+ ```bash
82
+ docker run -d --network host -e WEBHOOK_SECRET=my-secret shoofio/hookherald
83
+ ```
84
+
85
+ > **Note:** `--network host` requires Linux with standard Docker. For rootless Docker or Docker Desktop (Mac/Windows), use `hh router` instead.
86
+
87
+ ## Router API
88
+
89
+ | Method | Path | Description |
90
+ |--------|------|-------------|
91
+ | `POST` | `/` | Receive webhook (requires `X-Webhook-Token` or `X-Gitlab-Token` header) |
92
+ | `POST` | `/register` | Channel self-registration |
93
+ | `POST` | `/unregister` | Channel self-unregistration |
94
+ | `POST` | `/api/kill` | Remove a session (signals channel to shut down) |
95
+ | `GET` | `/` | Session management dashboard |
96
+ | `GET` | `/api/health` | Router health check |
97
+ | `GET` | `/api/sessions` | List active sessions with metrics |
98
+ | `GET` | `/api/events` | Query events (`?slug=`, `?limit=`, `?offset=`) |
99
+ | `GET` | `/api/events/:id` | Single event by ID |
100
+ | `GET` | `/api/stats` | Aggregated statistics |
101
+ | `GET` | `/api/stream` | SSE live updates |
102
+ | `GET` | `/routes` | Raw routing table |
103
+ | `GET` | `/metrics` | Prometheus format metrics |
104
+
105
+ ## Dashboard
106
+
107
+ The router serves a live session management UI at `http://127.0.0.1:9000/`:
108
+
109
+ - **Sessions table** — status, port, event count, errors, latency, kill button
110
+ - **Event feed** — click sessions to filter, ctrl/shift+click for multi-select
111
+ - **Event detail** — expand inline for summary, trace waterfall, raw payload
112
+ - **Live updates** — SSE-powered, no refresh needed
113
+
114
+ ## GitLab CI Integration
115
+
116
+ Add a webhook in your GitLab project/group settings:
117
+ - **URL**: `http://<your-machine>:9000/`
118
+ - **Secret token**: your `WEBHOOK_SECRET`
119
+ - **Trigger**: Pipeline events (or any events you want)
120
+
121
+ Or add a `curl` step in `.gitlab-ci.yml` for custom payloads:
122
+
123
+ ```yaml
124
+ notify:
125
+ stage: .post
126
+ script:
127
+ - |
128
+ curl -s -X POST http://$ROUTER_HOST:9000/ \
129
+ -H "Content-Type: application/json" \
130
+ -H "X-Webhook-Token: $WEBHOOK_SECRET" \
131
+ -d "{\"project_slug\":\"$CI_PROJECT_PATH\",\"status\":\"$CI_PIPELINE_STATUS\",\"branch\":\"$CI_COMMIT_BRANCH\",\"sha\":\"$CI_COMMIT_SHA\",\"pipeline\":\"$CI_PIPELINE_URL\"}"
132
+ ```
133
+
134
+ ## Environment Variables
135
+
136
+ | Variable | Default | Description |
137
+ |----------|---------|-------------|
138
+ | `ROUTER_PORT` | `9000` | Router listen port |
139
+ | `ROUTER_HOST` | `127.0.0.1` | Router bind address (`0.0.0.0` for Docker) |
140
+ | `WEBHOOK_SECRET` | `dev-secret` | Shared secret for webhook auth |
141
+ | `PROJECT_SLUG` | `unknown/project` | Channel's project identifier |
142
+ | `ROUTER_URL` | `http://127.0.0.1:9000` | Channel's router address |
143
+ | `LOG_LEVEL` | `info` | Log level (debug/info/warn/error) |
144
+ | `HH_HEARTBEAT_MS` | `30000` | Channel heartbeat interval |
145
+
146
+ ## Alternative: Go CLI
147
+
148
+ A compiled Go binary is also available for faster startup (~5ms vs ~700ms). See `cmd/hh/` and [releases](https://github.com/Shoofio/HookHerald/releases).
149
+
150
+ ## Testing
151
+
152
+ ```bash
153
+ npm test # 79 tests across 4 suites
154
+ ```
155
+
156
+ Tests are integration-heavy — they spawn real processes and make real HTTP requests. Safe to run with a live router.
package/bin/hh ADDED
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec npx tsx "$(dirname "$(readlink -f "$0")")/../src/cli.ts" "$@"
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "hookherald",
3
+ "version": "0.3.0",
4
+ "description": "Webhook relay for Claude Code — push notifications from any HTTP POST into running sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "hh": "bin/hh"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "bin/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "scripts": {
17
+ "router": "npx tsx src/webhook-router.ts",
18
+ "channel": "npx tsx src/webhook-channel.ts",
19
+ "build:go": "go build -ldflags \"-X main.projectRoot=$(pwd)\" -o hh ./cmd/hh/",
20
+ "test": "npx tsx --test-force-exit --test tests/observability.test.ts && npx tsx --test-force-exit --test tests/channel.test.ts && npx tsx --test-force-exit --test tests/router.test.ts && npx tsx --test-force-exit --test tests/cli.test.ts"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.12.1",
24
+ "tsx": "^4.19.0"
25
+ }
26
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,304 @@
1
+ import { execSync, spawn } from "node:child_process";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
3
+ import { resolve, dirname, basename } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const CHANNEL_PATH = resolve(__dirname, "webhook-channel.ts");
9
+ const ROUTER_PATH = resolve(__dirname, "webhook-router.ts");
10
+
11
+ // Resolve tsx loader path relative to this package (works from any CWD)
12
+ const require = createRequire(import.meta.url);
13
+ const TSX_PATH = dirname(require.resolve("tsx/package.json"));
14
+ const PID_DIR = resolve(process.env.HOME || "/tmp", ".hookherald");
15
+ const PID_FILE = resolve(PID_DIR, "router.pid");
16
+
17
+ const DEFAULT_ROUTER_URL = "http://127.0.0.1:9000";
18
+ const DEFAULT_PORT = "9000";
19
+ const DEFAULT_SECRET = "dev-secret";
20
+
21
+ const args = process.argv.slice(2);
22
+ const command = args[0];
23
+
24
+ // --- Flag parsing ---
25
+
26
+ function getFlag(name: string): string | undefined {
27
+ const flag = `--${name}`;
28
+ for (let i = 0; i < args.length; i++) {
29
+ if (args[i] === flag && i + 1 < args.length) return args[i + 1];
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ function hasFlag(name: string): boolean {
35
+ return args.includes(`--${name}`);
36
+ }
37
+
38
+ // --- Slug detection ---
39
+
40
+ function detectSlug(): string {
41
+ try {
42
+ const remote = execSync("git remote get-url origin", {
43
+ encoding: "utf-8",
44
+ stdio: ["pipe", "pipe", "pipe"],
45
+ }).trim();
46
+
47
+ // SSH: git@gitlab.com:group/project.git
48
+ const lastColon = remote.lastIndexOf(":");
49
+ if (lastColon !== -1 && !remote.slice(0, lastColon).includes("/")) {
50
+ const slug = remote.slice(lastColon + 1).replace(/\.git$/, "");
51
+ if (slug.includes("/")) return slug;
52
+ }
53
+
54
+ // HTTPS: https://gitlab.com/group/project.git
55
+ try {
56
+ const u = new URL(remote);
57
+ const slug = u.pathname.replace(/^\//, "").replace(/\.git$/, "");
58
+ if (slug.includes("/")) return slug;
59
+ } catch {}
60
+ } catch {}
61
+
62
+ return basename(process.cwd());
63
+ }
64
+
65
+ // --- Commands ---
66
+
67
+ async function cmdInit() {
68
+ const slug = getFlag("slug") || detectSlug();
69
+ const routerUrl = getFlag("router-url") || DEFAULT_ROUTER_URL;
70
+ const mcpPath = resolve(process.cwd(), ".mcp.json");
71
+
72
+ let config: any = {};
73
+ if (existsSync(mcpPath)) {
74
+ try {
75
+ config = JSON.parse(readFileSync(mcpPath, "utf-8"));
76
+ } catch {
77
+ console.error("Error: existing .mcp.json is not valid JSON");
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ if (!config.mcpServers) config.mcpServers = {};
83
+
84
+ config.mcpServers["webhook-channel"] = {
85
+ command: "node",
86
+ args: ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), CHANNEL_PATH],
87
+ env: {
88
+ PROJECT_SLUG: slug,
89
+ ROUTER_URL: routerUrl,
90
+ },
91
+ };
92
+
93
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
94
+ console.log(`Initialized HookHerald for ${slug} in .mcp.json`);
95
+ console.log(` Channel: ${CHANNEL_PATH}`);
96
+ console.log(` Router: ${routerUrl}`);
97
+ }
98
+
99
+ async function cmdStatus() {
100
+ const routerUrl = getFlag("router-url") || DEFAULT_ROUTER_URL;
101
+
102
+ let resp: Response;
103
+ try {
104
+ resp = await fetch(`${routerUrl}/api/sessions`, {
105
+ signal: AbortSignal.timeout(5000),
106
+ });
107
+ } catch {
108
+ console.error(`Router not reachable at ${routerUrl}`);
109
+ process.exit(1);
110
+ }
111
+
112
+ if (!resp.ok) {
113
+ console.error(`Router returned ${resp.status}`);
114
+ process.exit(1);
115
+ }
116
+
117
+ const sessions: any[] = await resp.json();
118
+
119
+ if (sessions.length === 0) {
120
+ console.log("No active sessions");
121
+ return;
122
+ }
123
+
124
+ console.log(
125
+ "SLUG".padEnd(30) +
126
+ "PORT".padEnd(8) +
127
+ "STATUS".padEnd(10) +
128
+ "EVENTS".padEnd(10) +
129
+ "ERRORS".padEnd(10) +
130
+ "LAST EVENT",
131
+ );
132
+ console.log("-".repeat(88));
133
+
134
+ for (const s of sessions) {
135
+ const lastEvent = s.lastEventAt ? timeAgo(s.lastEventAt) : "never";
136
+ console.log(
137
+ String(s.slug).padEnd(30) +
138
+ String(s.port).padEnd(8) +
139
+ String(s.status).padEnd(10) +
140
+ String(s.eventCount).padEnd(10) +
141
+ String(s.errorCount).padEnd(10) +
142
+ lastEvent,
143
+ );
144
+ }
145
+ }
146
+
147
+ async function cmdKill() {
148
+ const slug = args[1];
149
+ if (!slug || slug.startsWith("--")) {
150
+ console.error("Usage: hh kill <slug>");
151
+ process.exit(1);
152
+ }
153
+
154
+ const routerUrl = getFlag("router-url") || DEFAULT_ROUTER_URL;
155
+
156
+ let resp: Response;
157
+ try {
158
+ resp = await fetch(`${routerUrl}/api/kill`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({ project_slug: slug }),
162
+ signal: AbortSignal.timeout(5000),
163
+ });
164
+ } catch {
165
+ console.error(`Router not reachable at ${routerUrl}`);
166
+ process.exit(1);
167
+ }
168
+
169
+ const data = await resp.json();
170
+ if (!resp.ok) {
171
+ console.error(`Error: ${data.error}`);
172
+ process.exit(1);
173
+ }
174
+
175
+ console.log(`Killed session: ${data.slug} (port ${data.port}, ${data.eventCount} events)`);
176
+ }
177
+
178
+ function cmdRouter() {
179
+ const subcommand = args[1];
180
+
181
+ if (subcommand === "stop") {
182
+ cmdRouterStop();
183
+ return;
184
+ }
185
+
186
+ const port = getFlag("port") || process.env.ROUTER_PORT || DEFAULT_PORT;
187
+ const secret = getFlag("secret") || process.env.WEBHOOK_SECRET || DEFAULT_SECRET;
188
+ const bg = hasFlag("bg");
189
+
190
+ const child = spawn("node", ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), ROUTER_PATH], {
191
+ env: {
192
+ ...process.env,
193
+ ROUTER_PORT: port,
194
+ WEBHOOK_SECRET: secret,
195
+ },
196
+ stdio: bg ? "ignore" : "inherit",
197
+ detached: bg,
198
+ });
199
+
200
+ if (bg) {
201
+ // Write PID file and detach
202
+ mkdirSync(PID_DIR, { recursive: true });
203
+ writeFileSync(PID_FILE, String(child.pid));
204
+ child.unref();
205
+ console.log(`Router started in background (PID ${child.pid}, port ${port})`);
206
+ console.log(` Stop with: hh router stop`);
207
+ } else {
208
+ // Foreground: forward signals, wait for exit
209
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
210
+ process.on("SIGINT", () => child.kill("SIGINT"));
211
+ child.on("exit", (code) => process.exit(code ?? 0));
212
+ }
213
+ }
214
+
215
+ function cmdRouterStop() {
216
+ if (!existsSync(PID_FILE)) {
217
+ console.error("No background router found (no PID file)");
218
+ process.exit(1);
219
+ }
220
+
221
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
222
+
223
+ try {
224
+ process.kill(pid, "SIGTERM");
225
+ console.log(`Stopped router (PID ${pid})`);
226
+ } catch (err: any) {
227
+ if (err.code === "ESRCH") {
228
+ console.log(`Router already stopped (PID ${pid} not found)`);
229
+ } else {
230
+ console.error(`Failed to stop router: ${err.message}`);
231
+ process.exit(1);
232
+ }
233
+ }
234
+
235
+ try { unlinkSync(PID_FILE); } catch {}
236
+ }
237
+
238
+ // --- Helpers ---
239
+
240
+ function timeAgo(iso: string): string {
241
+ const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
242
+ if (diff < 5) return "just now";
243
+ if (diff < 60) return `${diff}s ago`;
244
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
245
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
246
+ return `${Math.floor(diff / 86400)}d ago`;
247
+ }
248
+
249
+ function usage() {
250
+ console.log(` ...::::::::..
251
+ .=%@@@*+=#@
252
+ -%@*.
253
+ .%@-
254
+ *#@*.
255
+ .=@@*
256
+ :. =@=.
257
+ =@@*. .=@+
258
+ -@.: =@+ .-%@@@@@@@@@
259
+ .@*:+@+ .=#@@@@@@@@@@@@@@@
260
+ .. .%@@@@@%@@@@@@@@@@@@@
261
+ =@@@@@@+ .@@@@@@@@@@@@
262
+ .*@@@@@@@@@@@@@@@@@
263
+ =******@@@@@@@@@@@@@@@@
264
+ .+@@@@@@@@@@@@@@@@@@@@
265
+ :%@@@@@@@@@@@@@@@@
266
+ .-+#@@@@@@@@@@
267
+
268
+ HookHerald — webhook relay for Claude Code
269
+
270
+ Usage: hh <command> [options]
271
+
272
+ Commands:
273
+ init [--slug <slug>] [--router-url <url>] Set up .mcp.json in current directory
274
+ status [--router-url <url>] Show active sessions
275
+ kill <slug> [--router-url <url>] Bounce a session (Claude Code respawns it)
276
+ router [--port <port>] [--secret <secret>] Start the webhook router
277
+ [--bg] Run in background
278
+ router stop Stop background router`);
279
+ }
280
+
281
+ // --- Main ---
282
+
283
+ switch (command) {
284
+ case "init":
285
+ await cmdInit();
286
+ break;
287
+ case "status":
288
+ await cmdStatus();
289
+ break;
290
+ case "kill":
291
+ await cmdKill();
292
+ break;
293
+ case "router":
294
+ cmdRouter();
295
+ break;
296
+ case "--help":
297
+ case "-h":
298
+ case "help":
299
+ usage();
300
+ break;
301
+ default:
302
+ usage();
303
+ if (command) process.exit(1);
304
+ }