voyageai-cli 1.9.0 → 1.11.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/demo.gif CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
5
5
  "bin": {
6
6
  "vai": "./src/cli.js"
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ const { registerIngest } = require('./commands/ingest');
20
20
  const { registerCompletions } = require('./commands/completions');
21
21
  const { registerPlayground } = require('./commands/playground');
22
22
  const { registerBenchmark } = require('./commands/benchmark');
23
+ const { registerAbout } = require('./commands/about');
23
24
  const { showBanner, showQuickStart, getVersion } = require('./lib/banner');
24
25
 
25
26
  const version = getVersion();
@@ -44,6 +45,7 @@ registerIngest(program);
44
45
  registerCompletions(program);
45
46
  registerPlayground(program);
46
47
  registerBenchmark(program);
48
+ registerAbout(program);
47
49
 
48
50
  // Append disclaimer to all help output
49
51
  program.addHelpText('after', `
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const pc = require('picocolors');
4
+
5
+ /**
6
+ * Register the about command on a Commander program.
7
+ * @param {import('commander').Command} program
8
+ */
9
+ function registerAbout(program) {
10
+ program
11
+ .command('about')
12
+ .description('About this tool and its author')
13
+ .option('--json', 'Machine-readable JSON output')
14
+ .action((opts) => {
15
+ if (opts.json) {
16
+ console.log(JSON.stringify({
17
+ tool: 'voyageai-cli',
18
+ binary: 'vai',
19
+ author: {
20
+ name: 'Michael Lynn',
21
+ role: 'Principal Staff Developer Advocate, MongoDB',
22
+ github: 'https://github.com/mrlynn',
23
+ website: 'https://mlynn.org',
24
+ },
25
+ links: {
26
+ npm: 'https://www.npmjs.com/package/voyageai-cli',
27
+ github: 'https://github.com/mrlynn/voyageai-cli',
28
+ docs: 'https://www.mongodb.com/docs/voyageai/',
29
+ },
30
+ disclaimer: 'Community tool — not an official MongoDB or Voyage AI product.',
31
+ }, null, 2));
32
+ return;
33
+ }
34
+
35
+ console.log('');
36
+ console.log(` ${pc.bold(pc.cyan('voyageai-cli'))} ${pc.dim('(vai)')}`);
37
+ console.log(` ${pc.dim('Voyage AI embeddings, reranking & Atlas Vector Search CLI')}`);
38
+ console.log('');
39
+
40
+ // Author
41
+ console.log(` ${pc.bold('Author')}`);
42
+ console.log(` Michael Lynn`);
43
+ console.log(` ${pc.dim('Principal Staff Developer Advocate · MongoDB')}`);
44
+ console.log(` ${pc.dim('25+ years enterprise infrastructure · 10+ years at MongoDB')}`);
45
+ console.log('');
46
+
47
+ // About
48
+ console.log(` ${pc.bold('About This Project')}`);
49
+ console.log(` A community-built CLI for working with Voyage AI embeddings,`);
50
+ console.log(` reranking, and MongoDB Atlas Vector Search. Created to help`);
51
+ console.log(` developers explore, benchmark, and integrate Voyage AI models`);
52
+ console.log(` into their applications — right from the terminal.`);
53
+ console.log('');
54
+
55
+ // Features
56
+ console.log(` ${pc.bold('What You Can Do')}`);
57
+ console.log(` ${pc.cyan('vai embed')} Generate vector embeddings for text`);
58
+ console.log(` ${pc.cyan('vai similarity')} Compare texts with cosine similarity`);
59
+ console.log(` ${pc.cyan('vai rerank')} Rerank documents against a query`);
60
+ console.log(` ${pc.cyan('vai search')} Vector search against Atlas collections`);
61
+ console.log(` ${pc.cyan('vai store')} Embed and store documents in Atlas`);
62
+ console.log(` ${pc.cyan('vai benchmark')} Compare model latency, ranking & costs`);
63
+ console.log(` ${pc.cyan('vai explain')} Learn about embeddings, vector search & more`);
64
+ console.log(` ${pc.cyan('vai playground')} Launch interactive web playground`);
65
+ console.log('');
66
+
67
+ // Links
68
+ console.log(` ${pc.bold('Links')}`);
69
+ console.log(` ${pc.dim('npm:')} https://www.npmjs.com/package/voyageai-cli`);
70
+ console.log(` ${pc.dim('GitHub:')} https://github.com/mrlynn/voyageai-cli`);
71
+ console.log(` ${pc.dim('Docs:')} https://www.mongodb.com/docs/voyageai/`);
72
+ console.log(` ${pc.dim('Author:')} https://mlynn.org`);
73
+ console.log('');
74
+
75
+ // Disclaimer
76
+ console.log(` ${pc.yellow('⚠ Community Tool Disclaimer')}`);
77
+ console.log(` ${pc.dim('This tool is not an official product of MongoDB, Inc. or')}`);
78
+ console.log(` ${pc.dim('Voyage AI. It is independently built and maintained by')}`);
79
+ console.log(` ${pc.dim('Michael Lynn as a community resource. Not supported,')}`);
80
+ console.log(` ${pc.dim('endorsed, or guaranteed by either company.')}`);
81
+ console.log('');
82
+ });
83
+ }
84
+
85
+ module.exports = { registerAbout };
@@ -21,6 +21,8 @@ const SAMPLE_TEXTS = [
21
21
  'GraphQL provides a flexible query language that lets clients request exactly the data they need.',
22
22
  ];
