voyageai-cli 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,36 @@
1
1
  'use strict';
2
2
 
3
3
  const { MODEL_CATALOG } = require('../lib/catalog');
4
- const { API_BASE } = require('../lib/api');
4
+ const { getApiBase } = require('../lib/api');
5
5
  const { formatTable } = require('../lib/format');
6
6
  const ui = require('../lib/ui');
7
7
 
8
+ /**
9
+ * Shorten dimensions string for compact display.
10
+ * "1024 (default), 256, 512, 2048" → "1024*"
11
+ * "1024" → "1024"
12
+ * "—" → "—"
13
+ * @param {string} dims
14
+ * @returns {string}
15
+ */
16
+ function compactDimensions(dims) {
17
+ if (dims === '—') return dims;
18
+ const match = dims.match(/^(\d+)\s*\(default\)/);
19
+ if (match) return match[1] + '*';
20
+ return dims;
21
+ }
22
+
23
+ /**
24
+ * Shorten price string for compact display.
25
+ * "$0.12/1M tokens" → "$0.12/1M"
26
+ * "$0.12/M + $0.60/B px" → "$0.12/M+$0.60/Bpx"
27
+ * @param {string} price
28
+ * @returns {string}
29
+ */
30
+ function compactPrice(price) {
31
+ return price.replace('/1M tokens', '/1M').replace(' + ', '+').replace('/B px', '/Bpx');
32
+ }
33
+
8
34
  /**
9
35
  * Register the models command on a Commander program.
10
36
  * @param {import('commander').Command} program
@@ -14,15 +40,26 @@ function registerModels(program) {
14
40
  .command('models')
15
41
  .description('List available Voyage AI models')
16
42
  .option('-t, --type <type>', 'Filter by type: embedding, reranking, or all', 'all')
43
+ .option('-a, --all', 'Show all models including legacy')
44
+ .option('-w, --wide', 'Wide output (show all columns untruncated)')
17
45
  .option('--json', 'Machine-readable JSON output')
18
46
  .option('-q, --quiet', 'Suppress non-essential output')
19
47
  .action((opts) => {
20
48
  let models = MODEL_CATALOG;
21
49
 
50
+ // Separate current and legacy models
51
+ const showLegacy = opts.all;
52
+ const currentModels = models.filter(m => !m.legacy);
53
+ const legacyModels = models.filter(m => m.legacy);
54
+
22
55
  if (opts.type !== 'all') {
23
56
  models = models.filter(m => m.type === opts.type);
24
57
  }
25
58
 
59
+ if (!showLegacy) {
60
+ models = models.filter(m => !m.legacy);
61
+ }
62
+
26
63
  if (opts.json) {
27
64
  console.log(JSON.stringify(models, null, 2));
28
65
  return;
@@ -33,28 +70,69 @@ function registerModels(program) {
33
70
  return;
34
71
  }
35
72
 
73
+ const apiBase = getApiBase();
74
+
36
75
  if (!opts.quiet) {
37
76
  console.log(ui.bold('Voyage AI Models'));
38
- console.log(ui.dim(`(via MongoDB AI API — ${API_BASE})`));
77
+ console.log(ui.dim(`(via ${apiBase})`));
39
78
  console.log('');
40
79
  }
41
80
 
42
- const headers = ['Model', 'Type', 'Context', 'Dimensions', 'Price', 'Best For'];
43
- const rows = models.map(m => {
81
+ // Split models for display
82
+ const displayCurrent = models.filter(m => !m.legacy);
83
+ const displayLegacy = models.filter(m => m.legacy);
84
+
85
+ const formatWideRow = (m) => {
44
86
  const name = ui.cyan(m.name);
45
87
  const type = m.type === 'embedding' ? ui.green(m.type) : ui.yellow(m.type);
46
88
  const price = ui.dim(m.price);
47
89
  return [name, type, m.context, m.dimensions, price, m.bestFor];
48
- });
90
+ };
49
91
 
50
- // Use bold headers
51
- const boldHeaders = headers.map(h => ui.bold(h));
52
- console.log(formatTable(boldHeaders, rows));
92
+ const formatCompactRow = (m) => {
93
+ const name = ui.cyan(m.name);
94
+ const type = m.type === 'embedding' ? ui.green('embed') : ui.yellow('rerank');
95
+ const dims = compactDimensions(m.dimensions);
96
+ const price = ui.dim(compactPrice(m.price));
97
+ return [name, type, dims, price, m.shortFor || m.bestFor];
98
+ };
99
+
100
+ if (opts.wide) {
101
+ const headers = ['Model', 'Type', 'Context', 'Dimensions', 'Price', 'Best For'];
102
+ const boldHeaders = headers.map(h => ui.bold(h));
103
+ const rows = displayCurrent.map(formatWideRow);
104
+ console.log(formatTable(boldHeaders, rows));
105
+
106
+ if (showLegacy && displayLegacy.length > 0) {
107
+ console.log('');
108
+ console.log(ui.dim('Legacy Models (use latest for better quality)'));
109
+ const legacyRows = displayLegacy.map(formatWideRow);
110
+ console.log(formatTable(boldHeaders, legacyRows));
111
+ }
112
+ } else {
113
+ const headers = ['Model', 'Type', 'Dims', 'Price', 'Use Case'];
114
+ const boldHeaders = headers.map(h => ui.bold(h));
115
+ const rows = displayCurrent.map(formatCompactRow);
116
+ console.log(formatTable(boldHeaders, rows));
117
+
118
+ if (showLegacy && displayLegacy.length > 0) {
119
+ console.log('');
120
+ console.log(ui.dim('Legacy Models (use latest for better quality)'));
121
+ const legacyRows = displayLegacy.map(formatCompactRow);
122
+ console.log(formatTable(boldHeaders, legacyRows));
123
+ }
124
+ }
53
125
 
54
126
  if (!opts.quiet) {
55
127
  console.log('');
128
+ if (!opts.wide) {
129
+ console.log(ui.dim('* = also supports 256, 512, 2048 dimensions'));
130
+ }
56
131
  console.log(ui.dim('Free tier: 200M tokens (most models), 50M (domain-specific)'));
57
132
  console.log(ui.dim('All 4-series models share the same embedding space.'));
133
+ if (!opts.wide) {
134
+ console.log(ui.dim('Use --wide for full details.'));
135
+ }
58
136
  }
59
137
  });
60
138
  }
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { API_BASE, requireApiKey } = require('../lib/api');
3
+ const { getApiBase, requireApiKey } = require('../lib/api');
4
4
  const ui = require('../lib/ui');
5
5
 
6
6
  /**
@@ -28,6 +28,7 @@ function registerPing(program) {
28
28
  const useColor = !opts.json;
29
29
  const useSpinner = useColor && !opts.quiet;
30
30
 
31
+ const apiBase = getApiBase();
31
32
  const model = 'voyage-4-lite';
32
33
  const startTime = Date.now();
33
34
 
@@ -38,7 +39,7 @@ function registerPing(program) {
38
39
  }
39
40
 
40
41
  try {
41
- const response = await fetch(`${API_BASE}/embeddings`, {
42
+ const response = await fetch(`${apiBase}/embeddings`, {
42
43
  method: 'POST',
43
44
  headers: {
44
45
  'Content-Type': 'application/json',
@@ -83,7 +84,7 @@ function registerPing(program) {
83
84
  const dims = data.data && data.data[0] ? data.data[0].embedding.length : 'unknown';
84
85
  const tokens = data.usage ? data.usage.total_tokens : 'unknown';
85
86
 
86
- results.voyage = { ok: true, elapsed, model, dimensions: dims, tokens, endpoint: API_BASE };
87
+ results.voyage = { ok: true, elapsed, model, dimensions: dims, tokens, endpoint: apiBase };
87
88
 
88
89
  if (spin) spin.stop();
89
90
 
@@ -93,7 +94,7 @@ function registerPing(program) {
93
94
  console.log(`ok ${elapsed}ms`);
94
95
  } else {
95
96
  console.log(ui.success(`Connected to Voyage AI API ${ui.dim('(' + elapsed + 'ms)')}`));
96
- console.log(ui.label('Endpoint', API_BASE));
97
+ console.log(ui.label('Endpoint', apiBase));
97
98
  console.log(ui.label('Model', model));
98
99
  console.log(ui.label('Dimensions', String(dims)));
99
100
  console.log(ui.label('Tokens', String(tokens)));
@@ -18,6 +18,9 @@ function registerRerank(program) {
18
18
  .option('--documents-file <path>', 'File with documents (JSON array or newline-delimited)')
19
19
  .option('-m, --model <model>', 'Reranking model', DEFAULT_RERANK_MODEL)
20
20
  .option('-k, --top-k <n>', 'Return top K results', (v) => parseInt(v, 10))
21
+ .option('--truncation', 'Enable truncation for long inputs')
22
+ .option('--no-truncation', 'Disable truncation')
23
+ .option('--return-documents', 'Return document text in results')
21
24
  .option('--json', 'Machine-readable JSON output')
22
25
  .option('-q, --quiet', 'Suppress non-essential output')
23
26
  .action(async (opts) => {
@@ -76,6 +79,12 @@ function registerRerank(program) {
76
79
  if (opts.topK) {
77
80
  body.top_k = opts.topK;
78
81
  }
82
+ if (opts.truncation !== undefined) {
83
+ body.truncation = opts.truncation;
84
+ }
85
+ if (opts.returnDocuments) {
86
+ body.return_documents = true;
87
+ }
79
88
 
80
89
  const useColor = !opts.json;
81
90
  const useSpinner = useColor && !opts.quiet;
@@ -106,8 +115,9 @@ function registerRerank(program) {
106
115
 
107
116
  if (result.data) {
108
117
  for (const item of result.data) {
109
- const docPreview = documents[item.index].substring(0, 80);
110
- const ellipsis = documents[item.index].length > 80 ? '...' : '';
118
+ const docText = item.document || documents[item.index];
119
+ const docPreview = docText.substring(0, 80);
120
+ const ellipsis = docText.length > 80 ? '...' : '';
111
121
  console.log(`${ui.dim('[' + item.index + ']')} Score: ${ui.score(item.relevance_score)} ${ui.dim('"' + docPreview + ellipsis + '"')}`);
112
122
  }
113
123
  }
package/src/lib/api.js CHANGED
@@ -1,8 +1,32 @@
1
1
  'use strict';
2
2
 
3
- const API_BASE = 'https://ai.mongodb.com/v1';
3
+ const ATLAS_API_BASE = 'https://ai.mongodb.com/v1';
4
+ const VOYAGE_API_BASE = 'https://api.voyageai.com/v1';
4
5
  const MAX_RETRIES = 3;
5
6
 
7
+ /**
8
+ * Resolve the API base URL.
9
+ * Priority: VOYAGE_API_BASE env → config baseUrl → auto-detect from key prefix.
10
+ * Keys starting with 'pa-' that work on Voyage platform use VOYAGE_API_BASE.
11
+ * @returns {string}
12
+ */
13
+ function getApiBase() {
14
+ const { getConfigValue } = require('./config');
15
+
16
+ // Explicit override wins
17
+ const envBase = process.env.VOYAGE_API_BASE;
18
+ if (envBase) return envBase.replace(/\/+$/, '');
19
+
20
+ const configBase = getConfigValue('baseUrl');
21
+ if (configBase) return configBase.replace(/\/+$/, '');
22
+
23
+ // Default to Atlas endpoint
24
+ return ATLAS_API_BASE;
25
+ }
26
+
27
+ // Legacy export for backward compat
28
+ const API_BASE = ATLAS_API_BASE;
29
+
6
30
  /**
7
31
  * Get the Voyage API key or exit with a helpful error.
8
32
  * Checks: env var → config file.
@@ -18,6 +42,7 @@ function requireApiKey() {
18
42
  console.error('Option 2: vai config set api-key <your-key>');
19
43
  console.error('');
20
44
  console.error('Get one from MongoDB Atlas → AI Models → Create model API key');
45
+ console.error(' or Voyage AI platform → Dashboard → API Keys');
21
46
  process.exit(1);
22
47
  }
23
48
  return key;
@@ -40,7 +65,8 @@ function sleep(ms) {
40
65
  */
