voyageai-cli 1.26.1 → 1.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.26.1",
3
+ "version": "1.28.0",
4
4
  "description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
5
5
  "bin": {
6
6
  "vai": "./src/cli.js"
@@ -67,6 +67,39 @@ function registerConfig(program) {
67
67
  const storedValue = key === 'default-dimensions' ? parseInt(value, 10) : value;
68
68
  setConfigValue(internalKey, storedValue);
69
69
  console.log(ui.success(`Set ${ui.cyan(key)} = ${SECRET_KEYS.has(internalKey) ? ui.dim(maskSecret(String(storedValue))) : storedValue}`));
70
+
71
+ // When setting an API key, auto-configure the matching base URL
72
+ if (internalKey === 'apiKey') {
73
+ const { identifyKey, ATLAS_API_BASE, VOYAGE_API_BASE } = require('../lib/api');
74
+ const keyInfo = identifyKey(storedValue);
75
+ const currentBase = getConfigValue('baseUrl');
76
+
77
+ if (keyInfo.type === 'atlas') {
78
+ if (currentBase !== ATLAS_API_BASE) {
79
+ setConfigValue('baseUrl', ATLAS_API_BASE);
80
+ console.log('');
81
+ console.log(ui.success(`Auto-configured base URL → ${ui.cyan(ATLAS_API_BASE)}`));
82
+ }
83
+ console.log('');
84
+ console.log(` 🔑 ${ui.bold('MongoDB Atlas key detected')} (al-*)`);
85
+ console.log(` Endpoint: ${ui.cyan(ATLAS_API_BASE)}`);
86
+ console.log(` Atlas provides Voyage AI models with Atlas-native billing.`);
87
+ console.log(` All vai features work identically on both endpoints.`);
88
+ } else if (keyInfo.type === 'voyage') {
89
+ if (currentBase !== VOYAGE_API_BASE) {
90
+ setConfigValue('baseUrl', VOYAGE_API_BASE);
91
+ console.log('');
92
+ console.log(ui.success(`Auto-configured base URL → ${ui.cyan(VOYAGE_API_BASE)}`));
93
+ }
94
+ console.log('');
95
+ console.log(` 🔑 ${ui.bold('Voyage AI key detected')} (pa-*)`);
96
+ console.log(` Endpoint: ${ui.cyan(VOYAGE_API_BASE)}`);
97
+ } else {
98
+ console.log('');
99
+ console.log(` ⚠️ Unrecognized key prefix. Expected ${ui.cyan('al-*')} (Atlas) or ${ui.cyan('pa-*')} (Voyage AI).`);
100
+ console.log(` Make sure your base URL matches: ${ui.cyan('vai config set base-url <url>')}`);
101
+ }
102
+ }
70
103
  });
71
104
 
72
105
  // ── config get [key] ──
@@ -4,7 +4,7 @@ const os = require('os');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const pc = require('picocolors');
7
- const { getConfigValue } = require('../lib/config');
7
+ const { getConfigValue, setConfigValue, CONFIG_DIR, CONFIG_PATH } = require('../lib/config');
8
8
  const { getApiBase } = require('../lib/api');
9
9
 
