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.
@@ -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, if `envelope.data` is already a ToolResult
39
- * (`{ content: Array<...> }`), it is returned directly so we never
40
- * double-wrap handler output. Any other value is JSON-stringified into a
41
- * single text content block.
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 structured JSON error block is produced.
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, if `envelope.data` is already a ToolResult
13
- * (`{ content: Array<...> }`), it is returned directly so we never
14
- * double-wrap handler output. Any other value is JSON-stringified into a
15
- * single text content block.
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 structured JSON error block is produced.
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
- return data;
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
- return {
33
- content: [{
34
- type: 'text',
35
- text: JSON.stringify({
36
- status: 'error',
37
- code: envelope.error.code,
38
- message: envelope.error.message,
39
- retriable: envelope.error.retriable,
40
- hint: envelope.error.hint,
41
- meta: envelope.meta,
42
- }, null, 2),
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
- /** Entry point for CLI: nexus-prime install */
44
- export declare function cliSetup(args: string[]): Promise<void>;
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 { drawCard } from '../utils/ascii-card.js';
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(args) {
345
- const dryRun = args.includes('--dry-run');
346
- const verbose = !args.includes('--silent');
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
- // Step 1 detect IDEs
352
- log('\n◆ Nexus Prime Setup');
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
- log(`${dryRun ? ' Would clean up' : ' Cleaned up'} ${cleanup.staleLockfilesRemoved} stale lockfiles, killed ${cleanup.staleDaemonsKilled} stale daemons`);
355
- log(' Step 1/4 · Detecting IDEs…');
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
- if (result.skipped.length > 0) {
361
- log(` ✓ Already configured: ${result.skipped.join(', ')}`);
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
- if (result.errors.length > 0) {
364
- log(` ${result.errors.length} error(s). Check output above.`);
400
+ else {
401
+ log(` ${c('—', 'dim')} No IDEs auto-detected. Run: nexus-prime setup <ide>`);
365
402
  }
366
- else if (result.configured.length === 0 && result.skipped.length === 0) {
367
- log(' No IDEs auto-detected. Run: nexus-prime setup <ide>');
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 2 — write configs (done by runInstallWizard above, just report)
370
- log('◇ Step 2/4 · MCP configs written.');
371
- // Step 3 — start daemon
372
- log('◇ Step 3/4 · Starting daemon…');
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: 10_000, entrypoint: process.argv[1] });
377
- log(` ✓ Dashboard: ${dashUrl}`);
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 4 — auto-hire starter operative (opt-out via NEXUS_AUTO_HIRE_STARTER=0)
388
- log('◇ Step 4/4 · Hiring your first specialist…');
389
- if (!dryRun && process.env.NEXUS_AUTO_HIRE_STARTER !== '0') {
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
- // Enqueue welcome mission — fire-and-forget (daemon may still be warming up).
406
- try {
407
- await fetch(`${dashUrl}/api/orchestrate`, {
408
- method: 'POST',
409
- headers: { 'Content-Type': 'application/json' },
410
- body: JSON.stringify({
411
- goal: 'Introduce yourself to this codebase — read the README and top 3 source files, then store what you learn as memories.',
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 not ready yet — open dashboard to hire)`);
477
+ log(` ${c('', 'dim')} Hire skipped (daemon warming up) — open dashboard to hire`);
420
478
  }
421
479
  }
422
480
  catch {
423
- log(` — Hire skipped (open ${dashUrl}/#workforce to hire manually)`);
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 with $1 budget`);
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
- // Print next-actions card
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
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CLI orchestrate command — streams live SSE events as phase spinners.
3
+ * Usage: nexus-prime orchestrate "<goal>"
4
+ */
5
+ export declare function runOrchestrateStream(goal: string, opts?: {
6
+ port?: number;
7
+ timeoutMs?: number;
8
+ }): Promise<void>;