41
66
  async function apiRequest(endpoint, body) {
42
67
  const apiKey = requireApiKey();
43
- const url = `${API_BASE}${endpoint}`;
68
+ const base = getApiBase();
69
+ const url = `${base}${endpoint}`;
44
70
 
45
71
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
46
72
  const response = await fetch(url, {
@@ -69,6 +95,23 @@ async function apiRequest(endpoint, body) {
69
95
  errorDetail = await response.text();
70
96
  }
71
97
  console.error(`API Error (${response.status}): ${errorDetail}`);
98
+
99
+ // Help users diagnose endpoint mismatch
100
+ if (response.status === 403 && base === ATLAS_API_BASE) {
101
+ console.error('');
102
+ console.error('Hint: 403 on ai.mongodb.com often means your key is for the Voyage AI');
103
+ console.error('platform, not MongoDB Atlas. Try switching the base URL:');
104
+ console.error('');
105
+ console.error(' vai config set base-url https://api.voyageai.com/v1/');
106
+ console.error('');
107
+ console.error('Or set VOYAGE_API_BASE=https://api.voyageai.com/v1/ in your environment.');
108
+ } else if (response.status === 401 && base === VOYAGE_API_BASE) {
109
+ console.error('');
110
+ console.error('Hint: 401 on api.voyageai.com may mean your key is an Atlas AI key.');
111
+ console.error('Try switching back:');
112
+ console.error('');
113
+ console.error(' vai config set base-url https://ai.mongodb.com/v1/');
114
+ }
72
115
  process.exit(1);
73
116
  }
74
117
 
@@ -83,6 +126,7 @@ async function apiRequest(endpoint, body) {
83
126
  * @param {string} [options.model] - Model name
84
127
  * @param {string} [options.inputType] - Input type (query|document)
85
128
  * @param {number} [options.dimensions] - Output dimensions
129
+ * @param {boolean} [options.truncation] - Enable/disable truncation
86
130
  * @returns {Promise<object>} API response with embeddings
87
131
  */
88
132
  async function generateEmbeddings(texts, options = {}) {
@@ -99,12 +143,18 @@ async function generateEmbeddings(texts, options = {}) {
99
143
  if (options.dimensions) {
100
144
  body.output_dimension = options.dimensions;
101
145
  }
146
+ if (options.truncation !== undefined) {
147
+ body.truncation = options.truncation;
148
+ }
102
149
 
103
150
  return apiRequest('/embeddings', body);
104
151
  }
105
152
 
106
153
  module.exports = {
107
154
  API_BASE,
155
+ ATLAS_API_BASE,
156
+ VOYAGE_API_BASE,
157
+ getApiBase,
108
158
  requireApiKey,
109
159
  apiRequest,
110
160
  generateEmbeddings,
@@ -24,16 +24,25 @@ function getDefaultDimensions() {
24
24
 
25
25
  /** @type {Array<{name: string, type: string, context: string, dimensions: string, price: string, bestFor: string}>} */
26
26
  const MODEL_CATALOG = [
27
- { name: 'voyage-4-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/1M tokens', bestFor: 'Best quality, multilingual' },
28
- { name: 'voyage-4', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Balanced quality/perf' },
29
- { name: 'voyage-4-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Lowest cost' },
30
- { name: 'voyage-code-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Code retrieval' },
31
- { name: 'voyage-finance-2', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Finance' },
32
- { name: 'voyage-law-2', type: 'embedding', context: '16K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legal' },
33
- { name: 'voyage-context-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Contextualized chunks' },
34
- { name: 'voyage-multimodal-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/M + $0.60/B px', bestFor: 'Text + images + video' },
35
- { name: 'rerank-2.5', type: 'reranking', context: '32K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Best quality reranking' },
36
- { name: 'rerank-2.5-lite', type: 'reranking', context: '32K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Fast reranking' },
27
+ { 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' },
28
+ { name: 'voyage-4', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Balanced quality/perf', shortFor: 'Balanced' },
29
+ { name: 'voyage-4-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Lowest cost', shortFor: 'Budget' },
30
+ { name: 'voyage-code-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Code retrieval', shortFor: 'Code' },
31
+ { name: 'voyage-finance-2', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Finance', shortFor: 'Finance' },
32
+ { name: 'voyage-law-2', type: 'embedding', context: '16K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legal', shortFor: 'Legal' },
33
+ { name: 'voyage-context-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Contextualized chunks', shortFor: 'Context chunks' },
34
+ { name: 'voyage-multimodal-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/M + $0.60/B px', bestFor: 'Text + images + video', shortFor: 'Multimodal' },
35
+ { name: 'rerank-2.5', type: 'reranking', context: '32K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Best quality reranking', shortFor: 'Best reranker' },
36
+ { name: 'rerank-2.5-lite', type: 'reranking', context: '32K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Fast reranking', shortFor: 'Fast reranker' },
37
+ { name: 'voyage-4-nano', type: 'embedding', context: '32K', dimensions: '512 (default), 128, 256', price: 'Open-weight', bestFor: 'Open-weight / edge', shortFor: 'Open / edge' },
38
+ // Legacy models
39
+ { name: 'voyage-3-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Previous gen quality', shortFor: 'Previous gen quality', legacy: true },
40
+ { name: 'voyage-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Previous gen balanced', shortFor: 'Previous gen balanced', legacy: true },
41
+ { name: 'voyage-3.5-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Previous gen budget', shortFor: 'Previous gen budget', legacy: true },
42
+ { name: 'voyage-code-2', type: 'embedding', context: '16K', dimensions: '1536', price: '$0.12/1M tokens', bestFor: 'Legacy code', shortFor: 'Legacy code', legacy: true },
43
+ { name: 'voyage-multimodal-3', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legacy multimodal', shortFor: 'Legacy multimodal', legacy: true },
44
+ { name: 'rerank-2', type: 'reranking', context: '16K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Legacy reranker', shortFor: 'Legacy reranker', legacy: true },
45
+ { name: 'rerank-2-lite', type: 'reranking', context: '8K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Legacy fast reranker', shortFor: 'Legacy fast reranker', legacy: true },
37
46
  ];
38
47
 
39
48
  module.exports = {
package/src/lib/config.js CHANGED
@@ -13,6 +13,7 @@ const KEY_MAP = {
13
13
  'mongodb-uri': 'mongodbUri',
14
14
  'default-model': 'defaultModel',
15
15
  'default-dimensions': 'defaultDimensions',
16
+ 'base-url': 'baseUrl',
16
17
  };
17
18
 
18
19
  // Keys whose values should be masked in output
@@ -210,12 +210,15 @@ const concepts = {
210
210
  `The ${pc.cyan('input_type')} parameter tells the embedding model whether the text is a`,
211
211
  `${pc.cyan('search query')} or a ${pc.cyan('document')} being indexed. This matters for retrieval quality.`,
212
212
  ``,
213
- `${pc.bold('How it works:')} Voyage AI models internally prepend a short prompt to your text`,
214
- `based on input_type:`,
215
- ` ${pc.dim('• query →')} "Represent the query for retrieving relevant documents: "`,
216
- ` ${pc.dim('• document →')} "Represent the document for retrieval: "`,
213
+ `${pc.bold(' Do not omit this parameter for retrieval tasks.')} The official docs emphasize`,
214
+ `that omitting input_type degrades retrieval accuracy.`,
217
215
  ``,
218
- `These prompts bias the embedding to be ${pc.cyan('asymmetric')} query embeddings are`,
216
+ `${pc.bold('How it works:')} Voyage AI models internally prepend a specific prompt prefix`,
217
+ `to your text based on input_type:`,
218
+ ` ${pc.dim('• query →')} ${pc.cyan('"Represent the query for retrieving supporting documents: "')}`,
219
+ ` ${pc.dim('• document →')} ${pc.cyan('"Represent the document for retrieval: "')}`,
220
+ ``,
221
+ `These prefixes bias the embedding to be ${pc.cyan('asymmetric')} — query embeddings are`,
219
222
  `optimized to find relevant documents, and document embeddings are optimized`,
220
223
  `to be found by relevant queries.`,
221
224
  ``,
@@ -226,10 +229,10 @@ const concepts = {
226
229
  `${pc.bold('When to use each:')}`,
227
230
  ` ${pc.cyan('query')} — When embedding a search query or question`,
228
231
  ` ${pc.cyan('document')} — When embedding text to be stored and searched later`,
229
- ` ${pc.dim('(omit)')} — For clustering, classification, or symmetric similarity`,
232
+ ` ${pc.dim('(omit)')} — Only for clustering, classification, or symmetric similarity`,
230
233
  ``,
231
- `${pc.bold('Tip:')} Always use ${pc.cyan('--input-type document')} when running ${pc.cyan('vai store')}, and`,
232
- `${pc.cyan('--input-type query')} is the default for ${pc.cyan('vai search')}.`,
234
+ `${pc.bold('Tip:')} Always use ${pc.cyan('--input-type document')} when running ${pc.cyan('vai store')} or`,
235
+ `${pc.cyan('vai ingest')}, and ${pc.cyan('--input-type query')} when running ${pc.cyan('vai search')}.`,
233
236
  ].join('\n'),
234
237
  links: ['https://www.mongodb.com/docs/voyageai/models/text-embeddings/'],
235
238
  tryIt: [
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { Command } = require('commander');
6
+ const { registerCompletions, generateBashCompletions, generateZshCompletions } = require('../../src/commands/completions');
7
+
8
+ describe('completions command', () => {
9
+ let originalLog;
10
+ let originalWrite;
11
+ let originalError;
12
+ let originalExit;
13
+ let output;
14
+ let stdoutOutput;
15
+ let stderrOutput;
16
+
17
+ beforeEach(() => {
18
+ originalLog = console.log;
19
+ originalWrite = process.stdout.write;
20
+ originalError = console.error;
21
+ originalExit = process.exit;
22
+ output = [];
23
+ stdoutOutput = [];
24
+ stderrOutput = [];
25
+ console.log = (...args) => output.push(args.join(' '));
26
+ process.stdout.write = (data) => { stdoutOutput.push(data); return true; };
27
+ console.error = (...args) => stderrOutput.push(args.join(' '));
28
+ process.exit = (code) => { throw new Error(`EXIT_${code}`); };
29
+ });
30
+
31
+ afterEach(() => {
32
+ console.log = originalLog;
33
+ process.stdout.write = originalWrite;
34
+ console.error = originalError;
35
+ process.exit = originalExit;
36
+ });
37
+
38
+ it('registers correctly on a program', () => {
39
+ const program = new Command();
40
+ registerCompletions(program);
41
+ const cmd = program.commands.find(c => c.name() === 'completions');
42
+ assert.ok(cmd, 'completions command should be registered');
43
+ assert.ok(cmd.description().includes('completion'), 'should have a description about completions');
44
+ });
45
+
46
+ it('shows usage when called without shell argument', async () => {
47
+ const program = new Command();
48
+ program.exitOverride();
49
+ registerCompletions(program);
50
+
51
+ await program.parseAsync(['node', 'test', 'completions']);
52
+
53
+ const combined = output.join('\n');
54
+ assert.ok(combined.includes('bash'), 'should mention bash');
55
+ assert.ok(combined.includes('zsh'), 'should mention zsh');
56
+ });
57
+
58
+ it('outputs bash completion script', async () => {
59
+ const program = new Command();
60
+ program.exitOverride();
61
+ registerCompletions(program);
62
+
63
+ await program.parseAsync(['node', 'test', 'completions', 'bash']);
64
+
65
+ const combined = stdoutOutput.join('');
66
+ assert.ok(combined.includes('_vai_completions'), 'should contain bash completion function');
67
+ assert.ok(combined.includes('complete -F _vai_completions vai'), 'should register completion');
68
+ });
69
+
70
+ it('outputs zsh completion script', async () => {
71
+ const program = new Command();
72
+ program.exitOverride();
73
+ registerCompletions(program);
74
+
75
+ await program.parseAsync(['node', 'test', 'completions', 'zsh']);
76
+
77
+ const combined = stdoutOutput.join('');
78
+ assert.ok(combined.includes('#compdef vai'), 'should contain zsh compdef header');
79
+ assert.ok(combined.includes('_vai'), 'should contain zsh completion function');
80
+ });
81
+
82
+ it('rejects unknown shell', async () => {
83
+ const program = new Command();
84
+ program.exitOverride();
85
+ registerCompletions(program);
86
+
87
+ await assert.rejects(
88
+ () => program.parseAsync(['node', 'test', 'completions', 'fish']),
89
+ /EXIT_1/,
90
+ 'should exit with code 1 for unsupported shell'
91
+ );
92
+ const combined = stderrOutput.join('\n');
93
+ assert.ok(combined.includes('fish'), 'should mention the unknown shell name');
94
+ });
95
+ });
96
+
97
+ describe('generateBashCompletions', () => {
98
+ it('includes all 14 commands (including completions)', () => {
99
+ const script = generateBashCompletions();
100
+ const commands = ['embed', 'rerank', 'store', 'search', 'index', 'models', 'ping', 'config', 'demo', 'explain', 'similarity', 'ingest', 'completions', 'help'];
101
+ for (const cmd of commands) {
102
+ assert.ok(script.includes(cmd), `should include command: ${cmd}`);
103
+ }
104
+ });
105
+
106
+ it('includes model completions', () => {
107
+ const script = generateBashCompletions();
108
+ assert.ok(script.includes('voyage-4-large'), 'should include voyage-4-large model');
109
+ assert.ok(script.includes('rerank-2.5'), 'should include rerank-2.5 model');
110
+ });
111
+
112
+ it('includes flag completions for embed', () => {
113
+ const script = generateBashCompletions();
114
+ assert.ok(script.includes('--model'), 'should include --model flag');
115
+ assert.ok(script.includes('--dimensions'), 'should include --dimensions flag');
116
+ assert.ok(script.includes('--input-type'), 'should include --input-type flag');
117
+ });
118
+
119
+ it('includes index subcommands', () => {
120
+ const script = generateBashCompletions();
121
+ assert.ok(script.includes('create list delete'), 'should include index subcommands');
122
+ });
123
+
124
+ it('includes config subcommands', () => {
125
+ const script = generateBashCompletions();
126
+ assert.ok(script.includes('set get delete path reset'), 'should include config subcommands');
127
+ });
128
+
129
+ it('includes input-type values', () => {
130
+ const script = generateBashCompletions();
131
+ assert.ok(script.includes('query document'), 'should include input-type values');
132
+ });
133
+ });
134
+
135
+ describe('generateZshCompletions', () => {
136
+ it('includes compdef header', () => {
137
+ const script = generateZshCompletions();
138
+ assert.ok(script.startsWith('#compdef vai'), 'should start with #compdef vai');
139
+ });
140
+
141
+ it('includes all commands with descriptions', () => {
142
+ const script = generateZshCompletions();
143
+ const commands = ['embed', 'rerank', 'store', 'search', 'index', 'models', 'ping', 'config', 'demo', 'explain', 'similarity', 'ingest', 'completions'];
144
+ for (const cmd of commands) {
145
+ assert.ok(script.includes(`'${cmd}:`), `should include command with description: ${cmd}`);
146
+ }
147
+ });
148
+
149
+ it('includes model names', () => {
150
+ const script = generateZshCompletions();
151
+ assert.ok(script.includes('voyage-4-large'), 'should include voyage-4-large model');
152
+ assert.ok(script.includes('voyage-code-3'), 'should include voyage-code-3 model');
153
+ });
154
+
155
+ it('includes explain topics', () => {
156
+ const script = generateZshCompletions();
157
+ assert.ok(script.includes('embeddings'), 'should include embeddings topic');
158
+ assert.ok(script.includes('cosine-similarity'), 'should include cosine-similarity topic');
159
+ assert.ok(script.includes('batch-processing'), 'should include batch-processing topic');
160
+ });
161
+
162
+ it('includes file completion for --file flags', () => {
163
+ const script = generateZshCompletions();
164
+ assert.ok(script.includes('_files'), 'should use _files completion');
165
+ });
166
+ });
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, beforeEach, afterEach, mock } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { Command } = require('commander');
6
+ const { registerEmbed } = require('../../src/commands/embed');
7
+
8
+ describe('embed command', () => {
9
+ it('registers correctly on a program', () => {
10
+ const program = new Command();
11
+ registerEmbed(program);
12
+ const embedCmd = program.commands.find(c => c.name() === 'embed');
13
+ assert.ok(embedCmd, 'embed command should be registered');
14
+ });
15
+
16
+ it('has --truncation flag', () => {
17
+ const program = new Command();
18
+ registerEmbed(program);
19
+ const embedCmd = program.commands.find(c => c.name() === 'embed');
20
+ const optionNames = embedCmd.options.map(o => o.long);
21
+ assert.ok(optionNames.includes('--truncation'), 'should have --truncation option');
22
+ assert.ok(optionNames.includes('--no-truncation'), 'should have --no-truncation option');
23
+ });
24
+
25
+ it('has --input-type flag', () => {
26
+ const program = new Command();
27
+ registerEmbed(program);
28
+ const embedCmd = program.commands.find(c => c.name() === 'embed');
29
+ const optionNames = embedCmd.options.map(o => o.long);
30
+ assert.ok(optionNames.includes('--input-type'), 'should have --input-type option');
31
+ });
32
+ });
@@ -243,6 +243,19 @@ describe('ingest', () => {
243
243
  assert.ok(optionNames.includes('--json'), 'should have --json option');
244
244
  assert.ok(optionNames.includes('--quiet'), 'should have --quiet option');
245
245
  assert.ok(optionNames.includes('--strict'), 'should have --strict option');
246
+ assert.ok(optionNames.includes('--input-type'), 'should have --input-type option');
247
+ });
248
+
249
+ it('--input-type defaults to document', () => {
250
+ delete require.cache[require.resolve('../../src/commands/ingest')];
251
+ const { registerIngest } = require('../../src/commands/ingest');
252
+ const { Command } = require('commander');
253
+ const program = new Command();
254
+ registerIngest(program);
255
+
256
+ const ingestCmd = program.commands.find(c => c.name() === 'ingest');
257
+ const inputTypeOpt = ingestCmd.options.find(o => o.long === '--input-type');
258
+ assert.equal(inputTypeOpt.defaultValue, 'document', '--input-type should default to document');
246
259
  });
247
260
  });
248
261
  });