23
23
 
24
+ // If you're reading this, you're either benchmarking or procrastinating.
25
+ // Either way, we respect the hustle.
24
26
  const SAMPLE_QUERY = 'How do I search for similar documents using embeddings?';
25
27
 
26
28
  const SAMPLE_RERANK_DOCS = [
@@ -90,6 +90,28 @@ function createPlaygroundServer() {
90
90
  return;
91
91
  }
92
92
 
93
+ // API: Concepts (from vai explain)
94
+ if (req.method === 'GET' && req.url === '/api/concepts') {
95
+ const { concepts } = require('../lib/explanations');
96
+ // Strip picocolors ANSI from content for web display
97
+ // eslint-disable-next-line no-control-regex
98
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
99
+ const stripped = {};
100
+ for (const [key, concept] of Object.entries(concepts)) {
101
+ stripped[key] = {
102
+ title: concept.title,
103
+ summary: concept.summary,
104
+ content: (typeof concept.content === 'string' ? concept.content : concept.content).replace(ANSI_RE, ''),
105
+ links: concept.links || [],
106
+ tryIt: concept.tryIt || [],
107
+ keyPoints: concept.keyPoints || [],
108
+ };
109
+ }
110
+ res.writeHead(200, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({ concepts: stripped }));
112
+ return;
113
+ }
114
+
93
115
  // API: Config
94
116
  if (req.method === 'GET' && req.url === '/api/config') {
95
117
  const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
@@ -170,6 +170,8 @@ async function handleBatchStore(opts) {
170
170
 
171
171
  const { client: c, collection } = await getMongoCollection(opts.db, opts.collection);
172
172
  client = c;
173
+ // insertMany: because life's too short for one document at a time.
174
+ // This is the MongoDB equivalent of "I'll have what everyone's having."
173
175
  const result = await collection.insertMany(docs);
174
176
 
175
177
  if (spin) spin.stop();
package/src/lib/api.js CHANGED
@@ -78,6 +78,8 @@ async function apiRequest(endpoint, body) {
78
78
  body: JSON.stringify(body),
79
79
  });
80
80
 
81
+ // 429: The API said "slow down monkey" — respect the rate limit
82
+ // like you'd respect a $merge that's already running on your replica set.
81
83
  if (response.status === 429 && attempt < MAX_RETRIES) {
82
84
  const retryAfter = response.headers.get('Retry-After');
83
85
  const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempt) * 1000;
@@ -22,6 +22,8 @@ function getDefaultDimensions() {
22
22
  return getConfigValue('defaultDimensions') || DEFAULT_DIMENSIONS;
23
23
  }
24
24
 
25
+ // The model catalog: like a wine list (I don't drink :-P), except every choice
26
+ // leads to vectors instead of regret.
25
27
  /** @type {Array<{name: string, type: string, context: string, dimensions: string, price: string, bestFor: string}>} */
26
28
  const MODEL_CATALOG = [
27
29
  { name: 'voyage-4-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/1M tokens', bestFor: 'Best quality, multilingual', shortFor: 'Best quality' },
package/src/lib/math.js CHANGED
@@ -3,6 +3,11 @@
3
3
  /**
4
4
  * Compute cosine similarity between two vectors.
5
5
  * cosine_sim(a, b) = dot(a, b) / (||a|| * ||b||)
6
+ *
7
+ * Fun fact: this is basically asking "how much do these two vectors
8
+ * vibe?" — 1.0 means soulmates, 0.0 means strangers at a party,
9
+ * -1.0 means they're in a Twitter argument.
10
+ *
6
11
  * @param {number[]} a
7
12
  * @param {number[]} b
8
13
  * @returns {number} Similarity score in [-1, 1]
@@ -680,6 +680,57 @@ select:focus { outline: none; border-color: var(--accent); }
680
680
  margin-top: 4px;
681
681
  }
682
682
 
683
+ /* About page */
684
+ .about-container { max-width: 680px; margin: 0 auto; }
685
+ .about-header { display: flex; gap: 24px; align-items: center; margin-bottom: 24px; }
686
+ .about-avatar {
687
+ width: 120px; height: 120px;
688
+ border-radius: 50%;
689
+ border: 3px solid var(--accent);
690
+ box-shadow: 0 0 20px var(--accent-glow);
691
+ flex-shrink: 0;
692
+ }
693
+ .about-name { font-size: 24px; font-weight: 700; color: var(--text); }
694
+ .about-role { font-size: 14px; color: var(--accent); margin-top: 4px; }
695
+ .about-links { display: flex; gap: 12px; margin-top: 8px; }
696
+ .about-links a {
697
+ color: var(--text-dim);
698
+ font-size: 13px;
699
+ text-decoration: none;
700
+ transition: color 0.2s;
701
+ }
702
+ .about-links a:hover { color: var(--accent); }
703
+ .about-section { margin-bottom: 24px; }
704
+ .about-section-title {
705
+ font-size: 13px;
706
+ font-weight: 600;
707
+ color: var(--accent);
708
+ text-transform: uppercase;
709
+ letter-spacing: 0.5px;
710
+ margin-bottom: 8px;
711
+ }
712
+ .about-text { font-size: 14px; line-height: 1.8; color: var(--text); }
713
+ .about-text a { color: var(--accent); text-decoration: none; }
714
+ .about-text a:hover { text-decoration: underline; }
715
+ .about-disclaimer {
716
+ background: rgba(255, 215, 61, 0.08);
717
+ border: 1px solid rgba(255, 215, 61, 0.2);
718
+ border-radius: var(--radius);
719
+ padding: 16px 20px;
720
+ margin-top: 24px;
721
+ }
722
+ .about-disclaimer-title {
723
+ font-size: 13px;
724
+ font-weight: 600;
725
+ color: var(--warning);
726
+ margin-bottom: 6px;
727
+ }
728
+ .about-disclaimer-text {
729
+ font-size: 13px;
730
+ line-height: 1.7;
731
+ color: var(--text-dim);
732
+ }
733
+
683
734
  @media (max-width: 768px) {
684
735
  .compare-grid, .search-results { grid-template-columns: 1fr; }
685
736
  .nav { padding: 0 12px; }
@@ -711,6 +762,7 @@ select:focus { outline: none; border-color: var(--accent); }
711
762
  <button class="tab-btn" data-tab="search">🔍 Search</button>
712
763
  <button class="tab-btn" data-tab="benchmark">⏱ Benchmark</button>
713
764
  <button class="tab-btn" data-tab="explore">📚 Explore</button>
765
+ <button class="tab-btn" data-tab="about">ℹ️ About</button>
714
766
  </div>
715
767
 
716
768
  <div class="main">
@@ -1001,8 +1053,78 @@ Reranking models rescore initial search results to improve relevance ordering.</
1001
1053
 
1002
1054
  </div>
1003
1055
 
1056
+ <!-- ========== ABOUT TAB ========== -->
1057
+ <div class="tab-panel" id="tab-about">
1058
+ <div class="about-container">
1059
+ <div class="card">
1060
+ <div class="about-header">
1061
+ <img src="https://avatars.githubusercontent.com/u/192552?v=4" alt="Michael Lynn" class="about-avatar">
1062
+ <div>
1063
+ <div class="about-name">Michael Lynn</div>
1064
+ <div class="about-role">Principal Staff Developer Advocate · MongoDB</div>
1065
+ <div class="about-links">
1066
+ <a href="https://github.com/mrlynn" target="_blank" rel="noopener">🔗 GitHub</a>
1067
+ <a href="https://mlynn.org" target="_blank" rel="noopener">🌐 mlynn.org</a>
1068
+ <a href="https://www.npmjs.com/package/voyageai-cli" target="_blank" rel="noopener">📦 npm</a>
1069
+ </div>
1070
+ </div>
1071
+ </div>
1072
+
1073
+ <div class="about-section">
1074
+ <div class="about-section-title">About This Project</div>
1075
+ <div class="about-text">
1076
+ <strong>voyageai-cli</strong> (<code style="color:var(--accent);">vai</code>) is a community-built command-line tool for working with
1077
+ <a href="https://www.mongodb.com/docs/voyageai/" target="_blank">Voyage AI</a> embeddings, reranking, and
1078
+ <a href="https://www.mongodb.com/products/platform/atlas-vector-search" target="_blank">MongoDB Atlas Vector Search</a>.
1079
+ It was created to make it easier for developers to explore, benchmark, and integrate
1080
+ Voyage AI models into their applications — right from the terminal or this playground.
1081
+ </div>
1082
+ </div>
1083
+
1084
+ <div class="about-section">
1085
+ <div class="about-section-title">About Michael</div>
1086
+ <div class="about-text">
1087
+ Michael Lynn is a Principal Staff Developer Advocate at MongoDB with 25+ years in enterprise
1088
+ infrastructure and over a decade at MongoDB. He focuses on strategic developer relations,
1089
+ creating educational content around Vector Search, AI enablement, and developer tooling.
1090
+ He builds tools like this to help developers get hands-on with new technology faster.
1091
+ </div>
1092
+ </div>
1093
+
1094
+ <div class="about-section">
1095
+ <div class="about-section-title">What You Can Do Here</div>
1096
+ <div class="about-text">
1097
+ <strong>⚡ Embed</strong> — Generate vector embeddings for any text<br>
1098
+ <strong>⚖️ Compare</strong> — Measure cosine similarity between texts<br>
1099
+ <strong>🔍 Search</strong> — Semantic search with optional reranking<br>
1100
+ <strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
1101
+ <strong>📚 Explore</strong> — Learn about embeddings, vector search, RAG, and more
1102
+ </div>
1103
+ </div>
1104
+
1105
+ <div class="about-disclaimer">
1106
+ <div class="about-disclaimer-title">⚠️ Community Tool Disclaimer</div>
1107
+ <div class="about-disclaimer-text">
1108
+ This tool is <strong>not</strong> an official product of MongoDB, Inc. or Voyage AI.
1109
+ It is independently built and maintained by Michael Lynn as a community resource.
1110
+ It is not supported, endorsed, or guaranteed by either company. Use at your own discretion.
1111
+ For official documentation, visit
1112
+ <a href="https://www.mongodb.com/docs/voyageai/" target="_blank" style="color:var(--warning);">mongodb.com/docs/voyageai</a>.
1113
+ </div>
1114
+ </div>
1115
+ </div>
1116
+
1117
+ <div style="text-align:center;margin-top:16px;font-size:12px;color:var(--text-muted);">
1118
+ Made with ☕ and curiosity · <a href="https://github.com/mrlynn/voyageai-cli" target="_blank" style="color:var(--text-dim);">Source on GitHub</a>
1119
+ </div>
1120
+ </div>
1121
+ </div>
1122
+
1004
1123
  <!-- ========== EXPLORE TAB ========== -->
1005
1124
  <div class="tab-panel" id="tab-explore">
1125
+ <div style="margin-bottom:16px;">
1126
+ <input type="text" id="exploreSearch" placeholder="🔍 Search concepts..." oninput="filterExplore()" style="max-width:400px;">
1127
+ </div>
1006
1128
  <div class="explore-grid" id="exploreGrid"></div>
1007
1129
  </div>
1008
1130
 
@@ -1022,7 +1144,7 @@ let lastEmbedding = null;
1022
1144
  async function init() {
1023
1145
  setupTabs();
1024
1146
  await loadConfig();
1025
- await loadModels();
1147
+ await Promise.all([loadModels(), loadConcepts()]);
1026
1148
  populateModelSelects();
1027
1149
  buildExploreCards();
1028
1150
  }
@@ -1393,103 +1515,102 @@ function createResultItem(rank, result, maxScore, movement) {
1393
1515
  }
1394
1516
 
1395
1517
  // ── Explore ──
1396
- const exploreTopics = [
1397
- {
1398
- key: 'embeddings', icon: '🧮', title: 'Embeddings',
1399
- summary: 'Numerical representations that capture meaning',
1400
- content: 'Vector embeddings are arrays of floating-point numbers (typically 256–2048 dimensions) that capture the semantic meaning of text. When you embed text, a neural network reads the input and produces a fixed-size vector. Texts with similar meanings end up close together in this high-dimensional space, even if they share no words.\n\nHigher dimensions capture more nuance but cost more to store and search. Voyage 4 models default to 1024 dimensions but support 256–2048 via Matryoshka representation learning — you can truncate embeddings without retraining.',
1401
- tab: 'embed', prefill: () => { document.getElementById('embedInput').value = 'Artificial intelligence is transforming how we build software applications.'; }
1402
- },
1403
- {
1404
- key: 'reranking', icon: '🏆', title: 'Reranking',
1405
- summary: 'Second-stage precision with cross-attention',
1406
- content: 'Reranking re-scores candidate documents against a query using cross-attention — it reads the query and each document together, producing much more accurate relevance scores than embedding similarity alone.\n\nThe two-stage pattern: embedding search retrieves a broad set (high recall), then the reranker re-orders them (high precision). This adds ~50-200ms but dramatically improves result quality.',
1407
- tab: 'search', prefill: () => {
1408
- document.getElementById('searchQuery').value = 'How do I implement semantic search?';
1409
- document.getElementById('searchDocs').value = 'MongoDB Atlas provides vector search capabilities\nThe recipe calls for two cups of flour\nSemantic search uses embeddings to find meaning\nVector databases store high-dimensional data\nThe weather forecast predicts rain tomorrow';
1410
- }
1411
- },
1412
- {
1413
- key: 'vector-search', icon: '🔎', title: 'Vector Search',
1414
- summary: 'Finding documents by meaning, not keywords',
1415
- content: 'Vector search finds documents whose embeddings are closest to a query embedding. Instead of matching keywords, it matches meaning. MongoDB Atlas Vector Search uses $vectorSearch with HNSW (Hierarchical Navigable Small World) graph indexes for fast approximate nearest neighbor search.\n\nSimilarity functions: cosine (direction, ignoring magnitude — best default), dotProduct (magnitude-sensitive), euclidean (straight-line distance).',
1416
- tab: 'search', prefill: () => {}
1417
- },
1418
- {
1419
- key: 'rag', icon: '🤖', title: 'RAG',
1420
- summary: 'Retrieval-Augmented Generation',
1421
- content: 'RAG combines retrieval with LLM generation: instead of relying on the LLM\'s training data alone, you retrieve relevant context from your own data and include it in the prompt.\n\nThe pattern: 1) Embed your corpus and store vectors, 2) Embed the user\'s question and run vector search, 3) Pass retrieved documents + question to an LLM. Adding reranking between steps 2 and 3 dramatically improves answer quality.',
1422
- tab: 'search', prefill: () => {}
1423
- },
1424
- {
1425
- key: 'cosine', icon: '📐', title: 'Cosine Similarity',
1426
- summary: 'Measuring the angle between vectors',
1427
- content: 'Cosine similarity measures the angle between two vectors, ignoring magnitude. Vectors pointing the same direction score 1, perpendicular score 0, opposite score -1.\n\nFor text embeddings (which are typically normalized), cosine similarity and dot product give identical rankings. Cosine is preferred because it\'s intuitive: it measures how similar the direction (meaning) is, regardless of scale.',
1428
- tab: 'compare', prefill: () => {
1429
- document.getElementById('compareA').value = 'The database stores information efficiently';
1430
- document.getElementById('compareB').value = 'Data is saved in an optimized storage system';
1431
- }
1432
- },
1433
- {
1434
- key: 'two-stage', icon: '🎯', title: 'Two-Stage Retrieval',
1435
- summary: 'Embed → Search → Rerank for best results',
1436
- content: 'Two-stage retrieval combines a fast first stage (embedding search for recall) with a precise second stage (reranking for precision).\n\nStage 1: Embed query, run ANN search, retrieve top-100 candidates (fast, milliseconds). Stage 2: Feed query + candidates to a reranker with cross-attention, return top-5-10 (precise, ~100ms extra). This gives you both speed and accuracy.',
1437
- tab: 'search', prefill: () => {}
1438
- },
1439
- {
1440
- key: 'input-types', icon: '🏷️', title: 'Input Types',
1441
- summary: 'Query vs document — why it matters',
1442
- content: 'The input_type parameter tells the model whether text is a search query or a document being indexed. The model internally prepends different prompt prefixes for each, optimizing embeddings for asymmetric retrieval.\n\nAlways use input_type="query" for search queries and input_type="document" for corpus text. Omitting this parameter degrades retrieval accuracy.',
1443
- tab: 'embed', prefill: () => {
1444
- document.getElementById('embedInput').value = 'What is vector search and how does it work?';
1445
- document.getElementById('embedInputType').value = 'query';
1446
- }
1447
- },
1448
- {
1449
- key: 'models', icon: '🧠', title: 'Models',
1450
- summary: 'Choosing the right model for your task',
1451
- content: 'Voyage 4 Series: voyage-4-large (best quality, $0.12/1M tokens), voyage-4 (balanced, $0.06), voyage-4-lite (budget, $0.02). All share the same embedding space — you can mix models.\n\nDomain-specific: voyage-code-3 (code), voyage-finance-2 (financial), voyage-law-2 (legal). Rerankers: rerank-2.5 (best quality), rerank-2.5-lite (faster). Start with voyage-4 for general use.',
1452
- tab: 'embed', prefill: () => {}
1453
- },
1454
- ];
1518
+ // ── Explore: icons and tab mappings per concept ──
1519
+ const CONCEPT_META = {
1520
+ embeddings: { icon: '🧮', tab: 'embed' },
1521
+ reranking: { icon: '🏆', tab: 'search' },
1522
+ 'vector-search': { icon: '🔎', tab: 'search' },
1523
+ rag: { icon: '🤖', tab: 'search' },
1524
+ 'cosine-similarity': { icon: '📐', tab: 'compare' },
1525
+ 'two-stage-retrieval': { icon: '🎯', tab: 'search' },
1526
+ 'input-type': { icon: '🏷️', tab: 'embed' },
1527
+ models: { icon: '🧠', tab: 'embed' },
1528
+ 'api-keys': { icon: '🔑', tab: 'embed' },
1529
+ 'api-access': { icon: '🌐', tab: 'embed' },
1530
+ 'batch-processing': { icon: '📦', tab: 'embed' },
1531
+ benchmarking: { icon: '', tab: 'benchmark' },
1532
+ };
1533
+
1534
+ let exploreConcepts = {};
1535
+
1536
+ async function loadConcepts() {
1537
+ try {
1538
+ const res = await fetch('/api/concepts');
1539
+ const data = await res.json();
1540
+ exploreConcepts = data.concepts || {};
1541
+ } catch {
1542
+ console.error('Failed to load concepts');
1543
+ }
1544
+ }
1545
+
1546
+ function escapeHtml(str) {
1547
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1548
+ }
1455
1549
 
1456
1550
  function buildExploreCards() {
1457
1551
  const grid = document.getElementById('exploreGrid');
1458
1552
  grid.innerHTML = '';
1459
- exploreTopics.forEach(topic => {
1553
+
1554
+ for (const [key, concept] of Object.entries(exploreConcepts)) {
1555
+ const meta = CONCEPT_META[key] || { icon: '📚', tab: 'embed' };
1460
1556
  const card = document.createElement('div');
1461
1557
  card.className = 'explore-card';
1558
+ card.dataset.key = key;
1559
+
1560
+ // Build links HTML
1561
+ let linksHtml = '';
1562
+ if (concept.links && concept.links.length > 0) {
1563
+ linksHtml = '<div style="margin-top:12px;"><strong style="color:var(--accent);font-size:12px;">LEARN MORE</strong><br>' +
1564
+ concept.links.map(url => `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;word-break:break-all;">${escapeHtml(url)}</a>`).join('<br>') +
1565
+ '</div>';
1566
+ }
1567
+
1568
+ // Build try-it HTML
1569
+ let tryItHtml = '';
1570
+ if (concept.tryIt && concept.tryIt.length > 0) {
1571
+ tryItHtml = '<div style="margin-top:12px;"><strong style="color:var(--accent);font-size:12px;">TRY IT</strong>' +
1572
+ concept.tryIt.map(cmd => `<div style="font-family:var(--mono);font-size:12px;color:var(--text-dim);background:var(--bg);padding:4px 8px;border-radius:4px;margin-top:4px;">$ ${escapeHtml(cmd)}</div>`).join('') +
1573
+ '</div>';
1574
+ }
1575
+
1462
1576
  card.innerHTML = `
1463
- <div class="explore-card-icon">${topic.icon}</div>
1464
- <div class="explore-card-title">${topic.title}</div>
1465
- <div class="explore-card-summary">${topic.summary}</div>
1466
- <div class="explore-card-content">${topic.content}</div>
1577
+ <div class="explore-card-icon">${meta.icon}</div>
1578
+ <div class="explore-card-title">${escapeHtml(concept.title)}</div>
1579
+ <div class="explore-card-summary">${escapeHtml(concept.summary)}</div>
1580
+ <div class="explore-card-content">${escapeHtml(concept.content)}${linksHtml}${tryItHtml}</div>
1467
1581
  <div class="explore-card-actions">
1468
- <button class="btn btn-small" onclick="tryTopic('${topic.key}')">Try it →</button>
1582
+ <button class="btn btn-small" onclick="tryTopic('${escapeHtml(key)}')">Try it in playground →</button>
1469
1583
  <button class="btn btn-secondary btn-small" onclick="collapseTopic(this)">Collapse</button>
1470
1584
  </div>
1471
1585
  `;
1472
1586
  card.addEventListener('click', function(e) {
1473
- if (e.target.tagName === 'BUTTON') return;
1587
+ if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') return;
1474
1588
  if (!this.classList.contains('expanded')) {
1475
1589
  this.classList.add('expanded');
1476
1590
  }
1477
1591
  });
1478
1592
  grid.appendChild(card);
1479
- });
1593
+ }
1480
1594
  }
1481
1595
 
1482
1596
  window.tryTopic = function(key) {
1483
- const topic = exploreTopics.find(t => t.key === key);
1484
- if (!topic) return;
1485
- if (topic.prefill) topic.prefill();
1486
- switchTab(topic.tab);
1597
+ const meta = CONCEPT_META[key];
1598
+ if (meta) switchTab(meta.tab);
1487
1599
  };
1488
1600
 
1489
1601
  window.collapseTopic = function(btn) {
1490
1602
  btn.closest('.explore-card').classList.remove('expanded');
1491
1603
  };
1492
1604
 
1605
+ window.filterExplore = function() {
1606
+ const q = document.getElementById('exploreSearch').value.toLowerCase().trim();
1607
+ document.querySelectorAll('#exploreGrid .explore-card').forEach(card => {
1608
+ if (!q) { card.style.display = ''; return; }
1609
+ const text = card.textContent.toLowerCase();
1610
+ card.style.display = text.includes(q) ? '' : 'none';
1611
+ });
1612
+ };
1613
+
1493
1614
  // ── Benchmark: Sub-panel switching ──
1494
1615
  document.querySelectorAll('.bench-panel-btn').forEach(btn => {
1495
1616
  btn.addEventListener('click', () => {
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { Command } = require('commander');
6
+ const { registerAbout } = require('../../src/commands/about');
7
+
8
+ describe('about command', () => {
9
+ it('registers correctly on a program', () => {
10
+ const program = new Command();
11
+ registerAbout(program);
12
+ const aboutCmd = program.commands.find(c => c.name() === 'about');
13
+ assert.ok(aboutCmd, 'about command should be registered');
14
+ });
15
+
16
+ it('has --json option', () => {
17
+ const program = new Command();
18
+ registerAbout(program);
19
+ const aboutCmd = program.commands.find(c => c.name() === 'about');
20
+ const optionNames = aboutCmd.options.map(o => o.long);
21
+ assert.ok(optionNames.includes('--json'), 'should have --json option');
22
+ });
23
+ });
Binary file