10
10
  /**
@@ -240,15 +240,83 @@ async function checkConfig() {
240
240
  };
241
241
  }
242
242
 
243
+ // ── Fix functions (for --fix mode) ──
244
+
245
+ async function fixApiKey() {
246
+ const readline = require('readline');
247
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
248
+
249
+ return new Promise((resolve) => {
250
+ console.log(pc.cyan('\n Fixing: Voyage AI API Key'));
251
+ console.log(pc.dim(' Get a key at: https://dash.voyageai.com/api-keys\n'));
252
+
253
+ rl.question(' Enter your Voyage AI API key: ', (key) => {
254
+ rl.close();
255
+ const trimmed = (key || '').trim();
256
+ if (!trimmed) {
257
+ console.log(pc.yellow(' Skipped (no key entered)'));
258
+ resolve(false);
259
+ return;
260
+ }
261
+ try {
262
+ setConfigValue('apiKey', trimmed);
263
+ // Also set in env for the current session so the connection check passes
264
+ process.env.VOYAGE_API_KEY = trimmed;
265
+ console.log(pc.green(' ✓ API key saved to ~/.vai/config.json'));
266
+ resolve(true);
267
+ } catch (err) {
268
+ console.log(pc.red(` ✗ Failed to save: ${err.message}`));
269
+ resolve(false);
270
+ }
271
+ });
272
+ });
273
+ }
274
+
275
+ function fixConfigPermissions() {
276
+ try {
277
+ fs.chmodSync(CONFIG_PATH, 0o600);
278
+ console.log(pc.green(' ✓ Fixed ~/.vai/config.json permissions to 600'));
279
+ return true;
280
+ } catch (err) {
281
+ console.log(pc.red(` ✗ Failed to fix permissions: ${err.message}`));
282
+ return false;
283
+ }
284
+ }
285
+
286
+ function fixConfigDir() {
287
+ try {
288
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
289
+ console.log(pc.green(' ✓ Created ~/.vai/ directory'));
290
+ return true;
291
+ } catch (err) {
292
+ console.log(pc.red(` ✗ Failed to create directory: ${err.message}`));
293
+ return false;
294
+ }
295
+ }
296
+
297
+ async function fixPdfParse() {
298
+ const { execSync } = require('child_process');
299
+ console.log(pc.cyan('\n Installing pdf-parse...'));
300
+ try {
301
+ execSync('npm install pdf-parse', { stdio: 'pipe' });
302
+ console.log(pc.green(' ✓ pdf-parse installed'));
303
+ return true;
304
+ } catch (err) {
305
+ console.log(pc.red(` ✗ Failed to install: ${err.message}`));
306
+ return false;
307
+ }
308
+ }
309
+
243
310
  async function runDoctor(options = {}) {
244
- const { json, verbose } = options;
245
-
311
+ const { json, verbose, fix } = options;
312
+
246
313
  console.log(pc.bold('\n🩺 Voyage AI CLI Health Check\n'));
247
-
314
+
248
315
  const results = {};
249
316
  let hasError = false;
250
317
  let hasWarning = false;
251
-
318
+ const fixable = [];
319
+
252
320
  // Run all checks
253
321
  const checks = [
254
322
  { key: 'node', fn: checkNodeVersion },
@@ -258,12 +326,12 @@ async function runDoctor(options = {}) {
258
326
  { key: 'pdfParse', fn: checkPdfParse },
259
327
  { key: 'config', fn: checkConfig },
260
328
  ];
261
-
329
+
262
330
  for (const { key, fn } of checks) {
263
331
  const check = CHECKS[key];
264
332
  const result = await fn();
265
333
  results[key] = result;
266
-
334
+
267
335
  // Determine status icon
268
336
  let icon;
269
337
  if (result.ok === true) {
@@ -276,15 +344,23 @@ async function runDoctor(options = {}) {
276
344
  icon = warnMark();
277
345
  hasWarning = true;
278
346
  }
279
-
347
+
280
348
  // Print result
281
349
  console.log(` ${icon} ${pc.bold(check.name)}: ${result.message}`);
282
-
350
+
283
351
  if (result.hint && (verbose || result.ok === false)) {
284
352
  console.log(` ${pc.dim(result.hint)}`);
285
353
  }
354
+
355
+ // Track fixable issues
356
+ if (result.ok !== true) {
357
+ if (key === 'apiKey' && result.ok === false) fixable.push('apiKey');
358
+ if (key === 'pdfParse' && result.ok === null) fixable.push('pdfParse');
359
+ if (key === 'config' && result.message && result.message.includes('permissions')) fixable.push('configPerms');
360
+ if (key === 'config' && result.ok === null) fixable.push('configDir');
361
+ }
286
362
  }
287
-
363
+
288
364
  // Summary
289
365
  console.log('');
290
366
  if (hasError) {
@@ -294,7 +370,43 @@ async function runDoctor(options = {}) {
294
370
  } else {
295
371
  console.log(pc.green(' ✓ All checks passed. vai is ready to use!\n'));
296
372
  }
297
-
373
+
374
+ // --fix mode: attempt automatic repairs
375
+ if (fix && fixable.length > 0) {
376
+ console.log(pc.bold(' 🔧 Attempting fixes...\n'));
377
+ let fixed = 0;
378
+
379
+ for (const item of fixable) {
380
+ if (item === 'configDir') {
381
+ if (fixConfigDir()) fixed++;
382
+ }
383
+ if (item === 'apiKey') {
384
+ if (await fixApiKey()) fixed++;
385
+ }
386
+ if (item === 'configPerms') {
387
+ if (fixConfigPermissions()) fixed++;
388
+ }
389
+ if (item === 'pdfParse') {
390
+ if (await fixPdfParse()) fixed++;
391
+ }
392
+ }
393
+
394
+ console.log('');
395
+ if (fixed > 0) {
396
+ console.log(pc.green(` ✓ Fixed ${fixed} issue${fixed === 1 ? '' : 's'}. Run ${pc.bold('vai doctor')} again to verify.\n`));
397
+ } else {
398
+ console.log(pc.yellow(' No issues were fixed. See hints above for manual steps.\n'));
399
+ }
400
+ return 0;
401
+ } else if (fix && fixable.length === 0 && (hasError || hasWarning)) {
402
+ console.log(pc.dim(' No auto-fixable issues found. See hints above for manual steps.\n'));
403
+ }
404
+
405
+ // Suggest --fix if there are fixable issues and not in fix mode
406
+ if (!fix && fixable.length > 0) {
407
+ console.log(pc.dim(` Tip: run ${pc.bold('vai doctor --fix')} to attempt automatic repairs\n`));
408
+ }
409
+
298
410
  // Suggest next steps
299
411
  if (!hasError) {
300
412
  console.log(pc.dim(' Next steps:'));
@@ -302,11 +414,11 @@ async function runDoctor(options = {}) {
302
414
  console.log(pc.dim(' vai quickstart — Zero-to-search tutorial'));
303
415
  console.log(pc.dim(' vai explain — Learn key concepts\n'));
304
416
  }
305
-
417
+
306
418
  if (json) {
307
419
  console.log(JSON.stringify(results, null, 2));
308
420
  }
309
-
421
+
310
422
  return hasError ? 1 : 0;
311
423
  }
312
424
 
@@ -316,10 +428,41 @@ function register(program) {
316
428
  .description('Run health checks on your vai setup')
317
429
  .option('--json', 'Output results as JSON')
318
430
  .option('-v, --verbose', 'Show hints for all checks')
431
+ .option('-f, --fix', 'Attempt to fix issues automatically')
319
432
  .action(async (options) => {
320
433
  const exitCode = await runDoctor(options);
321
434
  if (exitCode !== 0) process.exit(exitCode);
322
435
  });
323
436
  }
324
437
 
325
- module.exports = { register, runDoctor };
438
+ /**
439
+ * Run all checks programmatically and return structured results.
440
+ * Used by the playground /api/doctor endpoint.
441
+ */
442
+ async function runChecks() {
443
+ const checks = [
444
+ { key: 'node', fn: checkNodeVersion },
445
+ { key: 'apiKey', fn: checkApiKey },
446
+ { key: 'apiConnection', fn: checkApiConnection },
447
+ { key: 'mongodb', fn: checkMongoDB },
448
+ { key: 'pdfParse', fn: checkPdfParse },
449
+ { key: 'config', fn: checkConfig },
450
+ ];
451
+
452
+ const results = {};
453
+ for (const { key, fn } of checks) {
454
+ const result = await fn();
455
+ // Strip picocolors ANSI codes from messages for JSON output
456
+ const clean = (s) => typeof s === 'string' ? s.replace(/\x1b\[[0-9;]*m/g, '') : s;
457
+ results[key] = {
458
+ name: CHECKS[key].name,
459
+ required: CHECKS[key].required,
460
+ ok: result.ok,
461
+ message: clean(result.message),
462
+ hint: result.hint ? clean(result.hint) : null,
463
+ };
464
+ }
465
+ return results;
466
+ }
467
+
468
+ module.exports = { register, runDoctor, runChecks };
@@ -14,6 +14,7 @@ function registerMcpServer(program) {
14
14
  .option('--host <address>', 'Bind address (http transport only)', '127.0.0.1')
15
15
  .option('--db <name>', 'Default MongoDB database for tools')
16
16
  .option('--collection <name>', 'Default collection for tools')
17
+ .option('--no-sse', 'Disable SSE transport (SSE is enabled by default for HTTP)')
17
18
  .option('--verbose', 'Enable debug logging to stderr')
18
19
  .action(async (opts) => {
19
20
  if (opts.verbose) {
@@ -27,7 +28,9 @@ function registerMcpServer(program) {
27
28
  const { runStdioServer, runHttpServer } = require('../mcp/server');
28
29
 
29
30
  if (opts.transport === 'http') {
30
- await runHttpServer({ port: opts.port, host: opts.host });
31
+ // SSE is enabled by default for HTTP transport (required for n8n, etc.)
32
+ // Use --no-sse to disable if needed
33
+ await runHttpServer({ port: opts.port, host: opts.host, sse: opts.sse !== false });
31
34
  } else if (opts.transport === 'stdio') {
32
35
  await runStdioServer();
33
36
  } else {
@@ -14,13 +14,16 @@ function registerPlayground(program) {
14
14
  .command('playground')
15
15
  .description('Launch interactive web playground for Voyage AI')
16
16
  .option('-p, --port <port>', 'Port to serve on', '3333')
17
+ .option('--host <address>', 'Bind address', '127.0.0.1')
17
18
  .option('--no-open', 'Skip auto-opening browser')
18
19
  .action(async (opts) => {
19
20
  const port = parseInt(opts.port, 10) || 3333;
21
+ const host = opts.host || '127.0.0.1';
20
22
  const server = createPlaygroundServer();
21
23
 
22
- server.listen(port, () => {
23
- const url = `http://localhost:${port}`;
24
+ server.listen(port, host, () => {
25
+ const displayHost = host === '0.0.0.0' ? 'localhost' : host;
26
+ const url = `http://${displayHost}:${port}`;
24
27
  console.log(`🧭 Playground running at ${url} — Press Ctrl+C to stop`);
25
28
 
26
29
  if (opts.open !== false) {
@@ -309,6 +312,20 @@ function createPlaygroundServer() {
309
312
  return;
310
313
  }
311
314
 
315
+ // API: Doctor health checks
316
+ if (req.method === 'GET' && req.url === '/api/doctor') {
317
+ try {
318
+ const { runChecks } = require('./doctor');
319
+ const results = await runChecks();
320
+ res.writeHead(200, { 'Content-Type': 'application/json' });
321
+ res.end(JSON.stringify(results));
322
+ } catch (err) {
323
+ res.writeHead(500, { 'Content-Type': 'application/json' });
324
+ res.end(JSON.stringify({ error: err.message }));
325
+ }
326
+ return;
327
+ }
328
+
312
329
  // API: Settings origins — where each config value comes from
313
330
  if (req.method === 'GET' && req.url === '/api/settings/origins') {
314
331
  const { resolveLLMConfig } = require('../lib/llm');
@@ -338,6 +355,54 @@ function createPlaygroundServer() {
338
355
  return;
339
356
  }
340
357
 
358
+ // API: List built-in workflows
359
+ if (req.method === 'GET' && req.url === '/api/workflows') {
360
+ try {
361
+ const { listBuiltinWorkflows } = require('../lib/workflow');
362
+ const workflows = listBuiltinWorkflows();
363
+ res.writeHead(200, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify({ workflows }));
365
+ } catch (err) {
366
+ res.writeHead(500, { 'Content-Type': 'application/json' });
367
+ res.end(JSON.stringify({ error: err.message }));
368
+ }
369
+ return;
370
+ }
371
+
372
+ // API: List example workflows (must be before the :name route)
373
+ if (req.method === 'GET' && req.url === '/api/workflows/examples') {
374
+ try {
375
+ const { listExampleWorkflows } = require('../lib/workflow');
376
+ const examples = listExampleWorkflows();
377
+ res.writeHead(200, { 'Content-Type': 'application/json' });
378
+ res.end(JSON.stringify({ examples }));
379
+ } catch (err) {
380
+ res.writeHead(500, { 'Content-Type': 'application/json' });
381
+ res.end(JSON.stringify({ error: err.message }));
382
+ }
383
+ return;
384
+ }
385
+
386
+ // API: Get a specific workflow by name
387
+ if (req.method === 'GET' && req.url?.startsWith('/api/workflows/')) {
388
+ const name = decodeURIComponent(req.url.replace('/api/workflows/', ''));
389
+ if (!name) {
390
+ res.writeHead(400, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify({ error: 'Workflow name is required' }));
392
+ return;
393
+ }
394
+ try {
395
+ const { loadWorkflow } = require('../lib/workflow');
396
+ const definition = loadWorkflow(name);
397
+ res.writeHead(200, { 'Content-Type': 'application/json' });
398
+ res.end(JSON.stringify({ definition }));
399
+ } catch (err) {
400
+ res.writeHead(404, { 'Content-Type': 'application/json' });
401
+ res.end(JSON.stringify({ error: err.message }));
402
+ }
403
+ return;
404
+ }
405
+
341
406
  // API: Save chat config (POST) — persists to .vai.json
342
407
  // Placed before generic POST handler so it doesn't require Voyage API key
343
408
  if (req.method === 'POST' && req.url === '/api/chat/config') {
@@ -459,8 +524,37 @@ function createPlaygroundServer() {
459
524
  opts: { systemPrompt, db: db || undefined, collection: collection || undefined },
460
525
  })) {
461
526
  if (event.type === 'tool_call') {
462
- const { name, args, timeMs, error } = event.data;
463
- res.write(`event: tool_call\ndata: ${JSON.stringify({ name, args, timeMs, error })}\n\n`);
527
+ const { name, args, timeMs, error, result } = event.data;
528
+ // Build a short human-readable summary of the tool result
529
+ let resultSummary = '';
530
+ if (!error && result) {
531
+ if (name === 'vai_query' || name === 'vai_search') {
532
+ const docs = result.results || result.documents || [];
533
+ resultSummary = `Found ${docs.length} result${docs.length !== 1 ? 's' : ''}`;
534
+ } else if (name === 'vai_collections') {
535
+ const colls = result.collections || [];
536
+ resultSummary = colls.length > 0
537
+ ? colls.map(c => `<code>${c.name || c}</code>`).slice(0, 5).join(', ')
538
+ : 'No collections found';
539
+ } else if (name === 'vai_models') {
540
+ const models = result.models || [];
541
+ resultSummary = `${models.length} model${models.length !== 1 ? 's' : ''} available`;
542
+ } else if (name === 'vai_embed') {
543
+ const dims = result.dimensions || result.embedding?.length || '?';
544
+ resultSummary = `${dims}-dim vector`;
545
+ } else if (name === 'vai_similarity') {
546
+ const score = result.similarity ?? result.score;
547
+ resultSummary = score !== undefined ? `Score: ${Number(score).toFixed(4)}` : '';
548
+ } else if (name === 'vai_rerank') {
549
+ const items = result.results || [];
550
+ resultSummary = `Reranked ${items.length} result${items.length !== 1 ? 's' : ''}`;
551
+ } else if (name === 'vai_estimate') {
552
+ resultSummary = result.recommendation || '';
553
+ } else if (name === 'vai_explain' || name === 'vai_topics') {
554
+ resultSummary = result.title || result.topic || '';
555
+ }
556
+ }
557
+ res.write(`event: tool_call\ndata: ${JSON.stringify({ name, args, timeMs, error, resultSummary })}\n\n`);
464
558
  } else if (event.type === 'chunk') {
465
559
  res.write(`event: chunk\ndata: ${JSON.stringify({ text: event.data })}\n\n`);
466
560
  } else if (event.type === 'done') {
@@ -645,6 +739,120 @@ function createPlaygroundServer() {
645
739
  }));
646
740
  return;
647
741
  }
742
+
743
+ // API: Validate a workflow definition
744
+ if (req.url === '/api/workflows/validate') {
745
+ const { validateWorkflow } = require('../lib/workflow');
746
+ const { definition } = parsed;
747
+ if (!definition) {
748
+ res.writeHead(400, { 'Content-Type': 'application/json' });
749
+ res.end(JSON.stringify({ error: 'definition is required' }));
750
+ return;
751
+ }
752
+ const errors = validateWorkflow(definition);
753
+ res.writeHead(200, { 'Content-Type': 'application/json' });
754
+ res.end(JSON.stringify({ valid: errors.length === 0, errors }));
755
+ return;
756
+ }
757
+
758
+ // API: Get execution plan (layers + dependency graph)
759
+ if (req.url === '/api/workflows/plan') {
760
+ const { buildExecutionPlan, buildDependencyGraph, validateWorkflow } = require('../lib/workflow');
761
+ const { definition } = parsed;
762
+ if (!definition || !definition.steps) {
763
+ res.writeHead(400, { 'Content-Type': 'application/json' });
764
+ res.end(JSON.stringify({ error: 'definition with steps is required' }));
765
+ return;
766
+ }
767
+ const errors = validateWorkflow(definition);
768
+ if (errors.length > 0) {
769
+ res.writeHead(400, { 'Content-Type': 'application/json' });
770
+ res.end(JSON.stringify({ error: 'Invalid workflow', errors }));
771
+ return;
772
+ }
773
+ const layers = buildExecutionPlan(definition.steps);
774
+ const graphMap = buildDependencyGraph(definition.steps);
775
+ // Convert Map<string, Set<string>> to plain object for JSON serialization
776
+ const graph = {};
777
+ for (const [stepId, deps] of graphMap) {
778
+ graph[stepId] = Array.from(deps);
779
+ }
780
+ res.writeHead(200, { 'Content-Type': 'application/json' });
781
+ res.end(JSON.stringify({ layers, graph }));
782
+ return;
783
+ }
784
+
785
+ // API: Execute a workflow (streaming SSE)
786
+ if (req.url === '/api/workflows/execute') {
787
+ const { executeWorkflow, validateWorkflow: validateWf } = require('../lib/workflow');
788
+ const { definition, inputs } = parsed;
789
+ if (!definition) {
790
+ res.writeHead(400, { 'Content-Type': 'application/json' });
791
+ res.end(JSON.stringify({ error: 'definition is required' }));
792
+ return;
793
+ }
794
+ const errors = validateWf(definition);
795
+ if (errors.length > 0) {
796
+ res.writeHead(400, { 'Content-Type': 'application/json' });
797
+ res.end(JSON.stringify({ error: 'Invalid workflow', errors }));
798
+ return;
799
+ }
800
+
801
+ // Stream execution events as SSE
802
+ res.writeHead(200, {
803
+ 'Content-Type': 'text/event-stream',
804
+ 'Cache-Control': 'no-cache',
805
+ 'Connection': 'keep-alive',
806
+ });
807
+
808
+ try {
809
+ const result = await executeWorkflow(definition, {
810
+ inputs: inputs || {},
811
+ onStepStart: (stepId, stepDef) => {
812
+ res.write(`event: step_start\ndata: ${JSON.stringify({
813
+ stepId,
814
+ name: stepDef.name || stepId,
815
+ tool: stepDef.tool,
816
+ })}\n\n`);
817
+ },
818
+ onStepComplete: (stepId, output, timeMs) => {
819
+ // Summarize output to avoid huge payloads
820
+ let summary = '';
821
+ if (output && typeof output === 'object') {
822
+ if (output.results) summary = `${output.results.length} results`;
823
+ else if (output.similarity !== undefined) summary = `similarity: ${Number(output.similarity).toFixed(4)}`;
824
+ else if (output.text) summary = output.text.slice(0, 100) + (output.text.length > 100 ? '...' : '');
825
+ else summary = JSON.stringify(output).slice(0, 200);
826
+ }
827
+ res.write(`event: step_complete\ndata: ${JSON.stringify({
828
+ stepId, timeMs, summary,
829
+ output: JSON.stringify(output).length < 5000 ? output : { _truncated: true, summary },
830
+ })}\n\n`);
831
+ },
832
+ onStepSkip: (stepId, reason) => {
833
+ res.write(`event: step_skip\ndata: ${JSON.stringify({ stepId, reason })}\n\n`);
834
+ },
835
+ onStepError: (stepId, error) => {
836
+ res.write(`event: step_error\ndata: ${JSON.stringify({
837
+ stepId,
838
+ error: error.message || String(error),
839
+ })}\n\n`);
840
+ },
841
+ });
842
+
843
+ res.write(`event: done\ndata: ${JSON.stringify({
844
+ output: result.output,
845
+ totalTimeMs: result.totalTimeMs,
846
+ layers: result.layers,
847
+ steps: result.steps,
848
+ })}\n\n`);
849
+ } catch (err) {
850
+ res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
851
+ }
852
+
853
+ res.end();
854
+ return;
855
+ }
648
856
  }
649
857
 
650
858
  // 404
@@ -22,6 +22,45 @@ function collectInputs(pair, prev) {
22
22
  return prev;
23
23
  }
24
24
 
25
+ /**
26
+ * Interactively prompt the user for missing workflow inputs using @clack/prompts.
27
+ * Only prompts for inputs not already provided via --input flags.
28
+ *
29
+ * @param {object} definition - Workflow definition
30
+ * @param {object} existingInputs - Inputs already provided via --input
31
+ * @returns {Promise<object>} Merged inputs (existing + prompted)
32
+ */
33
+ async function promptForInputs(definition, existingInputs) {
34
+ const { buildInputSteps } = require('../lib/workflow');
35
+ const { createCLIRenderer } = require('../lib/wizard-cli');
36
+ const { runWizard } = require('../lib/wizard');
37
+
38
+ const allSteps = buildInputSteps(definition);
39
+ // Only prompt for inputs not already provided
40
+ const steps = allSteps.filter(s => !(s.id in existingInputs));
41
+ if (steps.length === 0) return existingInputs;
42
+
43
+ const renderer = createCLIRenderer({
44
+ title: `${definition.name || 'Workflow'} inputs`,
45
+ doneMessage: 'Inputs ready.',
46
+ showBackHint: true,
47
+ });
48
+
49
+ const { answers, cancelled } = await runWizard({
50
+ steps,
51
+ config: {},
52
+ renderer,
53
+ initial: {},
54
+ });
55
+
56
+ if (cancelled) {
57
+ console.log(pc.dim('Cancelled.'));
58
+ process.exit(0);
59
+ }
60
+
61
+ return { ...existingInputs, ...answers };
62
+ }
63
+
25
64
  /**
26
65
  * Register the workflow command on a Commander program.
27
66
  * @param {import('commander').Command} program
@@ -43,6 +82,7 @@ function registerWorkflow(program) {
43
82
  .option('--quiet', 'Suppress progress output', false)
44
83
  .option('--dry-run', 'Show execution plan without running', false)
45
84
  .option('--verbose', 'Show step details', false)
85
+ .option('--no-interactive', 'Disable interactive input prompting')
46
86
  .action(async (file, opts) => {
47
87
  const { loadWorkflow, executeWorkflow, buildExecutionPlan, validateWorkflow } = require('../lib/workflow');
48
88
 
@@ -64,6 +104,11 @@ function registerWorkflow(program) {
64
104
 
65
105
  const workflowName = definition.name || file;
66
106
 
107
+ // Interactive prompting for missing inputs
108
+ if (opts.interactive !== false && process.stdin.isTTY) {
109
+ opts.input = await promptForInputs(definition, opts.input);
110
+ }
111
+
67
112
  if (opts.dryRun) {
68
113
  // Dry run: show plan
69
114
  const layers = buildExecutionPlan(definition.steps);