voyageai-cli 1.20.6 → 1.22.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +142 -26
  2. package/README.md +130 -2
  3. package/package.json +3 -2
  4. package/src/cli.js +10 -0
  5. package/src/commands/bug.js +249 -0
  6. package/src/commands/eval.js +420 -10
  7. package/src/commands/generate.js +220 -0
  8. package/src/commands/playground.js +93 -0
  9. package/src/commands/purge.js +271 -0
  10. package/src/commands/refresh.js +322 -0
  11. package/src/commands/scaffold.js +217 -0
  12. package/src/lib/codegen.js +339 -0
  13. package/src/lib/explanations.js +155 -0
  14. package/src/lib/scaffold-structure.js +114 -0
  15. package/src/lib/templates/nextjs/README.md.tpl +106 -0
  16. package/src/lib/templates/nextjs/env.example.tpl +8 -0
  17. package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
  18. package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
  19. package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
  20. package/src/lib/templates/nextjs/package.json.tpl +33 -0
  21. package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
  22. package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
  23. package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
  24. package/src/lib/templates/nextjs/theme.js.tpl +84 -0
  25. package/src/lib/templates/python/README.md.tpl +145 -0
  26. package/src/lib/templates/python/app.py.tpl +221 -0
  27. package/src/lib/templates/python/chunker.py.tpl +127 -0
  28. package/src/lib/templates/python/env.example.tpl +12 -0
  29. package/src/lib/templates/python/mongo_client.py.tpl +125 -0
  30. package/src/lib/templates/python/requirements.txt.tpl +10 -0
  31. package/src/lib/templates/python/voyage_client.py.tpl +124 -0
  32. package/src/lib/templates/vanilla/README.md.tpl +156 -0
  33. package/src/lib/templates/vanilla/client.js.tpl +103 -0
  34. package/src/lib/templates/vanilla/connection.js.tpl +126 -0
  35. package/src/lib/templates/vanilla/env.example.tpl +11 -0
  36. package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
  37. package/src/lib/templates/vanilla/package.json.tpl +31 -0
  38. package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
  39. package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
  40. package/src/lib/templates/vanilla/server.js.tpl +81 -0
  41. package/src/lib/zip.js +130 -0
  42. package/src/playground/index.html +708 -3
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const p = require('@clack/prompts');
6
+ const { loadProject } = require('../lib/project');
7
+ const { renderTemplate, buildContext, listTemplates } = require('../lib/codegen');
8
+ const { PROJECT_STRUCTURE } = require('../lib/scaffold-structure');
9
+ const ui = require('../lib/ui');
10
+
11
+ /**
12
+ * Create a directory if it doesn't exist.
13
+ */
14
+ function ensureDir(dirPath) {
15
+ if (!fs.existsSync(dirPath)) {
16
+ fs.mkdirSync(dirPath, { recursive: true });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Write a file, creating parent directories as needed.
22
+ */
23
+ function writeFile(filePath, content) {
24
+ ensureDir(path.dirname(filePath));
25
+ fs.writeFileSync(filePath, content, 'utf8');
26
+ }
27
+
28
+ /**
29
+ * Check if a directory exists and is not empty.
30
+ */
31
+ function directoryExists(dirPath) {
32
+ if (!fs.existsSync(dirPath)) return false;
33
+ const files = fs.readdirSync(dirPath);
34
+ return files.length > 0;
35
+ }
36
+
37
+ /**
38
+ * Register the scaffold command.
39
+ * @param {import('commander').Command} program
40
+ */
41
+ function registerScaffold(program) {
42
+ program
43
+ .command('scaffold <name>')
44
+ .description('Create a complete starter project')
45
+ .option('-t, --target <target>', 'Target framework: vanilla, nextjs, python', 'vanilla')
46
+ .option('-m, --model <model>', 'Override embedding model')
47
+ .option('--db <database>', 'Override database name')
48
+ .option('--collection <name>', 'Override collection name')
49
+ .option('--field <name>', 'Override embedding field name')
50
+ .option('--index <name>', 'Override vector index name')
51
+ .option('-d, --dimensions <n>', 'Override dimensions', parseInt)
52
+ .option('--no-rerank', 'Omit reranking from generated code')
53
+ .option('--rerank-model <model>', 'Rerank model to use')
54
+ .option('--force', 'Overwrite existing directory')
55
+ .option('--json', 'Output file manifest as JSON (no file creation)')
56
+ .option('--dry-run', 'Show what would be created without writing')
57
+ .option('-q, --quiet', 'Suppress non-essential output')
58
+ .action(async (name, opts) => {
59
+ try {
60
+ const target = opts.target;
61
+ const projectDir = path.resolve(process.cwd(), name);
62
+
63
+ // Validate target
64
+ if (!PROJECT_STRUCTURE[target]) {
65
+ console.error(ui.error(`Invalid target: ${target}`));
66
+ console.error(ui.dim(` Valid targets: ${Object.keys(PROJECT_STRUCTURE).join(', ')}`));
67
+ process.exit(1);
68
+ }
69
+
70
+ const structure = PROJECT_STRUCTURE[target];
71
+
72
+ // Check if directory exists
73
+ if (directoryExists(projectDir) && !opts.force && !opts.dryRun && !opts.json) {
74
+ console.error(ui.error(`Directory already exists: ${name}`));
75
+ console.error(ui.dim(' Use --force to overwrite.'));
76
+ process.exit(1);
77
+ }
78
+
79
+ // Load project config
80
+ let project = {};
81
+ try {
82
+ project = loadProject();
83
+ } catch (e) {
84
+ // No .vai.json, use defaults
85
+ if (!opts.quiet && !opts.json) {
86
+ console.error(ui.warn('No .vai.json found, using defaults'));
87
+ }
88
+ }
89
+
90
+ // Build context with overrides
91
+ const context = buildContext(project, {
92
+ model: opts.model,
93
+ db: opts.db,
94
+ collection: opts.collection,
95
+ field: opts.field,
96
+ index: opts.index,
97
+ dimensions: opts.dimensions,
98
+ rerank: opts.rerank,
99
+ rerankModel: opts.rerankModel,
100
+ projectName: name,
101
+ });
102
+
103
+ // Build file manifest
104
+ const manifest = [];
105
+
106
+ // Render template files
107
+ for (const file of structure.files) {
108
+ const content = renderTemplate(target, file.template, context);
109
+ manifest.push({
110
+ path: file.output,
111
+ fullPath: path.join(projectDir, file.output),
112
+ source: `${target}/${file.template}`,
113
+ size: content.length,
114
+ content,
115
+ });
116
+ }
117
+
118
+ // Add extra static files
119
+ if (structure.extraFiles) {
120
+ for (const file of structure.extraFiles) {
121
+ const content = typeof file.content === 'function'
122
+ ? file.content(context)
123
+ : file.content;
124
+ manifest.push({
125
+ path: file.output,
126
+ fullPath: path.join(projectDir, file.output),
127
+ source: 'static',
128
+ size: content.length,
129
+ content,
130
+ });
131
+ }
132
+ }
133
+
134
+ // JSON output mode
135
+ if (opts.json) {
136
+ const output = {
137
+ name,
138
+ target,
139
+ directory: projectDir,
140
+ files: manifest.map(f => ({
141
+ path: f.path,
142
+ size: f.size,
143
+ source: f.source,
144
+ })),
145
+ config: context,
146
+ };
147
+ console.log(JSON.stringify(output, null, 2));
148
+ return;
149
+ }
150
+
151
+ // Dry run mode
152
+ if (opts.dryRun) {
153
+ console.log('');
154
+ console.log(ui.bold(`Would create: ${name}/ (${structure.description})`));
155
+ console.log('');
156
+ for (const file of manifest) {
157
+ console.log(` ${ui.cyan('+')} ${file.path} ${ui.dim(`(${file.size} bytes)`)}`);
158
+ }
159
+ console.log('');
160
+ console.log(ui.dim(`Total: ${manifest.length} files`));
161
+ return;
162
+ }
163
+
164
+ // Create project directory
165
+ if (!opts.quiet) {
166
+ console.log('');
167
+ console.log(ui.bold(`Creating ${name}/ (${structure.description})`));
168
+ console.log('');
169
+ }
170
+
171
+ ensureDir(projectDir);
172
+
173
+ // Write all files
174
+ for (const file of manifest) {
175
+ writeFile(file.fullPath, file.content);
176
+ if (!opts.quiet) {
177
+ console.log(` ${ui.cyan('✓')} ${file.path}`);
178
+ }
179
+ }
180
+
181
+ // Success message with next steps
182
+ if (!opts.quiet) {
183
+ console.log('');
184
+ console.log(ui.success(`Created ${manifest.length} files in ${name}/`));
185
+ console.log('');
186
+
187
+ // Use clack's note for next steps
188
+ const steps = [
189
+ `cd ${name}`,
190
+ `cp .env.example .env`,
191
+ `# Edit .env with your API keys`,
192
+ structure.postInstall,
193
+ structure.startCommand,
194
+ ];
195
+
196
+ p.note(steps.join('\n'), 'Next steps');
197
+
198
+ console.log('');
199
+ console.log(ui.dim('Configuration:'));
200
+ console.log(ui.dim(` Model: ${context.model}`));
201
+ console.log(ui.dim(` Database: ${context.db}.${context.collection}`));
202
+ console.log(ui.dim(` Dimensions: ${context.dimensions}`));
203
+ if (context.rerank) {
204
+ console.log(ui.dim(` Reranking: ${context.rerankModel}`));
205
+ }
206
+ console.log('');
207
+ }
208
+
209
+ } catch (err) {
210
+ console.error(ui.error(err.message));
211
+ if (process.env.DEBUG) console.error(err.stack);
212
+ process.exit(1);
213
+ }
214
+ });
215
+ }
216
+
217
+ module.exports = { registerScaffold, PROJECT_STRUCTURE };
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Safely get the CLI version, handling both development and packaged Electron app.
8
+ * @returns {string} The version string or 'unknown'
9
+ */
10
+ function getCliVersion() {
11
+ // Try multiple paths to find package.json
12
+ const possiblePaths = [
13
+ path.join(__dirname, '..', '..', 'package.json'), // Development: src/lib -> root
14
+ path.join(process.resourcesPath || '', 'cli-package.json'), // Packaged Electron app
15
+ path.join(__dirname, '..', 'package.json'), // Alternative structure
16
+ ];
17
+
18
+ for (const pkgPath of possiblePaths) {
19
+ try {
20
+ if (fs.existsSync(pkgPath)) {
21
+ const pkg = require(pkgPath);
22
+ if (pkg.version) return pkg.version;
23
+ }
24
+ } catch {
25
+ // Try next path
26
+ }
27
+ }
28
+
29
+ return 'unknown';
30
+ }
31
+
32
+ /**
33
+ * Lightweight template engine for code generation.
34
+ *
35
+ * Syntax:
36
+ * {{variable}} - Variable substitution
37
+ * {{variable.nested}} - Nested property access
38
+ * {{#if condition}}...{{/if}} - Conditional block
39
+ * {{#unless condition}}...{{/unless}} - Inverse conditional
40
+ * {{#each items}}...{{/each}} - Loop over array
41
+ * {{@index}} - Current loop index (0-based)
42
+ * {{@first}} - true if first iteration
43
+ * {{@last}} - true if last iteration
44
+ * {{this}} - Current item in loop
45
+ *
46
+ * No dependencies. All templates are .tpl files.
47
+ */
48
+
49
+ /**
50
+ * Get a nested property from an object using dot notation.
51
+ * @param {object} obj - The object to query
52
+ * @param {string} path - Dot-separated path (e.g., "chunk.strategy")
53
+ * @returns {*} The value or undefined
54
+ */
55
+ function getPath(obj, path) {
56
+ if (!obj || !path) return undefined;
57
+ const parts = path.split('.');
58
+ let current = obj;
59
+ for (const part of parts) {
60
+ if (current == null) return undefined;
61
+ current = current[part];
62
+ }
63
+ return current;
64
+ }
65
+
66
+ /**
67
+ * Check if a value is truthy for template conditionals.
68
+ * Empty arrays and empty strings are falsy.
69
+ * @param {*} value
70
+ * @returns {boolean}
71
+ */
72
+ function isTruthy(value) {
73
+ if (Array.isArray(value)) return value.length > 0;
74
+ if (value === '') return false;
75
+ return Boolean(value);
76
+ }
77
+
78
+ /**
79
+ * Escape special regex characters in a string.
80
+ * @param {string} str
81
+ * @returns {string}
82
+ */
83
+ function escapeRegex(str) {
84
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
+ }
86
+
87
+ /**
88
+ * Render a template string with the given context.
89
+ * @param {string} template - Template string with {{...}} placeholders
90
+ * @param {object} context - Data object for substitution
91
+ * @returns {string} Rendered output
92
+ */
93
+ function render(template, context = {}) {
94
+ let result = template;
95
+
96
+ // Process {{#each items}}...{{/each}} blocks first (can be nested)
97
+ result = processEachBlocks(result, context);
98
+
99
+ // Process {{#if condition}}...{{/if}} blocks
100
+ result = processIfBlocks(result, context);
101
+
102
+ // Process {{#unless condition}}...{{/unless}} blocks
103
+ result = processUnlessBlocks(result, context);
104
+
105
+ // Process simple variable substitutions {{variable}} and {{variable.nested}}
106
+ result = result.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, varPath) => {
107
+ const value = getPath(context, varPath);
108
+ if (value === undefined || value === null) return '';
109
+ if (typeof value === 'object') return JSON.stringify(value);
110
+ return String(value);
111
+ });
112
+
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * Process {{#each items}}...{{/each}} blocks.
118
+ * Supports nested each blocks and special variables: @index, @first, @last, this
119
+ */
120
+ function processEachBlocks(template, context) {
121
+ // Match {{#each varName}}...{{/each}} - non-greedy, handles nesting
122
+ const eachRegex = /\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g;
123
+
124
+ let result = template;
125
+ let match;
126
+ let iterations = 0;
127
+ const maxIterations = 100; // Prevent infinite loops
128
+
129
+ // Keep processing until no more matches (handles nested blocks)
130
+ while ((match = eachRegex.exec(result)) !== null && iterations < maxIterations) {
131
+ const [fullMatch, varName, blockContent] = match;
132
+ const items = getPath(context, varName);
133
+
134
+ if (!Array.isArray(items) || items.length === 0) {
135
+ result = result.replace(fullMatch, '');
136
+ eachRegex.lastIndex = 0; // Reset regex
137
+ iterations++;
138
+ continue;
139
+ }
140
+
141
+ const rendered = items.map((item, index) => {
142
+ // Create loop context with special variables
143
+ const loopContext = {
144
+ ...context,
145
+ '@index': index,
146
+ '@first': index === 0,
147
+ '@last': index === items.length - 1,
148
+ 'this': item,
149
+ };
150
+
151
+ // If item is an object, spread its properties into context
152
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
153
+ Object.assign(loopContext, item);
154
+ }
155
+
156
+ return render(blockContent, loopContext);
157
+ }).join('');
158
+
159
+ result = result.replace(fullMatch, rendered);
160
+ eachRegex.lastIndex = 0; // Reset regex for next iteration
161
+ iterations++;
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Process {{#if condition}}...{{/if}} and {{#if condition}}...{{else}}...{{/if}} blocks.
169
+ * Handles nested blocks by processing iteratively from innermost to outermost.
170
+ */
171
+ function processIfBlocks(template, context) {
172
+ let result = template;
173
+ let changed = true;
174
+ let iterations = 0;
175
+ const maxIterations = 50;
176
+
177
+ // Process iteratively until no more matches (handles nesting)
178
+ while (changed && iterations < maxIterations) {
179
+ changed = false;
180
+ iterations++;
181
+
182
+ // Match if-else blocks that don't contain nested {{#if (innermost first)
183
+ // This regex ensures we don't have another {{#if inside the captured groups
184
+ const ifElseRegex = /\{\{#if\s+(\w+(?:\.\w+)*)\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{else\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{\/if\}\}/;
185
+
186
+ let match = result.match(ifElseRegex);
187
+ if (match) {
188
+ const [fullMatch, varPath, ifBlock, elseBlock] = match;
189
+ const value = getPath(context, varPath);
190
+ const replacement = isTruthy(value) ? render(ifBlock, context) : render(elseBlock, context);
191
+ result = result.replace(fullMatch, replacement);
192
+ changed = true;
193
+ continue;
194
+ }
195
+
196
+ // Match simple if blocks that don't contain nested {{#if
197
+ const ifRegex = /\{\{#if\s+(\w+(?:\.\w+)*)\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{\/if\}\}/;
198
+
199
+ match = result.match(ifRegex);
200
+ if (match) {
201
+ const [fullMatch, varPath, blockContent] = match;
202
+ const value = getPath(context, varPath);
203
+ const replacement = isTruthy(value) ? render(blockContent, context) : '';
204
+ result = result.replace(fullMatch, replacement);
205
+ changed = true;
206
+ }
207
+ }
208
+
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * Process {{#unless condition}}...{{/unless}} blocks.
214
+ */
215
+ function processUnlessBlocks(template, context) {
216
+ const unlessRegex = /\{\{#unless\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/unless\}\}/g;
217
+
218
+ return template.replace(unlessRegex, (match, varPath, blockContent) => {
219
+ const value = getPath(context, varPath);
220
+ return isTruthy(value) ? '' : render(blockContent, context);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Load a template file from the templates directory.
226
+ * @param {string} target - Target framework: 'vanilla', 'nextjs', 'python'
227
+ * @param {string} name - Template name (with or without .tpl extension)
228
+ * @returns {string} Template content
229
+ */
230
+ function loadTemplate(target, name) {
231
+ const templatesDir = path.join(__dirname, 'templates', target);
232
+
233
+ // Try exact name with .tpl
234
+ let filePath = path.join(templatesDir, name.endsWith('.tpl') ? name : `${name}.tpl`);
235
+
236
+ if (fs.existsSync(filePath)) {
237
+ return fs.readFileSync(filePath, 'utf8');
238
+ }
239
+
240
+ // Try with common extensions
241
+ const extensions = ['.js.tpl', '.jsx.tpl', '.py.tpl', '.json.tpl', '.md.tpl'];
242
+ for (const ext of extensions) {
243
+ filePath = path.join(templatesDir, `${name}${ext}`);
244
+ if (fs.existsSync(filePath)) {
245
+ return fs.readFileSync(filePath, 'utf8');
246
+ }
247
+ }
248
+
249
+ throw new Error(`Template not found: ${target}/${name}`);
250
+ }
251
+
252
+ /**
253
+ * List available templates for a target.
254
+ * @param {string} target - Target framework
255
+ * @returns {string[]} List of template names (without .tpl extension)
256
+ */
257
+ function listTemplates(target) {
258
+ const templatesDir = path.join(__dirname, 'templates', target);
259
+
260
+ if (!fs.existsSync(templatesDir)) {
261
+ return [];
262
+ }
263
+
264
+ return fs.readdirSync(templatesDir)
265
+ .filter(f => f.endsWith('.tpl'))
266
+ .map(f => f.replace('.tpl', ''));
267
+ }
268
+
269
+ /**
270
+ * Get all available targets (framework directories).
271
+ * @returns {string[]} List of target names
272
+ */
273
+ function listTargets() {
274
+ const templatesDir = path.join(__dirname, 'templates');
275
+
276
+ if (!fs.existsSync(templatesDir)) {
277
+ return [];
278
+ }
279
+
280
+ return fs.readdirSync(templatesDir)
281
+ .filter(f => fs.statSync(path.join(templatesDir, f)).isDirectory());
282
+ }
283
+
284
+ /**
285
+ * Render a template file with context.
286
+ * @param {string} target - Target framework
287
+ * @param {string} name - Template name
288
+ * @param {object} context - Data for substitution
289
+ * @returns {string} Rendered output
290
+ */
291
+ function renderTemplate(target, name, context) {
292
+ const template = loadTemplate(target, name);
293
+ return render(template, context);
294
+ }
295
+
296
+ /**
297
+ * Build a context object from project config and CLI options.
298
+ * @param {object} project - Project config from .vai.json
299
+ * @param {object} options - CLI options (overrides)
300
+ * @returns {object} Merged context for templates
301
+ */
302
+ function buildContext(project, options = {}) {
303
+ const context = {
304
+ // Core config
305
+ model: options.model || project.model || 'voyage-3-large',
306
+ db: options.db || project.db || 'myapp',
307
+ collection: options.collection || project.collection || 'documents',
308
+ field: options.field || project.field || 'embedding',
309
+ index: options.index || project.index || 'vector_index',
310
+ dimensions: options.dimensions || project.dimensions || 1024,
311
+ inputType: options.inputType || project.inputType || 'document',
312
+
313
+ // Chunk config
314
+ chunkStrategy: project.chunk?.strategy || 'recursive',
315
+ chunkSize: project.chunk?.size || 1000,
316
+ chunkOverlap: project.chunk?.overlap || 200,
317
+
318
+ // Feature flags
319
+ rerank: options.rerank !== false && options.noRerank !== true,
320
+ rerankModel: options.rerankModel || 'rerank-2.5',
321
+
322
+ // Metadata
323
+ generatedAt: new Date().toISOString(),
324
+ vaiVersion: getCliVersion(),
325
+ };
326
+
327
+ return context;
328
+ }
329
+
330
+ module.exports = {
331
+ render,
332
+ loadTemplate,
333
+ listTemplates,
334
+ listTargets,
335
+ renderTemplate,
336
+ buildContext,
337
+ getPath,
338
+ isTruthy,
339
+ };