voyageai-cli 1.6.1 → 1.7.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/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # voyageai-cli
2
2
 
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/mrlynn/voyageai-cli/main/voyageai-cli.png" alt="voyageai-cli" width="600" />
5
+ </p>
6
+
3
7
  [![CI](https://github.com/mrlynn/voyageai-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mrlynn/voyageai-cli/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/voyageai-cli.svg)](https://www.npmjs.com/package/voyageai-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/node/v/voyageai-cli.svg)](https://nodejs.org)
4
8
 
5
9
  CLI for [Voyage AI](https://www.mongodb.com/docs/voyageai/) embeddings, reranking, and [MongoDB Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/). Pure Node.js — no Python required.
6
10
 
7
- <!-- TODO: Add demo GIF -->
8
- <!-- ![vai demo](demo.gif) -->
9
-
10
11
  Generate embeddings, rerank search results, store vectors in Atlas, and run semantic search — all from the command line.
11
12
 
12
13
  > **⚠️ Disclaimer:** This is an independent, community-built tool. It is **not** an official product of MongoDB, Inc. or Voyage AI. It is not supported, endorsed, or maintained by either company. For official documentation, support, and products, visit:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.6.1",
3
+ "version": "1.7.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
@@ -18,6 +18,7 @@ const { registerExplain } = require('./commands/explain');
18
18
  const { registerSimilarity } = require('./commands/similarity');
19
19
  const { registerIngest } = require('./commands/ingest');
20
20
  const { registerCompletions } = require('./commands/completions');
21
+ const { registerPlayground } = require('./commands/playground');
21
22
  const { showBanner, showQuickStart, getVersion } = require('./lib/banner');
22
23
 
23
24
  const version = getVersion();
@@ -40,6 +41,7 @@ registerExplain(program);
40
41
  registerSimilarity(program);
41
42
  registerIngest(program);
42
43
  registerCompletions(program);
44
+ registerPlayground(program);
43
45
 
44
46
  // Append disclaimer to all help output
45
47
  program.addHelpText('after', `
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { exec } = require('child_process');
7
+
8
+ /**
9
+ * Register the playground command on a Commander program.
10
+ * @param {import('commander').Command} program
11
+ */
12
+ function registerPlayground(program) {
13
+ program
14
+ .command('playground')
15
+ .description('Launch interactive web playground for Voyage AI')
16
+ .option('-p, --port <port>', 'Port to serve on', '3333')
17
+ .option('--no-open', 'Skip auto-opening browser')
18
+ .action(async (opts) => {
19
+ const port = parseInt(opts.port, 10) || 3333;
20
+ const server = createPlaygroundServer();
21
+
22
+ server.listen(port, () => {
23
+ const url = `http://localhost:${port}`;
24
+ console.log(`🧭 Playground running at ${url} — Press Ctrl+C to stop`);
25
+
26
+ if (opts.open !== false) {
27
+ openBrowser(url);
28
+ }
29
+ });
30
+
31
+ server.on('error', (err) => {
32
+ if (err.code === 'EADDRINUSE') {
33
+ console.error(`Error: Port ${port} is already in use. Try --port <other-port>`);
34
+ } else {
35
+ console.error(`Server error: ${err.message}`);
36
+ }
37
+ process.exit(1);
38
+ });
39
+
40
+ // Graceful shutdown
41
+ const shutdown = () => {
42
+ console.log('\n🧭 Playground stopped.');
43
+ server.close(() => process.exit(0));
44
+ // Force exit after 2s if connections linger
45
+ setTimeout(() => process.exit(0), 2000);
46
+ };
47
+ process.on('SIGINT', shutdown);
48
+ process.on('SIGTERM', shutdown);
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Create the playground HTTP server (exported for testing).
54
+ * @returns {http.Server}
55
+ */
56
+ function createPlaygroundServer() {
57
+ const { getApiBase, requireApiKey, generateEmbeddings } = require('../lib/api');
58
+ const { MODEL_CATALOG } = require('../lib/catalog');
59
+ const { cosineSimilarity } = require('../lib/math');
60
+ const { getConfigValue } = require('../lib/config');
61
+
62
+ const htmlPath = path.join(__dirname, '..', 'playground', 'index.html');
63
+
64
+ const server = http.createServer(async (req, res) => {
65
+ // CORS headers for local dev
66
+ res.setHeader('Access-Control-Allow-Origin', '*');
67
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
68
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
69
+
70
+ if (req.method === 'OPTIONS') {
71
+ res.writeHead(204);
72
+ res.end();
73
+ return;
74
+ }
75
+
76
+ try {
77
+ // Serve HTML
78
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
79
+ const html = fs.readFileSync(htmlPath, 'utf8');
80
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
81
+ res.end(html);
82
+ return;
83
+ }
84
+
85
+ // API: Models
86
+ if (req.method === 'GET' && req.url === '/api/models') {
87
+ const models = MODEL_CATALOG.filter(m => !m.legacy);
88
+ res.writeHead(200, { 'Content-Type': 'application/json' });
89
+ res.end(JSON.stringify({ models }));
90
+ return;
91
+ }
92
+
93
+ // API: Config
94
+ if (req.method === 'GET' && req.url === '/api/config') {
95
+ const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
96
+ res.writeHead(200, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({
98
+ baseUrl: getApiBase(),
99
+ hasKey: !!key,
100
+ }));
101
+ return;
102
+ }
103
+
104
+ // Parse JSON body for POST routes
105
+ if (req.method === 'POST') {
106
+ const body = await readBody(req);
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(body);
110
+ } catch {
111
+ res.writeHead(400, { 'Content-Type': 'application/json' });
112
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
113
+ return;
114
+ }
115
+
116
+ // API: Embed
117
+ if (req.url === '/api/embed') {
118
+ const { texts, model, inputType, dimensions } = parsed;
119
+ if (!texts || !Array.isArray(texts) || texts.length === 0) {
120
+ res.writeHead(400, { 'Content-Type': 'application/json' });
121
+ res.end(JSON.stringify({ error: 'texts must be a non-empty array' }));
122
+ return;
123
+ }
124
+ const result = await generateEmbeddings(texts, {
125
+ model: model || undefined,
126
+ inputType: inputType || undefined,
127
+ dimensions: dimensions || undefined,
128
+ });
129
+ res.writeHead(200, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify(result));
131
+ return;
132
+ }
133
+
134
+ // API: Rerank
135
+ if (req.url === '/api/rerank') {
136
+ const { query, documents, model, topK } = parsed;
137
+ if (!query || !documents || !Array.isArray(documents)) {
138
+ res.writeHead(400, { 'Content-Type': 'application/json' });
139
+ res.end(JSON.stringify({ error: 'query and documents are required' }));
140
+ return;
141
+ }
142
+ const { apiRequest } = require('../lib/api');
143
+ const rerankBody = {
144
+ query,
145
+ documents,
146
+ model: model || 'rerank-2.5',
147
+ };
148
+ if (topK) rerankBody.top_k = topK;
149
+ const result = await apiRequest('/rerank', rerankBody);
150
+ res.writeHead(200, { 'Content-Type': 'application/json' });
151
+ res.end(JSON.stringify(result));
152
+ return;
153
+ }
154
+
155
+ // API: Similarity
156
+ if (req.url === '/api/similarity') {
157
+ const { texts, model } = parsed;
158
+ if (!texts || !Array.isArray(texts) || texts.length < 2) {
159
+ res.writeHead(400, { 'Content-Type': 'application/json' });
160
+ res.end(JSON.stringify({ error: 'texts must be an array with at least 2 items' }));
161
+ return;
162
+ }
163
+ const result = await generateEmbeddings(texts, { model: model || undefined });
164
+ const embeddings = result.data.map(d => d.embedding);
165
+
166
+ // Compute pairwise similarity matrix
167
+ const matrix = [];
168
+ for (let i = 0; i < embeddings.length; i++) {
169
+ const row = [];
170
+ for (let j = 0; j < embeddings.length; j++) {
171
+ row.push(i === j ? 1.0 : cosineSimilarity(embeddings[i], embeddings[j]));
172
+ }
173
+ matrix.push(row);
174
+ }
175
+
176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
177
+ res.end(JSON.stringify({
178
+ matrix,
179
+ embeddings: result.data,
180
+ usage: result.usage,
181
+ model: result.model,
182
+ }));
183
+ return;
184
+ }
185
+ }
186
+
187
+ // 404
188
+ res.writeHead(404, { 'Content-Type': 'application/json' });
189
+ res.end(JSON.stringify({ error: 'Not found' }));
190
+
191
+ } catch (err) {
192
+ // Catch API errors that call process.exit — we override for playground
193
+ console.error(`Playground API error: ${err.message}`);
194
+ if (!res.headersSent) {
195
+ res.writeHead(500, { 'Content-Type': 'application/json' });
196
+ res.end(JSON.stringify({ error: err.message || 'Internal server error' }));
197
+ }
198
+ }
199
+ });
200
+
201
+ return server;
202
+ }
203
+
204
+ /**
205
+ * Read the full request body as a string.
206
+ * @param {http.IncomingMessage} req
207
+ * @returns {Promise<string>}
208
+ */
209
+ function readBody(req) {
210
+ return new Promise((resolve, reject) => {
211
+ const chunks = [];
212
+ req.on('data', chunk => chunks.push(chunk));
213
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
214
+ req.on('error', reject);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Open a URL in the default browser (cross-platform).
220
+ * @param {string} url
221
+ */
222
+ function openBrowser(url) {
223
+ const platform = process.platform;
224
+ let cmd;
225
+ if (platform === 'darwin') cmd = `open "${url}"`;
226
+ else if (platform === 'win32') cmd = `start "${url}"`;
227
+ else cmd = `xdg-open "${url}"`;
228
+
229
+ exec(cmd, (err) => {
230
+ if (err) {
231
+ console.log(`Could not auto-open browser. Visit: ${url}`);
232
+ }
233
+ });
234
+ }
235
+
236
+ module.exports = { registerPlayground, createPlaygroundServer };
package/src/lib/ui.js CHANGED
@@ -1,8 +1,32 @@
1
1
  'use strict';
2
2
 
3
3
  const pc = require('picocolors');
4
- const oraModule = require('ora');
5
- const ora = oraModule.default || oraModule;
4
+
5
+ // ora v9 is ESM-only. Use dynamic import with a sync fallback for environments
6
+ // that don't support top-level require() of ESM (Node 18).
7
+ let _ora = null;
8
+
9
+ /**
10
+ * Get the ora spinner function. Lazy-loaded via dynamic import.
11
+ * Returns a no-op fallback if ora can't be loaded (Node 18 CJS compat).
12
+ * @returns {Promise<Function>}
13
+ */
14
+ async function getOra() {
15
+ if (_ora) return _ora;
16
+ try {
17
+ const mod = await import('ora');
18
+ _ora = mod.default || mod;
19
+ } catch {
20
+ // Fallback: no-op spinner for environments where ora can't load
21
+ _ora = ({ text }) => ({
22
+ start() { if (text) process.stderr.write(text + '\n'); return this; },
23
+ stop() { return this; },
24
+ succeed() { return this; },
25
+ fail() { return this; },
26
+ });
27
+ }
28
+ return _ora;
29
+ }
6
30
 
7
31
  // Semantic color helpers
8
32
  const ui = {
@@ -40,8 +64,33 @@ const ui = {
40
64
  return s;
41
65
  },
42
66
 
43
- // Spinner
44
- spinner: (text) => ora({ text, color: 'cyan' }),
67
+ /**
68
+ * Create a spinner. Returns an object with start()/stop().
69
+ * Because ora is loaded async, this returns a proxy that buffers
70
+ * the start call until ora is ready.
71
+ * @param {string} text
72
+ * @returns {{ start: Function, stop: Function }}
73
+ */
74
+ spinner: (text) => {
75
+ let realSpinner = null;
76
+ let started = false;
77
+ const proxy = {
78
+ start() {
79
+ started = true;
80
+ getOra().then(ora => {
81
+ realSpinner = ora({ text, color: 'cyan' });
82
+ if (started) realSpinner.start();
83
+ });
84
+ return proxy;
85
+ },
86
+ stop() {
87
+ started = false;
88
+ if (realSpinner) realSpinner.stop();
89
+ return proxy;
90
+ },
91
+ };
92
+ return proxy;
93
+ },
45
94
  };
46
95
 
47
96
  module.exports = ui;