@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.
- package/README.md +8 -20
- package/core/server.bundled.mjs +2 -1
- package/core/server.bundled.mjs.map +2 -2
- package/core/version.txt +1 -1
- package/dist/audio/capture.d.ts +5 -2
- package/dist/audio/capture.d.ts.map +1 -1
- package/dist/audio/capture.js +127 -31
- package/dist/audio/capture.js.map +1 -1
- package/dist/commands/backup.js +3 -3
- package/dist/commands/backup.js.map +1 -1
- package/dist/commands/install.d.ts +0 -23
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +501 -177
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/uninstall.js +3 -3
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/providers.d.ts +66 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +309 -0
- package/dist/providers.js.map +1 -0
- package/dist/validate-key.d.ts +13 -0
- package/dist/validate-key.d.ts.map +1 -0
- package/dist/validate-key.js +53 -0
- package/dist/validate-key.js.map +1 -0
- package/package.json +3 -2
package/dist/commands/install.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
13
|
-
|
|
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 [];
|
|
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;
|
|
26
|
+
continue;
|
|
39
27
|
copyFileSync(join(src, entry), destPath);
|
|
40
28
|
copied.push(entry);
|
|
41
29
|
}
|
|
42
30
|
}
|
|
43
31
|
catch {
|
|
44
|
-
// Non-fatal
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
-
|
|
98
|
+
p.log.success(`Core is already running on port ${existing.port}`);
|
|
64
99
|
if (!nonInteractive) {
|
|
65
|
-
const reinstall = await confirm({
|
|
66
|
-
|
|
67
|
-
|
|
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 (
|
|
70
|
-
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 (
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
468
|
+
portSource = 'auto-assigned';
|
|
139
469
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 (
|
|
156
|
-
|
|
157
|
-
|
|
497
|
+
if (p.isCancel(voiceSelect))
|
|
498
|
+
cancelled();
|
|
499
|
+
voice = voiceSelect;
|
|
158
500
|
}
|
|
159
|
-
//
|
|
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
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
config.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
'
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
544
|
+
// Service may not be running
|
|
217
545
|
}
|
|
218
546
|
}
|
|
219
547
|
await svc.install();
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
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
|
-
|
|
230
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
571
|
+
// Wait for health
|
|
248
572
|
if (serviceRegistered) {
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
577
|
+
hs.stop(`Core healthy on port ${health.port}`);
|
|
578
|
+
fixWindowsStdin();
|
|
254
579
|
}
|
|
255
580
|
else {
|
|
256
|
-
|
|
581
|
+
hs.stop('Core did not respond within 15s');
|
|
582
|
+
fixWindowsStdin();
|
|
583
|
+
p.log.warn('Check logs: neura logs');
|
|
257
584
|
}
|
|
258
585
|
}
|
|
259
|
-
|
|
586
|
+
// ── Done ─────────────────────────────────────────────────────
|
|
260
587
|
if (serviceRegistered) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
+
p.outro('Setup complete.');
|
|
273
597
|
}
|
|
274
598
|
// Exported for tests only.
|
|
275
599
|
export const __test__ = {
|