claude-phone-local 2.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.
Files changed (96) hide show
  1. package/.env.example +108 -0
  2. package/Dockerfile +84 -0
  3. package/LICENSE +22 -0
  4. package/README.md +231 -0
  5. package/claude-api-server/package-lock.json +832 -0
  6. package/claude-api-server/package.json +20 -0
  7. package/claude-api-server/server.js +540 -0
  8. package/claude-api-server/structured.js +208 -0
  9. package/cli/README.md +231 -0
  10. package/cli/bin/check-publish-safety.js +53 -0
  11. package/cli/bin/claude-phone.js +23 -0
  12. package/cli/bin/cli-main.js +284 -0
  13. package/cli/bin/postinstall.js +20 -0
  14. package/cli/lib/commands/api-server.js +77 -0
  15. package/cli/lib/commands/backup.js +69 -0
  16. package/cli/lib/commands/config/path.js +30 -0
  17. package/cli/lib/commands/config/reset.js +73 -0
  18. package/cli/lib/commands/config/show.js +86 -0
  19. package/cli/lib/commands/device/add.js +143 -0
  20. package/cli/lib/commands/device/list.js +55 -0
  21. package/cli/lib/commands/device/remove.js +83 -0
  22. package/cli/lib/commands/doctor.js +422 -0
  23. package/cli/lib/commands/logs.js +186 -0
  24. package/cli/lib/commands/restore.js +172 -0
  25. package/cli/lib/commands/setup.js +1246 -0
  26. package/cli/lib/commands/start.js +346 -0
  27. package/cli/lib/commands/status.js +139 -0
  28. package/cli/lib/commands/stop.js +101 -0
  29. package/cli/lib/commands/uninstall.js +183 -0
  30. package/cli/lib/commands/update.js +205 -0
  31. package/cli/lib/config.js +113 -0
  32. package/cli/lib/docker.js +384 -0
  33. package/cli/lib/mcp-register.js +108 -0
  34. package/cli/lib/network.js +109 -0
  35. package/cli/lib/platform.js +85 -0
  36. package/cli/lib/port-check.js +135 -0
  37. package/cli/lib/prereqs/checks/compose.js +147 -0
  38. package/cli/lib/prereqs/checks/disk.js +89 -0
  39. package/cli/lib/prereqs/checks/docker.js +155 -0
  40. package/cli/lib/prereqs/checks/network.js +78 -0
  41. package/cli/lib/prereqs/checks/node.js +95 -0
  42. package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
  43. package/cli/lib/prereqs/installers/docker.js +229 -0
  44. package/cli/lib/prereqs/installers/node.js +254 -0
  45. package/cli/lib/prereqs/platform.js +175 -0
  46. package/cli/lib/prereqs/utils/execute.js +208 -0
  47. package/cli/lib/prereqs/utils/rollback.js +223 -0
  48. package/cli/lib/prereqs/utils/sudo.js +177 -0
  49. package/cli/lib/prereqs.js +228 -0
  50. package/cli/lib/prerequisites.js +115 -0
  51. package/cli/lib/process-manager.js +176 -0
  52. package/cli/lib/utils.js +78 -0
  53. package/cli/lib/validators.js +219 -0
  54. package/cli/lib/voice-downloader.js +79 -0
  55. package/docker/entrypoint.sh +110 -0
  56. package/docker/supervisord.conf +78 -0
  57. package/docker-compose.yml +40 -0
  58. package/docs/CLAUDE-CODE-SKILL.md +45 -0
  59. package/docs/LANGUAGES.md +113 -0
  60. package/docs/MCP-SERVER.md +111 -0
  61. package/docs/TROUBLESHOOTING.md +288 -0
  62. package/mcp-server/index.js +159 -0
  63. package/mcp-server/package-lock.json +1201 -0
  64. package/mcp-server/package.json +9 -0
  65. package/package.json +73 -0
  66. package/stt-local/Dockerfile +12 -0
  67. package/stt-local/server.py +45 -0
  68. package/tts-local/Dockerfile +9 -0
  69. package/tts-local/server.py +83 -0
  70. package/voice-app/API-QUERY-CONTRACT.md +238 -0
  71. package/voice-app/DEPLOYMENT.md +176 -0
  72. package/voice-app/Dockerfile +20 -0
  73. package/voice-app/README-OUTBOUND.md +316 -0
  74. package/voice-app/config/devices.json.example +18 -0
  75. package/voice-app/index.js +273 -0
  76. package/voice-app/lib/audio-fork.js +465 -0
  77. package/voice-app/lib/claude-bridge.js +113 -0
  78. package/voice-app/lib/connection-retry.js +58 -0
  79. package/voice-app/lib/conversation-loop.js +490 -0
  80. package/voice-app/lib/device-registry.js +171 -0
  81. package/voice-app/lib/http-server.js +242 -0
  82. package/voice-app/lib/logger.js +35 -0
  83. package/voice-app/lib/multi-registrar.js +133 -0
  84. package/voice-app/lib/outbound-handler.js +299 -0
  85. package/voice-app/lib/outbound-routes.js +417 -0
  86. package/voice-app/lib/outbound-session.js +324 -0
  87. package/voice-app/lib/query-routes.js +484 -0
  88. package/voice-app/lib/registrar.js +144 -0
  89. package/voice-app/lib/sip-handler.js +490 -0
  90. package/voice-app/lib/tts-service.js +205 -0
  91. package/voice-app/lib/whisper-client.js +140 -0
  92. package/voice-app/package.json +38 -0
  93. package/voice-app/static/gotit-beep.wav +0 -0
  94. package/voice-app/static/hold-music.wav +0 -0
  95. package/voice-app/static/ready-beep.wav +0 -0
  96. package/voice-app/test/freeswitch-retry.test.js +100 -0
