voyageai-cli 1.27.0 → 1.29.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.
@@ -5,6 +5,79 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { exec } = require('child_process');
7
7
 
8
+ /**
9
+ * Load announcements from the markdown file.
10
+ * Format: Each announcement is separated by `---` and has YAML-like metadata
11
+ * followed by a ## title and description paragraph.
12
+ */
13
+ function loadAnnouncementsFromMarkdown() {
14
+ const mdPath = path.join(__dirname, '..', 'playground', 'announcements.md');
15
+
16
+ if (!fs.existsSync(mdPath)) {
17
+ console.warn('Announcements file not found:', mdPath);
18
+ return [];
19
+ }
20
+
21
+ const content = fs.readFileSync(mdPath, 'utf-8');
22
+
23
+ // Split by --- separator (skip the header section before first ---)
24
+ const sections = content.split(/\n---\n/).slice(1);
25
+
26
+ const announcements = [];
27
+
28
+ for (const section of sections) {
29
+ const lines = section.trim().split('\n');
30
+ const metadata = {};
31
+ let titleIndex = -1;
32
+
33
+ // Parse metadata lines (key: value format)
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i].trim();
36
+
37
+ // Stop at the title (## heading)
38
+ if (line.startsWith('## ')) {
39
+ titleIndex = i;
40
+ break;
41
+ }
42
+
43
+ // Parse key: value
44
+ const match = line.match(/^([a-z_]+):\s*(.+)$/i);
45
+ if (match) {
46
+ metadata[match[1]] = match[2].trim();
47
+ }
48
+ }
49
+
50
+ if (titleIndex === -1 || !metadata.id) continue;
51
+
52
+ // Extract title (## heading)
53
+ const title = lines[titleIndex].replace(/^##\s*/, '').trim();
54
+
55
+ // Extract description (paragraphs after the title)
56
+ const descriptionLines = [];
57
+ for (let i = titleIndex + 1; i < lines.length; i++) {
58
+ const line = lines[i].trim();
59
+ if (line) descriptionLines.push(line);
60
+ }
61
+ const description = descriptionLines.join(' ');
62
+
63
+ announcements.push({
64
+ id: metadata.id,
65
+ title,
66
+ description,
67
+ badge: metadata.badge || 'Info',
68
+ published: metadata.published || new Date().toISOString().split('T')[0],
69
+ expires: metadata.expires || '2099-12-31',
70
+ cta: {
71
+ label: metadata.cta_label || 'Learn More',
72
+ action: metadata.cta_action || 'link',
73
+ target: metadata.cta_target || '#'
74
+ }
75
+ });
76
+ }
77
+
78
+ return announcements;
79
+ }
80
+
8
81
  /**
9
82
  * Register the playground command on a Commander program.
10
83
  * @param {import('commander').Command} program
@@ -14,13 +87,16 @@ function registerPlayground(program) {
14
87
  .command('playground')
15
88
  .description('Launch interactive web playground for Voyage AI')
16
89
  .option('-p, --port <port>', 'Port to serve on', '3333')
90
+ .option('--host <address>', 'Bind address', '127.0.0.1')
17
91
  .option('--no-open', 'Skip auto-opening browser')
18
92
  .action(async (opts) => {
19
93
  const port = parseInt(opts.port, 10) || 3333;
94
+ const host = opts.host || '127.0.0.1';
20
95
  const server = createPlaygroundServer();
21
96
 
22
- server.listen(port, () => {
23
- const url = `http://localhost:${port}`;
97
+ server.listen(port, host, () => {
98
+ const displayHost = host === '0.0.0.0' ? 'localhost' : host;
99
+ const url = `http://${displayHost}:${port}`;
24
100
  console.log(`🧭 Playground running at ${url} — Press Ctrl+C to stop`);
25
101
 
26
102
  if (opts.open !== false) {
@@ -64,6 +140,10 @@ function createPlaygroundServer() {
64
140
  // Chat history — scoped to the server lifetime (in-memory, no persistence)
65
141
  let _chatHistory = null;
66
142
 
143
+ // Workflow store catalog cache (15 min TTL)
144
+ let _catalogCache = null;
145
+ let _catalogCacheTime = 0;
146
+
67
147
  const server = http.createServer(async (req, res) => {
68
148
  // CORS headers for local dev
69
149
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -102,6 +182,23 @@ function createPlaygroundServer() {
102
182
  return;
103
183
  }
104
184
 
185
+ // Serve V.png logo
186
+ if (req.method === 'GET' && req.url === '/icons/V.png') {
187
+ const logoPath = path.join(__dirname, '..', 'playground', 'icons', 'V.png');
188
+ if (fs.existsSync(logoPath)) {
189
+ const data = fs.readFileSync(logoPath);
190
+ res.writeHead(200, {
191
+ 'Content-Type': 'image/png',
192
+ 'Cache-Control': 'public, max-age=86400',
193
+ });
194
+ res.end(data);
195
+ } else {
196
+ res.writeHead(404);
197
+ res.end('Logo not found');
198
+ }
199
+ return;
200
+ }
201
+
105
202
  // Serve icon assets: /icons/{dark|light}/{size}.png
106
203
  const iconMatch = req.url.match(/^\/icons\/(dark|light)\/(\d+)\.png$/);
107
204
  if (req.method === 'GET' && iconMatch) {
@@ -309,6 +406,14 @@ function createPlaygroundServer() {
309
406
  return;
310
407
  }
311
408
 
409
+ // API: Version — return CLI package version
410
+ if (req.method === 'GET' && req.url === '/api/version') {
411
+ const pkg = require('../../package.json');
412
+ res.writeHead(200, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ version: pkg.version }));
414
+ return;
415
+ }
416
+
312
417
  // API: Doctor health checks
313
418
  if (req.method === 'GET' && req.url === '/api/doctor') {
314
419
  try {
@@ -352,13 +457,310 @@ function createPlaygroundServer() {
352
457
  return;
353
458
  }
354
459
 
460
+ // API: Workflow Store catalog (cached 15 min)
461
+ if (req.method === 'GET' && req.url === '/api/workflows/catalog') {
462
+ const _catStart = Date.now();
463
+ try {
464
+ // Check cache
465
+ if (_catalogCache && (Date.now() - _catalogCacheTime < 15 * 60 * 1000)) {
466
+ console.log(`[catalog] served from cache in ${Date.now() - _catStart}ms`);
467
+ res.writeHead(200, { 'Content-Type': 'application/json' });
468
+ res.end(JSON.stringify(_catalogCache));
469
+ return;
470
+ }
471
+
472
+ const { getRegistry } = require('../lib/workflow-registry');
473
+ const registry = getRegistry({ force: true });
474
+
475
+ // Build set of installed package names
476
+ const installedNames = new Set();
477
+ for (const c of [...(registry.official || []), ...(registry.community || [])]) {
478
+ if (c.name) installedNames.add(c.name);
479
+ }
480
+
481
+ // Try to fetch from npm
482
+ let npmWorkflows = [];
483
+ try {
484
+ const { searchNpm } = require('../lib/npm-utils');
485
+ const results = await searchNpm('', { limit: 50 });
486
+ npmWorkflows = results || [];
487
+ } catch (e) {
488
+ // npm unreachable — fall back to installed only
489
+ }
490
+
491
+ // Fetch registry metadata (for vai-workflow field, inputs, author)
492
+ // Only one request per package — no unpkg or downloads API on the critical path
493
+ const metadataCache = {};
494
+ await Promise.all(npmWorkflows.map(async (r) => {
495
+ try {
496
+ const encodedName = r.name.startsWith('@')
497
+ ? `@${encodeURIComponent(r.name.slice(1))}`
498
+ : encodeURIComponent(r.name);
499
+ const regRes = await fetch(`https://registry.npmjs.org/${encodedName}/latest`, {
500
+ headers: { 'Accept': 'application/json' },
501
+ signal: AbortSignal.timeout(5000),
502
+ });
503
+ if (regRes.ok) {
504
+ metadataCache[r.name] = await regRes.json();
505
+ }
506
+ } catch {
507
+ // Fall back to basic data from search
508
+ }
509
+ }));
510
+
511
+ // ── Lucide icon paths for workflow branding ──
512
+ // Curated subset of Lucide icons (lucide.dev, MIT) for the store.
513
+ // Each value is an SVG path (or multiple paths separated by a convention
514
+ // that the client renders inside a 24x24 viewBox with stroke).
515
+ const STORE_ICONS = {
516
+ trophy: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z',
517
+ search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
518
+ 'dollar-sign':'M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6',
519
+ split: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
520
+ 'file-search': 'M14 2v4a2 2 0 0 0 2 2h4M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM9.5 12.5a2.5 2.5 0 1 0 5 0 2.5 2.5 0 1 0-5 0M13.3 14.3 15 16',
521
+ database: 'M21 5c0 1.1-3.134 3-9 3S3 6.1 3 5M21 5c0-1.1-3.134-3-9-3S3 3.9 3 5M21 5v14c0 1.1-3.134 3-9 3s-9-1.9-9-3V5M21 12c0 1.1-3.134 3-9 3s-9-1.9-9-3',
522
+ activity: 'M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36-3.18-19.64A2 2 0 0 0 10.12 1h-.24a2 2 0 0 0-1.94 1.55L5.18 12H2',
523
+ globe: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z',
524
+ 'shield-alert':'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .5-.87l7-4a1 1 0 0 1 1 0l7 4A1 1 0 0 1 20 6zM12 8v4M12 16h.01',
525
+ timer: 'M10 2h4M12 14l3-3M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
526
+ 'refresh-cw': 'M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16',
527
+ 'flask-conical':'M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2M8.5 2h7M7 16h10',
528
+ target: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0',
529
+ code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
530
+ 'clipboard-list':'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM12 11h4M12 16h4M8 11h.01M8 16h.01',
531
+ layers: 'M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84zM2 12l8.58 3.91a2 2 0 0 0 1.66 0L22 12M2 17l8.58 3.91a2 2 0 0 0 1.66 0L22 17',
532
+ 'bar-chart-3': 'M12 20V10M18 20V4M6 20v-4',
533
+ 'heart-pulse': 'M19.5 12.572l-7.5 7.428-7.5-7.428A5 5 0 0 1 7.5 5c1.8 0 3.3.9 4.5 2.7C13.2 5.9 14.7 5 16.5 5a5 5 0 0 1 3 9.572zM12 6l-1 4h4l-1 4',
534
+ brain: 'M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2zM14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z',
535
+ 'check-circle':'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3',
536
+ zap: 'M13 2 3 14h9l-1 8 10-12h-9l1-8z',
537
+ package: 'M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12',
538
+ microscope: 'M6 18h8M3 22h18M14 22a7 7 0 1 0 0-14h-1M9 14h2M9 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2z',
539
+ sparkle: 'M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z',
540
+ scale: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
541
+ 'file-text': 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v4a2 2 0 0 0 2 2h4M10 13h4M10 17h4M10 9h1',
542
+ filter: 'M3 6h18M7 12h10M10 18h4',
543
+ };
544
+
545
+ // Category fallback icons (used when a workflow has no branding)
546
+ const CATEGORY_ICONS = {
547
+ retrieval: 'search',
548
+ analysis: 'bar-chart-3',
549
+ 'domain-specific': 'target',
550
+ ingestion: 'database',
551
+ utility: 'zap',
552
+ integration: 'package',
553
+ };
554
+
555
+ // Default branding for the 20 official workflows
556
+ const DEFAULT_BRANDING = {
557
+ 'model-shootout': { icon: 'trophy', color: '#0D9488' },
558
+ 'asymmetric-search': { icon: 'split', color: '#00D4AA' },
559
+ 'cost-optimizer': { icon: 'dollar-sign', color: '#F59E0B' },
560
+ 'question-decomposition': { icon: 'sparkle', color: '#8B5CF6' },
561
+ 'contract-clause-finder': { icon: 'file-search', color: '#1E40AF' },
562
+ 'knowledge-base-bootstrap': { icon: 'database', color: '#059669' },
563
+ 'embedding-drift-detector': { icon: 'activity', color: '#DC2626' },
564
+ 'multilingual-search': { icon: 'globe', color: '#0EA5E9' },
565
+ 'financial-risk-scanner': { icon: 'shield-alert', color: '#B45309' },
566
+ 'doc-freshness': { icon: 'timer', color: '#4338CA' },
567
+ 'incremental-sync': { icon: 'refresh-cw', color: '#15803D' },
568
+ 'rag-ab-test': { icon: 'flask-conical', color: '#BE185D' },
569
+ 'hybrid-precision-search': { icon: 'target', color: '#0891B2' },
570
+ 'code-migration-helper': { icon: 'code', color: '#475569' },
571
+ 'meeting-action-items': { icon: 'clipboard-list',color: '#7C2D12' },
572
+ 'collection-overlap-audit': { icon: 'layers', color: '#6D28D9' },
573
+ 'query-quality-scorer': { icon: 'microscope', color: '#9333EA' },
574
+ 'clinical-protocol-match': { icon: 'heart-pulse', color: '#0F766E' },
575
+ 'batch-quality-gate': { icon: 'check-circle', color: '#166534' },
576
+ 'index-health-check': { icon: 'bar-chart-3', color: '#1D4ED8' },
577
+ };
578
+
579
+ // Static gradient/featured config
580
+ const GRADIENTS = {
581
+ 'model-shootout': 'linear-gradient(135deg, #0D9488, #06B6D4)',
582
+ 'asymmetric-search': 'linear-gradient(135deg, #00D4AA, #40E0FF)',
583
+ 'cost-optimizer': 'linear-gradient(135deg, #F59E0B, #EF4444)',
584
+ 'question-decomposition': 'linear-gradient(135deg, #8B5CF6, #EC4899)',
585
+ 'contract-clause-finder': 'linear-gradient(135deg, #1E40AF, #7C3AED)',
586
+ 'knowledge-base-bootstrap': 'linear-gradient(135deg, #059669, #10B981)',
587
+ 'embedding-drift-detector': 'linear-gradient(135deg, #DC2626, #F97316)',
588
+ 'multilingual-search': 'linear-gradient(135deg, #0EA5E9, #6366F1)',
589
+ 'financial-risk-scanner': 'linear-gradient(135deg, #B45309, #D97706)',
590
+ 'doc-freshness': 'linear-gradient(135deg, #4338CA, #7C3AED)',
591
+ 'incremental-sync': 'linear-gradient(135deg, #15803D, #4ADE80)',
592
+ 'rag-ab-test': 'linear-gradient(135deg, #BE185D, #F472B6)',
593
+ 'hybrid-precision-search': 'linear-gradient(135deg, #0891B2, #22D3EE)',
594
+ 'code-migration-helper': 'linear-gradient(135deg, #475569, #94A3B8)',
595
+ 'meeting-action-items': 'linear-gradient(135deg, #7C2D12, #EA580C)',
596
+ 'collection-overlap-audit': 'linear-gradient(135deg, #6D28D9, #A78BFA)',
597
+ 'query-quality-scorer': 'linear-gradient(135deg, #9333EA, #C084FC)',
598
+ 'clinical-protocol-match': 'linear-gradient(135deg, #0F766E, #2DD4BF)',
599
+ 'batch-quality-gate': 'linear-gradient(135deg, #166534, #86EFAC)',
600
+ 'index-health-check': 'linear-gradient(135deg, #1D4ED8, #60A5FA)',
601
+ };
602
+ const FEATURED = ['model-shootout', 'asymmetric-search', 'cost-optimizer'];
603
+ const DEFAULT_GRADIENT = 'linear-gradient(135deg, #334155, #64748B)';
604
+
605
+ const workflows = npmWorkflows.map(r => {
606
+ const shortName = (r.name || '').replace(/^@vaicli\/vai-workflow-/, '').replace(/^vai-workflow-/, '');
607
+ const meta = metadataCache[r.name]; // raw registry JSON
608
+ const vai = (meta && meta['vai-workflow']) || (meta && meta.vai) || r.vai || {};
609
+ const vaiAuthor = vai.author || null;
610
+ const version = (meta && meta.version) || r.version || '1.0.0';
611
+
612
+ // Author attribution: vai.author > package.json author
613
+ let author = { name: 'unknown' };
614
+ if (vaiAuthor && vaiAuthor.name) {
615
+ author = { name: vaiAuthor.name, url: vaiAuthor.url || undefined };
616
+ if (vaiAuthor.avatar) {
617
+ author.avatar = `https://unpkg.com/${r.name}@${version}/${vaiAuthor.avatar}`;
618
+ }
619
+ } else if (meta && meta.author) {
620
+ const rawAuthor = meta.author;
621
+ author = { name: typeof rawAuthor === 'string' ? rawAuthor : (rawAuthor.name || 'unknown') };
622
+ } else if (r.author) {
623
+ author = { name: r.author };
624
+ }
625
+
626
+ // Assets: construct CDN URLs from vai.assets paths
627
+ const vaiAssets = vai.assets || {};
628
+ const assets = {};
629
+ if (vaiAssets.icon) assets.icon = `https://unpkg.com/${r.name}@${version}/${vaiAssets.icon}`;
630
+ if (vaiAssets.banner) assets.banner = `https://unpkg.com/${r.name}@${version}/${vaiAssets.banner}`;
631
+ if (vaiAssets.screenshots && Array.isArray(vaiAssets.screenshots)) {
632
+ assets.screenshots = vaiAssets.screenshots.map(s => `https://unpkg.com/${r.name}@${version}/${s}`);
633
+ }
634
+
635
+ // Branding: vai.branding from package > DEFAULT_BRANDING > category fallback
636
+ const vaiBranding = vai.branding || {};
637
+ const defaultBrand = DEFAULT_BRANDING[shortName] || {};
638
+ const category = vai.category || 'utility';
639
+ const brandingIcon = vaiBranding.icon || defaultBrand.icon || CATEGORY_ICONS[category] || 'zap';
640
+ const brandingColor = vaiBranding.color || defaultBrand.color || '#64748B';
641
+ const branding = {
642
+ icon: brandingIcon,
643
+ color: brandingColor,
644
+ // Resolve the icon name to its SVG path data for client rendering
645
+ iconPath: STORE_ICONS[brandingIcon] || STORE_ICONS.zap,
646
+ };
647
+
648
+ // Inputs: extract from vai-workflow field (has inputs map), fall back to vai.inputs
649
+ const vaiWorkflowField = meta && meta['vai-workflow'] ? meta['vai-workflow'] : {};
650
+ const rawInputs = vaiWorkflowField.inputs || vai.inputs || {};
651
+ const inputs = Object.entries(rawInputs).map(([name, def]) => ({
652
+ name,
653
+ type: def.type || 'string',
654
+ required: !!def.required,
655
+ default: def.default !== undefined ? def.default : undefined,
656
+ description: def.description || '',
657
+ }));
658
+
659
+ return {
660
+ name: shortName,
661
+ packageName: r.name,
662
+ version,
663
+ description: r.description || '',
664
+ category,
665
+ tags: vai.tags || [],
666
+ tools: vai.tools || [],
667
+ steps: vai.steps || (vai.tools || []).length || 0,
668
+ tools: vai.tools || [],
669
+ toolCount: (vai.tools || []).length,
670
+ tier: (r.name || '').startsWith('@vaicli/') ? 'official' : 'community',
671
+ downloads: 0,
672
+ featured: FEATURED.includes(shortName),
673
+ installed: installedNames.has(r.name),
674
+ gradient: GRADIENTS[shortName] || DEFAULT_GRADIENT,
675
+ branding,
676
+ author,
677
+ assets,
678
+ inputs,
679
+ };
680
+ });
681
+
682
+ const result = { workflows, icons: STORE_ICONS, lastUpdated: new Date().toISOString() };
683
+ _catalogCache = result;
684
+ _catalogCacheTime = Date.now();
685
+
686
+ console.log(`[catalog] built fresh in ${Date.now() - _catStart}ms (${workflows.length} workflows)`);
687
+ res.writeHead(200, { 'Content-Type': 'application/json' });
688
+ res.end(JSON.stringify(result));
689
+
690
+ // Background enrichment: fetch real step counts + download stats
691
+ // Updates the cache silently — next client request gets enriched data
692
+ (async () => {
693
+ try {
694
+ let enriched = false;
695
+ await Promise.all(workflows.map(async (wf) => {
696
+ const [defRes, dlRes] = await Promise.all([
697
+ fetch(`https://unpkg.com/${wf.packageName}@${wf.version || 'latest'}/workflow.json`, {
698
+ headers: { 'Accept': 'application/json' },
699
+ signal: AbortSignal.timeout(8000),
700
+ }).catch(() => null),
701
+ fetch(`https://api.npmjs.org/downloads/point/last-month/${encodeURIComponent(wf.packageName)}`, {
702
+ headers: { 'Accept': 'application/json' },
703
+ signal: AbortSignal.timeout(8000),
704
+ }).catch(() => null),
705
+ ]);
706
+ if (defRes && defRes.ok) {
707
+ try {
708
+ const def = await defRes.json();
709
+ if (def.steps && Array.isArray(def.steps) && def.steps.length > 0) {
710
+ wf.steps = def.steps.length;
711
+ enriched = true;
712
+ }
713
+ } catch {}
714
+ }
715
+ if (dlRes && dlRes.ok) {
716
+ try {
717
+ const dlData = await dlRes.json();
718
+ if (dlData.downloads > 0) {
719
+ wf.downloads = dlData.downloads;
720
+ enriched = true;
721
+ }
722
+ } catch {}
723
+ }
724
+ }));
725
+ if (enriched) {
726
+ _catalogCache = { ...result, workflows, lastUpdated: new Date().toISOString() };
727
+ }
728
+ } catch {}
729
+ })();
730
+ } catch (err) {
731
+ res.writeHead(500, { 'Content-Type': 'application/json' });
732
+ res.end(JSON.stringify({ error: err.message }));
733
+ }
734
+ return;
735
+ }
736
+
355
737
  // API: List built-in workflows
356
738
  if (req.method === 'GET' && req.url === '/api/workflows') {
357
739
  try {
358
- const { listBuiltinWorkflows } = require('../lib/workflow');
359
- const workflows = listBuiltinWorkflows();
740
+ const { getRegistry } = require('../lib/workflow-registry');
741
+ const registry = getRegistry({ force: true });
742
+ const workflows = registry.builtIn;
743
+ const mapPkg = (c, source) => ({
744
+ name: c.name,
745
+ description: c.pkg?.description || c.definition?.description || '',
746
+ version: c.pkg?.version,
747
+ author: typeof c.pkg?.author === 'string' ? c.pkg.author : c.pkg?.author?.name || '',
748
+ category: c.pkg?.vai?.category || 'utility',
749
+ tags: c.pkg?.vai?.tags || [],
750
+ tools: c.pkg?.vai?.tools || [],
751
+ source,
752
+ scope: c.scope,
753
+ });
754
+ // Include workflows that have a definition, even if they have non-fatal errors
755
+ // (e.g., "Missing vai field" is a warning, not a blocker)
756
+ const official = registry.official
757
+ .filter(c => c.definition)
758
+ .map(c => mapPkg(c, 'official'));
759
+ const community = registry.community
760
+ .filter(c => c.definition)
761
+ .map(c => mapPkg(c, 'community'));
360
762
  res.writeHead(200, { 'Content-Type': 'application/json' });
361
- res.end(JSON.stringify({ workflows }));
763
+ res.end(JSON.stringify({ workflows, official, community }));
362
764
  } catch (err) {
363
765
  res.writeHead(500, { 'Content-Type': 'application/json' });
364
766
  res.end(JSON.stringify({ error: err.message }));
@@ -366,7 +768,64 @@ function createPlaygroundServer() {
366
768
  return;
367
769
  }
368
770
 
369
- // API: Get a specific workflow by name
771
+ // API: Community workflow operations
772
+ if (req.method === 'GET' && req.url === '/api/workflows/community') {
773
+ try {
774
+ const { getRegistry } = require('../lib/workflow-registry');
775
+ const registry = getRegistry({ force: true });
776
+ const mapPkg = (c) => ({
777
+ name: c.name,
778
+ description: c.pkg?.description || '',
779
+ version: c.pkg?.version,
780
+ author: typeof c.pkg?.author === 'string' ? c.pkg.author : c.pkg?.author?.name || '',
781
+ category: c.pkg?.vai?.category || 'utility',
782
+ tags: c.pkg?.vai?.tags || [],
783
+ valid: c.errors.length === 0,
784
+ errors: c.errors,
785
+ warnings: c.warnings,
786
+ });
787
+ const official = registry.official.map(mapPkg);
788
+ const community = registry.community.map(mapPkg);
789
+ res.writeHead(200, { 'Content-Type': 'application/json' });
790
+ res.end(JSON.stringify({ official, community }));
791
+ } catch (err) {
792
+ res.writeHead(500, { 'Content-Type': 'application/json' });
793
+ res.end(JSON.stringify({ error: err.message }));
794
+ }
795
+ return;
796
+ }
797
+
798
+ if (req.method === 'GET' && req.url?.startsWith('/api/workflows/community/search?')) {
799
+ try {
800
+ const { searchNpm } = require('../lib/npm-utils');
801
+ const urlObj = new URL(req.url, `http://localhost`);
802
+ const query = urlObj.searchParams.get('q') || '';
803
+ const limit = parseInt(urlObj.searchParams.get('limit') || '10', 10);
804
+ const results = await searchNpm(query, { limit });
805
+ res.writeHead(200, { 'Content-Type': 'application/json' });
806
+ res.end(JSON.stringify({ results }));
807
+ } catch (err) {
808
+ res.writeHead(500, { 'Content-Type': 'application/json' });
809
+ res.end(JSON.stringify({ error: err.message }));
810
+ }
811
+ return;
812
+ }
813
+
814
+ // API: List example workflows (must be before the :name route)
815
+ if (req.method === 'GET' && req.url === '/api/workflows/examples') {
816
+ try {
817
+ const { listExampleWorkflows } = require('../lib/workflow');
818
+ const examples = listExampleWorkflows();
819
+ res.writeHead(200, { 'Content-Type': 'application/json' });
820
+ res.end(JSON.stringify({ examples }));
821
+ } catch (err) {
822
+ res.writeHead(500, { 'Content-Type': 'application/json' });
823
+ res.end(JSON.stringify({ error: err.message }));
824
+ }
825
+ return;
826
+ }
827
+
828
+ // API: Get a specific workflow by name (built-in, community, or example)
370
829
  if (req.method === 'GET' && req.url?.startsWith('/api/workflows/')) {
371
830
  const name = decodeURIComponent(req.url.replace('/api/workflows/', ''));
372
831
  if (!name) {
@@ -375,10 +834,10 @@ function createPlaygroundServer() {
375
834
  return;
376
835
  }
377
836
  try {
378
- const { loadWorkflow } = require('../lib/workflow');
379
- const definition = loadWorkflow(name);
837
+ const { resolveWorkflow } = require('../lib/workflow-registry');
838
+ const resolved = resolveWorkflow(name);
380
839
  res.writeHead(200, { 'Content-Type': 'application/json' });
381
- res.end(JSON.stringify({ definition }));
840
+ res.end(JSON.stringify({ definition: resolved.definition, source: resolved.source, metadata: resolved.metadata }));
382
841
  } catch (err) {
383
842
  res.writeHead(404, { 'Content-Type': 'application/json' });
384
843
  res.end(JSON.stringify({ error: err.message }));
@@ -386,6 +845,148 @@ function createPlaygroundServer() {
386
845
  return;
387
846
  }
388
847
 
848
+ // API: Home announcements (loaded from markdown file)
849
+ if (req.method === 'GET' && req.url === '/api/home/announcements') {
850
+ try {
851
+ const announcements = loadAnnouncementsFromMarkdown();
852
+
853
+ // Filter out expired announcements
854
+ const now = new Date();
855
+ const activeAnnouncements = announcements.filter(a => {
856
+ const expires = new Date(a.expires);
857
+ return expires > now;
858
+ });
859
+
860
+ res.writeHead(200, { 'Content-Type': 'application/json' });
861
+ res.end(JSON.stringify({ announcements: activeAnnouncements }));
862
+ } catch (err) {
863
+ console.error('Failed to load announcements:', err);
864
+ res.writeHead(200, { 'Content-Type': 'application/json' });
865
+ res.end(JSON.stringify({ announcements: [] }));
866
+ }
867
+ return;
868
+ }
869
+
870
+ // API: Home releases
871
+ if (req.method === 'GET' && req.url === '/api/home/releases') {
872
+ try {
873
+ // Fetch from GitHub API with caching
874
+ const cacheKey = 'github-releases-cache';
875
+ const cached = global[cacheKey];
876
+
877
+ if (cached && Date.now() - cached.timestamp < 30 * 60 * 1000) {
878
+ // Use cached data if less than 30 minutes old
879
+ res.writeHead(200, { 'Content-Type': 'application/json' });
880
+ res.end(JSON.stringify(cached.data));
881
+ return;
882
+ }
883
+
884
+ const https = require('https');
885
+
886
+ const fetchGitHub = () => new Promise((resolve, reject) => {
887
+ const options = {
888
+ hostname: 'api.github.com',
889
+ path: '/repos/mrlynn/voyageai-cli/releases?per_page=5',
890
+ method: 'GET',
891
+ headers: {
892
+ 'User-Agent': 'VAI-Playground',
893
+ 'Accept': 'application/vnd.github.v3+json'
894
+ }
895
+ };
896
+
897
+ const req = https.request(options, (response) => {
898
+ let data = '';
899
+ response.on('data', chunk => data += chunk);
900
+ response.on('end', () => {
901
+ if (response.statusCode === 200) {
902
+ try {
903
+ resolve(JSON.parse(data));
904
+ } catch (e) {
905
+ reject(new Error('Failed to parse GitHub response'));
906
+ }
907
+ } else {
908
+ console.error(`GitHub API error: ${response.statusCode} - ${data.substring(0, 200)}`);
909
+ reject(new Error(`GitHub API returned ${response.statusCode}`));
910
+ }
911
+ });
912
+ });
913
+ req.on('error', (err) => {
914
+ console.error('GitHub request error:', err.message);
915
+ reject(err);
916
+ });
917
+ req.setTimeout(10000, () => {
918
+ req.destroy();
919
+ reject(new Error('Request timeout'));
920
+ });
921
+ req.end();
922
+ });
923
+
924
+ const githubReleases = await fetchGitHub();
925
+
926
+ // Parse release notes
927
+ const releases = githubReleases.map(release => {
928
+ let highlights = [];
929
+
930
+ if (release.body) {
931
+ // Extract bullet points from markdown body
932
+ const lines = release.body.split('\n');
933
+ highlights = lines
934
+ .filter(line => line.trim().startsWith('-') || line.trim().startsWith('*'))
935
+ .map(line => line.replace(/^[-*]\s*/, '').trim())
936
+ .filter(line => line.length > 0)
937
+ .slice(0, 5); // Max 5 highlights
938
+ }
939
+
940
+ if (highlights.length === 0) {
941
+ highlights = ['New features and improvements'];
942
+ }
943
+
944
+ return {
945
+ version: release.tag_name || release.name,
946
+ date: release.published_at,
947
+ highlights,
948
+ url: release.html_url
949
+ };
950
+ });
951
+
952
+ const result = { releases };
953
+
954
+ // Cache the result
955
+ global[cacheKey] = {
956
+ data: result,
957
+ timestamp: Date.now()
958
+ };
959
+
960
+ res.writeHead(200, { 'Content-Type': 'application/json' });
961
+ res.end(JSON.stringify(result));
962
+
963
+ } catch (err) {
964
+ console.error('Failed to fetch GitHub releases:', err);
965
+
966
+ // Return fallback data
967
+ const fallback = {
968
+ releases: [
969
+ {
970
+ version: 'v1.0.0',
971
+ date: '2026-02-14T00:00:00Z',
972
+ highlights: [
973
+ 'Initial release of VAI Playground',
974
+ 'Support for all Voyage AI models',
975
+ 'Interactive embedding visualization',
976
+ 'Model comparison tools',
977
+ 'Vector similarity analysis'
978
+ ],
979
+ url: 'https://github.com/mrlynn/voyageai-cli/releases'
980
+ }
981
+ ]
982
+ };
983
+
984
+ res.writeHead(200, { 'Content-Type': 'application/json' });
985
+ res.end(JSON.stringify(fallback));
986
+ }
987
+ return;
988
+ }
989
+
389
990
  // API: Save chat config (POST) — persists to .vai.json
