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,1246 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import {
|
|
9
|
+
loadConfig,
|
|
10
|
+
saveConfig,
|
|
11
|
+
configExists
|
|
12
|
+
} from '../config.js';
|
|
13
|
+
import {
|
|
14
|
+
validateElevenLabsKey,
|
|
15
|
+
validateOpenAIKey,
|
|
16
|
+
validateVoiceId,
|
|
17
|
+
validateExtension,
|
|
18
|
+
validateIP,
|
|
19
|
+
validateHostname
|
|
20
|
+
} from '../validators.js';
|
|
21
|
+
import { ensureVoiceModel, KNOWN_VOICES } from '../voice-downloader.js';
|
|
22
|
+
import { getLocalIP, getProjectRoot } from '../utils.js';
|
|
23
|
+
import { isRaspberryPi } from '../platform.js';
|
|
24
|
+
import { detect3cxSbc } from '../port-check.js';
|
|
25
|
+
import { checkPiPrerequisites } from '../prerequisites.js';
|
|
26
|
+
import { checkClaudeApiServer } from '../network.js';
|
|
27
|
+
import { runPrereqChecks } from '../prereqs.js';
|
|
28
|
+
import { registerMcpServerWithOutput } from '../mcp-register.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Prompt for installation type
|
|
32
|
+
* @param {string} currentType - Current installation type
|
|
33
|
+
* @returns {Promise<string>} Selected installation type
|
|
34
|
+
*/
|
|
35
|
+
async function promptInstallationType(currentType = 'both') {
|
|
36
|
+
const { type } = await inquirer.prompt([{
|
|
37
|
+
type: 'list',
|
|
38
|
+
name: 'type',
|
|
39
|
+
message: 'What are you installing?',
|
|
40
|
+
default: currentType,
|
|
41
|
+
choices: [
|
|
42
|
+
{
|
|
43
|
+
name: 'Voice Server (Pi/Linux) - Handles calls, needs Docker',
|
|
44
|
+
value: 'voice-server'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'API Server - Claude Code wrapper, minimal setup',
|
|
48
|
+
value: 'api-server'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'Both (all-in-one) - Full stack on one machine',
|
|
52
|
+
value: 'both'
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}]);
|
|
56
|
+
|
|
57
|
+
console.log(chalk.cyan(`\nYou selected: ${type === 'voice-server' ? 'Voice Server' : type === 'api-server' ? 'API Server' : 'Both (all-in-one)'}\n`));
|
|
58
|
+
|
|
59
|
+
return type;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Setup command - Interactive wizard for configuration
|
|
64
|
+
* @param {object} options - Command options
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
export async function setupCommand(options = {}) {
|
|
68
|
+
console.log(chalk.bold.cyan('\n🎯 Claude Phone Setup\n'));
|
|
69
|
+
|
|
70
|
+
// Run minimal prerequisite check first (Node.js only)
|
|
71
|
+
if (!options.skipPrereqs) {
|
|
72
|
+
const minimalPrereq = await runPrereqChecks({ type: 'minimal' });
|
|
73
|
+
|
|
74
|
+
if (!minimalPrereq.success) {
|
|
75
|
+
console.log(chalk.red('\n❌ Prerequisites not met. Please fix the issues above and try again.\n'));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
console.log(chalk.yellow('⚠️ Skipping prerequisite checks (--skip-prereqs flag)\n'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check if config exists
|
|
83
|
+
const hasConfig = configExists();
|
|
84
|
+
let existingConfig = null;
|
|
85
|
+
|
|
86
|
+
if (hasConfig) {
|
|
87
|
+
console.log(chalk.yellow('⚠️ Configuration already exists.'));
|
|
88
|
+
const { shouldContinue } = await inquirer.prompt([
|
|
89
|
+
{
|
|
90
|
+
type: 'confirm',
|
|
91
|
+
name: 'shouldContinue',
|
|
92
|
+
message: 'Do you want to update your configuration?',
|
|
93
|
+
default: false
|
|
94
|
+
}
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
if (!shouldContinue) {
|
|
98
|
+
console.log(chalk.gray('Setup cancelled.'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
existingConfig = await loadConfig();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Prompt for installation type
|
|
106
|
+
console.log(chalk.bold.cyan('\n📦 Installation Type\n'));
|
|
107
|
+
const installationType = await promptInstallationType(
|
|
108
|
+
existingConfig ? existingConfig.installationType : 'both'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Detect platform (for Pi split-mode detection)
|
|
112
|
+
const isPi = await isRaspberryPi();
|
|
113
|
+
|
|
114
|
+
// If Pi detected and user selected "both", recommend voice-server
|
|
115
|
+
if (isPi && installationType === 'both') {
|
|
116
|
+
console.log(chalk.yellow('\n⚠️ Raspberry Pi detected!'));
|
|
117
|
+
console.log(chalk.gray('For best performance, consider selecting "Voice Server" instead of "Both".'));
|
|
118
|
+
console.log(chalk.gray('This allows the API server to run on a more powerful machine.\n'));
|
|
119
|
+
|
|
120
|
+
const { changeToPi } = await inquirer.prompt([
|
|
121
|
+
{
|
|
122
|
+
type: 'confirm',
|
|
123
|
+
name: 'changeToPi',
|
|
124
|
+
message: 'Switch to Voice Server mode?',
|
|
125
|
+
default: true
|
|
126
|
+
}
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
if (changeToPi) {
|
|
130
|
+
// Re-run with voice-server type
|
|
131
|
+
return setupInstallationType('voice-server', existingConfig, isPi, options);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Run type-specific setup
|
|
136
|
+
try {
|
|
137
|
+
await setupInstallationType(installationType, existingConfig, isPi, options);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error(chalk.red('\n\n❌ Setup failed with error:'));
|
|
140
|
+
console.error(chalk.red(error.message));
|
|
141
|
+
console.error(chalk.gray('\nStack trace:'));
|
|
142
|
+
console.error(chalk.gray(error.stack));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Route to type-specific setup
|
|
149
|
+
* @param {string} installationType - Installation type
|
|
150
|
+
* @param {object} existingConfig - Existing config or null
|
|
151
|
+
* @param {boolean} isPi - Is Raspberry Pi
|
|
152
|
+
* @param {object} options - Command options
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async function setupInstallationType(installationType, existingConfig, isPi, options) {
|
|
156
|
+
// Load existing config or create default
|
|
157
|
+
const baseConfig = existingConfig || createDefaultConfig();
|
|
158
|
+
|
|
159
|
+
// Run type-specific prereq checks (unless skipped)
|
|
160
|
+
if (!options.skipPrereqs && installationType !== 'api-server') {
|
|
161
|
+
console.log(chalk.bold.cyan(`\n🔍 Checking ${installationType === 'voice-server' ? 'Voice Server' : 'All'} prerequisites...\n`));
|
|
162
|
+
const prereqResult = await runPrereqChecks({ type: installationType });
|
|
163
|
+
|
|
164
|
+
if (!prereqResult.success) {
|
|
165
|
+
console.log(chalk.red('\n❌ Prerequisites not met. Please fix the issues above and try again.\n'));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let config;
|
|
171
|
+
|
|
172
|
+
switch (installationType) {
|
|
173
|
+
case 'api-server':
|
|
174
|
+
config = await setupApiServer(baseConfig);
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case 'voice-server':
|
|
178
|
+
// Check if Pi - use Pi setup flow
|
|
179
|
+
if (isPi) {
|
|
180
|
+
config = await setupPi(baseConfig);
|
|
181
|
+
} else {
|
|
182
|
+
config = await setupVoiceServer(baseConfig);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
|
|
186
|
+
case 'both':
|
|
187
|
+
default:
|
|
188
|
+
// Check if Pi - use Pi setup but with "both" type
|
|
189
|
+
if (isPi) {
|
|
190
|
+
config = await setupPi(baseConfig);
|
|
191
|
+
} else {
|
|
192
|
+
config = await setupBoth(baseConfig);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Set installation type in config
|
|
198
|
+
config.installationType = installationType;
|
|
199
|
+
|
|
200
|
+
// Save configuration
|
|
201
|
+
const spinner = ora('Saving configuration...').start();
|
|
202
|
+
try {
|
|
203
|
+
await saveConfig(config);
|
|
204
|
+
spinner.succeed('Configuration saved');
|
|
205
|
+
} catch (error) {
|
|
206
|
+
spinner.fail(`Failed to save configuration: ${error.message}`);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Install dependencies for API server types
|
|
211
|
+
if (installationType === 'api-server' || installationType === 'both') {
|
|
212
|
+
const apiServerPath = config.paths?.claudeApiServer;
|
|
213
|
+
if (apiServerPath && fs.existsSync(apiServerPath)) {
|
|
214
|
+
const nodeModulesPath = path.join(apiServerPath, 'node_modules');
|
|
215
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
216
|
+
const installSpinner = ora('Installing API server dependencies...').start();
|
|
217
|
+
try {
|
|
218
|
+
execSync('npm install', {
|
|
219
|
+
cwd: apiServerPath,
|
|
220
|
+
stdio: 'pipe'
|
|
221
|
+
});
|
|
222
|
+
installSpinner.succeed('API server dependencies installed');
|
|
223
|
+
} catch (error) {
|
|
224
|
+
installSpinner.fail(`Failed to install dependencies: ${error.message}`);
|
|
225
|
+
console.log(chalk.yellow('\nYou can install manually with:'));
|
|
226
|
+
console.log(chalk.cyan(` cd ${apiServerPath} && npm install\n`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Register the MCP server so Claude Code can phone the user. Done here rather
|
|
233
|
+
// than in postinstall because only now do we know which extension to ring.
|
|
234
|
+
if (installationType !== 'api-server') {
|
|
235
|
+
console.log(chalk.bold('\nMCP server (lets Claude call you)'));
|
|
236
|
+
const firstDevice = config.devices && Object.keys(config.devices).length
|
|
237
|
+
? Object.values(config.devices)[0]
|
|
238
|
+
: null;
|
|
239
|
+
registerMcpServerWithOutput({
|
|
240
|
+
defaultTo: config.sip?.callbackNumber || firstDevice?.extension || config.sip?.extension,
|
|
241
|
+
httpPort: config.server?.httpPort || 3000,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Type-specific success messages
|
|
246
|
+
console.log(chalk.bold.green('\n✓ Setup complete!\n'));
|
|
247
|
+
|
|
248
|
+
if (installationType === 'api-server') {
|
|
249
|
+
console.log(chalk.gray('To start the API server:'));
|
|
250
|
+
console.log(chalk.gray(' claude-phone start\n'));
|
|
251
|
+
console.log(chalk.gray(`The API server will listen on port ${config.server.claudeApiPort}.`));
|
|
252
|
+
console.log(chalk.gray('Voice servers can connect to: http://YOUR_IP:' + config.server.claudeApiPort + '\n'));
|
|
253
|
+
} else if (installationType === 'voice-server') {
|
|
254
|
+
if (isPi) {
|
|
255
|
+
console.log(chalk.bold.cyan('📋 API server instructions:\n'));
|
|
256
|
+
console.log(chalk.gray(' On your API server, run:'));
|
|
257
|
+
console.log(chalk.white(` claude-phone api-server --port ${config.server.claudeApiPort}\n`));
|
|
258
|
+
console.log(chalk.gray(' This starts the Claude API wrapper that the Pi will connect to.\n'));
|
|
259
|
+
console.log(chalk.bold.cyan('📋 Pi-side next steps:\n'));
|
|
260
|
+
console.log(chalk.gray(' 1. Run "claude-phone start" to launch voice-app'));
|
|
261
|
+
console.log(chalk.gray(' 2. Call extension ' + config.devices[0].extension + ' from your phone'));
|
|
262
|
+
console.log(chalk.gray(' 3. Start talking to Claude!\n'));
|
|
263
|
+
} else {
|
|
264
|
+
console.log(chalk.gray('Make sure your API server is running with:'));
|
|
265
|
+
console.log(chalk.gray(' claude-phone api-server (on the API server machine)\n'));
|
|
266
|
+
console.log(chalk.gray('Next steps:'));
|
|
267
|
+
console.log(chalk.gray(' 1. Run "claude-phone start" to launch voice services'));
|
|
268
|
+
console.log(chalk.gray(' 2. Call extension ' + config.devices[0].extension + ' from your phone'));
|
|
269
|
+
console.log(chalk.gray(' 3. Start talking to Claude!\n'));
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
// Both
|
|
273
|
+
console.log(chalk.gray('Next steps:'));
|
|
274
|
+
console.log(chalk.gray(' 1. Run "claude-phone start" to launch all services'));
|
|
275
|
+
console.log(chalk.gray(' 2. Call extension ' + config.devices[0].extension + ' from your phone'));
|
|
276
|
+
console.log(chalk.gray(' 3. Start talking to Claude!\n'));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* API Server only setup (minimal configuration)
|
|
282
|
+
* @param {object} config - Current config
|
|
283
|
+
* @returns {Promise<object>} Updated config
|
|
284
|
+
*/
|
|
285
|
+
async function setupApiServer(config) {
|
|
286
|
+
console.log(chalk.bold.cyan('\n🖥️ API Server Configuration\n'));
|
|
287
|
+
|
|
288
|
+
const answers = await inquirer.prompt([{
|
|
289
|
+
type: 'input',
|
|
290
|
+
name: 'port',
|
|
291
|
+
message: 'API server port:',
|
|
292
|
+
default: config.server?.claudeApiPort || 3333,
|
|
293
|
+
validate: (input) => {
|
|
294
|
+
const port = parseInt(input, 10);
|
|
295
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
296
|
+
return 'Port must be between 1024 and 65535';
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}]);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
...config,
|
|
304
|
+
server: {
|
|
305
|
+
...config.server,
|
|
306
|
+
claudeApiPort: parseInt(answers.port, 10)
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Voice Server only setup (non-Pi)
|
|
313
|
+
* Asks for SIP, API keys, devices, and API server connection
|
|
314
|
+
* @param {object} config - Current config
|
|
315
|
+
* @returns {Promise<object>} Updated config
|
|
316
|
+
*/
|
|
317
|
+
async function setupVoiceServer(config) {
|
|
318
|
+
// Ensure secrets exist
|
|
319
|
+
if (!config.secrets) {
|
|
320
|
+
config.secrets = {
|
|
321
|
+
drachtio: generateSecret(),
|
|
322
|
+
freeswitch: generateSecret()
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Set deployment mode
|
|
327
|
+
if (!config.deployment) {
|
|
328
|
+
config.deployment = { mode: 'voice-server' };
|
|
329
|
+
} else {
|
|
330
|
+
config.deployment.mode = 'voice-server';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Step 1: 3CX/SIP Configuration
|
|
334
|
+
console.log(chalk.bold('\n☎️ SIP Configuration'));
|
|
335
|
+
config = await setupSIP(config);
|
|
336
|
+
|
|
337
|
+
// Step 2: API Server Connection
|
|
338
|
+
console.log(chalk.bold('\n🖥️ API Server Connection'));
|
|
339
|
+
const apiServerAnswers = await inquirer.prompt([
|
|
340
|
+
{
|
|
341
|
+
type: 'input',
|
|
342
|
+
name: 'apiServerIp',
|
|
343
|
+
message: 'API Server IP address:',
|
|
344
|
+
default: config.deployment.apiServerIp || '',
|
|
345
|
+
validate: (input) => {
|
|
346
|
+
if (!input || input.trim() === '') {
|
|
347
|
+
return 'API Server IP is required';
|
|
348
|
+
}
|
|
349
|
+
if (!validateIP(input)) {
|
|
350
|
+
return 'Invalid IP address format';
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
type: 'input',
|
|
357
|
+
name: 'apiServerPort',
|
|
358
|
+
message: 'API Server port:',
|
|
359
|
+
default: config.server?.claudeApiPort || 3333,
|
|
360
|
+
validate: (input) => {
|
|
361
|
+
const port = parseInt(input, 10);
|
|
362
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
363
|
+
return 'Port must be between 1024 and 65535';
|
|
364
|
+
}
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
config.deployment.apiServerIp = apiServerAnswers.apiServerIp;
|
|
371
|
+
config.server = config.server || {};
|
|
372
|
+
config.server.claudeApiPort = parseInt(apiServerAnswers.apiServerPort, 10);
|
|
373
|
+
|
|
374
|
+
// Step 3: API Keys (for TTS/STT)
|
|
375
|
+
console.log(chalk.bold('\n📡 API Configuration'));
|
|
376
|
+
config = await setupAPIKeys(config);
|
|
377
|
+
|
|
378
|
+
// Step 4: Device Configuration
|
|
379
|
+
console.log(chalk.bold('\n🤖 Device Configuration'));
|
|
380
|
+
config = await setupDevice(config);
|
|
381
|
+
|
|
382
|
+
// Step 5: Server Configuration (IP only, no API port)
|
|
383
|
+
console.log(chalk.bold('\n⚙️ Server Configuration'));
|
|
384
|
+
const localIp = getLocalIP();
|
|
385
|
+
const serverAnswers = await inquirer.prompt([
|
|
386
|
+
{
|
|
387
|
+
type: 'input',
|
|
388
|
+
name: 'externalIp',
|
|
389
|
+
message: 'Server LAN IP (for RTP audio):',
|
|
390
|
+
default: config.server.externalIp === 'auto' ? localIp : config.server.externalIp,
|
|
391
|
+
validate: (input) => {
|
|
392
|
+
if (!input || input.trim() === '') {
|
|
393
|
+
return 'IP address is required';
|
|
394
|
+
}
|
|
395
|
+
if (!validateIP(input)) {
|
|
396
|
+
return 'Invalid IP address format';
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
type: 'input',
|
|
403
|
+
name: 'httpPort',
|
|
404
|
+
message: 'Voice app HTTP port:',
|
|
405
|
+
default: config.server.httpPort || 3000,
|
|
406
|
+
validate: (input) => {
|
|
407
|
+
const port = parseInt(input, 10);
|
|
408
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
409
|
+
return 'Port must be between 1024 and 65535';
|
|
410
|
+
}
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
]);
|
|
415
|
+
|
|
416
|
+
config.server.externalIp = serverAnswers.externalIp;
|
|
417
|
+
config.server.httpPort = parseInt(serverAnswers.httpPort, 10);
|
|
418
|
+
|
|
419
|
+
return config;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Both (all-in-one) setup flow
|
|
424
|
+
* @param {object} config - Current config
|
|
425
|
+
* @returns {Promise<object>} Updated config
|
|
426
|
+
*/
|
|
427
|
+
async function setupBoth(config) {
|
|
428
|
+
// Ensure secrets exist for existing configs (backwards compatibility)
|
|
429
|
+
if (!config.secrets) {
|
|
430
|
+
config.secrets = {
|
|
431
|
+
drachtio: generateSecret(),
|
|
432
|
+
freeswitch: generateSecret()
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Ensure deployment mode exists
|
|
437
|
+
if (!config.deployment) {
|
|
438
|
+
config.deployment = { mode: 'both' };
|
|
439
|
+
} else {
|
|
440
|
+
config.deployment.mode = 'both';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Step 1: API Keys
|
|
444
|
+
console.log(chalk.bold('\n📡 API Configuration'));
|
|
445
|
+
config = await setupAPIKeys(config);
|
|
446
|
+
|
|
447
|
+
// Step 2: 3CX/SIP Configuration
|
|
448
|
+
console.log(chalk.bold('\n☎️ SIP Configuration'));
|
|
449
|
+
config = await setupSIP(config);
|
|
450
|
+
|
|
451
|
+
// Step 3: Device Configuration
|
|
452
|
+
console.log(chalk.bold('\n🤖 Device Configuration'));
|
|
453
|
+
config = await setupDevice(config);
|
|
454
|
+
|
|
455
|
+
// Step 4: Server Configuration
|
|
456
|
+
console.log(chalk.bold('\n⚙️ Server Configuration'));
|
|
457
|
+
config = await setupServer(config);
|
|
458
|
+
|
|
459
|
+
return config;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Raspberry Pi split-mode setup flow
|
|
464
|
+
* @param {object} config - Current config
|
|
465
|
+
* @returns {Promise<void>}
|
|
466
|
+
*/
|
|
467
|
+
async function setupPi(config) {
|
|
468
|
+
console.log(chalk.bold.yellow('\n🥧 Raspberry Pi Split-Mode Setup\n'));
|
|
469
|
+
console.log(chalk.gray('In this mode, the Pi runs voice-app (Docker) and your API server runs claude-api-server.\n'));
|
|
470
|
+
|
|
471
|
+
// AC23: Handle existing standard config migration
|
|
472
|
+
if (config.deployment && config.deployment.mode === 'standard') {
|
|
473
|
+
console.log(chalk.yellow('\n⚠️ Detected existing standard configuration'));
|
|
474
|
+
console.log(chalk.gray('Your config will be migrated to Pi split-mode while preserving:'));
|
|
475
|
+
console.log(chalk.gray(' • API keys (ElevenLabs, OpenAI)'));
|
|
476
|
+
console.log(chalk.gray(' • Device configurations'));
|
|
477
|
+
console.log(chalk.gray(' • SIP settings\n'));
|
|
478
|
+
|
|
479
|
+
const { confirmMigration } = await inquirer.prompt([
|
|
480
|
+
{
|
|
481
|
+
type: 'confirm',
|
|
482
|
+
name: 'confirmMigration',
|
|
483
|
+
message: 'Continue with migration to Pi split-mode?',
|
|
484
|
+
default: true
|
|
485
|
+
}
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
if (!confirmMigration) {
|
|
489
|
+
console.log(chalk.gray('\nSetup cancelled.\n'));
|
|
490
|
+
process.exit(0);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
console.log(chalk.green('✓ Preserving existing configuration\n'));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Check prerequisites
|
|
497
|
+
console.log(chalk.bold('\n✅ Prerequisites Check'));
|
|
498
|
+
const prereqs = await checkPiPrerequisites();
|
|
499
|
+
let allPrereqsPassed = true;
|
|
500
|
+
|
|
501
|
+
for (const prereq of prereqs) {
|
|
502
|
+
if (prereq.installed) {
|
|
503
|
+
console.log(chalk.green(` ✓ ${prereq.name}`));
|
|
504
|
+
} else {
|
|
505
|
+
console.log(chalk.red(` ✗ ${prereq.name}: ${prereq.error}`));
|
|
506
|
+
if (prereq.installUrl) {
|
|
507
|
+
console.log(chalk.gray(` → ${prereq.installUrl}`));
|
|
508
|
+
}
|
|
509
|
+
allPrereqsPassed = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!allPrereqsPassed) {
|
|
514
|
+
console.log(chalk.red('\n✗ Prerequisites missing. Install them before continuing.\n'));
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Ensure secrets exist
|
|
519
|
+
if (!config.secrets) {
|
|
520
|
+
config.secrets = {
|
|
521
|
+
drachtio: generateSecret(),
|
|
522
|
+
freeswitch: generateSecret()
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Initialize deployment config
|
|
527
|
+
if (!config.deployment) {
|
|
528
|
+
config.deployment = { mode: 'pi-split', pi: {} };
|
|
529
|
+
} else {
|
|
530
|
+
config.deployment.mode = 'pi-split';
|
|
531
|
+
if (!config.deployment.pi) {
|
|
532
|
+
config.deployment.pi = {};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Detect 3CX SBC (AC24: Handle port detection failure)
|
|
537
|
+
console.log(chalk.bold('\n🔍 Network Detection'));
|
|
538
|
+
const sbc3cxSpinner = ora('Checking for 3CX SBC (process + UDP/TCP port 5060)...').start();
|
|
539
|
+
|
|
540
|
+
let has3cxSbc;
|
|
541
|
+
let portCheckError = false;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
has3cxSbc = await detect3cxSbc();
|
|
545
|
+
if (has3cxSbc) {
|
|
546
|
+
sbc3cxSpinner.succeed('3CX SBC detected - will use port 5070 for drachtio');
|
|
547
|
+
} else {
|
|
548
|
+
sbc3cxSpinner.succeed('No 3CX SBC detected - will use standard port 5060');
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
portCheckError = true;
|
|
552
|
+
sbc3cxSpinner.warn('Port detection failed: ' + err.message);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// AC24: Manual override when port detection fails
|
|
556
|
+
if (portCheckError) {
|
|
557
|
+
console.log(chalk.yellow('\n⚠️ Could not automatically detect 3CX SBC'));
|
|
558
|
+
const { manualSbc } = await inquirer.prompt([
|
|
559
|
+
{
|
|
560
|
+
type: 'confirm',
|
|
561
|
+
name: 'manualSbc',
|
|
562
|
+
message: 'Is 3CX SBC running on port 5060?',
|
|
563
|
+
default: false
|
|
564
|
+
}
|
|
565
|
+
]);
|
|
566
|
+
has3cxSbc = manualSbc;
|
|
567
|
+
|
|
568
|
+
if (has3cxSbc) {
|
|
569
|
+
console.log(chalk.green('✓ Will use port 5070 for drachtio (avoid conflict with SBC)\n'));
|
|
570
|
+
} else {
|
|
571
|
+
console.log(chalk.green('✓ Will use port 5060 for drachtio\n'));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
config.deployment.pi.has3cxSbc = has3cxSbc;
|
|
576
|
+
config.deployment.pi.drachtioPort = has3cxSbc ? 5070 : 5060;
|
|
577
|
+
|
|
578
|
+
// Ask for API server IP and port first, then check connectivity
|
|
579
|
+
const apiServerAnswers = await inquirer.prompt([
|
|
580
|
+
{
|
|
581
|
+
type: 'input',
|
|
582
|
+
name: 'macIp',
|
|
583
|
+
message: 'API server IP address (where claude-api-server runs):',
|
|
584
|
+
default: config.deployment.pi.macIp || '',
|
|
585
|
+
validate: (input) => {
|
|
586
|
+
if (!input || input.trim() === '') {
|
|
587
|
+
return 'API server IP is required';
|
|
588
|
+
}
|
|
589
|
+
if (!validateIP(input)) {
|
|
590
|
+
return 'Invalid IP address format';
|
|
591
|
+
}
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
type: 'input',
|
|
597
|
+
name: 'claudeApiPort',
|
|
598
|
+
message: 'Claude API server port:',
|
|
599
|
+
default: String(config.server?.claudeApiPort || 3333),
|
|
600
|
+
validate: (input) => {
|
|
601
|
+
const port = parseInt(input, 10);
|
|
602
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
603
|
+
return 'Port must be between 1024 and 65535';
|
|
604
|
+
}
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
const { macIp, claudeApiPort } = apiServerAnswers;
|
|
611
|
+
|
|
612
|
+
config.deployment.pi.macIp = macIp;
|
|
613
|
+
config.server = config.server || {};
|
|
614
|
+
config.server.claudeApiPort = parseInt(claudeApiPort, 10);
|
|
615
|
+
|
|
616
|
+
// Now check connectivity on the specified port
|
|
617
|
+
const reachSpinner = ora(`Checking API server at ${macIp}:${claudeApiPort}...`).start();
|
|
618
|
+
const apiUrl = `http://${macIp}:${claudeApiPort}`;
|
|
619
|
+
const apiHealth = await checkClaudeApiServer(apiUrl);
|
|
620
|
+
|
|
621
|
+
if (apiHealth.healthy) {
|
|
622
|
+
reachSpinner.succeed(`API server is healthy at ${apiUrl}`);
|
|
623
|
+
} else if (apiHealth.reachable) {
|
|
624
|
+
reachSpinner.warn(`API server reachable but not responding at ${apiUrl}`);
|
|
625
|
+
console.log(chalk.yellow(' ⚠️ Make sure claude-api-server is running\n'));
|
|
626
|
+
} else {
|
|
627
|
+
reachSpinner.warn(`Cannot reach API server at ${apiUrl}`);
|
|
628
|
+
console.log(chalk.yellow(' ⚠️ Make sure API server is running and port is open (firewall)\n'));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Step 1: API Keys (only for voice services - TTS/STT)
|
|
632
|
+
console.log(chalk.bold('\n📡 API Configuration'));
|
|
633
|
+
config = await setupAPIKeys(config);
|
|
634
|
+
|
|
635
|
+
// Step 2: 3CX SBC Configuration (Pi mode uses SBC)
|
|
636
|
+
console.log(chalk.bold('\n📡 3CX SBC Connection'));
|
|
637
|
+
config = await setupSBC(config);
|
|
638
|
+
|
|
639
|
+
// Step 3: Device Configuration
|
|
640
|
+
console.log(chalk.bold('\n🤖 Device Configuration'));
|
|
641
|
+
config = await setupDevice(config);
|
|
642
|
+
|
|
643
|
+
// Step 4: Server Configuration (Pi-specific)
|
|
644
|
+
console.log(chalk.bold('\n⚙️ Server Configuration'));
|
|
645
|
+
config = await setupPiServer(config);
|
|
646
|
+
|
|
647
|
+
// Save configuration
|
|
648
|
+
const spinner = ora('Saving configuration...').start();
|
|
649
|
+
try {
|
|
650
|
+
await saveConfig(config);
|
|
651
|
+
spinner.succeed('Configuration saved');
|
|
652
|
+
} catch (error) {
|
|
653
|
+
spinner.fail(`Failed to save configuration: ${error.message}`);
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Summary
|
|
658
|
+
console.log(chalk.bold.green('\n✓ Pi Setup complete!\n'));
|
|
659
|
+
console.log(chalk.bold.cyan('📋 API server instructions:\n'));
|
|
660
|
+
console.log(chalk.gray(' On your API server, run:'));
|
|
661
|
+
console.log(chalk.white(` claude-phone api-server --port ${config.server.claudeApiPort}\n`));
|
|
662
|
+
console.log(chalk.gray(' This starts the Claude API wrapper that the Pi will connect to.\n'));
|
|
663
|
+
console.log(chalk.bold.cyan('📋 Pi-side next steps:\n'));
|
|
664
|
+
console.log(chalk.gray(' 1. Run "claude-phone start" to launch voice-app'));
|
|
665
|
+
console.log(chalk.gray(' 2. Call extension ' + config.devices[0].extension + ' from your phone'));
|
|
666
|
+
console.log(chalk.gray(' 3. Start talking to Claude!\n'));
|
|
667
|
+
|
|
668
|
+
return config;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Generate a random secret for Docker services
|
|
673
|
+
* @returns {string} Random 32-character hex string
|
|
674
|
+
*/
|
|
675
|
+
function generateSecret() {
|
|
676
|
+
return crypto.randomBytes(16).toString('hex');
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Create default configuration
|
|
681
|
+
* @returns {object} Default config
|
|
682
|
+
*/
|
|
683
|
+
function createDefaultConfig() {
|
|
684
|
+
return {
|
|
685
|
+
version: '1.0.0',
|
|
686
|
+
api: {
|
|
687
|
+
mode: 'local', // 'local' (no API keys, offline STT/TTS) or 'cloud' (ElevenLabs + OpenAI)
|
|
688
|
+
local: {
|
|
689
|
+
sttUrl: 'http://127.0.0.1:9001',
|
|
690
|
+
ttsUrl: 'http://127.0.0.1:9002',
|
|
691
|
+
voice: 'en_US-lessac-medium',
|
|
692
|
+
whisperModel: 'small'
|
|
693
|
+
},
|
|
694
|
+
elevenlabs: { apiKey: '', defaultVoiceId: '', validated: false },
|
|
695
|
+
openai: { apiKey: '', validated: false }
|
|
696
|
+
},
|
|
697
|
+
sip: {
|
|
698
|
+
domain: '',
|
|
699
|
+
registrar: '',
|
|
700
|
+
transport: 'udp'
|
|
701
|
+
},
|
|
702
|
+
server: {
|
|
703
|
+
claudeApiPort: 3333,
|
|
704
|
+
httpPort: 3000,
|
|
705
|
+
externalIp: 'auto'
|
|
706
|
+
},
|
|
707
|
+
secrets: {
|
|
708
|
+
drachtio: generateSecret(),
|
|
709
|
+
freeswitch: generateSecret()
|
|
710
|
+
},
|
|
711
|
+
devices: [],
|
|
712
|
+
paths: {
|
|
713
|
+
voiceApp: path.join(getProjectRoot(), 'voice-app'),
|
|
714
|
+
claudeApiServer: path.join(getProjectRoot(), 'claude-api-server'),
|
|
715
|
+
sttLocal: path.join(getProjectRoot(), 'stt-local'),
|
|
716
|
+
ttsLocal: path.join(getProjectRoot(), 'tts-local')
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Setup API keys with validation
|
|
723
|
+
* @param {object} config - Current config
|
|
724
|
+
* @returns {Promise<object>} Updated config
|
|
725
|
+
*/
|
|
726
|
+
async function setupAPIKeys(config) {
|
|
727
|
+
// ---- Local vs Cloud STT/TTS ----
|
|
728
|
+
const { mode } = await inquirer.prompt([
|
|
729
|
+
{
|
|
730
|
+
type: 'list',
|
|
731
|
+
name: 'mode',
|
|
732
|
+
message: 'How should speech-to-text and text-to-speech work?',
|
|
733
|
+
choices: [
|
|
734
|
+
{
|
|
735
|
+
name: 'Local (offline, no API keys, no cost — faster-whisper + Piper)',
|
|
736
|
+
value: 'local'
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
name: 'Cloud (ElevenLabs + OpenAI — requires paid API keys)',
|
|
740
|
+
value: 'cloud'
|
|
741
|
+
}
|
|
742
|
+
],
|
|
743
|
+
default: config.api.mode || 'local'
|
|
744
|
+
}
|
|
745
|
+
]);
|
|
746
|
+
|
|
747
|
+
config.api.mode = mode;
|
|
748
|
+
|
|
749
|
+
if (mode === 'local') {
|
|
750
|
+
const localAnswers = await inquirer.prompt([
|
|
751
|
+
{
|
|
752
|
+
type: 'input',
|
|
753
|
+
name: 'voice',
|
|
754
|
+
message: 'Piper voice to use (default: en_US-lessac-medium):',
|
|
755
|
+
default: config.api.local?.voice || 'en_US-lessac-medium'
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
type: 'list',
|
|
759
|
+
name: 'whisperModel',
|
|
760
|
+
message: 'faster-whisper model size (bigger = more accurate, slower):',
|
|
761
|
+
choices: ['tiny', 'base', 'small', 'medium'],
|
|
762
|
+
default: config.api.local?.whisperModel || 'small'
|
|
763
|
+
}
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
config.api.local = {
|
|
767
|
+
sttUrl: config.api.local?.sttUrl || 'http://stt-local:9001',
|
|
768
|
+
ttsUrl: config.api.local?.ttsUrl || 'http://tts-local:9002',
|
|
769
|
+
voice: localAnswers.voice,
|
|
770
|
+
whisperModel: localAnswers.whisperModel
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// No cloud keys needed — leave them blank so nothing downstream requires them.
|
|
774
|
+
config.api.elevenlabs = { apiKey: '', defaultVoiceId: '', validated: false };
|
|
775
|
+
config.api.openai = { apiKey: '', validated: false };
|
|
776
|
+
|
|
777
|
+
// Auto-download the Piper voice model so setup is zero-manual-download
|
|
778
|
+
// for any of the common voices. Unknown/custom voice names are skipped
|
|
779
|
+
// with a note — those still need to be placed manually.
|
|
780
|
+
const voicesDir = path.join(config.paths.ttsLocal, 'voices');
|
|
781
|
+
const dlSpinner = ora(`Checking Piper voice "${localAnswers.voice}"...`).start();
|
|
782
|
+
try {
|
|
783
|
+
const result = await ensureVoiceModel(localAnswers.voice, voicesDir);
|
|
784
|
+
if (result.downloaded) {
|
|
785
|
+
dlSpinner.succeed(chalk.green(`Downloaded voice "${localAnswers.voice}"`));
|
|
786
|
+
} else if (result.skipped) {
|
|
787
|
+
dlSpinner.succeed(chalk.green(`Voice "${localAnswers.voice}" already present`));
|
|
788
|
+
} else {
|
|
789
|
+
dlSpinner.warn(chalk.yellow(result.error));
|
|
790
|
+
}
|
|
791
|
+
} catch (error) {
|
|
792
|
+
dlSpinner.warn(chalk.yellow(`Could not auto-download voice: ${error.message}`));
|
|
793
|
+
console.log(chalk.gray(
|
|
794
|
+
` Known auto-downloadable voices: ${Object.keys(KNOWN_VOICES).join(', ')}\n` +
|
|
795
|
+
` For others, place the .onnx + .onnx.json pair in ${voicesDir}/\n`
|
|
796
|
+
));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
console.log(chalk.green('\n✓ Local mode selected — no API keys required.'));
|
|
800
|
+
console.log(chalk.gray(' Piper (TTS) and faster-whisper (STT) will run as local Docker containers.\n'));
|
|
801
|
+
|
|
802
|
+
return config;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---- Cloud mode: original ElevenLabs + OpenAI flow ----
|
|
806
|
+
// ElevenLabs API Key
|
|
807
|
+
const elevenLabsAnswers = await inquirer.prompt([
|
|
808
|
+
{
|
|
809
|
+
type: 'password',
|
|
810
|
+
name: 'apiKey',
|
|
811
|
+
message: 'ElevenLabs API key:',
|
|
812
|
+
default: config.api.elevenlabs.apiKey,
|
|
813
|
+
validate: (input) => {
|
|
814
|
+
if (!input || input.trim() === '') {
|
|
815
|
+
return 'API key is required';
|
|
816
|
+
}
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
]);
|
|
821
|
+
|
|
822
|
+
const elevenLabsKey = elevenLabsAnswers.apiKey;
|
|
823
|
+
const spinner = ora('Validating ElevenLabs API key...').start();
|
|
824
|
+
|
|
825
|
+
const elevenLabsResult = await validateElevenLabsKey(elevenLabsKey);
|
|
826
|
+
if (!elevenLabsResult.valid) {
|
|
827
|
+
spinner.fail(`Invalid ElevenLabs API key: ${elevenLabsResult.error}`);
|
|
828
|
+
console.log(chalk.yellow('\n⚠️ You can continue setup, but the key may not work.'));
|
|
829
|
+
const { continueAnyway } = await inquirer.prompt([
|
|
830
|
+
{
|
|
831
|
+
type: 'confirm',
|
|
832
|
+
name: 'continueAnyway',
|
|
833
|
+
message: 'Continue anyway?',
|
|
834
|
+
default: false
|
|
835
|
+
}
|
|
836
|
+
]);
|
|
837
|
+
|
|
838
|
+
if (!continueAnyway) {
|
|
839
|
+
throw new Error('Setup cancelled due to invalid API key');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
config.api.elevenlabs = { apiKey: elevenLabsKey, defaultVoiceId: '', validated: false };
|
|
843
|
+
} else {
|
|
844
|
+
spinner.succeed('ElevenLabs API key validated');
|
|
845
|
+
config.api.elevenlabs = { apiKey: elevenLabsKey, defaultVoiceId: '', validated: true };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Ask for default voice ID immediately after API key
|
|
849
|
+
const voiceIdAnswers = await inquirer.prompt([
|
|
850
|
+
{
|
|
851
|
+
type: 'input',
|
|
852
|
+
name: 'voiceId',
|
|
853
|
+
message: 'ElevenLabs default voice ID (for all devices):',
|
|
854
|
+
default: config.api.elevenlabs.defaultVoiceId || '',
|
|
855
|
+
validate: (input) => {
|
|
856
|
+
if (!input || input.trim() === '') {
|
|
857
|
+
return 'Voice ID is required';
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
]);
|
|
863
|
+
|
|
864
|
+
const defaultVoiceId = voiceIdAnswers.voiceId;
|
|
865
|
+
const voiceSpinner = ora('Validating ElevenLabs voice ID...').start();
|
|
866
|
+
|
|
867
|
+
const voiceValidation = await validateVoiceId(elevenLabsKey, defaultVoiceId);
|
|
868
|
+
if (!voiceValidation.valid) {
|
|
869
|
+
voiceSpinner.fail(`Voice ID validation failed: ${voiceValidation.error}`);
|
|
870
|
+
console.log(chalk.yellow('\n⚠️ You can continue setup, but the voice ID may not work.'));
|
|
871
|
+
const { continueAnyway } = await inquirer.prompt([
|
|
872
|
+
{
|
|
873
|
+
type: 'confirm',
|
|
874
|
+
name: 'continueAnyway',
|
|
875
|
+
message: 'Continue anyway?',
|
|
876
|
+
default: false
|
|
877
|
+
}
|
|
878
|
+
]);
|
|
879
|
+
|
|
880
|
+
if (!continueAnyway) {
|
|
881
|
+
throw new Error('Setup cancelled due to invalid voice ID');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
config.api.elevenlabs.defaultVoiceId = defaultVoiceId;
|
|
885
|
+
} else {
|
|
886
|
+
voiceSpinner.succeed(`Voice ID validated: ${voiceValidation.name}`);
|
|
887
|
+
config.api.elevenlabs.defaultVoiceId = defaultVoiceId;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// OpenAI API Key
|
|
891
|
+
const openAIAnswers = await inquirer.prompt([
|
|
892
|
+
{
|
|
893
|
+
type: 'password',
|
|
894
|
+
name: 'apiKey',
|
|
895
|
+
message: 'OpenAI API key (for Whisper STT):',
|
|
896
|
+
default: config.api.openai.apiKey,
|
|
897
|
+
validate: (input) => {
|
|
898
|
+
if (!input || input.trim() === '') {
|
|
899
|
+
return 'API key is required';
|
|
900
|
+
}
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
]);
|
|
905
|
+
|
|
906
|
+
const openAIKey = openAIAnswers.apiKey;
|
|
907
|
+
const openAISpinner = ora('Validating OpenAI API key...').start();
|
|
908
|
+
|
|
909
|
+
const openAIResult = await validateOpenAIKey(openAIKey);
|
|
910
|
+
if (!openAIResult.valid) {
|
|
911
|
+
openAISpinner.fail(`Invalid OpenAI API key: ${openAIResult.error}`);
|
|
912
|
+
console.log(chalk.yellow('\n⚠️ You can continue setup, but the key may not work.'));
|
|
913
|
+
const { continueAnyway } = await inquirer.prompt([
|
|
914
|
+
{
|
|
915
|
+
type: 'confirm',
|
|
916
|
+
name: 'continueAnyway',
|
|
917
|
+
message: 'Continue anyway?',
|
|
918
|
+
default: false
|
|
919
|
+
}
|
|
920
|
+
]);
|
|
921
|
+
|
|
922
|
+
if (!continueAnyway) {
|
|
923
|
+
throw new Error('Setup cancelled due to invalid API key');
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
config.api.openai = { apiKey: openAIKey, validated: false };
|
|
927
|
+
} else {
|
|
928
|
+
openAISpinner.succeed('OpenAI API key validated');
|
|
929
|
+
config.api.openai = { apiKey: openAIKey, validated: true };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return config;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Setup SIP configuration (standard mode)
|
|
937
|
+
* @param {object} config - Current config
|
|
938
|
+
* @returns {Promise<object>} Updated config
|
|
939
|
+
*/
|
|
940
|
+
async function setupSIP(config) {
|
|
941
|
+
const answers = await inquirer.prompt([
|
|
942
|
+
{
|
|
943
|
+
type: 'input',
|
|
944
|
+
name: 'domain',
|
|
945
|
+
message: '3CX domain (e.g., your-3cx.3cx.us):',
|
|
946
|
+
default: config.sip.domain,
|
|
947
|
+
validate: (input) => {
|
|
948
|
+
if (!input || input.trim() === '') {
|
|
949
|
+
return 'SIP domain is required';
|
|
950
|
+
}
|
|
951
|
+
if (!validateHostname(input)) {
|
|
952
|
+
return 'Invalid hostname format';
|
|
953
|
+
}
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
type: 'input',
|
|
959
|
+
name: 'registrar',
|
|
960
|
+
message: '3CX registrar IP (e.g., 192.168.1.100):',
|
|
961
|
+
default: config.sip.registrar,
|
|
962
|
+
validate: (input) => {
|
|
963
|
+
if (!input || input.trim() === '') {
|
|
964
|
+
return 'SIP registrar IP is required';
|
|
965
|
+
}
|
|
966
|
+
if (!validateIP(input)) {
|
|
967
|
+
return 'Invalid IP address format';
|
|
968
|
+
}
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
]);
|
|
973
|
+
|
|
974
|
+
config.sip.domain = answers.domain;
|
|
975
|
+
config.sip.registrar = answers.registrar;
|
|
976
|
+
|
|
977
|
+
return config;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Setup SBC configuration (Pi mode only)
|
|
982
|
+
* @param {object} config - Current config
|
|
983
|
+
* @returns {Promise<object>} Updated config
|
|
984
|
+
*/
|
|
985
|
+
async function setupSBC(config) {
|
|
986
|
+
// Display pre-requisite information
|
|
987
|
+
console.log(chalk.cyan('\nℹ️ Pre-requisite: You must create an SBC in 3CX Admin first'));
|
|
988
|
+
console.log(chalk.gray(' (Admin → Settings → SBC → Add SBC → Raspberry Pi)\n'));
|
|
989
|
+
|
|
990
|
+
const answers = await inquirer.prompt([
|
|
991
|
+
{
|
|
992
|
+
type: 'input',
|
|
993
|
+
name: 'fqdn',
|
|
994
|
+
message: '3CX FQDN (e.g., mycompany.3cx.us):',
|
|
995
|
+
default: config.sip.domain,
|
|
996
|
+
validate: (input) => {
|
|
997
|
+
if (!input || input.trim() === '') {
|
|
998
|
+
return '3CX FQDN is required';
|
|
999
|
+
}
|
|
1000
|
+
if (!validateHostname(input)) {
|
|
1001
|
+
return 'Invalid hostname format';
|
|
1002
|
+
}
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
]);
|
|
1007
|
+
|
|
1008
|
+
// Domain is the 3CX FQDN (for From/To SIP headers)
|
|
1009
|
+
config.sip.domain = answers.fqdn;
|
|
1010
|
+
// Registrar is the LOCAL SBC (drachtio registers with local SBC, not cloud)
|
|
1011
|
+
config.sip.registrar = '127.0.0.1';
|
|
1012
|
+
|
|
1013
|
+
return config;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Setup device configuration
|
|
1018
|
+
* @param {object} config - Current config
|
|
1019
|
+
* @returns {Promise<object>} Updated config
|
|
1020
|
+
*/
|
|
1021
|
+
async function setupDevice(config) {
|
|
1022
|
+
// Get first device or create new
|
|
1023
|
+
const existingDevice = config.devices.length > 0 ? config.devices[0] : null;
|
|
1024
|
+
|
|
1025
|
+
const answers = await inquirer.prompt([
|
|
1026
|
+
{
|
|
1027
|
+
type: 'input',
|
|
1028
|
+
name: 'name',
|
|
1029
|
+
message: 'Device name (e.g., Morpheus):',
|
|
1030
|
+
default: existingDevice?.name || 'Morpheus',
|
|
1031
|
+
validate: (input) => {
|
|
1032
|
+
if (!input || input.trim() === '') {
|
|
1033
|
+
return 'Device name is required';
|
|
1034
|
+
}
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
type: 'input',
|
|
1040
|
+
name: 'extension',
|
|
1041
|
+
message: 'SIP extension number (e.g., 9000):',
|
|
1042
|
+
default: existingDevice?.extension || '9000',
|
|
1043
|
+
validate: (input) => {
|
|
1044
|
+
if (!validateExtension(input)) {
|
|
1045
|
+
return 'Extension must be 4-5 digits';
|
|
1046
|
+
}
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
type: 'input',
|
|
1052
|
+
name: 'authId',
|
|
1053
|
+
message: 'SIP auth ID:',
|
|
1054
|
+
default: existingDevice?.authId || '',
|
|
1055
|
+
validate: (input) => {
|
|
1056
|
+
if (!input || input.trim() === '') {
|
|
1057
|
+
return 'Auth ID is required';
|
|
1058
|
+
}
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
type: 'password',
|
|
1064
|
+
name: 'password',
|
|
1065
|
+
message: 'SIP password:',
|
|
1066
|
+
default: existingDevice?.password || '',
|
|
1067
|
+
validate: (input) => {
|
|
1068
|
+
if (!input || input.trim() === '') {
|
|
1069
|
+
return 'Password is required';
|
|
1070
|
+
}
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
type: 'input',
|
|
1076
|
+
name: 'voiceId',
|
|
1077
|
+
message: 'ElevenLabs voice ID:',
|
|
1078
|
+
default: existingDevice?.voiceId || config.api.elevenlabs.defaultVoiceId || '',
|
|
1079
|
+
validate: (input) => {
|
|
1080
|
+
if (!input || input.trim() === '') {
|
|
1081
|
+
return 'Voice ID is required';
|
|
1082
|
+
}
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
type: 'input',
|
|
1088
|
+
name: 'prompt',
|
|
1089
|
+
message: 'System prompt:',
|
|
1090
|
+
default: existingDevice?.prompt || 'You are a helpful AI assistant. Keep voice responses under 40 words.',
|
|
1091
|
+
validate: (input) => {
|
|
1092
|
+
if (!input || input.trim() === '') {
|
|
1093
|
+
return 'System prompt is required';
|
|
1094
|
+
}
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
]);
|
|
1099
|
+
|
|
1100
|
+
// Validate voice ID with ElevenLabs API
|
|
1101
|
+
const voiceSpinner = ora('Validating ElevenLabs voice ID...').start();
|
|
1102
|
+
const voiceValidation = await validateVoiceId(config.api.elevenlabs.apiKey, answers.voiceId);
|
|
1103
|
+
|
|
1104
|
+
if (!voiceValidation.valid) {
|
|
1105
|
+
voiceSpinner.fail(`Voice ID validation failed: ${voiceValidation.error}`);
|
|
1106
|
+
console.log(chalk.yellow('\n⚠️ You can continue setup, but the voice ID may not work.'));
|
|
1107
|
+
const { continueAnyway } = await inquirer.prompt([
|
|
1108
|
+
{
|
|
1109
|
+
type: 'confirm',
|
|
1110
|
+
name: 'continueAnyway',
|
|
1111
|
+
message: 'Continue anyway?',
|
|
1112
|
+
default: false
|
|
1113
|
+
}
|
|
1114
|
+
]);
|
|
1115
|
+
|
|
1116
|
+
if (!continueAnyway) {
|
|
1117
|
+
// Let user re-enter voice ID
|
|
1118
|
+
console.log(chalk.gray('\nReturning to device setup...'));
|
|
1119
|
+
return setupDevice(config);
|
|
1120
|
+
}
|
|
1121
|
+
} else {
|
|
1122
|
+
voiceSpinner.succeed(`Voice ID validated: ${voiceValidation.name}`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const device = {
|
|
1126
|
+
name: answers.name,
|
|
1127
|
+
extension: answers.extension,
|
|
1128
|
+
authId: answers.authId,
|
|
1129
|
+
password: answers.password,
|
|
1130
|
+
voiceId: answers.voiceId,
|
|
1131
|
+
prompt: answers.prompt
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
// Replace first device or add new
|
|
1135
|
+
if (config.devices.length > 0) {
|
|
1136
|
+
config.devices[0] = device;
|
|
1137
|
+
} else {
|
|
1138
|
+
config.devices.push(device);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
return config;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Setup server configuration (standard mode)
|
|
1146
|
+
* @param {object} config - Current config
|
|
1147
|
+
* @returns {Promise<object>} Updated config
|
|
1148
|
+
*/
|
|
1149
|
+
async function setupServer(config) {
|
|
1150
|
+
const localIp = getLocalIP();
|
|
1151
|
+
|
|
1152
|
+
const answers = await inquirer.prompt([
|
|
1153
|
+
{
|
|
1154
|
+
type: 'input',
|
|
1155
|
+
name: 'externalIp',
|
|
1156
|
+
message: 'Server LAN IP (for RTP audio):',
|
|
1157
|
+
default: config.server.externalIp === 'auto' ? localIp : config.server.externalIp,
|
|
1158
|
+
validate: (input) => {
|
|
1159
|
+
if (!input || input.trim() === '') {
|
|
1160
|
+
return 'IP address is required';
|
|
1161
|
+
}
|
|
1162
|
+
if (!validateIP(input)) {
|
|
1163
|
+
return 'Invalid IP address format';
|
|
1164
|
+
}
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
type: 'input',
|
|
1170
|
+
name: 'claudeApiPort',
|
|
1171
|
+
message: 'Claude API server port:',
|
|
1172
|
+
default: config.server.claudeApiPort,
|
|
1173
|
+
validate: (input) => {
|
|
1174
|
+
const port = parseInt(input, 10);
|
|
1175
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
1176
|
+
return 'Port must be between 1024 and 65535';
|
|
1177
|
+
}
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
type: 'input',
|
|
1183
|
+
name: 'httpPort',
|
|
1184
|
+
message: 'Voice app HTTP port:',
|
|
1185
|
+
default: config.server.httpPort,
|
|
1186
|
+
validate: (input) => {
|
|
1187
|
+
const port = parseInt(input, 10);
|
|
1188
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
1189
|
+
return 'Port must be between 1024 and 65535';
|
|
1190
|
+
}
|
|
1191
|
+
return true;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
]);
|
|
1195
|
+
|
|
1196
|
+
config.server.externalIp = answers.externalIp;
|
|
1197
|
+
config.server.claudeApiPort = parseInt(answers.claudeApiPort, 10);
|
|
1198
|
+
config.server.httpPort = parseInt(answers.httpPort, 10);
|
|
1199
|
+
|
|
1200
|
+
return config;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Setup Pi-specific server configuration
|
|
1205
|
+
* @param {object} config - Current config
|
|
1206
|
+
* @returns {Promise<object>} Updated config
|
|
1207
|
+
*/
|
|
1208
|
+
async function setupPiServer(config) {
|
|
1209
|
+
const localIp = getLocalIP();
|
|
1210
|
+
|
|
1211
|
+
const answers = await inquirer.prompt([
|
|
1212
|
+
{
|
|
1213
|
+
type: 'input',
|
|
1214
|
+
name: 'externalIp',
|
|
1215
|
+
message: 'Pi LAN IP (for RTP audio):',
|
|
1216
|
+
default: config.server.externalIp === 'auto' ? localIp : config.server.externalIp,
|
|
1217
|
+
validate: (input) => {
|
|
1218
|
+
if (!input || input.trim() === '') {
|
|
1219
|
+
return 'IP address is required';
|
|
1220
|
+
}
|
|
1221
|
+
if (!validateIP(input)) {
|
|
1222
|
+
return 'Invalid IP address format';
|
|
1223
|
+
}
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
type: 'input',
|
|
1229
|
+
name: 'httpPort',
|
|
1230
|
+
message: 'Voice app HTTP port:',
|
|
1231
|
+
default: config.server.httpPort || 3000,
|
|
1232
|
+
validate: (input) => {
|
|
1233
|
+
const port = parseInt(input, 10);
|
|
1234
|
+
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
1235
|
+
return 'Port must be between 1024 and 65535';
|
|
1236
|
+
}
|
|
1237
|
+
return true;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
]);
|
|
1241
|
+
|
|
1242
|
+
config.server.externalIp = answers.externalIp;
|
|
1243
|
+
config.server.httpPort = parseInt(answers.httpPort, 10);
|
|
1244
|
+
|
|
1245
|
+
return config;
|
|
1246
|
+
}
|