@@ -0,0 +1,384 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import {
5
+ getDockerComposePath,
6
+ getEnvPath,
7
+ getConfigDir
8
+ } from './config.js';
9
+
10
+ /**
11
+ * Detect which docker compose command to use
12
+ * Some systems have 'docker compose' (plugin), others have 'docker-compose' (standalone)
13
+ * @returns {{cmd: string, args: string[]}} Command and base args for compose
14
+ */
15
+ function getComposeCommand() {
16
+ // Try 'docker compose' (plugin) first
17
+ try {
18
+ execSync('docker compose version', { stdio: 'pipe' });
19
+ return { cmd: 'docker', args: ['compose'] };
20
+ } catch (e) {
21
+ // Fall back to standalone docker-compose
22
+ try {
23
+ execSync('docker-compose --version', { stdio: 'pipe' });
24
+ return { cmd: 'docker-compose', args: [] };
25
+ } catch (e2) {
26
+ // Default to plugin style, let it fail with helpful error
27
+ return { cmd: 'docker', args: ['compose'] };
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Generate a random secret for Docker services
34
+ * @returns {string} Random 32-character hex string
35
+ */
36
+ function generateSecret() {
37
+ return crypto.randomBytes(16).toString('hex');
38
+ }
39
+
40
+ /**
41
+ * Check if Docker is installed and running
42
+ * @returns {Promise<{installed: boolean, running: boolean, error?: string}>}
43
+ */
44
+ export async function checkDocker() {
45
+ // Check if docker command exists
46
+ const installed = await new Promise((resolve) => {
47
+ const check = spawn('docker', ['--version']);
48
+ check.on('close', (code) => resolve(code === 0));
49
+ check.on('error', () => resolve(false));
50
+ });
51
+
52
+ if (!installed) {
53
+ return {
54
+ installed: false,
55
+ running: false,
56
+ error: 'Docker not found. Please install Docker from https://docs.docker.com/engine/install/'
57
+ };
58
+ }
59
+
60
+ // Check if Docker daemon is running by running a simple command
61
+ const running = await new Promise((resolve) => {
62
+ const check = spawn('docker', ['ps', '-q'], {
63
+ stdio: ['pipe', 'pipe', 'pipe']
64
+ });
65
+ check.on('close', (code) => resolve(code === 0));
66
+ check.on('error', () => resolve(false));
67
+ });
68
+
69
+ if (!running) {
70
+ return {
71
+ installed: true,
72
+ running: false,
73
+ error: 'Docker is installed but not running. Please start Docker Desktop.'
74
+ };
75
+ }
76
+
77
+ return {
78
+ installed: true,
79
+ running: true
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Generate docker-compose.yml from config
85
+ * @param {object} config - Configuration object
86
+ * @returns {string} Docker compose YAML content
87
+ */
88
+ export function generateDockerCompose(config) {
89
+ const externalIp = config.server.externalIp === 'auto' ? '${EXTERNAL_IP}' : config.server.externalIp;
90
+ const drachtioPort = config.deployment?.pi?.drachtioPort || 5070;
91
+ const rtpStart = config.server.rtpStart || 30000;
92
+ const rtpEnd = config.server.rtpEnd || 30100;
93
+
94
+ // Single image: drachtio + FreeSWITCH + voice-app + STT + TTS under supervisord.
95
+ // They talk over 127.0.0.1, so there is no container DNS to misconfigure.
96
+ // Everything that must outlive a restart is on the ./data bind mount.
97
+ return `# Generated by \`claude-phone setup\` - edit .env, not this file.
98
+ services:
99
+ claude-phone:
100
+ build: .
101
+ image: claude-phone-local:latest
102
+ container_name: claude-phone
103
+ restart: unless-stopped
104
+ env_file:
105
+ - .env
106
+ environment:
107
+ - DRACHTIO_HOST=127.0.0.1
108
+ - FREESWITCH_HOST=127.0.0.1
109
+ - STT_LOCAL_URL=http://127.0.0.1:9001
110
+ - TTS_LOCAL_URL=http://127.0.0.1:9002
111
+ - AUDIO_BASE_URL=http://127.0.0.1:3000
112
+ - EXTERNAL_IP=${externalIp}
113
+ - RTP_START=${rtpStart}
114
+ - RTP_END=${rtpEnd}
115
+ ports:
116
+ - "3000:3000"
117
+ - "${drachtioPort}:${drachtioPort}/udp"
118
+ - "${drachtioPort}:${drachtioPort}/tcp"
119
+ - "${rtpStart}-${rtpEnd}:${rtpStart}-${rtpEnd}/udp"
120
+ volumes:
121
+ - ./data:/data
122
+ `;
123
+ }
124
+
125
+ /**
126
+ * Generate .env file from config
127
+ * @param {object} config - Configuration object
128
+ * @returns {string} Environment file content
129
+ */
130
+ export function generateEnvFile(config) {
131
+ // Ensure secrets exist in config
132
+ if (!config.secrets) {
133
+ config.secrets = {
134
+ drachtio: generateSecret(),
135
+ freeswitch: generateSecret()
136
+ };
137
+ }
138
+
139
+ // Determine Claude API URL based on deployment mode
140
+ let claudeApiUrl;
141
+ if (config.deployment && config.deployment.mode === 'pi-split' && config.deployment.pi && config.deployment.pi.macIp) {
142
+ // Pi mode: point to remote API server
143
+ claudeApiUrl = `http://${config.deployment.pi.macIp}:${config.server.claudeApiPort}`;
144
+ } else if (config.deployment && config.deployment.mode === 'voice-server' && config.deployment.apiServerIp) {
145
+ // Voice server mode (non-Pi): point to remote API server
146
+ claudeApiUrl = `http://${config.deployment.apiServerIp}:${config.server.claudeApiPort}`;
147
+ } else {
148
+ // Both or api-server mode: local API server runs on the HOST (it shells
149
+ // out to the `claude` CLI directly), while voice-app runs in Docker on
150
+ // a bridge network — so it must reach the host via host.docker.internal,
151
+ // not localhost (which inside the container means the container itself).
152
+ claudeApiUrl = `http://host.docker.internal:${config.server.claudeApiPort}`;
153
+ }
154
+
155
+ const lines = [
156
+ '# ====================================',
157
+ '# WARNING: DO NOT SHARE THIS FILE',
158
+ '# Contains API keys and passwords',
159
+ '# ====================================',
160
+ '# Claude Phone Configuration',
161
+ '# Generated by claude-phone CLI',
162
+ '# ====================================',
163
+ '',
164
+ '# Network Configuration',
165
+ `EXTERNAL_IP=${config.server.externalIp === 'auto' ? 'auto' : config.server.externalIp}`,
166
+ '',
167
+ '# Drachtio Configuration',
168
+ 'DRACHTIO_HOST=drachtio',
169
+ 'DRACHTIO_PORT=9022',
170
+ `DRACHTIO_SECRET=${config.secrets.drachtio}`,
171
+ // SIP port for Contact header (5070 when 3CX SBC is present, 5060 otherwise)
172
+ `DRACHTIO_SIP_PORT=${config.deployment?.pi?.drachtioPort || 5060}`,
173
+ '',
174
+ '# FreeSWITCH Configuration',
175
+ 'FREESWITCH_HOST=freeswitch',
176
+ 'FREESWITCH_PORT=8021',
177
+ // Note: This is the default ESL password for drachtio/drachtio-freeswitch-mrf
178
+ 'FREESWITCH_SECRET=JambonzR0ck$',
179
+ '',
180
+ '# 3CX / SIP Configuration',
181
+ `SIP_DOMAIN=${config.sip.domain}`,
182
+ `SIP_REGISTRAR=${config.sip.registrar}`,
183
+ '',
184
+ '# Default extension (primary device)',
185
+ `SIP_EXTENSION=${config.devices[0].extension}`,
186
+ `SIP_AUTH_ID=${config.devices[0].authId}`,
187
+ `SIP_PASSWORD=${config.devices[0].password}`,
188
+ '',
189
+ '# Claude API Server',
190
+ `CLAUDE_API_URL=${claudeApiUrl}`,
191
+ '',
192
+ ...(config.api.mode === 'local' ? [
193
+ '# Local STT/TTS (no API keys — faster-whisper + Piper containers)',
194
+ 'STT_MODE=local',
195
+ 'TTS_MODE=local',
196
+ `STT_LOCAL_URL=${config.api.local?.sttUrl || 'http://stt-local:9001'}`,
197
+ `TTS_LOCAL_URL=${config.api.local?.ttsUrl || 'http://tts-local:9002'}`,
198
+ `AUDIO_BASE_URL=${config.api.local?.audioBaseUrl || 'http://voice-app:3000'}`,
199
+ `PIPER_VOICE=${config.devices[0].voiceId || config.api.local?.voice || 'en_US-lessac-medium'}`,
200
+ `WHISPER_MODEL=${config.api.local?.whisperModel || 'small'}`,
201
+ ''
202
+ ] : [
203
+ '# STT/TTS mode',
204
+ 'STT_MODE=cloud',
205
+ 'TTS_MODE=cloud',
206
+ '',
207
+ '# ElevenLabs TTS',
208
+ `ELEVENLABS_API_KEY=${config.api.elevenlabs.apiKey}`,
209
+ `ELEVENLABS_VOICE_ID=${config.devices[0].voiceId}`,
210
+ '',
211
+ '# OpenAI (Whisper STT)',
212
+ `OPENAI_API_KEY=${config.api.openai.apiKey}`,
213
+ ''
214
+ ]),
215
+ '# Application Settings',
216
+ `HTTP_PORT=${config.server.httpPort}`,
217
+ 'WS_PORT=3001',
218
+ 'AUDIO_DIR=/app/audio',
219
+ '',
220
+ '# Outbound Call Settings',
221
+ 'MAX_CONVERSATION_TURNS=10',
222
+ 'OUTBOUND_RING_TIMEOUT=30',
223
+ ''
224
+ ];
225
+
226
+ return lines.join('\n');
227
+ }
228
+
229
+ /**
230
+ * Write Docker configuration files
231
+ * @param {object} config - Configuration object
232
+ * @returns {Promise<void>}
233
+ */
234
+ export async function writeDockerConfig(config) {
235
+ const dockerComposePath = getDockerComposePath();
236
+ const envPath = getEnvPath();
237
+
238
+ const dockerComposeContent = generateDockerCompose(config);
239
+ const envContent = generateEnvFile(config);
240
+
241
+ await fs.promises.writeFile(dockerComposePath, dockerComposeContent, { mode: 0o644 });
242
+ await fs.promises.writeFile(envPath, envContent, { mode: 0o600 });
243
+ }
244
+
245
+ /**
246
+ * Start Docker containers
247
+ * @returns {Promise<void>}
248
+ */
249
+ export async function startContainers() {
250
+ const configDir = getConfigDir();
251
+ const dockerComposePath = getDockerComposePath();
252
+
253
+ if (!fs.existsSync(dockerComposePath)) {
254
+ throw new Error('Docker configuration not found. Run "claude-phone setup" first.');
255
+ }
256
+
257
+ const compose = getComposeCommand();
258
+ const composeArgs = [...compose.args, '-f', dockerComposePath, 'up', '-d'];
259
+
260
+ return new Promise((resolve, reject) => {
261
+ const child = spawn(compose.cmd, composeArgs, {
262
+ cwd: configDir,
263
+ stdio: 'pipe'
264
+ });
265
+
266
+ let output = '';
267
+ child.stdout.on('data', (data) => {
268
+ output += data.toString();
269
+ });
270
+
271
+ child.stderr.on('data', (data) => {
272
+ output += data.toString();
273
+ });
274
+
275
+ child.on('close', (code) => {
276
+ if (code === 0) {
277
+ resolve();
278
+ } else {
279
+ // AC22: Detect ARM64 image pull failure
280
+ if (output.includes('no matching manifest') ||
281
+ output.includes('image with reference') && output.includes('arm64')) {
282
+ const error = new Error(
283
+ 'ARM64 Docker image pull failed.\n\n' +
284
+ 'Try manually pulling images:\n' +
285
+ ' docker pull drachtio/drachtio-server:latest\n' +
286
+ ' docker pull drachtio/drachtio-freeswitch-mrf:latest\n\n' +
287
+ 'If images are not available for ARM64, you may need to build them locally.'
288
+ );
289
+ reject(error);
290
+ } else {
291
+ reject(new Error(`Docker compose failed (exit ${code}): ${output}`));
292
+ }
293
+ }
294
+ });
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Stop Docker containers
300
+ * @returns {Promise<void>}
301
+ */
302
+ export async function stopContainers() {
303
+ const configDir = getConfigDir();
304
+ const dockerComposePath = getDockerComposePath();
305
+
306
+ if (!fs.existsSync(dockerComposePath)) {
307
+ // No containers to stop
308
+ return;
309
+ }
310
+
311
+ const compose = getComposeCommand();
312
+ const composeArgs = [...compose.args, '-f', dockerComposePath, 'down'];
313
+
314
+ return new Promise((resolve, reject) => {
315
+ const child = spawn(compose.cmd, composeArgs, {
316
+ cwd: configDir,
317
+ stdio: 'pipe'
318
+ });
319
+
320
+ let output = '';
321
+ child.stdout.on('data', (data) => {
322
+ output += data.toString();
323
+ });
324
+
325
+ child.stderr.on('data', (data) => {
326
+ output += data.toString();
327
+ });
328
+
329
+ child.on('close', (code) => {
330
+ if (code === 0) {
331
+ resolve();
332
+ } else {
333
+ reject(new Error(`Docker compose down failed (exit ${code}): ${output}`));
334
+ }
335
+ });
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Get status of Docker containers
341
+ * @returns {Promise<Array<{name: string, status: string}>>}
342
+ */
343
+ export async function getContainerStatus() {
344
+ const dockerComposePath = getDockerComposePath();
345
+
346
+ if (!fs.existsSync(dockerComposePath)) {
347
+ return [];
348
+ }
349
+
350
+ const compose = getComposeCommand();
351
+ const composeArgs = [...compose.args, '-f', dockerComposePath, 'ps', '--format', 'json'];
352
+
353
+ return new Promise((resolve) => {
354
+ const child = spawn(compose.cmd, composeArgs, {
355
+ stdio: 'pipe'
356
+ });
357
+
358
+ let output = '';
359
+ child.stdout.on('data', (data) => {
360
+ output += data.toString();
361
+ });
362
+
363
+ child.on('close', (code) => {
364
+ if (code === 0) {
365
+ try {
366
+ // Parse JSON lines (one per container)
367
+ const lines = output.trim().split('\n').filter(l => l);
368
+ const containers = lines.map(line => {
369
+ const data = JSON.parse(line);
370
+ return {
371
+ name: data.Name || data.Service,
372
+ status: data.State || data.Status
373
+ };
374
+ });
375
+ resolve(containers);
376
+ } catch (error) {
377
+ resolve([]);
378
+ }
379
+ } else {
380
+ resolve([]);
381
+ }
382
+ });
383
+ });
384
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Register the claude-phone MCP server with the Claude Code CLI.
3
+ *
4
+ * Done from `claude-phone setup` (and `claude-phone mcp install`) rather than
5
+ * from npm's postinstall, for two reasons:
6
+ * - postinstall runs before setup, so the extension to call is not known yet;
7
+ * - silently editing the user's ~/.claude.json during `npm install` is rude.
8
+ *
9
+ * Everything here is best-effort: a missing `claude` CLI is reported and
10
+ * skipped, never fatal.
11
+ */
12
+
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import chalk from 'chalk';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ /** Absolute path to the MCP server entry point inside the installed package. */
22
+ export function mcpServerPath() {
23
+ return path.resolve(__dirname, '..', '..', 'mcp-server', 'index.js');
24
+ }
25
+
26
+ function claudeAvailable() {
27
+ try {
28
+ execFileSync('claude', ['--version'], { stdio: 'ignore' });
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function alreadyRegistered() {
36
+ try {
37
+ const out = execFileSync('claude', ['mcp', 'list'], {
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ });
41
+ return /^\s*claude-phone:/m.test(out);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @param {object} opts
49
+ * @param {string} [opts.defaultTo] Extension/number Claude should ring.
50
+ * @param {number} [opts.httpPort] voice-app HTTP port.
51
+ * @param {boolean} [opts.force] Re-register even if already present.
52
+ * @returns {{ok: boolean, reason?: string}}
53
+ */
54
+ export function registerMcpServer({ defaultTo, httpPort = 3000, force = false } = {}) {
55
+ const serverPath = mcpServerPath();
56
+
57
+ if (!existsSync(serverPath)) {
58
+ return { ok: false, reason: `MCP server not found at ${serverPath}` };
59
+ }
60
+ if (!claudeAvailable()) {
61
+ return { ok: false, reason: 'Claude Code CLI not found on PATH' };
62
+ }
63
+
64
+ if (alreadyRegistered()) {
65
+ if (!force) return { ok: true, reason: 'already registered' };
66
+ try {
67
+ execFileSync('claude', ['mcp', 'remove', 'claude-phone', '--scope', 'user'], { stdio: 'ignore' });
68
+ } catch {
69
+ // Not fatal - add may still succeed, or report a clearer error.
70
+ }
71
+ }
72
+
73
+ const args = [
74
+ 'mcp', 'add', 'claude-phone',
75
+ '--scope', 'user',
76
+ '--env', `VOICE_APP_URL=http://127.0.0.1:${httpPort}`,
77
+ ];
78
+ if (defaultTo) args.push('--env', `PHONE_DEFAULT_TO=${defaultTo}`);
79
+ args.push('--', 'node', serverPath);
80
+
81
+ try {
82
+ execFileSync('claude', args, { stdio: ['ignore', 'pipe', 'pipe'] });
83
+ return { ok: true };
84
+ } catch (err) {
85
+ const detail = (err.stderr && err.stderr.toString().trim()) || err.message;
86
+ return { ok: false, reason: detail };
87
+ }
88
+ }
89
+
90
+ /** Register and print a short human-readable result. Never throws. */
91
+ export function registerMcpServerWithOutput(opts = {}) {
92
+ const result = registerMcpServer(opts);
93
+
94
+ if (result.ok && result.reason === 'already registered') {
95
+ console.log(chalk.gray(' MCP server already registered (use --force to update)'));
96
+ } else if (result.ok) {
97
+ console.log(chalk.green(' MCP server registered as "claude-phone"'));
98
+ if (opts.defaultTo) {
99
+ console.log(chalk.gray(` Claude will ring ${opts.defaultTo} by default`));
100
+ }
101
+ console.log(chalk.yellow(' Restart Claude Code, then try: "call me when the build finishes"'));
102
+ } else {
103
+ console.log(chalk.yellow(` Skipped MCP registration: ${result.reason}`));
104
+ console.log(chalk.gray(' Register later with: claude-phone mcp install'));
105
+ }
106
+
107
+ return result;
108
+ }
@@ -0,0 +1,109 @@
1
+ import net from 'net';
2
+ import http from 'http';
3
+
4
+ /**
5
+ * Check if an IP address is reachable on the network
6
+ * @param {string} ip - IP address to check
7
+ * @param {number} [port=3333] - Port to check (defaults to API server port)
8
+ * @param {number} [timeout=3000] - Timeout in milliseconds
9
+ * @returns {Promise<boolean>} True if IP is reachable
10
+ */
11
+ export async function isReachable(ip, port = 3333, timeout = 3000) {
12
+ return new Promise((resolve) => {
13
+ // Validate IP format first
14
+ const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
15
+ if (!ipPattern.test(ip)) {
16
+ resolve(false);
17
+ return;
18
+ }
19
+
20
+ const socket = new net.Socket();
21
+
22
+ // Set timeout
23
+ const timer = setTimeout(() => {
24
+ socket.destroy();
25
+ resolve(false);
26
+ }, timeout);
27
+
28
+ socket.on('connect', () => {
29
+ clearTimeout(timer);
30
+ socket.destroy();
31
+ resolve(true);
32
+ });
33
+
34
+ socket.on('error', (err) => {
35
+ clearTimeout(timer);
36
+ socket.destroy();
37
+
38
+ // ECONNREFUSED means the IP is reachable but nothing is listening on that port
39
+ // This is actually a GOOD sign - the IP responded
40
+ if (err.code === 'ECONNREFUSED') {
41
+ resolve(true);
42
+ } else {
43
+ // ETIMEDOUT, EHOSTUNREACH, etc. mean IP is not reachable
44
+ resolve(false);
45
+ }
46
+ });
47
+
48
+ // Connect to the specified port (default 3333 for API server)
49
+ socket.connect(port, ip);
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Check if claude-api-server is responding at a given URL
55
+ * @param {string} url - Full URL to claude-api-server (e.g., http://192.168.1.100:3333)
56
+ * @returns {Promise<object>} Check result
57
+ * @property {boolean} reachable - True if server is reachable
58
+ * @property {boolean} [healthy] - True if server responds with success (only if reachable)
59
+ * @property {string} [error] - Error message if check failed
60
+ */
61
+ export async function checkClaudeApiServer(url) {
62
+ return new Promise((resolve) => {
63
+ try {
64
+ // Validate URL format
65
+ // URL is a global in Node.js (no import needed)
66
+ // eslint-disable-next-line no-undef, no-new
67
+ new URL(url);
68
+
69
+ // Try to reach /health endpoint (or root if /health doesn't exist)
70
+ const healthUrl = url + '/health';
71
+
72
+ const req = http.get(healthUrl, { timeout: 3000 }, (res) => {
73
+ // Server is reachable
74
+ const healthy = res.statusCode >= 200 && res.statusCode < 400;
75
+
76
+ resolve({
77
+ reachable: true,
78
+ healthy: healthy,
79
+ statusCode: res.statusCode
80
+ });
81
+
82
+ // Consume response to free up memory
83
+ res.resume();
84
+ });
85
+
86
+ req.on('error', (err) => {
87
+ // Connection error - server not reachable
88
+ resolve({
89
+ reachable: false,
90
+ error: err.message
91
+ });
92
+ });
93
+
94
+ req.on('timeout', () => {
95
+ req.destroy();
96
+ resolve({
97
+ reachable: false,
98
+ error: 'Connection timeout'
99
+ });
100
+ });
101
+ } catch (err) {
102
+ // Invalid URL format
103
+ resolve({
104
+ reachable: false,
105
+ error: err.message
106
+ });
107
+ }
108
+ });
109
+ }
@@ -0,0 +1,85 @@
1
+ import os from 'os';
2
+ import fs from 'fs';
3
+
4
+ /**
5
+ * Get Raspberry Pi model string from /proc/device-tree/model
6
+ * @returns {Promise<string|null>} Pi model string or null if not Pi
7
+ */
8
+ export async function getPiModel() {
9
+ const modelPath = '/proc/device-tree/model';
10
+
11
+ try {
12
+ // Check if file exists
13
+ if (!fs.existsSync(modelPath)) {
14
+ return null;
15
+ }
16
+
17
+ // Read model file
18
+ const data = await fs.promises.readFile(modelPath, 'utf8');
19
+
20
+ // File contains null-terminated string, clean it
21
+ const model = data.replace(/\0/g, '').trim();
22
+
23
+ // Only return if it's actually a Raspberry Pi
24
+ if (model.includes('Raspberry Pi')) {
25
+ return model;
26
+ }
27
+
28
+ return null;
29
+ } catch (error) {
30
+ // If we can't read the file, we're not on a Pi
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Check if running on Raspberry Pi
37
+ * @returns {Promise<boolean>} True if running on Raspberry Pi
38
+ */
39
+ export async function isRaspberryPi() {
40
+ // Pi detection requires:
41
+ // 1. Linux platform
42
+ // 2. ARM64 architecture
43
+ // 3. Raspberry Pi model string in /proc/device-tree/model
44
+
45
+ if (os.platform() !== 'linux') {
46
+ return false;
47
+ }
48
+
49
+ if (os.arch() !== 'arm64') {
50
+ return false;
51
+ }
52
+
53
+ const model = await getPiModel();
54
+ return model !== null;
55
+ }
56
+
57
+ /**
58
+ * Detect current platform and Raspberry Pi status
59
+ * @returns {Promise<object>} Platform information
60
+ * @property {string} os - Operating system (darwin, linux, win32)
61
+ * @property {string} arch - CPU architecture (x64, arm64, arm)
62
+ * @property {boolean} isPi - True if Raspberry Pi detected
63
+ * @property {string} [piModel] - Pi model string if isPi is true
64
+ */
65
+ export async function detectPlatform() {
66
+ const platform = os.platform();
67
+ const arch = os.arch();
68
+ const isPi = await isRaspberryPi();
69
+
70
+ const result = {
71
+ os: platform,
72
+ arch: arch,
73
+ isPi: isPi
74
+ };
75
+
76
+ // Add Pi model if detected
77
+ if (isPi) {
78
+ const model = await getPiModel();
79
+ if (model) {
80
+ result.piModel = model;
81
+ }
82
+ }
83
+
84
+ return result;
85
+ }