claude-watch-relay 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.
Files changed (3) hide show
  1. package/README.md +62 -0
  2. package/package.json +41 -0
  3. package/src/index.js +468 -0
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # claude-watch-relay
2
+
3
+ Mac relay server for **Claude Watch Remote** — bridges Claude Code CLI prompts to your iPhone and Apple Watch via WebSocket.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ Claude Code (PTY) ←→ claude-watch-relay (WebSocket + Bonjour) ←→ iPhone ←→ Apple Watch
9
+ ```
10
+
11
+ The relay wraps `claude` in a PTY, detects interactive prompts (permissions, file access, y/n), and broadcasts them over WebSocket to the Claude Watch Remote iOS app.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g claude-watch-relay
17
+ ```
18
+
19
+ > Requires macOS and Node.js 18+. Xcode Command Line Tools needed for native module build (`xcode-select --install`).
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ # Instead of running `claude` directly:
25
+ claude-watch-relay
26
+
27
+ # Pass arguments to Claude Code:
28
+ claude-watch-relay --help
29
+ claude-watch-relay "fix the bug in auth.ts"
30
+ ```
31
+
32
+ On startup, the relay prints a **6-digit pairing code**. Enter this code in the Claude Watch Remote iPhone app to pair.
33
+
34
+ ## Configuration
35
+
36
+ | Environment Variable | Default | Description |
37
+ |---------------------|---------|-------------|
38
+ | `RELAY_PORT` | `19876` | WebSocket server port |
39
+
40
+ ## How the iPhone/Watch finds your Mac
41
+
42
+ The relay advertises itself via **Bonjour** (mDNS) as `_claude-watch._tcp`. The iPhone app auto-discovers Macs on the local network — no manual IP entry needed.
43
+
44
+ ## Protocol
45
+
46
+ WebSocket JSON messages:
47
+
48
+ | Direction | Type | Fields |
49
+ |-----------|------|--------|
50
+ | Client → Relay | `pair` | `code` |
51
+ | Relay → Client | `pair_result` | `success`, `message` |
52
+ | Relay → Client | `prompt` | `prompt: { id, type, text, detail, options, timestamp }` |
53
+ | Client → Relay | `response` | `promptId`, `answer` |
54
+ | Client → Relay | `voice_command` | `text` |
55
+ | Relay → Client | `voice_ack` | `text`, `timestamp` |
56
+ | Relay → Client | `prompt_resolved` | `promptId`, `answer` |
57
+ | Relay → Client | `terminal_output` | `data` |
58
+ | Relay → Client | `terminal_exit` | `exitCode` |
59
+
60
+ ## License
61
+
62
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "claude-watch-relay",
3
+ "version": "0.1.0",
4
+ "description": "Mac relay server for Claude Watch Remote — bridges Claude Code CLI prompts to iPhone/Apple Watch",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "claude-watch-relay": "src/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "test": "node test/test-detector.mjs && node test/test-websocket.mjs",
13
+ "postinstall": "npx node-gyp rebuild --directory=node_modules/node-pty 2>/dev/null || true"
14
+ },
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "apple-watch",
19
+ "relay",
20
+ "remote",
21
+ "pty",
22
+ "websocket",
23
+ "bonjour"
24
+ ],
25
+ "author": "",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": ""
29
+ },
30
+ "homepage": "",
31
+ "os": ["darwin"],
32
+ "dependencies": {
33
+ "node-pty": "^1.0.0",
34
+ "ws": "^8.16.0",
35
+ "bonjour-service": "^1.2.1"
36
+ },
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ },
40
+ "license": "MIT"
41
+ }
package/src/index.js ADDED
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node-pty";
4
+ import { WebSocketServer } from "ws";
5
+ import { Bonjour } from "bonjour-service";
6
+ import { randomBytes } from "crypto";
7
+ import { execSync } from "child_process";
8
+
9
+ // ─── Prompt Detector ───────────────────────────────────────────
10
+
11
+ class PromptDetector {
12
+ constructor() {
13
+ // Patterns that Claude Code uses for interactive prompts
14
+ this.patterns = [
15
+ // Yes/No permission prompts
16
+ {
17
+ type: "permission",
18
+ regex:
19
+ /Do you want to (proceed|continue|allow|accept|run|execute|install)\?/i,
20
+ options: ["Yes", "No"],
21
+ },
22
+ // File access prompts
23
+ {
24
+ type: "file_access",
25
+ regex: /Allow (?:read|write|access) to (.+)\?/i,
26
+ options: ["Allow", "Deny"],
27
+ },
28
+ // Tool use approval
29
+ {
30
+ type: "tool_approval",
31
+ regex:
32
+ /(?:May I|Can I|Allow me to|I'd like to) (?:run|execute|use|call) (.+?)[\?\n]/i,
33
+ options: ["Yes", "No"],
34
+ },
35
+ // Generic yes/no
36
+ {
37
+ type: "yes_no",
38
+ regex: /\(y\/n\)/i,
39
+ options: ["y", "n"],
40
+ },
41
+ // Bash tool approval (Claude Code specific)
42
+ {
43
+ type: "bash_approval",
44
+ regex: /❯ (.+)\n.*(?:Allow|Run|Execute)\?/s,
45
+ options: ["Allow", "Deny"],
46
+ },
47
+ // Edit file approval
48
+ {
49
+ type: "edit_approval",
50
+ regex: /(?:Edit|Write|Create) (?:file )?(.+?)[\?\n]/i,
51
+ options: ["Allow", "Deny"],
52
+ },
53
+ ];
54
+
55
+ this.buffer = "";
56
+ this.bufferTimeout = null;
57
+ }
58
+
59
+ /**
60
+ * Feed terminal output and detect prompts.
61
+ * Returns detected prompt or null.
62
+ */
63
+ feed(data) {
64
+ // Accumulate output in a rolling buffer
65
+ this.buffer += data;
66
+
67
+ // Keep buffer manageable (last 4KB)
68
+ if (this.buffer.length > 4096) {
69
+ this.buffer = this.buffer.slice(-4096);
70
+ }
71
+
72
+ // Strip ANSI escape codes for matching
73
+ const clean = this.stripAnsi(this.buffer);
74
+
75
+ for (const pattern of this.patterns) {
76
+ const match = clean.match(pattern.regex);
77
+ if (match) {
78
+ const prompt = {
79
+ id: randomBytes(8).toString("hex"),
80
+ type: pattern.type,
81
+ text: match[0].trim(),
82
+ detail: match[1] || null,
83
+ options: pattern.options,
84
+ timestamp: Date.now(),
85
+ };
86
+ // Clear the buffer after detection to avoid re-triggering
87
+ this.buffer = "";
88
+ return prompt;
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ stripAnsi(str) {
96
+ // eslint-disable-next-line no-control-regex
97
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
98
+ }
99
+ }
100
+
101
+ // ─── Terminal Manager ──────────────────────────────────────────
102
+
103
+ class TerminalManager {
104
+ constructor(onData, onExit) {
105
+ this.pty = null;
106
+ this.onData = onData;
107
+ this.onExit = onExit;
108
+ }
109
+
110
+ /**
111
+ * Spawn Claude Code in a PTY.
112
+ * Passes through all args after the relay command.
113
+ */
114
+ start(args = []) {
115
+ // Resolve claude command — it may be an alias, npx, or a real binary
116
+ let claudeCmd;
117
+ let claudeArgs;
118
+
119
+ try {
120
+ // Check for a real binary first
121
+ const realPath = execSync("command -v claude 2>/dev/null || true", {
122
+ encoding: "utf8",
123
+ shell: "/bin/zsh",
124
+ }).trim();
125
+
126
+ if (realPath && !realPath.includes("alias") && !realPath.includes("not found")) {
127
+ claudeCmd = realPath;
128
+ claudeArgs = args;
129
+ } else {
130
+ throw new Error("not a binary");
131
+ }
132
+ } catch {
133
+ // Fall back: launch via npx
134
+ const npxPath = execSync("which npx", {
135
+ encoding: "utf8",
136
+ shell: "/bin/zsh",
137
+ }).trim();
138
+
139
+ if (!npxPath) {
140
+ console.error(
141
+ "[relay] Error: Neither 'claude' nor 'npx' found in PATH.\n" +
142
+ " Install Claude Code CLI: npm install -g @anthropic-ai/claude-code"
143
+ );
144
+ process.exit(1);
145
+ }
146
+
147
+ claudeCmd = npxPath;
148
+ claudeArgs = ["@anthropic-ai/claude-code", ...args];
149
+ }
150
+
151
+ console.log(`[relay] Spawning: ${claudeCmd} ${claudeArgs.join(" ")}`);
152
+
153
+ this.pty = spawn(claudeCmd, claudeArgs, {
154
+ name: "xterm-256color",
155
+ cols: process.stdout.columns || 120,
156
+ rows: process.stdout.rows || 40,
157
+ cwd: process.cwd(),
158
+ env: { ...process.env, TERM: "xterm-256color" },
159
+ });
160
+
161
+ this.pty.onData((data) => {
162
+ // Pass through to local terminal
163
+ process.stdout.write(data);
164
+ // Notify callback for prompt detection
165
+ this.onData(data);
166
+ });
167
+
168
+ this.pty.onExit(({ exitCode }) => {
169
+ console.log(`\n[relay] Claude Code exited with code ${exitCode}`);
170
+ this.onExit(exitCode);
171
+ });
172
+
173
+ // Handle terminal resize
174
+ process.stdout.on("resize", () => {
175
+ if (this.pty) {
176
+ this.pty.resize(
177
+ process.stdout.columns || 120,
178
+ process.stdout.rows || 40
179
+ );
180
+ }
181
+ });
182
+
183
+ // Forward local stdin to PTY
184
+ if (process.stdin.isTTY) {
185
+ process.stdin.setRawMode(true);
186
+ }
187
+ process.stdin.resume();
188
+ process.stdin.on("data", (data) => {
189
+ if (this.pty) {
190
+ this.pty.write(data.toString());
191
+ }
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Send a response string to the PTY (simulates user input).
197
+ */
198
+ sendResponse(text) {
199
+ if (this.pty) {
200
+ this.pty.write(text);
201
+ }
202
+ }
203
+
204
+ kill() {
205
+ if (this.pty) {
206
+ this.pty.kill();
207
+ }
208
+ }
209
+ }
210
+
211
+ // ─── Relay Server (WebSocket + Bonjour) ────────────────────────
212
+
213
+ class RelayServer {
214
+ constructor() {
215
+ this.wss = null;
216
+ this.bonjour = null;
217
+ this.clients = new Map(); // ws -> { authenticated, pairCode }
218
+ this.pairCode = this.generatePairCode();
219
+ this.detector = new PromptDetector();
220
+ this.terminal = null;
221
+ this.pendingPrompts = new Map(); // promptId -> prompt
222
+ }
223
+
224
+ generatePairCode() {
225
+ return Math.floor(100000 + Math.random() * 900000).toString();
226
+ }
227
+
228
+ start() {
229
+ const port = parseInt(process.env.RELAY_PORT || "19876", 10);
230
+
231
+ // Start WebSocket server
232
+ this.wss = new WebSocketServer({ port });
233
+ console.log(`[relay] WebSocket server listening on port ${port}`);
234
+ console.log(`[relay] Pairing code: ${this.pairCode}`);
235
+
236
+ this.wss.on("connection", (ws) => {
237
+ console.log("[relay] New client connected");
238
+ this.clients.set(ws, { authenticated: false });
239
+
240
+ ws.on("message", (raw) => {
241
+ this.handleMessage(ws, raw);
242
+ });
243
+
244
+ ws.on("close", () => {
245
+ console.log("[relay] Client disconnected");
246
+ this.clients.delete(ws);
247
+ });
248
+
249
+ ws.on("error", (err) => {
250
+ console.error("[relay] WebSocket error:", err.message);
251
+ this.clients.delete(ws);
252
+ });
253
+ });
254
+
255
+ // Advertise via Bonjour/mDNS
256
+ this.bonjour = new Bonjour();
257
+ this.bonjour.publish({
258
+ name: "Claude Watch Relay",
259
+ type: "claude-watch",
260
+ protocol: "tcp",
261
+ port,
262
+ txt: {
263
+ version: "1",
264
+ },
265
+ });
266
+ console.log("[relay] Bonjour service published: _claude-watch._tcp");
267
+
268
+ // Start Claude Code in PTY
269
+ const claudeArgs = process.argv.slice(2);
270
+ this.terminal = new TerminalManager(
271
+ (data) => this.onTerminalData(data),
272
+ (code) => this.onTerminalExit(code)
273
+ );
274
+ this.terminal.start(claudeArgs);
275
+ }
276
+
277
+ handleMessage(ws, raw) {
278
+ let msg;
279
+ try {
280
+ msg = JSON.parse(raw.toString());
281
+ } catch {
282
+ ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
283
+ return;
284
+ }
285
+
286
+ const client = this.clients.get(ws);
287
+
288
+ switch (msg.type) {
289
+ case "pair": {
290
+ if (msg.code === this.pairCode) {
291
+ client.authenticated = true;
292
+ ws.send(
293
+ JSON.stringify({
294
+ type: "pair_result",
295
+ success: true,
296
+ message: "Paired successfully",
297
+ })
298
+ );
299
+ console.log("[relay] Client authenticated");
300
+ // Send any pending prompts
301
+ for (const prompt of this.pendingPrompts.values()) {
302
+ ws.send(JSON.stringify({ type: "prompt", prompt }));
303
+ }
304
+ } else {
305
+ ws.send(
306
+ JSON.stringify({
307
+ type: "pair_result",
308
+ success: false,
309
+ message: "Invalid pairing code",
310
+ })
311
+ );
312
+ }
313
+ break;
314
+ }
315
+
316
+ case "response": {
317
+ if (!client.authenticated) {
318
+ ws.send(
319
+ JSON.stringify({ type: "error", message: "Not authenticated" })
320
+ );
321
+ return;
322
+ }
323
+ this.handlePromptResponse(msg);
324
+ break;
325
+ }
326
+
327
+ case "voice_command": {
328
+ if (!client.authenticated) {
329
+ ws.send(
330
+ JSON.stringify({ type: "error", message: "Not authenticated" })
331
+ );
332
+ return;
333
+ }
334
+ this.handleVoiceCommand(msg);
335
+ break;
336
+ }
337
+
338
+ case "ping": {
339
+ ws.send(JSON.stringify({ type: "pong" }));
340
+ break;
341
+ }
342
+
343
+ default:
344
+ ws.send(
345
+ JSON.stringify({ type: "error", message: `Unknown type: ${msg.type}` })
346
+ );
347
+ }
348
+ }
349
+
350
+ handlePromptResponse(msg) {
351
+ const { promptId, answer } = msg;
352
+ const prompt = this.pendingPrompts.get(promptId);
353
+ if (!prompt) {
354
+ console.log(`[relay] Unknown prompt ID: ${promptId}`);
355
+ return;
356
+ }
357
+
358
+ console.log(`[relay] Response for prompt ${promptId}: ${answer}`);
359
+ this.pendingPrompts.delete(promptId);
360
+
361
+ // Send the answer to Claude Code's PTY
362
+ // Map the answer to actual keystrokes
363
+ const keystroke = this.mapAnswerToKeystroke(prompt, answer);
364
+ this.terminal.sendResponse(keystroke);
365
+
366
+ // Notify all authenticated clients that the prompt was answered
367
+ this.broadcast({
368
+ type: "prompt_resolved",
369
+ promptId,
370
+ answer,
371
+ });
372
+ }
373
+
374
+ mapAnswerToKeystroke(prompt, answer) {
375
+ const lower = answer.toLowerCase();
376
+
377
+ if (prompt.type === "yes_no") {
378
+ return lower.startsWith("y") ? "y" : "n";
379
+ }
380
+
381
+ // For most Claude Code prompts, "y" + Enter or "n" + Enter
382
+ if (lower === "yes" || lower === "allow" || lower === "y") {
383
+ return "y\n";
384
+ }
385
+ if (lower === "no" || lower === "deny" || lower === "n") {
386
+ return "n\n";
387
+ }
388
+
389
+ // For free-form input, send as-is with Enter
390
+ return answer + "\n";
391
+ }
392
+
393
+ handleVoiceCommand(msg) {
394
+ const { text } = msg;
395
+ console.log(`[relay] Voice command: ${text}`);
396
+
397
+ // Send voice text directly to Claude Code's PTY as user input
398
+ this.terminal.sendResponse(text + "\n");
399
+
400
+ this.broadcast({
401
+ type: "voice_ack",
402
+ text,
403
+ timestamp: Date.now(),
404
+ });
405
+ }
406
+
407
+ onTerminalData(data) {
408
+ const prompt = this.detector.feed(data);
409
+ if (prompt) {
410
+ console.log(`[relay] Detected prompt: ${prompt.type} — ${prompt.text}`);
411
+ this.pendingPrompts.set(prompt.id, prompt);
412
+
413
+ // Send to all authenticated clients
414
+ this.broadcast({ type: "prompt", prompt });
415
+ }
416
+
417
+ // Also send raw terminal output to clients for live view (optional)
418
+ this.broadcast({
419
+ type: "terminal_output",
420
+ data: data.toString(),
421
+ });
422
+ }
423
+
424
+ onTerminalExit(code) {
425
+ this.broadcast({
426
+ type: "terminal_exit",
427
+ exitCode: code,
428
+ });
429
+
430
+ // Graceful shutdown
431
+ setTimeout(() => {
432
+ this.shutdown();
433
+ process.exit(code);
434
+ }, 1000);
435
+ }
436
+
437
+ broadcast(msg) {
438
+ const json = JSON.stringify(msg);
439
+ for (const [ws, client] of this.clients) {
440
+ if (client.authenticated && ws.readyState === 1) {
441
+ ws.send(json);
442
+ }
443
+ }
444
+ }
445
+
446
+ shutdown() {
447
+ console.log("[relay] Shutting down...");
448
+ if (this.terminal) this.terminal.kill();
449
+ if (this.bonjour) this.bonjour.destroy();
450
+ if (this.wss) this.wss.close();
451
+ }
452
+ }
453
+
454
+ // ─── Main ──────────────────────────────────────────────────────
455
+
456
+ const relay = new RelayServer();
457
+
458
+ process.on("SIGINT", () => {
459
+ relay.shutdown();
460
+ process.exit(0);
461
+ });
462
+
463
+ process.on("SIGTERM", () => {
464
+ relay.shutdown();
465
+ process.exit(0);
466
+ });
467
+
468
+ relay.start();