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,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
|
+
}
|