@mclean-capital/neura 3.1.0 → 3.3.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.
@@ -1,5 +1,4 @@
1
- import { input, password, confirm } from '@inquirer/prompts';
2
- import chalk from 'chalk';
1
+ import * as p from '@clack/prompts';
3
2
  import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
4
3
  import { join } from 'path';
5
4
  import { ensureNeuraHome, loadConfig, saveConfig, getNeuraHome, generateAuthToken, } from '../config.js';
@@ -8,24 +7,13 @@ import { getPlatformLabel } from '../service/detect.js';
8
7
  import { checkHealth, waitForHealthy } from '../health.js';
9
8
  import { hasCoreBinary, getBundledModelsDir } from '../download.js';
10
9
  import { findFreePort } from '../port.js';
11
- /**
12
- * Copy any ONNX wake-word models shipped inside the CLI package into
13
- * `$NEURA_HOME/models/`, but NEVER overwrite files that already exist.
14
- *
15
- * Why "never overwrite": users who train their own classifiers with
16
- * `tools/wake-word/scripts/train.sh` deploy them to the same directory
17
- * via `deploy.sh`. Overwriting on every `neura install` would clobber
18
- * their trained models on each upgrade. First-write-wins means the
19
- * bundled defaults only fill in gaps — once a user has their own
20
- * `jarvis.onnx` in place, the bundled one stops being used.
21
- *
22
- * Returns the list of files actually copied so the caller can print a
23
- * one-line summary to the user.
24
- */
10
+ import { PROVIDER_PRESETS, buildRoutingFromFeatures, getVoiceOptions, } from '../providers.js';
11
+ import { validateProviderKey } from '../validate-key.js';
12
+ // ─── Bundled Model Installer ───────────────────────────────────
25
13
  function installBundledModels(neuraHome) {
26
14
  const src = getBundledModelsDir();
27
15
  if (!existsSync(src))
28
- return []; // Running from dev / unusual layout
16
+ return [];
29
17
  const dest = join(neuraHome, 'models');
30
18
  mkdirSync(dest, { recursive: true });
31
19
  const copied = [];
@@ -35,241 +23,577 @@ function installBundledModels(neuraHome) {
35
23
  continue;
36
24
  const destPath = join(dest, entry);
37
25
  if (existsSync(destPath))
38
- continue; // Keep user-trained models
26
+ continue;
39
27
  copyFileSync(join(src, entry), destPath);
40
28
  copied.push(entry);
41
29
  }
42
30
  }
43
31
  catch {
44
- // Non-fatal: failing to seed the bundled models shouldn't block the
45
- // whole install. The core will print a clear warning at connection
46
- // time if models are missing, and the user can copy them manually.
32
+ // Non-fatal core warns if models are missing.
47
33
  }
48
34
  return copied;
49
35
  }
