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.
@@ -1,8 +1,22 @@
1
1
  'use strict';
2
2
 
3
+ const path = require('path');
3
4
  const pc = require('picocolors');
4
5
  const ui = require('../lib/ui');
5
6
 
7
+ /**
8
+ * Try to get the git user name for default author.
9
+ * @returns {string}
10
+ */
11
+ function getGitAuthor() {
12
+ try {
13
+ const { execSync } = require('child_process');
14
+ return execSync('git config user.name', { encoding: 'utf8' }).trim();
15
+ } catch {
16
+ return '';
17
+ }
18
+ }
19
+
6
20
  /**
7
21
  * Parse repeatable --input key=value options into an object.
8
22
  * Used as Commander's option reducer.
@@ -22,6 +36,45 @@ function collectInputs(pair, prev) {
22
36
  return prev;
23
37
  }
24
38
 
39
+ /**
40
+ * Interactively prompt the user for missing workflow inputs using @clack/prompts.
41
+ * Only prompts for inputs not already provided via --input flags.
42
+ *
43
+ * @param {object} definition - Workflow definition
44
+ * @param {object} existingInputs - Inputs already provided via --input
45
+ * @returns {Promise<object>} Merged inputs (existing + prompted)
46
+ */
47
+ async function promptForInputs(definition, existingInputs) {
48
+ const { buildInputSteps } = require('../lib/workflow');
49
+ const { createCLIRenderer } = require('../lib/wizard-cli');
50
+ const { runWizard } = require('../lib/wizard');
51
+
52
+ const allSteps = buildInputSteps(definition);
53
+ // Only prompt for inputs not already provided
54
+ const steps = allSteps.filter(s => !(s.id in existingInputs));
55
+ if (steps.length === 0) return existingInputs;
56
+
57
+ const renderer = createCLIRenderer({
58
+ title: `${definition.name || 'Workflow'} inputs`,
59
+ doneMessage: 'Inputs ready.',
60
+ showBackHint: true,
61
+ });
62
+
63
+ const { answers, cancelled } = await runWizard({
64
+ steps,
65
+ config: {},
66
+ renderer,
67
+ initial: {},
68
+ });
69
+
70
+ if (cancelled) {
71
+ console.log(pc.dim('Cancelled.'));
72
+ process.exit(0);
73
+ }
74
+
75
+ return { ...existingInputs, ...answers };
76
+ }
77
+
25
78
  /**
26
79
  * Register the workflow command on a Commander program.
27
80
  * @param {import('commander').Command} program
@@ -43,12 +96,33 @@ function registerWorkflow(program) {
43
96
  .option('--quiet', 'Suppress progress output', false)
44
97
  .option('--dry-run', 'Show execution plan without running', false)
45
98
  .option('--verbose', 'Show step details', false)
99
+ .option('--no-interactive', 'Disable interactive input prompting')
46
100
  .action(async (file, opts) => {
47
- const { loadWorkflow, executeWorkflow, buildExecutionPlan, validateWorkflow } = require('../lib/workflow');
101
+ const { executeWorkflow, buildExecutionPlan, validateWorkflow } = require('../lib/workflow');
102
+ const { resolveWorkflow } = require('../lib/workflow-registry');
48
103
 
49
104
  let definition;
50
105
  try {
51
- definition = loadWorkflow(file);
106
+ const resolved = resolveWorkflow(file);
107
+ definition = resolved.definition;
108
+
109
+ // Show workflow source notice
110
+ if ((resolved.source === 'community' || resolved.source === 'official') && !opts.quiet) {
111
+ const pkg = resolved.metadata?.package;
112
+ const author = typeof pkg?.author === 'string' ? pkg.author : pkg?.author?.name || 'unknown';
113
+ const tools = (pkg?.vai?.tools || []).join(', ');
114
+ const isOfficial = resolved.source === 'official';
115
+ const label = isOfficial ? 'official catalog workflow' : 'community workflow';
116
+ console.error(`${pc.dim('ℹ')} Running ${label}: ${pc.cyan(pkg?.name || file)} ${pc.dim(`v${pkg?.version || '?'}`)}`);
117
+ console.error(` ${pc.dim(`by ${author}`)}${tools ? pc.dim(` | Tools: ${tools}`) : ''}`);
118
+ if (!isOfficial) {
119
+ console.error(` ${pc.dim('This is a community-contributed workflow, not maintained by the vai project.')}`);
120
+ }
121
+ console.error();
122
+ for (const w of resolved.metadata?.warnings || []) {
123
+ console.error(` ${pc.yellow('⚠')} ${w}`);
124
+ }
125
+ }
52
126
  } catch (err) {
53
127
  console.error(ui.error(err.message));
54
128
  process.exit(1);
@@ -64,6 +138,11 @@ function registerWorkflow(program) {
64
138
 
65
139
  const workflowName = definition.name || file;
66
140
 
141
+ // Interactive prompting for missing inputs
142
+ if (opts.interactive !== false && process.stdin.isTTY) {
143
+ opts.input = await promptForInputs(definition, opts.input);
144
+ }
145
+
67
146
  if (opts.dryRun) {
68
147
  // Dry run: show plan
69
148
  const layers = buildExecutionPlan(definition.steps);
@@ -220,27 +299,396 @@ function registerWorkflow(program) {
220
299
  // ── workflow list ──
221
300
  wfCmd
222
301
  .command('list')
223
- .description('List built-in workflow templates')
224
- .action(() => {
225
- const { listBuiltinWorkflows } = require('../lib/workflow');
302
+ .description('List available workflows (built-in + official + community)')
303
+ .option('--built-in', 'Show only built-in workflows', false)
304
+ .option('--official', 'Show only official @vaicli workflows', false)
305
+ .option('--community', 'Show only community workflows', false)
306
+ .option('--category <name>', 'Filter by category')
307
+ .option('--tag <name>', 'Filter by tag')
308
+ .option('--json', 'Output JSON', false)
309
+ .action((opts) => {
310
+ const { getRegistry } = require('../lib/workflow-registry');
311
+ const registry = getRegistry({ force: true });
312
+
313
+ const showBuiltIn = !opts.community && !opts.official;
314
+ const showOfficial = !opts.builtIn && !opts.community;
315
+ const showCommunity = !opts.builtIn && !opts.official;
226
316
 
227
- const workflows = listBuiltinWorkflows();
228
- if (workflows.length === 0) {
229
- console.log(pc.dim('No built-in workflows found.'));
317
+ if (opts.json) {
318
+ const out = {};
319
+ if (showBuiltIn) out.builtIn = registry.builtIn;
320
+ if (showOfficial) out.official = registry.official.filter(c => c.errors.length === 0);
321
+ if (showCommunity) out.community = registry.community.filter(c => c.errors.length === 0);
322
+ console.log(JSON.stringify(out, null, 2));
230
323
  return;
231
324
  }
232
325
 
233
- console.log();
234
- console.log(pc.bold('Built-in workflow templates:'));
235
- console.log();
236
- for (const wf of workflows) {
237
- console.log(` ${pc.cyan(wf.name.padEnd(28))} ${wf.description}`);
326
+ /**
327
+ * Display a list of package-based workflows (official or community).
328
+ */
329
+ function displayPackageList(items, label, emptyHint) {
330
+ let filtered = items.filter(c => c.errors.length === 0);
331
+ if (opts.category) {
332
+ filtered = filtered.filter(c => (c.pkg?.vai?.category || 'utility') === opts.category);
333
+ }
334
+ if (opts.tag) {
335
+ filtered = filtered.filter(c => (c.pkg?.vai?.tags || []).includes(opts.tag));
336
+ }
337
+
338
+ console.log();
339
+ console.log(pc.bold(`${label} (${filtered.length})`));
340
+ if (filtered.length === 0) {
341
+ console.log(pc.dim(` (${emptyHint})`));
342
+ } else {
343
+ for (const wf of filtered) {
344
+ const pkg = wf.pkg || {};
345
+ const author = typeof pkg.author === 'string' ? pkg.author : pkg.author?.name || '';
346
+ const tags = (pkg.vai?.tags || []).join(' · ');
347
+ console.log(` ${pc.cyan(wf.name.padEnd(42))} ${pkg.description || ''}`);
348
+ if (author || tags) {
349
+ console.log(` ${pc.dim(`by ${author}`)}${pkg.version ? pc.dim(` | v${pkg.version}`) : ''}${tags ? pc.dim(` | ${tags}`) : ''}`);
350
+ }
351
+ }
352
+ }
353
+
354
+ // Show invalid packages as warnings
355
+ const invalid = items.filter(c => c.errors.length > 0);
356
+ if (invalid.length > 0) {
357
+ console.log();
358
+ for (const inv of invalid) {
359
+ console.error(` ${pc.yellow('⚠')} ${inv.name}: ${inv.errors[0]}`);
360
+ }
361
+ }
362
+ }
363
+
364
+ // Built-in
365
+ if (showBuiltIn) {
366
+ console.log();
367
+ console.log(pc.bold(`Built-in Workflows (${registry.builtIn.length})`));
368
+ if (registry.builtIn.length === 0) {
369
+ console.log(pc.dim(' (none)'));
370
+ } else {
371
+ for (const wf of registry.builtIn) {
372
+ console.log(` ${pc.cyan(wf.name.padEnd(28))} ${wf.description}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ // Official Catalog (@vaicli)
378
+ if (showOfficial) {
379
+ displayPackageList(registry.official, 'Official Catalog (@vaicli)', 'none installed');
380
+ }
381
+
382
+ // Community
383
+ if (showCommunity) {
384
+ displayPackageList(registry.community, 'Community Workflows', 'none installed — install with: vai workflow install <package-name>');
238
385
  }
386
+
239
387
  console.log();
240
388
  console.log(pc.dim('Run with: vai workflow run <name> --input key=value'));
241
389
  console.log();
242
390
  });
243
391
 
392
+ // ── workflow install ──
393
+ wfCmd
394
+ .command('install <package>')
395
+ .description('Install a workflow from npm')
396
+ .option('--global', 'Install globally', false)
397
+ .option('--json', 'Output JSON', false)
398
+ .action(async (packageName, opts) => {
399
+ const { installPackage, WORKFLOW_PREFIX, isWorkflowPackage, isOfficialPackage } = require('../lib/npm-utils');
400
+ const { validatePackage, clearRegistryCache } = require('../lib/workflow-registry');
401
+
402
+ // Auto-prefix if needed (but not for scoped packages)
403
+ if (!packageName.startsWith('@') && !packageName.startsWith(WORKFLOW_PREFIX)) {
404
+ packageName = WORKFLOW_PREFIX + packageName;
405
+ }
406
+
407
+ console.log(`Installing ${pc.cyan(packageName)}...`);
408
+
409
+ try {
410
+ const result = installPackage(packageName, { global: opts.global });
411
+ console.log(`${pc.green('✔')} Downloaded ${pc.cyan(packageName)}@${result.version}`);
412
+
413
+ // Validate
414
+ if (result.path) {
415
+ const validation = validatePackage(result.path);
416
+ if (validation.errors.length === 0) {
417
+ const steps = validation.definition?.steps?.length || 0;
418
+ const tools = (validation.pkg?.vai?.tools || []).join(', ');
419
+ console.log(`${pc.green('✔')} Validated workflow definition (${steps} steps${tools ? `, tools: ${tools}` : ''})`);
420
+ } else {
421
+ console.log(`${pc.yellow('⚠')} Validation issues:`);
422
+ for (const e of validation.errors) {
423
+ console.log(` ${pc.yellow('-')} ${e}`);
424
+ }
425
+ console.log();
426
+ console.log(pc.dim('The package was installed but the workflow may not execute correctly.'));
427
+ }
428
+ for (const w of validation.warnings) {
429
+ console.log(`${pc.yellow('⚠')} ${w}`);
430
+ }
431
+ }
432
+
433
+ clearRegistryCache();
434
+
435
+ if (opts.json) {
436
+ console.log(JSON.stringify(result, null, 2));
437
+ } else {
438
+ console.log();
439
+ console.log(`Installed. Run with:`);
440
+ console.log(` ${pc.cyan(`vai workflow run ${packageName}`)} --input key=value`);
441
+ }
442
+ } catch (err) {
443
+ console.error(ui.error(err.message));
444
+ process.exit(1);
445
+ }
446
+ });
447
+
448
+ // ── workflow uninstall ──
449
+ wfCmd
450
+ .command('uninstall <package>')
451
+ .description('Remove a workflow package')
452
+ .option('--global', 'Uninstall globally', false)
453
+ .action((packageName, opts) => {
454
+ const { uninstallPackage, WORKFLOW_PREFIX } = require('../lib/npm-utils');
455
+ const { clearRegistryCache } = require('../lib/workflow-registry');
456
+
457
+ if (!packageName.startsWith('@') && !packageName.startsWith(WORKFLOW_PREFIX)) {
458
+ packageName = WORKFLOW_PREFIX + packageName;
459
+ }
460
+
461
+ console.log(`Uninstalling ${pc.cyan(packageName)}...`);
462
+
463
+ try {
464
+ uninstallPackage(packageName, { global: opts.global });
465
+ clearRegistryCache();
466
+ console.log(`${pc.green('✔')} Removed ${packageName}`);
467
+ } catch (err) {
468
+ console.error(ui.error(err.message));
469
+ process.exit(1);
470
+ }
471
+ });
472
+
473
+ // ── workflow search ──
474
+ wfCmd
475
+ .command('search <query>')
476
+ .description('Search npm for community workflows')
477
+ .option('--limit <n>', 'Maximum results', '10')
478
+ .option('--json', 'Output JSON', false)
479
+ .action(async (query, opts) => {
480
+ const { searchNpm } = require('../lib/npm-utils');
481
+
482
+ console.log(`Searching npm for vai-workflow packages matching "${query}"...`);
483
+ console.log();
484
+
485
+ try {
486
+ const results = await searchNpm(query, { limit: parseInt(opts.limit, 10) });
487
+
488
+ if (opts.json) {
489
+ console.log(JSON.stringify(results, null, 2));
490
+ return;
491
+ }
492
+
493
+ if (results.length === 0) {
494
+ console.log(pc.dim(' No matching workflow packages found.'));
495
+ console.log();
496
+ return;
497
+ }
498
+
499
+ for (const r of results) {
500
+ const badge = r.official ? ` ${pc.green('[OFFICIAL]')}` : '';
501
+ console.log(` ${pc.cyan(r.name)} ${pc.dim(`v${r.version}`)}${badge}`);
502
+ if (r.description) console.log(` ${r.description}`);
503
+ console.log(` ${pc.dim(`by ${r.author}`)}${r.keywords.length ? pc.dim(` | ${r.keywords.slice(0, 5).join(', ')}`) : ''}`);
504
+ console.log();
505
+ }
506
+
507
+ console.log(pc.dim(`Install: vai workflow install <package-name>`));
508
+ console.log();
509
+ } catch (err) {
510
+ console.error(ui.error(err.message));
511
+ process.exit(1);
512
+ }
513
+ });
514
+
515
+ // ── workflow info ──
516
+ wfCmd
517
+ .command('info <name>')
518
+ .description('Show detailed info about an installed workflow')
519
+ .option('--json', 'Output JSON', false)
520
+ .action((name, opts) => {
521
+ const { resolveWorkflow, getRegistry } = require('../lib/workflow-registry');
522
+ const { WORKFLOW_PREFIX } = require('../lib/npm-utils');
523
+
524
+ try {
525
+ const resolved = resolveWorkflow(name);
526
+
527
+ if (opts.json) {
528
+ console.log(JSON.stringify({ source: resolved.source, definition: resolved.definition, metadata: resolved.metadata }, null, 2));
529
+ return;
530
+ }
531
+
532
+ const def = resolved.definition;
533
+ console.log();
534
+
535
+ if (resolved.source === 'community' || resolved.source === 'official') {
536
+ const pkg = resolved.metadata?.package || {};
537
+ const author = typeof pkg.author === 'string' ? pkg.author : pkg.author?.name || 'unknown';
538
+ const vai = pkg.vai || {};
539
+
540
+ console.log(`${pc.bold(pc.cyan(pkg.name || name))} ${pc.dim(`v${pkg.version || '?'}`)}`);
541
+ console.log(` ${pkg.description || def.description || ''}`);
542
+ console.log();
543
+ console.log(` ${pc.dim('Author:')} ${author}`);
544
+ console.log(` ${pc.dim('License:')} ${pkg.license || 'unknown'}`);
545
+ console.log(` ${pc.dim('Category:')} ${vai.category || 'utility'}`);
546
+ if (vai.tags?.length) console.log(` ${pc.dim('Tags:')} ${vai.tags.join(', ')}`);
547
+ if (vai.minVaiVersion) console.log(` ${pc.dim('Min vai:')} v${vai.minVaiVersion}`);
548
+ if (vai.tools?.length) console.log(` ${pc.dim('Tools:')} ${vai.tools.join(', ')}`);
549
+ console.log(` ${pc.dim('Steps:')} ${def.steps?.length || 0}`);
550
+ console.log(` ${pc.dim('Source:')} ${resolved.metadata?.path || 'unknown'}`);
551
+ if (pkg.name) console.log(` ${pc.dim('npm:')} https://www.npmjs.com/package/${pkg.name}`);
552
+ } else {
553
+ console.log(`${pc.bold(pc.cyan(def.name || name))} ${pc.dim(`[${resolved.source}]`)}`);
554
+ console.log(` ${def.description || ''}`);
555
+ console.log(` ${pc.dim('Steps:')} ${def.steps?.length || 0}`);
556
+ }
557
+
558
+ // Show inputs
559
+ if (def.inputs && Object.keys(def.inputs).length > 0) {
560
+ console.log();
561
+ console.log(` ${pc.bold('Inputs:')}`);
562
+ for (const [key, schema] of Object.entries(def.inputs)) {
563
+ const req = schema.required ? pc.red('(required)') : pc.dim(`(default: ${schema.default ?? 'none'})`);
564
+ const desc = schema.description || '';
565
+ console.log(` ${pc.cyan(key.padEnd(16))} ${(schema.type || 'string').padEnd(8)} ${req} ${pc.dim(desc)}`);
566
+ }
567
+ }
568
+
569
+ console.log();
570
+ } catch (err) {
571
+ console.error(ui.error(err.message));
572
+ process.exit(1);
573
+ }
574
+ });
575
+
576
+ // ── workflow create ──
577
+ wfCmd
578
+ .command('create')
579
+ .description('Scaffold a publish-ready npm package from a workflow')
580
+ .option('--from <file>', 'Existing workflow JSON to package')
581
+ .option('--name <name>', 'Package name (without vai-workflow- prefix)')
582
+ .option('--author <name>', 'Author name')
583
+ .option('--description <desc>', 'Package description')
584
+ .option('--category <cat>', 'Category (retrieval, analysis, ingestion, domain-specific, utility, integration)')
585
+ .option('--scope <scope>', 'Package scope (e.g. "vaicli" for @vaicli/vai-workflow-*)')
586
+ .option('--output <dir>', 'Output directory')
587
+ .action(async (opts) => {
588
+ const { scaffoldPackage, toPackageName, CATEGORIES, emptyWorkflowTemplate } = require('../lib/workflow-scaffold');
589
+ const { loadWorkflow } = require('../lib/workflow');
590
+
591
+ let definition;
592
+ let name = opts.name;
593
+ let author = opts.author;
594
+ let description = opts.description;
595
+ let category = opts.category;
596
+
597
+ if (opts.from) {
598
+ // Package an existing workflow
599
+ try {
600
+ definition = loadWorkflow(opts.from);
601
+ } catch (err) {
602
+ console.error(ui.error(err.message));
603
+ process.exit(1);
604
+ }
605
+ if (!name) {
606
+ name = definition.name || path.basename(opts.from, '.json').replace('.vai-workflow', '');
607
+ }
608
+ if (!description) {
609
+ description = definition.description;
610
+ }
611
+ } else if (process.stdin.isTTY) {
612
+ // Interactive mode
613
+ try {
614
+ const p = require('@clack/prompts');
615
+ p.intro(pc.bold('Create a new workflow package'));
616
+
617
+ const answers = await p.group({
618
+ name: () => p.text({ message: 'Workflow name', placeholder: 'my-workflow', validate: v => v ? undefined : 'Required' }),
619
+ description: () => p.text({ message: 'Description', placeholder: 'A brief description of what this workflow does' }),
620
+ category: () => p.select({
621
+ message: 'Category',
622
+ options: CATEGORIES.map(c => ({ value: c, label: c })),
623
+ }),
624
+ author: () => p.text({ message: 'Author', placeholder: 'Your Name', defaultValue: getGitAuthor() }),
625
+ });
626
+
627
+ if (p.isCancel(answers)) {
628
+ p.cancel('Cancelled.');
629
+ process.exit(0);
630
+ }
631
+
632
+ name = answers.name;
633
+ description = answers.description;
634
+ category = answers.category;
635
+ author = answers.author;
636
+ definition = emptyWorkflowTemplate();
637
+ definition.name = name;
638
+ definition.description = description || '';
639
+ // Add a placeholder step so validation passes
640
+ definition.steps = [{
641
+ id: 'search',
642
+ tool: 'query',
643
+ name: 'Search',
644
+ inputs: { query: '{{ inputs.query }}' },
645
+ }];
646
+ definition.inputs = {
647
+ query: { type: 'string', required: true, description: 'Search query' },
648
+ };
649
+ } catch (err) {
650
+ console.error(ui.error(`Interactive mode failed: ${err.message}`));
651
+ process.exit(1);
652
+ }
653
+ } else {
654
+ console.error(ui.error('Provide --from <file> or run interactively (TTY required).'));
655
+ process.exit(1);
656
+ }
657
+
658
+ if (!name) {
659
+ console.error(ui.error('Workflow name is required. Use --name <name>.'));
660
+ process.exit(1);
661
+ }
662
+
663
+ try {
664
+ const result = scaffoldPackage({
665
+ definition,
666
+ name,
667
+ author,
668
+ description,
669
+ category,
670
+ scope: opts.scope,
671
+ outputDir: opts.output,
672
+ });
673
+
674
+ const pkgName = toPackageName(name, { scope: opts.scope });
675
+ console.log();
676
+ console.log(`${pc.green('✔')} Created ${pc.cyan(pkgName)}/`);
677
+ for (const f of result.files) {
678
+ console.log(` ${pc.dim('├──')} ${f}`);
679
+ }
680
+ console.log();
681
+ console.log('Next steps:');
682
+ console.log(` 1. ${opts.from ? '' : pc.dim('Edit workflow.json with your workflow definition')}${opts.from ? 'Review README.md' : ''}`);
683
+ console.log(` 2. cd ${pkgName}`);
684
+ console.log(` 3. npm publish`);
685
+ console.log();
686
+ } catch (err) {
687
+ console.error(ui.error(err.message));
688
+ process.exit(1);
689
+ }
690
+ });
691
+
244
692
  // ── workflow init ──