390
991
  // Placed before generic POST handler so it doesn't require Voyage API key
391
992
  if (req.method === 'POST' && req.url === '/api/chat/config') {
@@ -427,6 +1028,53 @@ function createPlaygroundServer() {
427
1028
  }
428
1029
 
429
1030
  // Parse JSON body for POST routes
1031
+ // Community workflow install (no API key needed)
1032
+ if (req.method === 'POST' && req.url === '/api/workflows/community/install') {
1033
+ try {
1034
+ const body = await readBody(req);
1035
+ const { name } = JSON.parse(body);
1036
+ if (!name) {
1037
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1038
+ res.end(JSON.stringify({ error: 'Package name is required' }));
1039
+ return;
1040
+ }
1041
+ const { installPackage, WORKFLOW_PREFIX, isWorkflowPackage } = require('../lib/npm-utils');
1042
+ const { validatePackage, clearRegistryCache } = require('../lib/workflow-registry');
1043
+ const packageName = name.startsWith('@') || name.startsWith(WORKFLOW_PREFIX) ? name : WORKFLOW_PREFIX + name;
1044
+ const result = installPackage(packageName);
1045
+ clearRegistryCache();
1046
+ _catalogCache = null; _catalogCacheTime = 0; // Invalidate store catalog cache
1047
+ let validation = null;
1048
+ if (result.path) {
1049
+ validation = validatePackage(result.path);
1050
+ }
1051
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1052
+ res.end(JSON.stringify({ success: true, version: result.version, path: result.path, validation: validation ? { valid: validation.errors.length === 0, errors: validation.errors, warnings: validation.warnings } : null }));
1053
+ } catch (err) {
1054
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1055
+ res.end(JSON.stringify({ error: err.message }));
1056
+ }
1057
+ return;
1058
+ }
1059
+
1060
+ // Community workflow uninstall (no API key needed)
1061
+ if (req.method === 'DELETE' && req.url?.startsWith('/api/workflows/community/')) {
1062
+ try {
1063
+ const packageName = decodeURIComponent(req.url.replace('/api/workflows/community/', ''));
1064
+ const { uninstallPackage } = require('../lib/npm-utils');
1065
+ const { clearRegistryCache } = require('../lib/workflow-registry');
1066
+ uninstallPackage(packageName);
1067
+ clearRegistryCache();
1068
+ _catalogCache = null; _catalogCacheTime = 0; // Invalidate store catalog cache
1069
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1070
+ res.end(JSON.stringify({ success: true }));
1071
+ } catch (err) {
1072
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1073
+ res.end(JSON.stringify({ error: err.message }));
1074
+ }
1075
+ return;
1076
+ }
1077
+
430
1078
  if (req.method === 'POST') {
431
1079
  // Check for API key before processing any API calls
432
1080
  const apiKeyConfigured = !!(process.env.VOYAGE_API_KEY || getConfigValue('apiKey'));