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.
- package/.env.example +108 -0
- package/Dockerfile +84 -0
- package/LICENSE +22 -0
- package/README.md +231 -0
- package/claude-api-server/package-lock.json +832 -0
- package/claude-api-server/package.json +20 -0
- package/claude-api-server/server.js +540 -0
- package/claude-api-server/structured.js +208 -0
- package/cli/README.md +231 -0
- package/cli/bin/check-publish-safety.js +53 -0
- package/cli/bin/claude-phone.js +23 -0
- package/cli/bin/cli-main.js +284 -0
- package/cli/bin/postinstall.js +20 -0
- package/cli/lib/commands/api-server.js +77 -0
- package/cli/lib/commands/backup.js +69 -0
- package/cli/lib/commands/config/path.js +30 -0
- package/cli/lib/commands/config/reset.js +73 -0
- package/cli/lib/commands/config/show.js +86 -0
- package/cli/lib/commands/device/add.js +143 -0
- package/cli/lib/commands/device/list.js +55 -0
- package/cli/lib/commands/device/remove.js +83 -0
- package/cli/lib/commands/doctor.js +422 -0
- package/cli/lib/commands/logs.js +186 -0
- package/cli/lib/commands/restore.js +172 -0
- package/cli/lib/commands/setup.js +1246 -0
- package/cli/lib/commands/start.js +346 -0
- package/cli/lib/commands/status.js +139 -0
- package/cli/lib/commands/stop.js +101 -0
- package/cli/lib/commands/uninstall.js +183 -0
- package/cli/lib/commands/update.js +205 -0
- package/cli/lib/config.js +113 -0
- package/cli/lib/docker.js +384 -0
- package/cli/lib/mcp-register.js +108 -0
- package/cli/lib/network.js +109 -0
- package/cli/lib/platform.js +85 -0
- package/cli/lib/port-check.js +135 -0
- package/cli/lib/prereqs/checks/compose.js +147 -0
- package/cli/lib/prereqs/checks/disk.js +89 -0
- package/cli/lib/prereqs/checks/docker.js +155 -0
- package/cli/lib/prereqs/checks/network.js +78 -0
- package/cli/lib/prereqs/checks/node.js +95 -0
- package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
- package/cli/lib/prereqs/installers/docker.js +229 -0
- package/cli/lib/prereqs/installers/node.js +254 -0
- package/cli/lib/prereqs/platform.js +175 -0
- package/cli/lib/prereqs/utils/execute.js +208 -0
- package/cli/lib/prereqs/utils/rollback.js +223 -0
- package/cli/lib/prereqs/utils/sudo.js +177 -0
- package/cli/lib/prereqs.js +228 -0
- package/cli/lib/prerequisites.js +115 -0
- package/cli/lib/process-manager.js +176 -0
- package/cli/lib/utils.js +78 -0
- package/cli/lib/validators.js +219 -0
- package/cli/lib/voice-downloader.js +79 -0
- package/docker/entrypoint.sh +110 -0
- package/docker/supervisord.conf +78 -0
- package/docker-compose.yml +40 -0
- package/docs/CLAUDE-CODE-SKILL.md +45 -0
- package/docs/LANGUAGES.md +113 -0
- package/docs/MCP-SERVER.md +111 -0
- package/docs/TROUBLESHOOTING.md +288 -0
- package/mcp-server/index.js +159 -0
- package/mcp-server/package-lock.json +1201 -0
- package/mcp-server/package.json +9 -0
- package/package.json +73 -0
- package/stt-local/Dockerfile +12 -0
- package/stt-local/server.py +45 -0
- package/tts-local/Dockerfile +9 -0
- package/tts-local/server.py +83 -0
- package/voice-app/API-QUERY-CONTRACT.md +238 -0
- package/voice-app/DEPLOYMENT.md +176 -0
- package/voice-app/Dockerfile +20 -0
- package/voice-app/README-OUTBOUND.md +316 -0
- package/voice-app/config/devices.json.example +18 -0
- package/voice-app/index.js +273 -0
- package/voice-app/lib/audio-fork.js +465 -0
- package/voice-app/lib/claude-bridge.js +113 -0
- package/voice-app/lib/connection-retry.js +58 -0
- package/voice-app/lib/conversation-loop.js +490 -0
- package/voice-app/lib/device-registry.js +171 -0
- package/voice-app/lib/http-server.js +242 -0
- package/voice-app/lib/logger.js +35 -0
- package/voice-app/lib/multi-registrar.js +133 -0
- package/voice-app/lib/outbound-handler.js +299 -0
- package/voice-app/lib/outbound-routes.js +417 -0
- package/voice-app/lib/outbound-session.js +324 -0
- package/voice-app/lib/query-routes.js +484 -0
- package/voice-app/lib/registrar.js +144 -0
- package/voice-app/lib/sip-handler.js +490 -0
- package/voice-app/lib/tts-service.js +205 -0
- package/voice-app/lib/whisper-client.js +140 -0
- package/voice-app/package.json +38 -0
- package/voice-app/static/gotit-beep.wav +0 -0
- package/voice-app/static/hold-music.wav +0 -0
- package/voice-app/static/ready-beep.wav +0 -0
- 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
|
+
}
|