nexus-prime 6.4.0 → 6.6.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/dist/agents/adapters/mcp/envelope.d.ts +6 -5
- package/dist/agents/adapters/mcp/envelope.js +86 -20
- package/dist/cli/install-wizard.d.ts +12 -2
- package/dist/cli/install-wizard.js +116 -62
- package/dist/cli/interactive-setup.d.ts +34 -0
- package/dist/cli/interactive-setup.js +163 -0
- package/dist/cli/orchestrate-stream.d.ts +8 -0
- package/dist/cli/orchestrate-stream.js +305 -0
- package/dist/cli/watch.d.ts +10 -0
- package/dist/cli/watch.js +117 -0
- package/dist/cli.js +58 -5
- package/dist/daemon/client.js +24 -1
- package/dist/daemon/lock.d.ts +2 -0
- package/dist/daemon/server.d.ts +1 -0
- package/dist/daemon/server.js +59 -1
- package/dist/dashboard/app/index.html +7 -0
- package/dist/dashboard/app/main.js +5 -0
- package/dist/dashboard/app/styles/runtime.css +343 -0
- package/dist/dashboard/app/views/license.js +31 -0
- package/dist/dashboard/app/views/runtime.js +414 -0
- package/dist/dashboard/stream/sse-broker.js +57 -11
- package/dist/dashboard/welcome.html +685 -0
- package/dist/engines/event-bus.d.ts +38 -1
- package/dist/engines/memory.js +6 -3
- package/dist/engines/orchestrator/types.d.ts +2 -0
- package/dist/engines/orchestrator.js +102 -22
- package/dist/index.js +25 -2
- package/dist/licensing/license-manager.js +16 -0
- package/dist/licensing/upgrade-prompts.js +10 -1
- package/dist/synapse/mandate/pipeline.js +24 -18
- package/package.json +2 -2
|
@@ -35,12 +35,13 @@ export interface RunHandlerOptions {
|
|
|
35
35
|
/**
|
|
36
36
|
* Convert a HandlerEnvelope into the MCP tool-call response shape.
|
|
37
37
|
*
|
|
38
|
-
* On success
|
|
39
|
-
* (`{ content: Array<...> }`),
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* On success:
|
|
39
|
+
* - If data is already a ToolResult (`{ content: Array<...> }`), appended footer + pass-through.
|
|
40
|
+
* - If data is an array of objects, rendered as ASCII table.
|
|
41
|
+
* - If data is a plain object, rendered as key-value rows.
|
|
42
|
+
* - If data is a string, returned as-is with footer appended.
|
|
42
43
|
*
|
|
43
|
-
* On error, a
|
|
44
|
+
* On error, a compact human-readable error block is produced.
|
|
44
45
|
*/
|
|
45
46
|
export declare function envelopeToMcpResponse<T>(envelope: HandlerEnvelope<T>): {
|
|
46
47
|
content: Array<{
|
|
@@ -5,18 +5,52 @@
|
|
|
5
5
|
* dashboard routes) can import the type without pulling in the full
|
|
6
6
|
* runHandler runtime. runHandler.ts re-exports these for back-compat.
|
|
7
7
|
*/
|
|
8
|
+
// ─── Pretty-print helpers ─────────────────────────────────────────────────────
|
|
9
|
+
const DASH_PORT = process.env.NEXUS_DASHBOARD_PORT ?? '3377';
|
|
10
|
+
/** Render an array of objects as an aligned ASCII table (max 20 rows × 6 cols). */
|
|
11
|
+
function renderTable(rows, maxRows = 20) {
|
|
12
|
+
if (rows.length === 0)
|
|
13
|
+
return '(empty)';
|
|
14
|
+
const cols = Object.keys(rows[0]).slice(0, 6);
|
|
15
|
+
const display = rows.slice(0, maxRows);
|
|
16
|
+
const widths = cols.map(col => Math.max(col.length, ...display.map(r => String(r[col] ?? '').length)));
|
|
17
|
+
const sep = '─'.repeat(widths.reduce((a, w) => a + w + 3, 1));
|
|
18
|
+
const header = '│ ' + cols.map((c, i) => c.toUpperCase().padEnd(widths[i])).join(' │ ') + ' │';
|
|
19
|
+
const dataRows = display.map(r => '│ ' + cols.map((c, i) => String(r[c] ?? '').slice(0, widths[i]).padEnd(widths[i])).join(' │ ') + ' │');
|
|
20
|
+
const truncNote = rows.length > maxRows ? `\n … ${rows.length - maxRows} more rows` : '';
|
|
21
|
+
return [sep, header, sep, ...dataRows, sep].join('\n') + truncNote;
|
|
22
|
+
}
|
|
23
|
+
/** Build a compact timing + savings footer line for tool responses. */
|
|
24
|
+
function buildMcpFooter(meta) {
|
|
25
|
+
const ms = meta.durationMs;
|
|
26
|
+
const timing = ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
27
|
+
return `\n\n─── nexus-prime · ${meta.toolName} · ${timing} · dashboard: http://localhost:${DASH_PORT}/#runtime ───`;
|
|
28
|
+
}
|
|
29
|
+
/** Format a plain object as key: value rows. */
|
|
30
|
+
function renderKV(obj, indent = ' ') {
|
|
31
|
+
return Object.entries(obj)
|
|
32
|
+
.filter(([, v]) => v !== null && v !== undefined && v !== '' && !Array.isArray(v))
|
|
33
|
+
.map(([k, v]) => `${indent}${k.padEnd(22)} ${String(v).slice(0, 120)}`)
|
|
34
|
+
.join('\n');
|
|
35
|
+
}
|
|
36
|
+
/** Detect if value is an array of objects suitable for table rendering. */
|
|
37
|
+
function isTableData(val) {
|
|
38
|
+
return Array.isArray(val) && val.length > 0 && typeof val[0] === 'object' && val[0] !== null;
|
|
39
|
+
}
|
|
8
40
|
// ─── Serialisation helper ─────────────────────────────────────────────────────
|
|
9
41
|
/**
|
|
10
42
|
* Convert a HandlerEnvelope into the MCP tool-call response shape.
|
|
11
43
|
*
|
|
12
|
-
* On success
|
|
13
|
-
* (`{ content: Array<...> }`),
|
|
14
|
-
*
|
|
15
|
-
*
|
|
44
|
+
* On success:
|
|
45
|
+
* - If data is already a ToolResult (`{ content: Array<...> }`), appended footer + pass-through.
|
|
46
|
+
* - If data is an array of objects, rendered as ASCII table.
|
|
47
|
+
* - If data is a plain object, rendered as key-value rows.
|
|
48
|
+
* - If data is a string, returned as-is with footer appended.
|
|
16
49
|
*
|
|
17
|
-
* On error, a
|
|
50
|
+
* On error, a compact human-readable error block is produced.
|
|
18
51
|
*/
|
|
19
52
|
export function envelopeToMcpResponse(envelope) {
|
|
53
|
+
const footer = buildMcpFooter(envelope.meta);
|
|
20
54
|
if (envelope.ok) {
|
|
21
55
|
const data = envelope.data;
|
|
22
56
|
// Pass-through: handler already returned a ToolResult-shaped object.
|
|
@@ -24,22 +58,54 @@ export function envelopeToMcpResponse(envelope) {
|
|
|
24
58
|
data !== undefined &&
|
|
25
59
|
typeof data === 'object' &&
|
|
26
60
|
Array.isArray(data.content)) {
|
|
27
|
-
|
|
61
|
+
const result = data;
|
|
62
|
+
// Append footer to last text block
|
|
63
|
+
const last = result.content[result.content.length - 1];
|
|
64
|
+
if (last?.type === 'text') {
|
|
65
|
+
last.text += footer;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
let text;
|
|
70
|
+
if (typeof data === 'string') {
|
|
71
|
+
text = data + footer;
|
|
72
|
+
}
|
|
73
|
+
else if (isTableData(data)) {
|
|
74
|
+
// Array of objects → ASCII table
|
|
75
|
+
const noun = envelope.meta.toolName.replace('nexus_list_', '').replace('nexus_', '');
|
|
76
|
+
text = `⚡ ${envelope.meta.toolName} · ${data.length} ${noun}\n\n${renderTable(data)}\n${footer}`;
|
|
77
|
+
}
|
|
78
|
+
else if (data !== null && data !== undefined && typeof data === 'object') {
|
|
79
|
+
// Plain object → key-value rows (compact, no JSON noise)
|
|
80
|
+
const obj = data;
|
|
81
|
+
// Check for a nested array payload (e.g. { skills: [...] })
|
|
82
|
+
const arrayKey = Object.keys(obj).find(k => isTableData(obj[k]));
|
|
83
|
+
if (arrayKey) {
|
|
84
|
+
const rows = obj[arrayKey];
|
|
85
|
+
const rest = Object.entries(obj).filter(([k]) => k !== arrayKey);
|
|
86
|
+
const kvSection = rest.length > 0 ? renderKV(Object.fromEntries(rest)) + '\n\n' : '';
|
|
87
|
+
text = `⚡ ${envelope.meta.toolName} · ${rows.length} ${arrayKey}\n\n${kvSection}${renderTable(rows)}${footer}`;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Flat KV display — avoids raw JSON blobs
|
|
91
|
+
const kvText = renderKV(obj);
|
|
92
|
+
text = `⚡ ${envelope.meta.toolName}\n\n${kvText || JSON.stringify(data, null, 2)}${footer}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
text = String(data ?? '') + footer;
|
|
28
97
|
}
|
|
29
|
-
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
30
98
|
return { content: [{ type: 'text', text }] };
|
|
31
99
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}],
|
|
44
|
-
};
|
|
100
|
+
// Error: compact human-readable format
|
|
101
|
+
const err = envelope.error;
|
|
102
|
+
const errText = [
|
|
103
|
+
`✗ ${envelope.meta.toolName} failed · ${err.code}`,
|
|
104
|
+
` ${err.message}`,
|
|
105
|
+
err.hint ? ` hint: ${err.hint}` : '',
|
|
106
|
+
err.retriable ? ' retriable: yes' : '',
|
|
107
|
+
` duration: ${envelope.meta.durationMs}ms · attempts: ${envelope.meta.attempts}`,
|
|
108
|
+
` dashboard: http://localhost:${DASH_PORT}/#runtime`,
|
|
109
|
+
].filter(Boolean).join('\n');
|
|
110
|
+
return { content: [{ type: 'text', text: errText }] };
|
|
45
111
|
}
|
|
@@ -40,5 +40,15 @@ export declare function configureIDE(ide: IDEId, opts?: WizardOptions): Promise<
|
|
|
40
40
|
export declare function runInstallWizard(opts?: WizardOptions): Promise<WizardResult>;
|
|
41
41
|
/** Print how to add nexus-prime manually to an MCP client. */
|
|
42
42
|
export declare function printManualMcpInstructions(): void;
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
export interface CliSetupOptions {
|
|
44
|
+
dryRun?: boolean;
|
|
45
|
+
verbose?: boolean;
|
|
46
|
+
/** Pre-validated license key to activate before starting daemon. */
|
|
47
|
+
licenseKey?: string;
|
|
48
|
+
/** Open browser to dashboard after daemon starts (default: true for new users). */
|
|
49
|
+
openDashboard?: boolean;
|
|
50
|
+
/** Whether this is a first-time install — affects banner and browser target. */
|
|
51
|
+
isNewUser?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** Entry point for CLI: nexus-prime install / setup auto */
|
|
54
|
+
export declare function cliSetup(opts?: CliSetupOptions | string[]): Promise<void>;
|
|
@@ -14,7 +14,7 @@ import { getSetupDefinition, installSetup } from '../engines/client-bootstrap.js
|
|
|
14
14
|
import { resolveNexusStateDir } from '../engines/runtime-registry.js';
|
|
15
15
|
import { resolveWorkspaceContext } from '../engines/workspace-resolver.js';
|
|
16
16
|
import { runPostinstallCleanup } from '../postinstall/cleanup.js';
|
|
17
|
-
import {
|
|
17
|
+
import { openBrowser, printHandoffBanner, withSpinner } from './interactive-setup.js';
|
|
18
18
|
/** Compute sha256 of a file's content. Returns null if the file cannot be read. */
|
|
19
19
|
export function computeFileHash(filePath) {
|
|
20
20
|
try {
|
|
@@ -340,101 +340,155 @@ export function printManualMcpInstructions() {
|
|
|
340
340
|
process.stdout.write(snippet.split('\n').map(l => ` ${l}`).join('\n') + '\n\n');
|
|
341
341
|
process.stdout.write(' Or run: npx nexus-prime@latest setup\n\n');
|
|
342
342
|
}
|
|
343
|
-
/** Entry point for CLI: nexus-prime install */
|
|
344
|
-
export async function cliSetup(
|
|
345
|
-
|
|
346
|
-
const
|
|
343
|
+
/** Entry point for CLI: nexus-prime install / setup auto */
|
|
344
|
+
export async function cliSetup(opts = []) {
|
|
345
|
+
// Backwards-compat: accept string[] args as before
|
|
346
|
+
const args = Array.isArray(opts) ? opts : [];
|
|
347
|
+
const options = Array.isArray(opts)
|
|
348
|
+
? { dryRun: args.includes('--dry-run'), verbose: !args.includes('--silent') }
|
|
349
|
+
: opts;
|
|
350
|
+
const dryRun = options.dryRun ?? false;
|
|
351
|
+
const verbose = options.verbose ?? true;
|
|
347
352
|
const port = process.env.NEXUS_DASHBOARD_PORT ?? '3377';
|
|
348
353
|
const dashUrl = `http://localhost:${port}`;
|
|
354
|
+
const isNew = options.isNewUser ?? false;
|
|
355
|
+
const ANSI = { cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', dim: '\x1b[2m', reset: '\x1b[0m' };
|
|
356
|
+
const isTTY = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
357
|
+
const c = (s, k) => isTTY ? `${ANSI[k]}${s}${ANSI.reset}` : s;
|
|
349
358
|
const log = (msg) => { if (verbose)
|
|
350
359
|
process.stdout.write(msg + '\n'); };
|
|
351
|
-
|
|
352
|
-
log('
|
|
360
|
+
const step = (n, t, label) => log(`${c(`◇ Step ${n}/${t}`, 'yellow')} · ${label}`);
|
|
361
|
+
log('');
|
|
362
|
+
log(c('◆ Nexus Prime Setup', 'cyan'));
|
|
353
363
|
const cleanup = await runPostinstallCleanup({ dryRun, silent: true });
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const result = await runInstallWizard({ dryRun, verbose: false });
|
|
357
|
-
if (result.configured.length > 0) {
|
|
358
|
-
log(` ✓ Configured: ${result.configured.join(', ')}`);
|
|
364
|
+
if (cleanup.staleLockfilesRemoved + cleanup.staleDaemonsKilled > 0) {
|
|
365
|
+
log(` ${c('↺', 'dim')} Cleaned ${cleanup.staleLockfilesRemoved} lockfiles, stopped ${cleanup.staleDaemonsKilled} stale daemons`);
|
|
359
366
|
}
|
|
360
|
-
|
|
361
|
-
|
|
367
|
+
// Step 1 — detect + configure IDEs (per-IDE animated checklist)
|
|
368
|
+
const totalSteps = options.licenseKey ? 5 : 4;
|
|
369
|
+
step(1, totalSteps, 'Detecting IDEs…');
|
|
370
|
+
// Emit install.step event helper (best-effort; daemon may not be running yet)
|
|
371
|
+
const emitInstallStep = (stepN, total, label, status, detail) => {
|
|
372
|
+
try {
|
|
373
|
+
import('../engines/event-bus.js').then(({ nexusEventBus }) => {
|
|
374
|
+
nexusEventBus.emit('install.step', { step: stepN, total, label, status, detail });
|
|
375
|
+
}).catch(() => { });
|
|
376
|
+
}
|
|
377
|
+
catch { /* non-fatal */ }
|
|
378
|
+
};
|
|
379
|
+
const result = await runInstallWizard({ dryRun, verbose: false });
|
|
380
|
+
const allIDEs = [...result.configured, ...result.skipped, ...result.errors.map(e => e.ide)];
|
|
381
|
+
if (allIDEs.length > 0) {
|
|
382
|
+
for (const ide of allIDEs) {
|
|
383
|
+
const isConfigured = result.configured.includes(ide);
|
|
384
|
+
const isSkipped = result.skipped.includes(ide);
|
|
385
|
+
const errEntry = result.errors.find(e => e.ide === ide);
|
|
386
|
+
if (isConfigured) {
|
|
387
|
+
await withSpinner(`${ide} · configuring`, Promise.resolve());
|
|
388
|
+
emitInstallStep(1, totalSteps, ide, 'ok', 'MCP config written');
|
|
389
|
+
}
|
|
390
|
+
else if (isSkipped) {
|
|
391
|
+
log(` ${c('─', 'dim')} ${c(ide, 'dim')} already set up`);
|
|
392
|
+
emitInstallStep(1, totalSteps, ide, 'skip', 'already configured');
|
|
393
|
+
}
|
|
394
|
+
else if (errEntry) {
|
|
395
|
+
log(` ${c('✗', 'yellow')} ${c(ide, 'yellow')} · ${errEntry.reason}`);
|
|
396
|
+
emitInstallStep(1, totalSteps, ide, 'fail', errEntry.reason);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
362
399
|
}
|
|
363
|
-
|
|
364
|
-
log(`
|
|
400
|
+
else {
|
|
401
|
+
log(` ${c('—', 'dim')} No IDEs auto-detected. Run: nexus-prime setup <ide>`);
|
|
365
402
|
}
|
|
366
|
-
|
|
367
|
-
|
|
403
|
+
// Step 2 — MCP configs confirmed
|
|
404
|
+
step(2, totalSteps, 'MCP configs written.');
|
|
405
|
+
// Optional license activation step
|
|
406
|
+
let licStepBase = 2;
|
|
407
|
+
if (options.licenseKey) {
|
|
408
|
+
licStepBase = 3;
|
|
409
|
+
step(3, totalSteps, 'Activating license…');
|
|
410
|
+
if (!dryRun) {
|
|
411
|
+
try {
|
|
412
|
+
const licRes = await fetch(`${dashUrl}/api/license/activate`, {
|
|
413
|
+
method: 'POST',
|
|
414
|
+
headers: { 'Content-Type': 'application/json' },
|
|
415
|
+
body: JSON.stringify({ key: options.licenseKey }),
|
|
416
|
+
signal: AbortSignal.timeout(8_000),
|
|
417
|
+
});
|
|
418
|
+
if (licRes.ok) {
|
|
419
|
+
const data = await licRes.json();
|
|
420
|
+
log(` ${c('✓', 'green')} License activated · ${c(data?.tier?.toUpperCase() ?? 'PRO', 'yellow')} plan`);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
log(` ${c('⚠', 'yellow')} License activation returned ${licRes.status} — check key and retry: nexus-prime license activate <key>`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
log(` ${c('—', 'dim')} License activation skipped (daemon not ready yet) — retry: nexus-prime license activate <key>`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
log(` ${c('[dry-run]', 'dim')} Would activate license key`);
|
|
432
|
+
}
|
|
368
433
|
}
|
|
369
|
-
// Step
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
434
|
+
// Step daemon
|
|
435
|
+
const daemonStep = licStepBase + 1;
|
|
436
|
+
step(daemonStep, totalSteps, 'Starting daemon…');
|
|
437
|
+
let daemonReady = false;
|
|
373
438
|
if (!dryRun) {
|
|
374
439
|
try {
|
|
375
440
|
const workspace = resolveWorkspaceContext({ workspaceRoot: process.cwd() });
|
|
376
|
-
await ensureDaemonReady(workspace, { timeoutMs:
|
|
377
|
-
|
|
441
|
+
await withSpinner('Connecting to daemon', ensureDaemonReady(workspace, { timeoutMs: 15_000, entrypoint: process.argv[1] }));
|
|
442
|
+
daemonReady = true;
|
|
443
|
+
log(` ${c('✓', 'green')} Dashboard: ${c(dashUrl, 'cyan')}`);
|
|
378
444
|
}
|
|
379
445
|
catch (err) {
|
|
380
|
-
log(` ✗ Daemon start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
381
|
-
log(` Start manually: nexus-prime daemon`);
|
|
446
|
+
log(` ${c('✗', 'yellow')} Daemon start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
447
|
+
log(` Start manually: ${c('nexus-prime daemon', 'dim')}`);
|
|
382
448
|
}
|
|
383
449
|
}
|
|
384
450
|
else {
|
|
385
|
-
log(` [dry-run] Would start daemon → ${dashUrl}`);
|
|
451
|
+
log(` ${c('[dry-run]', 'dim')} Would start daemon → ${dashUrl}`);
|
|
386
452
|
}
|
|
387
|
-
// Step
|
|
388
|
-
|
|
389
|
-
|
|
453
|
+
// Step hire
|
|
454
|
+
const hireStep = daemonStep + 1;
|
|
455
|
+
step(hireStep, totalSteps, 'Hiring your first specialist…');
|
|
456
|
+
if (!dryRun && daemonReady && process.env.NEXUS_AUTO_HIRE_STARTER !== '0') {
|
|
390
457
|
try {
|
|
391
458
|
const hireRes = await fetch(`${dashUrl}/api/synapse/hire`, {
|
|
392
459
|
method: 'POST',
|
|
393
460
|
headers: { 'Content-Type': 'application/json' },
|
|
394
|
-
body: JSON.stringify({
|
|
395
|
-
specialistId: 'engineering.rapid-prototyper',
|
|
396
|
-
budgetCapUsd: 1,
|
|
397
|
-
name: 'Rapid Prototyper',
|
|
398
|
-
}),
|
|
461
|
+
body: JSON.stringify({ specialistId: 'engineering.rapid-prototyper', budgetCapUsd: 1, name: 'Rapid Prototyper' }),
|
|
399
462
|
signal: AbortSignal.timeout(5_000),
|
|
400
463
|
});
|
|
401
464
|
if (hireRes.ok) {
|
|
402
465
|
const op = await hireRes.json();
|
|
403
466
|
const opId = op?.operative?.id ?? op?.id ?? 'op_???';
|
|
404
|
-
log(` ✓ Operative ${opId} · rapid-prototyper · hired`);
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}),
|
|
413
|
-
signal: AbortSignal.timeout(3_000),
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
catch { /* best-effort — not fatal */ }
|
|
467
|
+
log(` ${c('✓', 'green')} Operative ${c(String(opId), 'cyan')} · rapid-prototyper · hired`);
|
|
468
|
+
// Fire-and-forget welcome mission
|
|
469
|
+
fetch(`${dashUrl}/api/orchestrate`, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'Content-Type': 'application/json' },
|
|
472
|
+
body: JSON.stringify({ goal: 'Introduce yourself to this codebase — read the README and top 3 source files, then store what you learn as memories.' }),
|
|
473
|
+
signal: AbortSignal.timeout(3_000),
|
|
474
|
+
}).catch(() => { });
|
|
417
475
|
}
|
|
418
476
|
else {
|
|
419
|
-
log(` — Hire skipped (daemon
|
|
477
|
+
log(` ${c('—', 'dim')} Hire skipped (daemon warming up) — open dashboard to hire`);
|
|
420
478
|
}
|
|
421
479
|
}
|
|
422
480
|
catch {
|
|
423
|
-
log(` — Hire skipped
|
|
481
|
+
log(` ${c('—', 'dim')} Hire skipped — open ${c(`${dashUrl}/#workforce`, 'dim')} to hire manually`);
|
|
424
482
|
}
|
|
425
483
|
}
|
|
426
484
|
else if (dryRun) {
|
|
427
|
-
log(` [dry-run] Would hire engineering.rapid-prototyper
|
|
485
|
+
log(` ${c('[dry-run]', 'dim')} Would hire engineering.rapid-prototyper`);
|
|
486
|
+
}
|
|
487
|
+
// Handoff: open browser + print banner
|
|
488
|
+
if (!dryRun && daemonReady && (options.openDashboard ?? isNew)) {
|
|
489
|
+
const target = isNew ? `${dashUrl}/welcome` : dashUrl;
|
|
490
|
+
openBrowser(target);
|
|
491
|
+
log(` ${c('↗', 'cyan')} Opening ${c(target, 'cyan')} in browser…`);
|
|
428
492
|
}
|
|
429
|
-
|
|
430
|
-
drawCard({
|
|
431
|
-
title: 'NEXUS PRIME · READY',
|
|
432
|
-
lines: [
|
|
433
|
-
`Dashboard ${dashUrl}`,
|
|
434
|
-
`Welcome ${dashUrl}/welcome`,
|
|
435
|
-
'',
|
|
436
|
-
` [o] open dashboard [h] hire another [q] quit`,
|
|
437
|
-
],
|
|
438
|
-
color: '36',
|
|
439
|
-
});
|
|
493
|
+
printHandoffBanner(dashUrl, { isNew });
|
|
440
494
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive setup utilities — new-user detection, prompts, browser open, handoff.
|
|
3
|
+
* Uses only Node.js built-ins; no new runtime dependencies.
|
|
4
|
+
*/
|
|
5
|
+
/** Single-line readline prompt. Returns empty string in non-TTY environments. */
|
|
6
|
+
export declare function promptLine(question: string): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Keystroke-level prompt — shows question and waits for one of `validKeys`.
|
|
9
|
+
* Falls back to validKeys[0] in non-TTY or CI environments.
|
|
10
|
+
*/
|
|
11
|
+
export declare function promptKey(question: string, validKeys: string[]): Promise<string>;
|
|
12
|
+
/** Open browser cross-platform. Non-fatal on error. */
|
|
13
|
+
export declare function openBrowser(url: string): void;
|
|
14
|
+
/** True when no setup marker exists → first install. */
|
|
15
|
+
export declare function isNewUser(): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Interactive license key prompt shown to new users on the free tier.
|
|
18
|
+
* Returns the entered key or null if user chose to skip.
|
|
19
|
+
*/
|
|
20
|
+
export declare function promptLicenseKey(currentTier?: string): Promise<string | null>;
|
|
21
|
+
/** Print post-install handoff banner with dashboard URL and quick-start commands. */
|
|
22
|
+
export declare function printHandoffBanner(dashUrl: string, opts?: {
|
|
23
|
+
isNew?: boolean;
|
|
24
|
+
}): void;
|
|
25
|
+
/** Print a compact banner for returning users showing last-setup info. */
|
|
26
|
+
export declare function printReturningUserBanner(marker: {
|
|
27
|
+
configuredIDEs: string[];
|
|
28
|
+
setupCompletedAt: number;
|
|
29
|
+
}): void;
|
|
30
|
+
/**
|
|
31
|
+
* Animated "syncing" spinner that resolves when the provided promise resolves.
|
|
32
|
+
* Falls back to simple await in non-TTY environments.
|
|
33
|
+
*/
|
|
34
|
+
export declare function withSpinner<T>(label: string, work: Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive setup utilities — new-user detection, prompts, browser open, handoff.
|
|
3
|
+
* Uses only Node.js built-ins; no new runtime dependencies.
|
|
4
|
+
*/
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { readSetupMarker } from './install-wizard.js';
|
|
8
|
+
const ANSI = {
|
|
9
|
+
cyan: '\x1b[36m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
magenta: '\x1b[35m',
|
|
13
|
+
dim: '\x1b[2m',
|
|
14
|
+
bold: '\x1b[1m',
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
};
|
|
17
|
+
const tty = () => process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
18
|
+
const c = (s, k) => tty() ? `${ANSI[k]}${s}${ANSI.reset}` : s;
|
|
19
|
+
const log = (s) => process.stdout.write(s + '\n');
|
|
20
|
+
/** Single-line readline prompt. Returns empty string in non-TTY environments. */
|
|
21
|
+
export async function promptLine(question) {
|
|
22
|
+
if (!tty())
|
|
23
|
+
return '';
|
|
24
|
+
const rl = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout,
|
|
27
|
+
terminal: true,
|
|
28
|
+
});
|
|
29
|
+
return new Promise(resolve => {
|
|
30
|
+
rl.question(question, ans => {
|
|
31
|
+
rl.close();
|
|
32
|
+
resolve(ans.trim());
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Keystroke-level prompt — shows question and waits for one of `validKeys`.
|
|
38
|
+
* Falls back to validKeys[0] in non-TTY or CI environments.
|
|
39
|
+
*/
|
|
40
|
+
export async function promptKey(question, validKeys) {
|
|
41
|
+
if (!tty() || process.env.CI)
|
|
42
|
+
return validKeys[0];
|
|
43
|
+
process.stdout.write(question);
|
|
44
|
+
return new Promise(resolve => {
|
|
45
|
+
const stdin = process.stdin;
|
|
46
|
+
const wasRaw = stdin.isRaw;
|
|
47
|
+
if (stdin.setRawMode)
|
|
48
|
+
stdin.setRawMode(true);
|
|
49
|
+
stdin.resume();
|
|
50
|
+
stdin.setEncoding('utf8');
|
|
51
|
+
const handler = (key) => {
|
|
52
|
+
const k = key.toLowerCase();
|
|
53
|
+
const normalized = (k === '\r' || k === '\n') ? '\r' : k;
|
|
54
|
+
if (validKeys.includes(normalized) || validKeys.includes(k)) {
|
|
55
|
+
if (stdin.setRawMode)
|
|
56
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
57
|
+
stdin.pause();
|
|
58
|
+
stdin.removeListener('data', handler);
|
|
59
|
+
process.stdout.write('\n');
|
|
60
|
+
resolve(validKeys.includes(k) ? k : validKeys[0]);
|
|
61
|
+
}
|
|
62
|
+
else if (key === '\u0003') {
|
|
63
|
+
process.stdout.write('\n');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
stdin.on('data', handler);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Open browser cross-platform. Non-fatal on error. */
|
|
71
|
+
export function openBrowser(url) {
|
|
72
|
+
try {
|
|
73
|
+
const cmd = process.platform === 'darwin' ? `open "${url}"`
|
|
74
|
+
: process.platform === 'win32' ? `start "" "${url}"`
|
|
75
|
+
: `xdg-open "${url}"`;
|
|
76
|
+
execSync(cmd, { stdio: 'ignore', timeout: 3000 });
|
|
77
|
+
}
|
|
78
|
+
catch { /* non-fatal */ }
|
|
79
|
+
}
|
|
80
|
+
/** True when no setup marker exists → first install. */
|
|
81
|
+
export function isNewUser() {
|
|
82
|
+
return readSetupMarker() === null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Interactive license key prompt shown to new users on the free tier.
|
|
86
|
+
* Returns the entered key or null if user chose to skip.
|
|
87
|
+
*/
|
|
88
|
+
export async function promptLicenseKey(currentTier = 'free') {
|
|
89
|
+
if (!tty() || currentTier !== 'free')
|
|
90
|
+
return null;
|
|
91
|
+
log('');
|
|
92
|
+
log(c(' ┌─ LICENSE ──────────────────────────────────────────────────┐', 'magenta'));
|
|
93
|
+
log(c(' │', 'magenta') + ' You\'re on the ' + c('Free', 'yellow') + ' plan. ' + c('│', 'magenta'));
|
|
94
|
+
log(c(' │', 'magenta') + ' Get a license at ' + c('nexus-prime.cfd/license', 'cyan') + ' ' + c('│', 'magenta'));
|
|
95
|
+
log(c(' └────────────────────────────────────────────────────────────┘', 'magenta'));
|
|
96
|
+
log('');
|
|
97
|
+
const choice = await promptKey(` ${c('[L]', 'yellow')} Enter license key ${c('[S]', 'dim')} Skip for now › `, ['l', 's']);
|
|
98
|
+
if (choice === 'l') {
|
|
99
|
+
const key = await promptLine(`\n ${c('License key', 'cyan')}: `);
|
|
100
|
+
if (key.length > 4)
|
|
101
|
+
return key;
|
|
102
|
+
log(` ${c('(skipped — key too short)', 'dim')}`);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/** Print post-install handoff banner with dashboard URL and quick-start commands. */
|
|
107
|
+
export function printHandoffBanner(dashUrl, opts = {}) {
|
|
108
|
+
const bdr = '═'.repeat(62);
|
|
109
|
+
const row = (label, val) => {
|
|
110
|
+
const content = ` ${label.padEnd(12)} → ${val}`;
|
|
111
|
+
return c('║', 'cyan') + content.padEnd(64) + c('║', 'cyan');
|
|
112
|
+
};
|
|
113
|
+
const div = c('╠' + '─'.repeat(62) + '╣', 'dim');
|
|
114
|
+
log('');
|
|
115
|
+
log(c(`╔${bdr}╗`, 'cyan'));
|
|
116
|
+
log(c('║', 'cyan') + c(' ⚡ NEXUS PRIME · CONTROL PLANE ONLINE', 'bold').padEnd(75) + c('║', 'cyan'));
|
|
117
|
+
log(div);
|
|
118
|
+
log(row(c('Dashboard', 'yellow'), c(dashUrl, 'cyan')));
|
|
119
|
+
if (opts.isNew) {
|
|
120
|
+
log(row(c('Onboarding', 'yellow'), c(dashUrl + '/welcome', 'cyan')));
|
|
121
|
+
}
|
|
122
|
+
log(div);
|
|
123
|
+
log(row(c('Orchestrate', 'dim'), 'nexus-prime orchestrate "<goal>"'));
|
|
124
|
+
log(row(c('Status', 'dim'), 'nexus-prime status'));
|
|
125
|
+
log(row(c('Hire', 'dim'), 'nexus-prime setup auto (re-run anytime)'));
|
|
126
|
+
log(c(`╚${bdr}╝`, 'cyan'));
|
|
127
|
+
log('');
|
|
128
|
+
}
|
|
129
|
+
/** Print a compact banner for returning users showing last-setup info. */
|
|
130
|
+
export function printReturningUserBanner(marker) {
|
|
131
|
+
const ago = Math.round((Date.now() - marker.setupCompletedAt) / 86_400_000);
|
|
132
|
+
const agoStr = ago < 1 ? 'today' : ago === 1 ? 'yesterday' : `${ago}d ago`;
|
|
133
|
+
log('');
|
|
134
|
+
log(c(' ┌─ EXISTING INSTALLATION ─────────────────────────────────────┐', 'dim'));
|
|
135
|
+
log(c(' │', 'dim') + ` Last setup : ${c(agoStr.padEnd(48, ' '), 'cyan')}` + c('│', 'dim'));
|
|
136
|
+
log(c(' │', 'dim') + ` IDEs : ${c(marker.configuredIDEs.join(', ').substring(0, 48).padEnd(48, ' '), 'cyan')}` + c('│', 'dim'));
|
|
137
|
+
log(c(' └─────────────────────────────────────────────────────────────┘', 'dim'));
|
|
138
|
+
log('');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Animated "syncing" spinner that resolves when the provided promise resolves.
|
|
142
|
+
* Falls back to simple await in non-TTY environments.
|
|
143
|
+
*/
|
|
144
|
+
export async function withSpinner(label, work) {
|
|
145
|
+
if (!tty())
|
|
146
|
+
return work;
|
|
147
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
148
|
+
let i = 0;
|
|
149
|
+
const interval = setInterval(() => {
|
|
150
|
+
process.stdout.write(`\r ${c(frames[i++ % frames.length], 'cyan')} ${label} `);
|
|
151
|
+
}, 80);
|
|
152
|
+
try {
|
|
153
|
+
const result = await work;
|
|
154
|
+
clearInterval(interval);
|
|
155
|
+
process.stdout.write(`\r ${c('✓', 'green')} ${label} \n`);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
clearInterval(interval);
|
|
160
|
+
process.stdout.write(`\r ${c('✗', 'yellow')} ${label} \n`);
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|