245
693
  wfCmd
246
694
  .command('init')
package/src/lib/api.js CHANGED
@@ -4,10 +4,24 @@ const ATLAS_API_BASE = 'https://ai.mongodb.com/v1';
4
4
  const VOYAGE_API_BASE = 'https://api.voyageai.com/v1';
5
5
  const MAX_RETRIES = 3;
6
6
 
7
+ /**
8
+ * Identify the key type from its prefix.
9
+ * @param {string} key
10
+ * @returns {{ type: 'atlas'|'voyage'|'unknown', label: string, expectedBase: string }}
11
+ */
12
+ function identifyKey(key) {
13
+ if (key.startsWith('al-')) {
14
+ return { type: 'atlas', label: 'MongoDB Atlas', expectedBase: ATLAS_API_BASE };
15
+ }
16
+ if (key.startsWith('pa-')) {
17
+ return { type: 'voyage', label: 'Voyage AI (direct)', expectedBase: VOYAGE_API_BASE };
18
+ }
19
+ return { type: 'unknown', label: 'Unknown provider', expectedBase: ATLAS_API_BASE };
20
+ }
21
+
7
22
  /**
8
23
  * Resolve the API base URL.
9
24
  * 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
25
  * @returns {string}
12
26
  */
13
27
  function getApiBase() {
@@ -20,6 +34,10 @@ function getApiBase() {
20
34
  const configBase = getConfigValue('baseUrl');
21
35
  if (configBase) return configBase.replace(/\/+$/, '');
22
36
 
37
+ // Auto-detect from key prefix
38
+ const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
39
+ if (key) return identifyKey(key).expectedBase;
40
+
23
41
  // Default to Atlas endpoint
24
42
  return ATLAS_API_BASE;
25
43
  }
@@ -29,7 +47,7 @@ const API_BASE = ATLAS_API_BASE;
29
47
 
30
48
  /**
31
49
  * Get the Voyage API key or exit with a helpful error.
32
- * Checks: env var config file.
50
+ * Validates that the key prefix matches the configured base URL and warns on mismatch.
33
51
  * @returns {string}
34
52
  */
35
53
  function requireApiKey() {
@@ -43,6 +61,25 @@ function requireApiKey() {
43
61
  ' or Voyage AI platform > Dashboard > API Keys';
44
62
  throw new Error(msg);
45
63
  }
64
+
65
+ // Validate key/endpoint match and warn on mismatch
66
+ const base = getApiBase();
67
+ const keyInfo = identifyKey(key);
68
+
69
+ if (keyInfo.type !== 'unknown' && keyInfo.expectedBase !== base) {
70
+ const mismatch =
71
+ `\n⚠️ API key/endpoint mismatch detected!\n` +
72
+ ` Key type: ${keyInfo.label} (${key.slice(0, 5)}...)\n` +
73
+ ` Endpoint: ${base}\n` +
74
+ ` Expected: ${keyInfo.expectedBase}\n\n` +
75
+ ` This will likely cause a 401 or 403 error.\n\n` +
76
+ ` Fix: Update your base URL to match your key:\n` +
77
+ ` vai config set base-url ${keyInfo.expectedBase}\n\n` +
78
+ ` Or switch to a ${base.includes('ai.mongodb.com') ? 'MongoDB Atlas' : 'Voyage AI'} key:\n` +
79
+ ` vai config set api-key <your-${base.includes('ai.mongodb.com') ? 'atlas' : 'voyage'}-key>\n`;
80
+ process.stderr.write(mismatch);
81
+ }
82
+
46
83
  return key;
47
84
  }
48
85
 
@@ -162,6 +199,7 @@ module.exports = {
162
199
  API_BASE,
163
200
  ATLAS_API_BASE,
164
201
  VOYAGE_API_BASE,
202
+ identifyKey,
165
203
  getApiBase,
166
204
  requireApiKey,
167
205
  apiRequest,