36
+ // ─── Helpers ───────────────────────────────────────────────────
37
+ function cancelled() {
38
+ p.outro('Setup cancelled.');
39
+ process.exit(0);
40
+ }
41
+ /** Restore stdin raw mode on Windows after spinner stops (clack #408). */
42
+ function fixWindowsStdin() {
43
+ if (process.platform === 'win32' && process.stdin.isTTY) {
44
+ process.stdin.setRawMode(false);
45
+ }
46
+ }
47
+ function describeExistingFeatures(config) {
48
+ const features = [];
49
+ if (config.routing?.voice) {
50
+ const mode = config.routing.voice.mode === 'realtime' ? 'realtime' : 'pipeline';
51
+ features.push(`Voice (${mode})`);
52
+ }
53
+ if (config.routing?.vision) {
54
+ features.push(`Vision (${config.routing.vision.mode})`);
55
+ }
56
+ if (config.routing?.text) {
57
+ features.push(`Brain (${config.routing.text.provider})`);
58
+ }
59
+ if (config.routing?.embedding) {
60
+ features.push(`Memory (${config.routing.embedding.provider})`);
61
+ }
62
+ if (config.routing?.worker) {
63
+ features.push(`Agents (${config.routing.worker.provider})`);
64
+ }
65
+ return features;
66
+ }
67
+ // ─── Main Install Command ──────────────────────────────────────
50
68
  export async function installCommand(opts = {}) {
51
69
  const nonInteractive = !!opts.yes;
52
70
  const home = getNeuraHome();
53
- console.log();
54
- console.log(chalk.bold(' Neura Core — Setup'));
55
- console.log();
56
- console.log(` Platform: ${getPlatformLabel()}`);
57
- console.log(` Home: ${home}`);
58
- console.log();
59
- // Check if already installed (skip if port not yet assigned)
60
- const currentConfig = loadConfig();
61
- const existing = (currentConfig.port ?? 0) > 0 ? await checkHealth(currentConfig.port ?? 0) : null;
71
+ // ── Intro ────────────────────────────────────────────────────
72
+ p.intro('Neura Setup');
73
+ // Discover wake words
74
+ ensureNeuraHome();
75
+ const config = loadConfig();
76
+ const seededModels = installBundledModels(home);
77
+ const modelsDir = join(home, 'models');
78
+ const infra = new Set(['melspectrogram', 'embedding_model']);
79
+ let wakeWords = [];
80
+ try {
81
+ wakeWords = readdirSync(modelsDir)
82
+ .filter((f) => f.endsWith('.onnx'))
83
+ .map((f) => f.replace('.onnx', ''))
84
+ .filter((name) => !infra.has(name));
85
+ }
86
+ catch {
87
+ // models dir might not exist
88
+ }
89
+ const platformInfo = `Platform: ${getPlatformLabel()} · Home: ${home}`;
90
+ const wakeInfo = wakeWords.length > 0
91
+ ? `Wake words: ${wakeWords.join(', ')} (active: ${config.assistantName ?? 'jarvis'})`
92
+ : '';
93
+ const seedInfo = seededModels.length > 0 ? `Installed ${seededModels.length} wake-word model(s)` : '';
94
+ p.log.info([platformInfo, wakeInfo, seedInfo].filter(Boolean).join('\n'));
95
+ // ── Check if already running ─────────────────────────────────
96
+ const existing = (config.port ?? 0) > 0 ? await checkHealth(config.port ?? 0) : null;
62
97
  if (existing) {
63
- console.log(chalk.green(' Core is already running on port ' + existing.port));
98
+ p.log.success(`Core is already running on port ${existing.port}`);
64
99
  if (!nonInteractive) {
65
- const reinstall = await confirm({
66
- message: 'Reinstall?',
67
- default: false,
100
+ const reinstall = await p.confirm({ message: 'Reinstall?', initialValue: false });
101
+ if (p.isCancel(reinstall) || !reinstall)
102
+ cancelled();
103
+ }
104
+ }
105
+ // ── Non-interactive mode: skip wizard, go to service registration ──
106
+ if (nonInteractive) {
107
+ await registerServiceAndFinish(config);
108
+ return;
109
+ }
110
+ // ── Existing config detection ────────────────────────────────
111
+ const existingProviders = Object.keys(config.providers ?? {});
112
+ let keepExisting = false;
113
+ if (existingProviders.length > 0) {
114
+ const features = describeExistingFeatures(config);
115
+ if (features.length > 0) {
116
+ p.log.info(`Existing features: ${features.join(', ')}`);
117
+ }
118
+ const keep = await p.confirm({
119
+ message: "Re-use existing API keys? (you'll still pick features)",
120
+ initialValue: true,
121
+ });
122
+ if (p.isCancel(keep))
123
+ cancelled();
124
+ keepExisting = keep;
125
+ }
126
+ // ═══════════════════════════════════════════════════════════════
127
+ // PHASE 1 — Feature Selection
128
+ // ═══════════════════════════════════════════════════════════════
129
+ p.log.step("Let's configure your features.");
130
+ // ── Voice ────────────────────────────────────────────────────
131
+ const voiceChoice = await p.select({
132
+ message: 'Voice — How Neura speaks and listens',
133
+ options: [
134
+ {
135
+ value: 'realtime',
136
+ label: 'Realtime',
137
+ hint: 'natural, low-latency conversation (xAI Grok)',
138
+ },
139
+ {
140
+ value: 'pipeline',
141
+ label: 'Pipeline',
142
+ hint: 'mix-and-match speech providers (STT → LLM → TTS)',
143
+ },
144
+ { value: 'skip', label: 'Skip', hint: 'no voice, text-only mode' },
145
+ ],
146
+ });
147
+ if (p.isCancel(voiceChoice))
148
+ cancelled();
149
+ const voiceMode = voiceChoice;
150
+ let sttProvider;
151
+ let ttsProvider;
152
+ if (voiceMode === 'pipeline') {
153
+ const stt = await p.select({
154
+ message: 'Speech-to-Text provider',
155
+ options: [{ value: 'deepgram', label: 'Deepgram', hint: 'recommended' }],
156
+ });
157
+ if (p.isCancel(stt))
158
+ cancelled();
159
+ sttProvider = stt;
160
+ const tts = await p.select({
161
+ message: 'Text-to-Speech provider',
162
+ options: [
163
+ { value: 'elevenlabs', label: 'ElevenLabs', hint: 'recommended' },
164
+ { value: 'openai', label: 'OpenAI' },
165
+ ],
166
+ });
167
+ if (p.isCancel(tts))
168
+ cancelled();
169
+ ttsProvider = tts;
170
+ }
171
+ // ── Vision ───────────────────────────────────────────────────
172
+ const visionChoice = await p.select({
173
+ message: 'Vision — How Neura sees your screen and camera',
174
+ options: [
175
+ {
176
+ value: 'streaming',
177
+ label: 'Streaming',
178
+ hint: 'continuous awareness, ~0.5 FPS (Google Gemini)',
179
+ },
180
+ {
181
+ value: 'snapshot',
182
+ label: 'Snapshot',
183
+ hint: 'on-demand "what am I looking at?" (OpenAI, Anthropic)',
184
+ },
185
+ { value: 'skip', label: 'Skip', hint: 'no visual awareness' },
186
+ ],
187
+ });
188
+ if (p.isCancel(visionChoice))
189
+ cancelled();
190
+ const visionMode = visionChoice;
191
+ let snapshotProvider;
192
+ if (visionMode === 'snapshot') {
193
+ const snap = await p.select({
194
+ message: 'Snapshot vision provider',
195
+ options: [
196
+ { value: 'openai', label: 'OpenAI', hint: 'GPT-4.1' },
197
+ { value: 'anthropic', label: 'Anthropic', hint: 'Claude' },
198
+ ],
199
+ });
200
+ if (p.isCancel(snap))
201
+ cancelled();
202
+ snapshotProvider = snap;
203
+ }
204
+ // ── Brain (required) ─────────────────────────────────────────
205
+ const brainChoice = await p.select({
206
+ message: 'Brain — The core AI that powers thinking, tools, and conversation (required)',
207
+ options: [
208
+ { value: 'google', label: 'Google Gemini', hint: 'recommended' },
209
+ { value: 'openai', label: 'OpenAI' },
210
+ { value: 'anthropic', label: 'Anthropic (Claude)' },
211
+ { value: 'xai', label: 'xAI (Grok)' },
212
+ { value: 'openrouter', label: 'OpenRouter', hint: 'gateway to many models' },
213
+ { value: 'custom', label: 'Custom', hint: 'any OpenAI-compatible endpoint' },
214
+ ],
215
+ });
216
+ if (p.isCancel(brainChoice))
217
+ cancelled();
218
+ const brainProvider = brainChoice;
219
+ let customProvider;
220
+ if (brainProvider === 'custom') {
221
+ const name = await p.text({
222
+ message: 'Provider name (e.g. together, groq):',
223
+ validate: (v) => (!v || v.length === 0 ? 'Required' : undefined),
224
+ });
225
+ if (p.isCancel(name))
226
+ cancelled();
227
+ const baseUrl = await p.text({
228
+ message: 'Base URL (e.g. https://api.together.xyz/v1):',
229
+ validate: (v) => {
230
+ if (!v || v.length === 0)
231
+ return 'Required';
232
+ if (!v.startsWith('http'))
233
+ return 'Must start with http:// or https://';
234
+ return undefined;
235
+ },
236
+ });
237
+ if (p.isCancel(baseUrl))
238
+ cancelled();
239
+ const model = await p.text({
240
+ message: 'Default model (e.g. meta-llama/Llama-4-Scout-17B-16E):',
241
+ validate: (v) => (!v || v.length === 0 ? 'Required' : undefined),
242
+ });
243
+ if (p.isCancel(model))
244
+ cancelled();
245
+ customProvider = { name: name, baseUrl: baseUrl, model: model };
246
+ }
247
+ // ── Memory ───────────────────────────────────────────────────
248
+ const memoryChoice = await p.select({
249
+ message: 'Memory — Remember things across conversations',
250
+ options: [
251
+ {
252
+ value: 'google',
253
+ label: 'Google Gemini Embedding',
254
+ hint: 'recommended, best recall quality',
255
+ },
256
+ { value: 'openai', label: 'OpenAI Embedding' },
257
+ { value: 'skip', label: 'Skip', hint: 'keyword search only, no semantic recall' },
258
+ ],
259
+ });
260
+ if (p.isCancel(memoryChoice))
261
+ cancelled();
262
+ const memoryProvider = memoryChoice;
263
+ // ── Agents ───────────────────────────────────────────────────
264
+ const wantAgents = await p.confirm({
265
+ message: 'Agents — Delegate complex work to AI that operates independently and reports back',
266
+ initialValue: true,
267
+ });
268
+ if (p.isCancel(wantAgents))
269
+ cancelled();
270
+ let agentProvider = 'skip';
271
+ if (wantAgents) {
272
+ // Collect all text-capable providers the user has already selected
273
+ const textProviders = new Set();
274
+ if (voiceMode === 'realtime')
275
+ textProviders.add('xai');
276
+ if (brainProvider !== 'custom')
277
+ textProviders.add(brainProvider);
278
+ if (customProvider)
279
+ textProviders.add(customProvider.name);
280
+ // If vision streaming selected, google is available too
281
+ if (visionMode === 'streaming')
282
+ textProviders.add('google');
283
+ const providerOptions = [...textProviders]
284
+ .filter((id) => PROVIDER_PRESETS[id]?.capabilities.includes('worker') || id === customProvider?.name)
285
+ .map((id) => ({
286
+ value: id,
287
+ label: PROVIDER_PRESETS[id]?.label ?? id,
288
+ }));
289
+ if (providerOptions.length > 1) {
290
+ const agent = await p.select({
291
+ message: 'Agent provider',
292
+ options: providerOptions,
68
293
  });
69
- if (!reinstall)
70
- return;
294
+ if (p.isCancel(agent))
295
+ cancelled();
296
+ agentProvider = agent;
297
+ }
298
+ else if (providerOptions.length === 1) {
299
+ agentProvider = providerOptions[0].value;
300
+ }
301
+ else {
302
+ agentProvider =
303
+ brainProvider === 'custom' && customProvider ? customProvider.name : brainProvider;
71
304
  }
72
- // --yes: proceed without asking (this is exactly the post-update path)
73
305
  }
74
- // Ensure directory structure
75
- ensureNeuraHome();
76
- const config = loadConfig();
77
- // Seed the wake-word models if they aren't already installed. This
78
- // runs every `neura install` but is a no-op after the first one —
79
- // existing files are never overwritten, so user-trained classifiers
80
- // take priority. Prints a one-line summary if anything was copied
81
- // along with the list of available wake words.
82
- const seededModels = installBundledModels(home);
83
- {
84
- const modelsDir = join(home, 'models');
85
- const infra = new Set(['melspectrogram', 'embedding_model']);
86
- let available = [];
87
- try {
88
- available = readdirSync(modelsDir)
89
- .filter((f) => f.endsWith('.onnx'))
90
- .map((f) => f.replace('.onnx', ''))
91
- .filter((name) => !infra.has(name));
306
+ // ═══════════════════════════════════════════════════════════════
307
+ // Build routing from feature selections
308
+ // ═══════════════════════════════════════════════════════════════
309
+ const selections = {
310
+ voice: voiceMode,
311
+ sttProvider,
312
+ ttsProvider,
313
+ vision: visionMode,
314
+ snapshotProvider,
315
+ brainProvider,
316
+ memoryProvider,
317
+ agentProvider,
318
+ customProvider,
319
+ };
320
+ const result = buildRoutingFromFeatures(selections);
321
+ // ═══════════════════════════════════════════════════════════════
322
+ // PHASE 2 — API Key Collection
323
+ // ═══════════════════════════════════════════════════════════════
324
+ const keyLines = [];
325
+ for (const [providerId, features] of result.requiredProviders) {
326
+ const preset = PROVIDER_PRESETS[providerId];
327
+ const label = preset?.label ?? providerId;
328
+ const url = preset?.consoleUrl ?? '';
329
+ keyLines.push(`${label} → ${features.join(', ')}${url ? `\n ${url}` : ''}`);
330
+ }
331
+ p.note(keyLines.join('\n\n'), `You need ${result.requiredProviders.size} API key(s)`);
332
+ // Collect keys
333
+ const collectedKeys = {};
334
+ // Carry over existing keys if keeping config
335
+ if (keepExisting && config.providers) {
336
+ for (const [id, creds] of Object.entries(config.providers)) {
337
+ collectedKeys[id] = { ...creds };
92
338
  }
93
- catch {
94
- // models dir might not exist yet on a completely bare install
339
+ }
340
+ for (const [providerId] of result.requiredProviders) {
341
+ const preset = PROVIDER_PRESETS[providerId];
342
+ const label = preset?.label ?? providerId;
343
+ const existingKey = collectedKeys[providerId]?.apiKey;
344
+ // Custom provider — use the user-provided baseUrl
345
+ const isCustom = !preset && providerId === customProvider?.name;
346
+ const baseUrl = isCustom ? customProvider.baseUrl : undefined;
347
+ const hint = existingKey ? ' (press Enter to keep existing)' : '';
348
+ const key = await p.password({
349
+ message: `${label} API Key${hint}:`,
350
+ mask: '*',
351
+ });
352
+ if (p.isCancel(key))
353
+ cancelled();
354
+ let finalKey = key || existingKey || '';
355
+ if (!finalKey) {
356
+ p.log.warn(`No key provided for ${label} — features using this provider will be unavailable.`);
357
+ continue;
95
358
  }
96
- if (seededModels.length > 0 || available.length > 0) {
97
- console.log();
98
- console.log(chalk.dim(' Wake word models'));
99
- if (seededModels.length > 0) {
100
- console.log(chalk.green(` ✓ Installed ${seededModels.length} model(s) to ${home}/models/`));
359
+ // Validate
360
+ const s = p.spinner();
361
+ s.start(`Validating ${label} key...`);
362
+ const vr = await validateProviderKey(providerId, finalKey, baseUrl);
363
+ fixWindowsStdin();
364
+ let validated = false;
365
+ if (vr.valid) {
366
+ s.stop(`${label}: Valid`);
367
+ validated = true;
368
+ }
369
+ else {
370
+ s.stop(`${label}: ${vr.error ?? 'Invalid key'}`);
371
+ fixWindowsStdin();
372
+ p.log.warn(`Validation failed: ${vr.error ?? 'Invalid key'}`);
373
+ const retry = await p.confirm({
374
+ message: `Try a different ${label} key?`,
375
+ initialValue: true,
376
+ });
377
+ if (p.isCancel(retry))
378
+ cancelled();
379
+ if (retry) {
380
+ const retryKey = await p.password({
381
+ message: `${label} API Key:`,
382
+ mask: '*',
383
+ });
384
+ if (p.isCancel(retryKey))
385
+ cancelled();
386
+ if (retryKey) {
387
+ const vr2 = await validateProviderKey(providerId, retryKey, baseUrl);
388
+ fixWindowsStdin();
389
+ if (vr2.valid) {
390
+ finalKey = retryKey;
391
+ p.log.success(`${label}: Valid`);
392
+ validated = true;
393
+ }
394
+ else {
395
+ p.log.warn(`Retry failed: ${vr2.error ?? 'Invalid key'}`);
396
+ }
397
+ }
101
398
  }
102
- if (available.length > 0) {
103
- console.log(chalk.dim(` Available wake words: ${available.join(', ')}`));
104
- console.log(chalk.dim(` Active: ${config.assistantName ?? 'jarvis'} (set via: neura config set assistantName <name>)`));
399
+ if (!validated) {
400
+ const continueAnyway = await p.confirm({
401
+ message: `Continue without a valid ${label} key?`,
402
+ initialValue: false,
403
+ });
404
+ if (p.isCancel(continueAnyway))
405
+ cancelled();
406
+ if (!continueAnyway)
407
+ cancelled();
105
408
  }
106
409
  }
410
+ if (validated) {
411
+ collectedKeys[providerId] = { apiKey: finalKey, baseUrl };
412
+ }
413
+ }
414
+ // ═══════════════════════════════════════════════════════════════
415
+ // PHASE 3 — Final Config
416
+ // ═══════════════════════════════════════════════════════════════
417
+ // Show configuration summary
418
+ const summaryLines = [];
419
+ const r = result.routing;
420
+ if (r.voice) {
421
+ if (r.voice.mode === 'realtime') {
422
+ summaryLines.push(`✓ Voice: Realtime (${r.voice.provider} / ${r.voice.model})`);
423
+ }
424
+ else {
425
+ summaryLines.push(`✓ Voice: Pipeline (STT: ${r.voice.stt.provider}, LLM: ${r.voice.llm.provider}, TTS: ${r.voice.tts.provider})`);
426
+ }
427
+ }
428
+ else {
429
+ summaryLines.push('✗ Voice: Skipped');
430
+ }
431
+ if (r.vision) {
432
+ summaryLines.push(`✓ Vision: ${r.vision.mode === 'streaming' ? 'Streaming' : 'Snapshot'} (${r.vision.provider} / ${r.vision.model})`);
433
+ }
434
+ else {
435
+ summaryLines.push('✗ Vision: Skipped');
436
+ }
437
+ if (r.text) {
438
+ summaryLines.push(`✓ Brain: ${r.text.provider} / ${r.text.model}`);
439
+ }
440
+ if (r.embedding) {
441
+ summaryLines.push(`✓ Memory: ${r.embedding.provider} / ${r.embedding.model} (${r.embedding.dimensions}d)`);
442
+ }
443
+ else {
444
+ summaryLines.push('✗ Memory: Skipped');
445
+ }
446
+ if (r.worker) {
447
+ summaryLines.push(`✓ Agents: ${r.worker.provider} / ${r.worker.model}`);
448
+ }
449
+ else {
450
+ summaryLines.push('✗ Agents: Skipped');
451
+ }
452
+ if (result.warnings.length > 0) {
453
+ summaryLines.push('');
454
+ for (const w of result.warnings) {
455
+ summaryLines.push(`⚠ ${w}`);
456
+ }
107
457
  }
108
- // API keys — in --yes mode, reuse whatever is already in config.json.
109
- // The user already entered these on a previous install; the update
110
- // flow should not re-prompt for them.
111
- let xaiKey = config.providers?.xai?.apiKey ?? '';
112
- let googleKey = config.providers?.google?.apiKey ?? '';
113
- if (!nonInteractive) {
114
- console.log(chalk.dim(' API Keys'));
115
- xaiKey =
116
- (await password({
117
- message: `XAI_API_KEY${xaiKey ? ' (press Enter to keep existing)' : ''}:`,
118
- mask: '*',
119
- })) || xaiKey;
120
- googleKey =
121
- (await password({
122
- message: `GOOGLE_API_KEY${googleKey ? ' (press Enter to keep existing)' : ''}:`,
123
- mask: '*',
124
- })) || googleKey;
125
- }
126
- // Port — auto-assign unless user already has one configured
127
- console.log();
128
- console.log(chalk.dim(' Port'));
458
+ p.note(summaryLines.join('\n'), 'Configuration Summary');
459
+ // ── Port ─────────────────────────────────────────────────────
129
460
  let port;
461
+ let portSource;
130
462
  if ((config.port ?? 0) > 0) {
131
- // User has a previously configured port — keep it
132
463
  port = config.port;
133
- console.log(` Using configured port: ${port}`);
464
+ portSource = 'configured';
134
465
  }
135
466
  else {
136
- // Auto-assign a free port in the 18000-19000 range
137
467
  port = await findFreePort();
138
- console.log(chalk.green(` ✓ Auto-assigned: ${port}`));
468
+ portSource = 'auto-assigned';
139
469
  }
140
- if (!nonInteractive) {
141
- const customPort = await input({
142
- message: 'Custom port? (leave blank to keep):',
143
- default: '',
144
- validate: (v) => {
145
- if (v === '')
146
- return true;
147
- if (!/^\d+$/.test(v))
148
- return 'Must be a number';
149
- const n = parseInt(v, 10);
150
- if (n < 1 || n > 65535)
151
- return 'Must be 1-65535';
152
- return true;
153
- },
470
+ const customPort = await p.text({
471
+ message: `Port: ${port} (${portSource})`,
472
+ placeholder: 'press Enter to accept, or type a custom port',
473
+ defaultValue: String(port),
474
+ validate: (v) => {
475
+ if (!v || v === '' || v === String(port))
476
+ return undefined;
477
+ if (!/^\d+$/.test(v))
478
+ return 'Must be a number';
479
+ const n = parseInt(v, 10);
480
+ if (n < 1 || n > 65535)
481
+ return 'Must be 1-65535';
482
+ return undefined;
483
+ },
484
+ });
485
+ if (p.isCancel(customPort))
486
+ cancelled();
487
+ port = parseInt(customPort, 10);
488
+ // ── Voice selection ──────────────────────────────────────────
489
+ let voice;
490
+ const voiceOpts = getVoiceOptions(selections);
491
+ if (voiceOpts) {
492
+ const voiceSelect = await p.select({
493
+ message: 'Voice',
494
+ options: voiceOpts.voices,
495
+ initialValue: voiceOpts.defaultVoice,
154
496
  });
155
- if (customPort) {
156
- port = parseInt(customPort, 10);
157
- }
497
+ if (p.isCancel(voiceSelect))
498
+ cancelled();
499
+ voice = voiceSelect;
158
500
  }
159
- // Voice
160
- const currentVoice = config.routing?.voice?.mode === 'realtime'
161
- ? (config.routing.voice.voice ?? 'eve')
162
- : 'eve';
163
- const voice = nonInteractive
164
- ? currentVoice
165
- : await input({
166
- message: 'Voice:',
167
- default: currentVoice,
168
- });
169
- // Generate auth token if not already set
501
+ // ── Assemble and save config ─────────────────────────────────
170
502
  if (!config.authToken) {
171
503
  config.authToken = generateAuthToken();
172
504
  }
173
- // Save config in v3 format
174
- if (!config.providers)
175
- config.providers = {};
176
- if (xaiKey)
177
- config.providers.xai = { apiKey: xaiKey };
178
- if (googleKey)
179
- config.providers.google = { apiKey: googleKey };
180
- config.port = port;
181
- if (config.routing?.voice?.mode === 'realtime') {
182
- config.routing.voice.voice = voice;
505
+ // Merge collected keys into providers
506
+ config.providers = { ...(keepExisting ? config.providers : {}), ...collectedKeys };
507
+ // Set routing
508
+ config.routing = result.routing;
509
+ // Apply voice to the route
510
+ if (voice && config.routing.voice) {
511
+ if (config.routing.voice.mode === 'realtime') {
512
+ config.routing.voice.voice = voice;
513
+ }
514
+ else {
515
+ config.routing.voice.tts.voice = voice;
516
+ }
183
517
  }
518
+ config.port = port;
184
519
  saveConfig(config);
185
- console.log();
186
- console.log(chalk.dim(' Config saved to ' + home + '/config.json'));
187
- console.log(chalk.dim(' Auth token: ' + chalk.bold('generated')));
188
- // Sanity check — core is bundled inside this npm package since v1.11.0,
189
- // so it should always be present. If it isn't, the user's CLI install is
190
- // corrupted and the only fix is reinstalling from npm.
520
+ p.log.info(`Config saved to ${home}/config.json`);
521
+ // ── Service registration & health check ──────────────────────
522
+ await registerServiceAndFinish(config);
523
+ }
524
+ // ─── Service Registration (shared by interactive and --yes) ────
525
+ async function registerServiceAndFinish(config) {
526
+ // Sanity check — core is bundled inside this npm package
191
527
  if (!hasCoreBinary()) {
192
- console.log();
193
- console.log(chalk.red(' Core bundle not found inside this CLI install.\n' +
194
- ' This indicates a broken installation. Fix with:\n' +
195
- ' npm install -g @mclean-capital/neura@latest'));
196
- console.log();
528
+ p.log.error('Core bundle not found inside this CLI install.\n' +
529
+ 'This indicates a broken installation. Fix with:\n' +
530
+ ' npm install -g @mclean-capital/neura@latest');
197
531
  return;
198
532
  }
199
- // Register service
200
- console.log();
201
- console.log(chalk.dim(' Registering service...'));
533
+ const s = p.spinner();
534
+ s.start('Registering service...');
202
535
  let serviceRegistered = false;
203
536
  try {
204
537
  const svc = await getServiceManager();
205
538
  const wasInstalled = svc.isInstalled();
206
- // Always (re)write the service definition. If we only restarted when the
207
- // service was already registered, an upgrade that fixes the service file
208
- // (e.g. macOS plist ProgramArguments, systemd ExecStart) would never take
209
- // effect — the on-disk file would stay stale. Stop the old service first
210
- // so the new file gets cleanly loaded.
211
539
  if (wasInstalled) {
212
540
  try {
213
541
  svc.stop();
214
542
  }
215
543
  catch {
216
- // Service may not be running — that's fine.
544
+ // Service may not be running
217
545
  }
218
546
  }
219
547
  await svc.install();
220
- console.log(chalk.green(`Service ${wasInstalled ? 're-registered' : 'registered'} (${getPlatformLabel()})`));
221
- // Windows has two install paths (Scheduled Task → preferred, or
222
- // Startup folder shim → fallback). Tell the user which one was
223
- // used so they understand what to expect — e.g. Task Scheduler
224
- // manageability vs. runs-on-next-login. Empty import on non-Windows.
548
+ s.stop(`Service ${wasInstalled ? 're-registered' : 'registered'} (${getPlatformLabel()})`);
549
+ fixWindowsStdin();
550
+ // Windows install mode info
225
551
  if (process.platform === 'win32') {
226
552
  const win = await import('../service/windows.js');
227
553
  const mode = win.getLastInstallMode();
228
554
  if (mode === 'startup-shim') {
229
- console.log(chalk.dim(' (Using Startup folder shim — schtasks.exe refused to register\n' +
230
- ' the Scheduled Task on this machine, likely due to Windows\n' +
231
- ' policy or corporate restrictions. The core will still run\n' +
232
- ' at each user login. It can be removed from\n' +
233
- ' %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\.)'));
555
+ p.log.info('Using Startup folder shim — Scheduled Task registration was not available.\n' +
556
+ 'Core will still run at each user login.');
234
557
  }
235
558
  else if (mode === 'scheduled-task') {
236
- console.log(chalk.dim(' (Registered in Task Scheduler under name "neura-core")'));
559
+ p.log.info('Registered in Task Scheduler under name "neura-core"');
237
560
  }
238
561
  }
239
562
  svc.start();
240
563
  serviceRegistered = true;
241
564
  }
242
565
  catch (err) {
243
- console.log(chalk.yellow(' Service registration skipped:'));
244
- console.log(chalk.yellow(' ' + (err instanceof Error ? err.message : String(err))));
245
- console.log(chalk.dim(' Config was saved. Try again after resolving the issue.'));
566
+ s.stop('Service registration skipped');
567
+ fixWindowsStdin();
568
+ p.log.warn(err instanceof Error ? err.message : String(err));
569
+ p.log.info('Config was saved. Try again after resolving the issue.');
246
570
  }
247
- // Wait for health (only if service was started)
571
+ // Wait for health
248
572
  if (serviceRegistered) {
249
- console.log(chalk.dim(' Starting core...'));
573
+ const hs = p.spinner();
574
+ hs.start('Waiting for core...');
250
575
  const health = await waitForHealthy(config.port ?? 0);
251
576
  if (health) {
252
- console.log(chalk.green(`Core running on ws://localhost:${health.port}`));
253
- console.log(chalk.green(' ✓ Health check: ok'));
577
+ hs.stop(`Core healthy on port ${health.port}`);
578
+ fixWindowsStdin();
254
579
  }
255
580
  else {
256
- console.log(chalk.yellow(' Core did not respond within 15s. Check logs: neura logs'));
581
+ hs.stop('Core did not respond within 15s');
582
+ fixWindowsStdin();
583
+ p.log.warn('Check logs: neura logs');
257
584
  }
258
585
  }
259
- console.log();
586
+ // ── Done ─────────────────────────────────────────────────────
260
587
  if (serviceRegistered) {
261
- console.log(chalk.bold(' Done!') + ' Connect with any client:');
262
- console.log(' Desktop: Open the Neura desktop app');
263
- console.log(' Web: neura open');
264
- console.log(' Status: neura status');
265
- console.log(' Logs: neura logs');
588
+ p.note('Desktop: Open the Neura desktop app\n' +
589
+ 'Web: neura open\n' +
590
+ 'Status: neura status\n' +
591
+ 'Logs: neura logs', 'Ready!');
266
592
  }
267
593
  else {
268
- console.log(chalk.bold(' Config saved.') + ' Service not yet running.');
269
- console.log(' neura start Start the service');
270
- console.log(' neura status Check service state');
594
+ p.note('neura start Start the service\n' + 'neura status Check service state', 'Config saved. Service not yet running.');
271
595
  }
272
- console.log();
596
+ p.outro('Setup complete.');
273
597
  }
274
598
  // Exported for tests only.
275
599
  export const __test__ = {