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,143 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { loadConfig, saveConfig, configExists } from '../../config.js';
5
+ import { validateExtension, validateVoiceId } from '../../validators.js';
6
+ import { writeDockerConfig } from '../../docker.js';
7
+
8
+ /**
9
+ * Device add command - Add a new SIP device
10
+ * @returns {Promise<void>}
11
+ */
12
+ export async function deviceAddCommand() {
13
+ console.log(chalk.bold.cyan('\n➕ Add New Device\n'));
14
+
15
+ if (!configExists()) {
16
+ console.log(chalk.red('✗ Not configured'));
17
+ console.log(chalk.gray(' Run "claude-phone setup" first\n'));
18
+ process.exit(1);
19
+ }
20
+
21
+ const config = await loadConfig();
22
+
23
+ // Gather device information
24
+ const answers = await inquirer.prompt([
25
+ {
26
+ type: 'input',
27
+ name: 'name',
28
+ message: 'Device name:',
29
+ validate: (input) => {
30
+ if (!input || input.trim() === '') {
31
+ return 'Device name cannot be empty';
32
+ }
33
+ // Check for duplicate names
34
+ const duplicate = config.devices.find(d => d.name.toLowerCase() === input.trim().toLowerCase());
35
+ if (duplicate) {
36
+ return `Device name "${input}" already exists`;
37
+ }
38
+ return true;
39
+ }
40
+ },
41
+ {
42
+ type: 'input',
43
+ name: 'extension',
44
+ message: 'SIP extension (4-5 digits):',
45
+ validate: (input) => {
46
+ if (!validateExtension(input)) {
47
+ return 'Extension must be 4-5 digits';
48
+ }
49
+ // Check for duplicate extensions
50
+ const duplicate = config.devices.find(d => d.extension === input);
51
+ if (duplicate) {
52
+ return `Extension ${input} is already used by device "${duplicate.name}"`;
53
+ }
54
+ return true;
55
+ }
56
+ },
57
+ {
58
+ type: 'input',
59
+ name: 'authId',
60
+ message: 'SIP auth ID (press Enter to use extension):',
61
+ default: (answers) => answers.extension
62
+ },
63
+ {
64
+ type: 'password',
65
+ name: 'password',
66
+ message: 'SIP password:',
67
+ validate: (input) => {
68
+ if (!input || input.trim() === '') {
69
+ return 'Password cannot be empty';
70
+ }
71
+ return true;
72
+ }
73
+ },
74
+ {
75
+ type: 'input',
76
+ name: 'voiceId',
77
+ message: config.api.mode === 'local'
78
+ ? 'Piper voice for this device (e.g. en_US-lessac-medium):'
79
+ : 'ElevenLabs voice ID:',
80
+ default: config.api.mode === 'local'
81
+ ? (config.api.local?.voice || 'en_US-lessac-medium')
82
+ : (config.api.elevenlabs.defaultVoiceId || ''),
83
+ validate: (input) => {
84
+ if (!input || input.trim() === '') {
85
+ return 'Voice cannot be empty';
86
+ }
87
+ return true;
88
+ }
89
+ },
90
+ {
91
+ type: 'input',
92
+ name: 'prompt',
93
+ message: 'System prompt:',
94
+ default: (answers) => `You are ${answers.name}, a helpful AI assistant accessible via phone.`
95
+ }
96
+ ]);
97
+
98
+ // Validate voice ID with ElevenLabs API — only relevant in cloud mode.
99
+ // In local mode, the "voice" is just a Piper model name, nothing to validate remotely.
100
+ let voiceResult = { valid: true, name: answers.voiceId };
101
+ if (config.api.mode !== 'local') {
102
+ const spinner = ora('Validating voice ID with ElevenLabs...').start();
103
+ voiceResult = await validateVoiceId(config.api.elevenlabs.apiKey, answers.voiceId);
104
+
105
+ if (!voiceResult.valid) {
106
+ spinner.fail(chalk.red(`Voice ID validation failed: ${voiceResult.error}`));
107
+ console.log(chalk.gray('\nPlease check your voice ID and try again.\n'));
108
+ process.exit(1);
109
+ }
110
+
111
+ spinner.succeed(chalk.green(`Voice validated: ${voiceResult.name}`));
112
+ }
113
+
114
+ // Add device to config
115
+ const newDevice = {
116
+ name: answers.name.trim(),
117
+ extension: answers.extension,
118
+ authId: answers.authId || answers.extension,
119
+ password: answers.password,
120
+ voiceId: answers.voiceId,
121
+ prompt: answers.prompt || `You are ${answers.name}, a helpful AI assistant accessible via phone.`
122
+ };
123
+
124
+ config.devices.push(newDevice);
125
+
126
+ // Save config
127
+ const saveSpinner = ora('Saving configuration...').start();
128
+ await saveConfig(config);
129
+
130
+ // Regenerate Docker config with new device
131
+ await writeDockerConfig(config);
132
+
133
+ saveSpinner.succeed(chalk.green('Configuration saved'));
134
+
135
+ console.log(chalk.bold.green('\n✓ Device added successfully!'));
136
+ console.log(chalk.gray('\nDevice details:'));
137
+ console.log(chalk.gray(` Name: ${newDevice.name}`));
138
+ console.log(chalk.gray(` Extension: ${newDevice.extension}`));
139
+ console.log(chalk.gray(` Voice: ${voiceResult.name} (${newDevice.voiceId})`));
140
+ console.log(chalk.yellow('\n⚠ Restart services to apply changes:'));
141
+ console.log(chalk.gray(' claude-phone stop'));
142
+ console.log(chalk.gray(' claude-phone start\n'));
143
+ }
@@ -0,0 +1,55 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, configExists } from '../../config.js';
3
+
4
+ /**
5
+ * Device list command - List all configured devices
6
+ * @returns {Promise<void>}
7
+ */
8
+ export async function deviceListCommand() {
9
+ console.log(chalk.bold.cyan('\n📱 Configured Devices\n'));
10
+
11
+ if (!configExists()) {
12
+ console.log(chalk.red('✗ Not configured'));
13
+ console.log(chalk.gray(' Run "claude-phone setup" first\n'));
14
+ process.exit(1);
15
+ }
16
+
17
+ const config = await loadConfig();
18
+
19
+ if (config.devices.length === 0) {
20
+ console.log(chalk.yellow('No devices configured.'));
21
+ console.log(chalk.gray(' Run "claude-phone device add" to add a device\n'));
22
+ return;
23
+ }
24
+
25
+ // Calculate column widths
26
+ const nameWidth = Math.max(10, ...config.devices.map(d => d.name.length));
27
+ const extWidth = 9; // "Extension"
28
+ const voiceWidth = 30;
29
+
30
+ // Print header
31
+ const horizontalLine = '─'.repeat(nameWidth + extWidth + voiceWidth + 8);
32
+ console.log('┌' + horizontalLine + '┐');
33
+
34
+ const namePad = 'Name'.padEnd(nameWidth);
35
+ const extPad = 'Extension'.padEnd(extWidth);
36
+ const voicePad = 'Voice ID'.padEnd(voiceWidth);
37
+ console.log(`│ ${chalk.bold(namePad)} │ ${chalk.bold(extPad)} │ ${chalk.bold(voicePad)} │`);
38
+
39
+ console.log('├' + horizontalLine + '┤');
40
+
41
+ // Print devices
42
+ for (const device of config.devices) {
43
+ const namePad = device.name.padEnd(nameWidth);
44
+ const extPad = device.extension.padEnd(extWidth);
45
+ const voiceDisplay = device.voiceId.length > voiceWidth
46
+ ? device.voiceId.substring(0, voiceWidth - 3) + '...'
47
+ : device.voiceId.padEnd(voiceWidth);
48
+
49
+ console.log(`│ ${namePad} │ ${extPad} │ ${voiceDisplay} │`);
50
+ }
51
+
52
+ console.log('└' + horizontalLine + '┘');
53
+
54
+ console.log(chalk.gray(`\nTotal: ${config.devices.length} device(s)\n`));
55
+ }
@@ -0,0 +1,83 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { loadConfig, saveConfig, configExists } from '../../config.js';
5
+ import { writeDockerConfig } from '../../docker.js';
6
+
7
+ /**
8
+ * Device remove command - Remove a device
9
+ * @param {string} deviceName - Name of device to remove
10
+ * @returns {Promise<void>}
11
+ */
12
+ export async function deviceRemoveCommand(deviceName) {
13
+ console.log(chalk.bold.cyan('\n🗑️ Remove Device\n'));
14
+
15
+ if (!configExists()) {
16
+ console.log(chalk.red('✗ Not configured'));
17
+ console.log(chalk.gray(' Run "claude-phone setup" first\n'));
18
+ process.exit(1);
19
+ }
20
+
21
+ const config = await loadConfig();
22
+
23
+ // Find device
24
+ const deviceIndex = config.devices.findIndex(
25
+ d => d.name.toLowerCase() === deviceName.toLowerCase()
26
+ );
27
+
28
+ if (deviceIndex === -1) {
29
+ console.log(chalk.red(`✗ Device "${deviceName}" not found\n`));
30
+ console.log(chalk.gray('Available devices:'));
31
+ for (const device of config.devices) {
32
+ console.log(chalk.gray(` • ${device.name}`));
33
+ }
34
+ console.log();
35
+ process.exit(1);
36
+ }
37
+
38
+ const device = config.devices[deviceIndex];
39
+
40
+ // Prevent removing last device
41
+ if (config.devices.length === 1) {
42
+ console.log(chalk.red('✗ Cannot remove the last device'));
43
+ console.log(chalk.gray(' At least one device must be configured\n'));
44
+ process.exit(1);
45
+ }
46
+
47
+ // Confirm removal
48
+ console.log(chalk.yellow('Device details:'));
49
+ console.log(chalk.gray(` Name: ${device.name}`));
50
+ console.log(chalk.gray(` Extension: ${device.extension}`));
51
+ console.log(chalk.gray(` Voice ID: ${device.voiceId}\n`));
52
+
53
+ const { confirmed } = await inquirer.prompt([
54
+ {
55
+ type: 'confirm',
56
+ name: 'confirmed',
57
+ message: `Are you sure you want to remove device "${device.name}"?`,
58
+ default: false
59
+ }
60
+ ]);
61
+
62
+ if (!confirmed) {
63
+ console.log(chalk.gray('\nCancelled. No changes made.\n'));
64
+ return;
65
+ }
66
+
67
+ // Remove device
68
+ const spinner = ora('Removing device...').start();
69
+ config.devices.splice(deviceIndex, 1);
70
+
71
+ // Save config
72
+ await saveConfig(config);
73
+
74
+ // Regenerate Docker config without this device
75
+ await writeDockerConfig(config);
76
+
77
+ spinner.succeed(chalk.green('Device removed'));
78
+
79
+ console.log(chalk.bold.green('\n✓ Device removed successfully!'));
80
+ console.log(chalk.yellow('\n⚠ Restart services to apply changes:'));
81
+ console.log(chalk.gray(' claude-phone stop'));
82
+ console.log(chalk.gray(' claude-phone start\n'));
83
+ }
@@ -0,0 +1,422 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { spawn } from 'child_process';
4
+ import axios from 'axios';
5
+ import { loadConfig, configExists, getInstallationType } from '../config.js';
6
+ import { checkDocker, getContainerStatus } from '../docker.js';
7
+ import { isServerRunning, getServerPid } from '../process-manager.js';
8
+ import { validateElevenLabsKey, validateOpenAIKey } from '../validators.js';
9
+ import { isReachable, checkClaudeApiServer as checkClaudeApiHealth } from '../network.js';
10
+ import { checkPort } from '../port-check.js';
11
+
12
+ /**
13
+ * Check if Claude CLI is installed
14
+ * @returns {Promise<{installed: boolean, version?: string, error?: string}>}
15
+ */
16
+ async function checkClaudeCLI() {
17
+ return new Promise((resolve) => {
18
+ const child = spawn('claude', ['--version'], {
19
+ stdio: 'pipe'
20
+ });
21
+
22
+ let output = '';
23
+ child.stdout.on('data', (data) => {
24
+ output += data.toString();
25
+ });
26
+
27
+ child.stderr.on('data', (data) => {
28
+ output += data.toString();
29
+ });
30
+
31
+ child.on('close', (code) => {
32
+ if (code === 0) {
33
+ // Extract version from output
34
+ const versionMatch = output.match(/(\d+\.\d+\.\d+)/);
35
+ resolve({
36
+ installed: true,
37
+ version: versionMatch ? versionMatch[1] : 'unknown'
38
+ });
39
+ } else {
40
+ resolve({
41
+ installed: false,
42
+ error: 'Claude CLI not found in PATH'
43
+ });
44
+ }
45
+ });
46
+
47
+ child.on('error', () => {
48
+ resolve({
49
+ installed: false,
50
+ error: 'Claude CLI not found'
51
+ });
52
+ });
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Check ElevenLabs API connectivity
58
+ * @param {string} apiKey - ElevenLabs API key
59
+ * @returns {Promise<{connected: boolean, error?: string}>}
60
+ */
61
+ async function checkElevenLabsAPI(apiKey) {
62
+ try {
63
+ const result = await validateElevenLabsKey(apiKey);
64
+ if (result.valid) {
65
+ return { connected: true };
66
+ } else {
67
+ return { connected: false, error: result.error };
68
+ }
69
+ } catch (error) {
70
+ return { connected: false, error: error.message };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check OpenAI API connectivity
76
+ * @param {string} apiKey - OpenAI API key
77
+ * @returns {Promise<{connected: boolean, error?: string}>}
78
+ */
79
+ async function checkOpenAIAPI(apiKey) {
80
+ try {
81
+ const result = await validateOpenAIKey(apiKey);
82
+ if (result.valid) {
83
+ return { connected: true };
84
+ } else {
85
+ return { connected: false, error: result.error };
86
+ }
87
+ } catch (error) {
88
+ return { connected: false, error: error.message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Check if voice-app container is running
94
+ * @returns {Promise<{running: boolean, error?: string}>}
95
+ */
96
+ async function checkVoiceApp() {
97
+ const containers = await getContainerStatus();
98
+ const voiceApp = containers.find(c => c.name.includes('voice-app'));
99
+
100
+ if (!voiceApp) {
101
+ return {
102
+ running: false,
103
+ error: 'Container not found'
104
+ };
105
+ }
106
+
107
+ const isRunning = voiceApp.status.toLowerCase().includes('up') ||
108
+ voiceApp.status.toLowerCase().includes('running');
109
+
110
+ if (!isRunning) {
111
+ return {
112
+ running: false,
113
+ error: `Container status: ${voiceApp.status}`
114
+ };
115
+ }
116
+
117
+ return { running: true };
118
+ }
119
+
120
+ /**
121
+ * Check if claude-api-server is running
122
+ * @param {number} port - Port to check
123
+ * @returns {Promise<{running: boolean, pid?: number, healthy?: boolean, error?: string}>}
124
+ */
125
+ async function checkClaudeAPIServer(port) {
126
+ const running = await isServerRunning();
127
+
128
+ if (!running) {
129
+ return {
130
+ running: false,
131
+ error: 'Process not running'
132
+ };
133
+ }
134
+
135
+ const pid = getServerPid();
136
+
137
+ // Try HTTP health check
138
+ try {
139
+ const response = await axios.get(`http://localhost:${port}/health`, {
140
+ timeout: 5000
141
+ });
142
+
143
+ if (response.status === 200) {
144
+ return {
145
+ running: true,
146
+ pid,
147
+ healthy: true
148
+ };
149
+ } else {
150
+ return {
151
+ running: true,
152
+ pid,
153
+ healthy: false,
154
+ error: `Health check returned status ${response.status}`
155
+ };
156
+ }
157
+ } catch (error) {
158
+ return {
159
+ running: true,
160
+ pid,
161
+ healthy: false,
162
+ error: 'Health endpoint not responding'
163
+ };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Doctor command - Run health checks
169
+ * @returns {Promise<void>}
170
+ */
171
+ export async function doctorCommand() {
172
+ console.log(chalk.bold.cyan('\n🔍 Claude Phone Health Check\n'));
173
+
174
+ if (!configExists()) {
175
+ console.log(chalk.red('✗ Not configured'));
176
+ console.log(chalk.gray(' → Run "claude-phone setup" first\n'));
177
+ process.exit(1);
178
+ }
179
+
180
+ const config = await loadConfig();
181
+ const installationType = getInstallationType(config);
182
+ const isPiSplit = config.deployment && config.deployment.mode === 'pi-split';
183
+
184
+ console.log(chalk.bold(`Installation Type: ${installationType === 'api-server' ? 'API Server' : installationType === 'voice-server' ? 'Voice Server' : 'Both (all-in-one)'}\n`));
185
+
186
+ const checks = [];
187
+ let passedCount = 0;
188
+
189
+ // Run type-appropriate checks
190
+ if (installationType === 'api-server') {
191
+ const result = await runApiServerChecks(config);
192
+ checks.push(...result.checks);
193
+ passedCount += result.passedCount;
194
+ } else if (installationType === 'voice-server') {
195
+ const result = await runVoiceServerChecks(config, isPiSplit);
196
+ checks.push(...result.checks);
197
+ passedCount += result.passedCount;
198
+ } else {
199
+ // Both - run all checks
200
+ const apiResult = await runApiServerChecks(config);
201
+ checks.push(...apiResult.checks);
202
+ passedCount += apiResult.passedCount;
203
+
204
+ const voiceResult = await runVoiceServerChecks(config, isPiSplit);
205
+ checks.push(...voiceResult.checks);
206
+ passedCount += voiceResult.passedCount;
207
+ }
208
+
209
+ // Summary
210
+ console.log(chalk.bold(`\n${passedCount}/${checks.length} checks passed\n`));
211
+
212
+ if (passedCount === checks.length) {
213
+ console.log(chalk.green('✓ All systems operational!\n'));
214
+ process.exit(0);
215
+ } else if (passedCount > checks.length / 2) {
216
+ console.log(chalk.yellow('⚠ Some issues detected. Review the failures above.\n'));
217
+ process.exit(1);
218
+ } else {
219
+ console.log(chalk.red('✗ Multiple failures detected. Fix the issues above before using Claude Phone.\n'));
220
+ process.exit(1);
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Run API server health checks
226
+ * @param {object} config - Configuration
227
+ * @returns {Promise<{checks: Array, passedCount: number}>}
228
+ */
229
+ async function runApiServerChecks(config) {
230
+ const checks = [];
231
+ let passedCount = 0;
232
+
233
+ // Check Claude CLI
234
+ const claudeSpinner = ora('Checking Claude CLI...').start();
235
+ const claudeResult = await checkClaudeCLI();
236
+ if (claudeResult.installed) {
237
+ claudeSpinner.succeed(chalk.green(`Claude CLI installed (v${claudeResult.version})`));
238
+ passedCount++;
239
+ } else {
240
+ claudeSpinner.fail(chalk.red(`Claude CLI not found: ${claudeResult.error}`));
241
+ console.log(chalk.gray(' → Install Claude CLI: npm install -g @anthropic-ai/claude\n'));
242
+ }
243
+ checks.push({ name: 'Claude CLI', passed: claudeResult.installed });
244
+
245
+ // Check local Claude API server
246
+ const apiServerSpinner = ora('Checking Claude API server...').start();
247
+ const apiServerResult = await checkClaudeAPIServer(config.server.claudeApiPort);
248
+ if (apiServerResult.running && apiServerResult.healthy) {
249
+ apiServerSpinner.succeed(chalk.green(`Claude API server running (PID: ${apiServerResult.pid})`));
250
+ passedCount++;
251
+ } else if (apiServerResult.running && !apiServerResult.healthy) {
252
+ apiServerSpinner.warn(chalk.yellow(`Claude API server running but unhealthy (PID: ${apiServerResult.pid})`));
253
+ console.log(chalk.gray(` → ${apiServerResult.error}\n`));
254
+ passedCount++; // Count as partial pass
255
+ } else {
256
+ apiServerSpinner.fail(chalk.red(`Claude API server not running: ${apiServerResult.error}`));
257
+ console.log(chalk.gray(' → Run "claude-phone start" to launch services\n'));
258
+ }
259
+ checks.push({ name: 'Claude API server', passed: apiServerResult.running });
260
+
261
+ return { checks, passedCount };
262
+ }
263
+
264
+ /**
265
+ * Run voice server health checks
266
+ * @param {object} config - Configuration
267
+ * @param {boolean} isPiSplit - Is Pi split mode
268
+ * @returns {Promise<{checks: Array, passedCount: number}>}
269
+ */
270
+ async function runVoiceServerChecks(config, isPiSplit) {
271
+ const checks = [];
272
+ let passedCount = 0;
273
+
274
+ // Check Docker
275
+ const dockerSpinner = ora('Checking Docker...').start();
276
+ const dockerResult = await checkDocker();
277
+ if (dockerResult.installed && dockerResult.running) {
278
+ dockerSpinner.succeed(chalk.green('Docker is running'));
279
+ passedCount++;
280
+ } else {
281
+ dockerSpinner.fail(chalk.red(`Docker check failed: ${dockerResult.error}`));
282
+ console.log(chalk.gray(' → Install Docker Desktop from https://www.docker.com/products/docker-desktop\n'));
283
+ }
284
+ checks.push({ name: 'Docker', passed: dockerResult.installed && dockerResult.running });
285
+
286
+ if (config.api && config.api.mode === 'local') {
287
+ // Local mode: check the faster-whisper and Piper sidecar containers instead of cloud APIs
288
+ const sttSpinner = ora('Checking local STT (faster-whisper)...').start();
289
+ try {
290
+ // Doctor runs on the host, not inside the Docker network, so it must
291
+ // use the localhost-published port (see docker-compose.yml), not the
292
+ // internal service-name URL that voice-app uses.
293
+ await axios.get('http://127.0.0.1:9001/health', { timeout: 3000 });
294
+ sttSpinner.succeed(chalk.green('Local STT (stt-local) is reachable'));
295
+ passedCount++;
296
+ checks.push({ name: 'Local STT', passed: true });
297
+ } catch (error) {
298
+ sttSpinner.fail(chalk.red(`Local STT unreachable: ${error.message}`));
299
+ console.log(chalk.gray(' → Check "docker compose logs stt-local"\n'));
300
+ checks.push({ name: 'Local STT', passed: false });
301
+ }
302
+
303
+ const ttsSpinner = ora('Checking local TTS (Piper)...').start();
304
+ try {
305
+ await axios.get('http://127.0.0.1:9002/health', { timeout: 3000 });
306
+ ttsSpinner.succeed(chalk.green('Local TTS (tts-local) is reachable'));
307
+ passedCount++;
308
+ checks.push({ name: 'Local TTS', passed: true });
309
+ } catch (error) {
310
+ ttsSpinner.fail(chalk.red(`Local TTS unreachable: ${error.message}`));
311
+ console.log(chalk.gray(' → Check "docker compose logs tts-local"\n'));
312
+ checks.push({ name: 'Local TTS', passed: false });
313
+ }
314
+ } else {
315
+ // Check ElevenLabs API (only if configured)
316
+ if (config.api && config.api.elevenlabs && config.api.elevenlabs.apiKey) {
317
+ const elevenLabsSpinner = ora('Checking ElevenLabs API...').start();
318
+ const elevenLabsResult = await checkElevenLabsAPI(config.api.elevenlabs.apiKey);
319
+ if (elevenLabsResult.connected) {
320
+ elevenLabsSpinner.succeed(chalk.green('ElevenLabs API connected'));
321
+ passedCount++;
322
+ } else {
323
+ elevenLabsSpinner.fail(chalk.red(`ElevenLabs API failed: ${elevenLabsResult.error}`));
324
+ console.log(chalk.gray(' → Check your API key in ~/.claude-phone/config.json\n'));
325
+ }
326
+ checks.push({ name: 'ElevenLabs API', passed: elevenLabsResult.connected });
327
+ }
328
+
329
+ // Check OpenAI API (only if configured)
330
+ if (config.api && config.api.openai && config.api.openai.apiKey) {
331
+ const openAISpinner = ora('Checking OpenAI API...').start();
332
+ const openAIResult = await checkOpenAIAPI(config.api.openai.apiKey);
333
+ if (openAIResult.connected) {
334
+ openAISpinner.succeed(chalk.green('OpenAI API connected'));
335
+ passedCount++;
336
+ } else {
337
+ openAISpinner.fail(chalk.red(`OpenAI API failed: ${openAIResult.error}`));
338
+ console.log(chalk.gray(' → Check your API key in ~/.claude-phone/config.json\n'));
339
+ }
340
+ checks.push({ name: 'OpenAI API', passed: openAIResult.connected });
341
+ }
342
+ }
343
+
344
+ // Check Voice-app container
345
+ const voiceAppSpinner = ora('Checking voice-app container...').start();
346
+ const voiceAppResult = await checkVoiceApp();
347
+ if (voiceAppResult.running) {
348
+ voiceAppSpinner.succeed(chalk.green('Voice-app container running'));
349
+ passedCount++;
350
+ } else {
351
+ voiceAppSpinner.fail(chalk.red(`Voice-app container not running: ${voiceAppResult.error}`));
352
+ console.log(chalk.gray(' → Run "claude-phone start" to launch services\n'));
353
+ }
354
+ checks.push({ name: 'Voice-app container', passed: voiceAppResult.running });
355
+
356
+ // Check API server reachability (voice-server mode)
357
+ if (isPiSplit) {
358
+ // Pi-split mode: Check API server IP reachability
359
+ const apiIpSpinner = ora('Checking API server IP reachability...').start();
360
+ const apiServerIp = config.deployment.pi.macIp;
361
+ const apiServerReachable = await isReachable(apiServerIp);
362
+
363
+ if (apiServerReachable) {
364
+ apiIpSpinner.succeed(chalk.green(`API server IP reachable (${apiServerIp})`));
365
+ passedCount++;
366
+ } else {
367
+ apiIpSpinner.fail(chalk.red(`API server IP not reachable: ${apiServerIp}`));
368
+ console.log(chalk.gray(' → Check network connection between Pi and API server\n'));
369
+ }
370
+ checks.push({ name: 'API server IP reachability', passed: apiServerReachable });
371
+
372
+ // Check Claude API server on remote server
373
+ const apiServerSpinner = ora('Checking Claude API server...').start();
374
+ const apiUrl = `http://${apiServerIp}:${config.server.claudeApiPort}`;
375
+ const apiHealth = await checkClaudeApiHealth(apiUrl);
376
+
377
+ if (apiHealth.healthy) {
378
+ apiServerSpinner.succeed(chalk.green(`Claude API server healthy at ${apiUrl}`));
379
+ passedCount++;
380
+ } else {
381
+ apiServerSpinner.fail(chalk.red(`Claude API server not responding`));
382
+ console.log(chalk.gray(` → Run "claude-phone api-server" on your API server\n`));
383
+ }
384
+ checks.push({ name: 'Claude API server (remote)', passed: apiHealth.healthy });
385
+
386
+ // Check drachtio port availability
387
+ const drachtioPort = config.deployment.pi.drachtioPort || 5060;
388
+ const drachtioSpinner = ora(`Checking drachtio port ${drachtioPort}...`).start();
389
+ const drachtioPortCheck = await checkPort(drachtioPort);
390
+
391
+ if (drachtioPortCheck.inUse) {
392
+ if (drachtioPort === 5070) {
393
+ drachtioSpinner.succeed(chalk.green(`Port ${drachtioPort} in use (expected - drachtio running)`));
394
+ passedCount++;
395
+ } else {
396
+ drachtioSpinner.warn(chalk.yellow(`Port ${drachtioPort} in use (may conflict)`));
397
+ passedCount++; // Partial pass
398
+ }
399
+ } else {
400
+ drachtioSpinner.succeed(chalk.green(`Port ${drachtioPort} available`));
401
+ passedCount++;
402
+ }
403
+ checks.push({ name: `Drachtio port ${drachtioPort}`, passed: true });
404
+ } else if (config.deployment && config.deployment.apiServerIp) {
405
+ // Voice server mode (non-Pi): Check remote API server
406
+ const apiServerIp = config.deployment.apiServerIp;
407
+ const apiServerSpinner = ora('Checking remote API server...').start();
408
+ const apiUrl = `http://${apiServerIp}:${config.server.claudeApiPort}`;
409
+ const apiHealth = await checkClaudeApiHealth(apiUrl);
410
+
411
+ if (apiHealth.healthy) {
412
+ apiServerSpinner.succeed(chalk.green(`API server healthy at ${apiUrl}`));
413
+ passedCount++;
414
+ } else {
415
+ apiServerSpinner.fail(chalk.red(`API server not responding`));
416
+ console.log(chalk.gray(` → Run "claude-phone api-server" on your API server\n`));
417
+ }
418
+ checks.push({ name: 'API server (remote)', passed: apiHealth.healthy });
419
+ }
420
+
421
+ return { checks, passedCount };
422
+ }