voyageai-cli 1.28.0 → 1.30.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 (58) hide show
  1. package/README.md +82 -8
  2. package/package.json +2 -1
  3. package/src/commands/app.js +15 -0
  4. package/src/commands/benchmark.js +22 -8
  5. package/src/commands/chat.js +18 -0
  6. package/src/commands/chunk.js +10 -0
  7. package/src/commands/demo.js +4 -0
  8. package/src/commands/embed.js +13 -0
  9. package/src/commands/estimate.js +3 -0
  10. package/src/commands/eval.js +6 -0
  11. package/src/commands/explain.js +2 -0
  12. package/src/commands/generate.js +2 -0
  13. package/src/commands/ingest.js +4 -0
  14. package/src/commands/init.js +2 -0
  15. package/src/commands/mcp-server.js +2 -0
  16. package/src/commands/models.js +2 -0
  17. package/src/commands/ping.js +7 -0
  18. package/src/commands/pipeline.js +15 -0
  19. package/src/commands/playground.js +685 -8
  20. package/src/commands/query.js +16 -0
  21. package/src/commands/rerank.js +12 -0
  22. package/src/commands/scaffold.js +2 -0
  23. package/src/commands/search.js +11 -0
  24. package/src/commands/similarity.js +9 -0
  25. package/src/commands/store.js +4 -0
  26. package/src/commands/workflow.js +702 -13
  27. package/src/lib/capability-report.js +134 -0
  28. package/src/lib/chat.js +32 -1
  29. package/src/lib/config.js +2 -0
  30. package/src/lib/cost-display.js +107 -0
  31. package/src/lib/explanations.js +94 -0
  32. package/src/lib/llm.js +125 -18
  33. package/src/lib/npm-utils.js +265 -0
  34. package/src/lib/quality-audit.js +71 -0
  35. package/src/lib/security/blocked-domains.json +17 -0
  36. package/src/lib/security-audit.js +198 -0
  37. package/src/lib/telemetry.js +23 -1
  38. package/src/lib/workflow-registry.js +416 -0
  39. package/src/lib/workflow-scaffold.js +380 -0
  40. package/src/lib/workflow-test-runner.js +208 -0
  41. package/src/lib/workflow.js +559 -7
  42. package/src/playground/announcements.md +80 -0
  43. package/src/playground/assets/announcements/appstore.jpg +0 -0
  44. package/src/playground/assets/announcements/circuits.jpg +0 -0
  45. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  46. package/src/playground/assets/announcements/green-wave.jpg +0 -0
  47. package/src/playground/help/workflow-nodes.js +472 -0
  48. package/src/playground/icons/V.png +0 -0
  49. package/src/playground/index.html +3634 -226
  50. package/src/workflows/consistency-check.json +4 -0
  51. package/src/workflows/cost-analysis.json +4 -0
  52. package/src/workflows/enrich-and-ingest.json +56 -0
  53. package/src/workflows/intelligent-ingest.json +66 -0
  54. package/src/workflows/kb-health-report.json +45 -0
  55. package/src/workflows/multi-collection-search.json +4 -0
  56. package/src/workflows/research-and-summarize.json +4 -0
  57. package/src/workflows/search-with-fallback.json +66 -0
  58. package/src/workflows/smart-ingest.json +4 -0
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ════════════════════════════════════════════════════════════════════
7
+ // Blocked Domains
8
+ // ════════════════════════════════════════════════════════════════════
9
+
10
+ const BLOCKED_DOMAINS = new Set(
11
+ JSON.parse(fs.readFileSync(path.join(__dirname, 'security', 'blocked-domains.json'), 'utf8'))
12
+ );
13
+
14
+ /**
15
+ * Check if a URL targets a blocked domain.
16
+ * @param {string} url
17
+ * @returns {boolean}
18
+ */
19
+ function isBlockedDomain(url) {
20
+ try {
21
+ const hostname = new URL(url).hostname;
22
+ return BLOCKED_DOMAINS.has(hostname);
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ // ════════════════════════════════════════════════════════════════════
29
+ // Capability Flags
30
+ // ════════════════════════════════════════════════════════════════════
31
+
32
+ /**
33
+ * Extract capability flags from a workflow definition.
34
+ * @param {object} definition
35
+ * @returns {Set<string>}
36
+ */
37
+ function extractCapabilities(definition) {
38
+ const caps = new Set();
39
+ if (!definition || !Array.isArray(definition.steps)) return caps;
40
+
41
+ for (const step of definition.steps) {
42
+ switch (step.tool) {
43
+ case 'http':
44
+ caps.add('NETWORK');
45
+ break;
46
+ case 'ingest':
47
+ caps.add('WRITE_DB');
48
+ break;
49
+ case 'aggregate':
50
+ caps.add('READ_DB');
51
+ if (step.inputs?.allowWrites) caps.add('WRITE_DB');
52
+ if (Array.isArray(step.inputs?.pipeline)) {
53
+ for (const stage of step.inputs.pipeline) {
54
+ const key = Object.keys(stage)[0];
55
+ if (key === '$out' || key === '$merge') caps.add('WRITE_DB');
56
+ }
57
+ }
58
+ break;
59
+ case 'generate':
60
+ caps.add('LLM');
61
+ break;
62
+ case 'loop':
63
+ caps.add('LOOP');
64
+ break;
65
+ case 'query':
66
+ case 'search':
67
+ case 'collections':
68
+ caps.add('READ_DB');
69
+ break;
70
+ }
71
+
72
+ // forEach counts as LOOP
73
+ if (step.forEach) caps.add('LOOP');
74
+ }
75
+
76
+ return caps;
77
+ }
78
+
79
+ // ════════════════════════════════════════════════════════════════════
80
+ // Security Audit
81
+ // ════════════════════════════════════════════════════════════════════
82
+
83
+ /**
84
+ * Run security audit on a workflow definition.
85
+ * @param {object} definition - Parsed workflow JSON
86
+ * @param {string} [packagePath] - Path to the package directory (for package-level checks)
87
+ * @returns {Array<{severity: string, message: string, stepId?: string}>}
88
+ */
89
+ function securityAudit(definition, packagePath) {
90
+ const findings = [];
91
+
92
+ if (!definition || !Array.isArray(definition.steps)) return findings;
93
+
94
+ for (const step of definition.steps) {
95
+ // HTTP step checks
96
+ if (step.tool === 'http') {
97
+ const url = step.inputs?.url;
98
+ if (typeof url === 'string') {
99
+ if (!url.includes('{{')) {
100
+ // Static URL — check against blocklist
101
+ if (isBlockedDomain(url)) {
102
+ findings.push({ severity: 'critical', message: `HTTP step "${step.id}" targets blocked domain`, stepId: step.id });
103
+ }
104
+ // Flag non-HTTPS
105
+ if (url.startsWith('http://')) {
106
+ findings.push({ severity: 'medium', message: `HTTP step "${step.id}" uses insecure HTTP`, stepId: step.id });
107
+ }
108
+ } else {
109
+ // Dynamic URLs always flagged for review
110
+ findings.push({ severity: 'high', message: `HTTP step "${step.id}" has dynamic URL (needs review)`, stepId: step.id });
111
+ }
112
+ }
113
+ }
114
+
115
+ // Aggregate: Flag write stages
116
+ if (step.tool === 'aggregate') {
117
+ if (step.inputs?.allowWrites) {
118
+ findings.push({ severity: 'high', message: `Aggregate step "${step.id}" allows write operations ($out/$merge)`, stepId: step.id });
119
+ }
120
+ const pipeline = step.inputs?.pipeline;
121
+ if (Array.isArray(pipeline)) {
122
+ for (const stage of pipeline) {
123
+ const key = Object.keys(stage)[0];
124
+ if (key === '$out' || key === '$merge') {
125
+ findings.push({ severity: 'critical', message: `Aggregate step "${step.id}" contains ${key} stage`, stepId: step.id });
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Generate: Check for prompt injection patterns
132
+ if (step.tool === 'generate') {
133
+ const prompt = step.inputs?.prompt || '';
134
+ const systemPrompt = step.inputs?.systemPrompt || '';
135
+ for (const text of [prompt, systemPrompt]) {
136
+ if (typeof text === 'string') {
137
+ if (/ignore\s+(previous|all)\s+instructions/i.test(text)) {
138
+ findings.push({ severity: 'high', message: `Suspicious prompt pattern in "${step.id}"`, stepId: step.id });
139
+ }
140
+ if (/system\s*:\s*/i.test(text)) {
141
+ findings.push({ severity: 'medium', message: `Prompt contains "system:" prefix in "${step.id}"`, stepId: step.id });
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ // Ingest: Flag dynamic db/collection names
148
+ if (step.tool === 'ingest') {
149
+ const db = step.inputs?.db;
150
+ const coll = step.inputs?.collection;
151
+ if (typeof db === 'string' && db.includes('{{')) {
152
+ findings.push({ severity: 'medium', message: `Ingest step "${step.id}" uses dynamic database name`, stepId: step.id });
153
+ }
154
+ if (typeof coll === 'string' && coll.includes('{{')) {
155
+ findings.push({ severity: 'medium', message: `Ingest step "${step.id}" uses dynamic collection name`, stepId: step.id });
156
+ }
157
+ }
158
+
159
+ // Loop: Check for unbounded iterations
160
+ if (step.tool === 'loop') {
161
+ if (!step.inputs?.maxIterations || step.inputs.maxIterations > 1000) {
162
+ findings.push({ severity: 'medium', message: `Loop step "${step.id}" has high/unbounded maxIterations`, stepId: step.id });
163
+ }
164
+ }
165
+ }
166
+
167
+ // Package-level checks
168
+ if (packagePath) {
169
+ try {
170
+ const files = fs.readdirSync(packagePath);
171
+ const jsFiles = files.filter(f => /\.(js|ts|mjs|cjs)$/.test(f) && f !== 'node_modules');
172
+ if (jsFiles.length > 0) {
173
+ findings.push({ severity: 'critical', message: `Package contains executable code: ${jsFiles.join(', ')}` });
174
+ }
175
+ } catch {
176
+ // Ignore if can't read directory
177
+ }
178
+
179
+ try {
180
+ const pkgPath = path.join(packagePath, 'package.json');
181
+ if (fs.existsSync(pkgPath)) {
182
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
183
+ const dangerousScripts = ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall'];
184
+ for (const script of dangerousScripts) {
185
+ if (pkg.scripts?.[script]) {
186
+ findings.push({ severity: 'critical', message: `Package has "${script}" lifecycle script` });
187
+ }
188
+ }
189
+ }
190
+ } catch {
191
+ // Ignore if can't read package.json
192
+ }
193
+ }
194
+
195
+ return findings;
196
+ }
197
+
198
+ module.exports = { securityAudit, extractCapabilities, isBlockedDomain };
@@ -69,4 +69,26 @@ function send(event, extra = {}) {
69
69
  } catch { /* telemetry should never break the CLI */ }
70
70
  }
71
71
 
72
- module.exports = { send, isEnabled };
72
+ /**
73
+ * Create a timer that auto-sends a telemetry event on completion.
74
+ * Usage:
75
+ * const done = telemetry.timer('cli_query', { model: 'voyage-4-large' });
76
+ * // ... do work ...
77
+ * done({ resultCount: 5 }); // sends event with durationMs calculated
78
+ *
79
+ * @param {string} event - Event name
80
+ * @param {object} [baseFields] - Fields known at start time
81
+ * @returns {function} done(extraFields) - Call to send the event
82
+ */
83
+ function timer(event, baseFields = {}) {
84
+ const start = Date.now();
85
+ return (extraFields = {}) => {
86
+ send(event, {
87
+ ...baseFields,
88
+ ...extraFields,
89
+ durationMs: Date.now() - start,
90
+ });
91
+ };
92
+ }
93
+
94
+ module.exports = { send, isEnabled, timer };
@@ -0,0 +1,416 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { validateWorkflow, listBuiltinWorkflows, loadWorkflow, listExampleWorkflows } = require('./workflow');
6
+ const { findLocalNodeModules, findGlobalNodeModules, WORKFLOW_PREFIX, VAICLI_SCOPE, VAICLI_WORKFLOW_PREFIX, isOfficialPackage } = require('./npm-utils');
7
+
8
+ // In-memory cache for the duration of the process
9
+ let _registryCache = null;
10
+
11
+ /**
12
+ * Scan a node_modules directory for vai-workflow-* packages (both scoped and unscoped).
13
+ * @param {string} nodeModulesDir
14
+ * @returns {Array<{ name: string, packagePath: string, pkg: object, definition: object, errors: string[], warnings: string[], tier: string }>}
15
+ */
16
+ function scanNodeModules(nodeModulesDir) {
17
+ const results = [];
18
+ if (!nodeModulesDir || !fs.existsSync(nodeModulesDir)) return results;
19
+
20
+ let entries;
21
+ try {
22
+ entries = fs.readdirSync(nodeModulesDir);
23
+ } catch {
24
+ return results;
25
+ }
26
+
27
+ // Scan unscoped vai-workflow-* packages
28
+ for (const entry of entries) {
29
+ if (!entry.startsWith(WORKFLOW_PREFIX)) continue;
30
+
31
+ const packagePath = path.join(nodeModulesDir, entry);
32
+ const pkgJsonPath = path.join(packagePath, 'package.json');
33
+
34
+ if (!fs.existsSync(pkgJsonPath)) continue;
35
+
36
+ try {
37
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
38
+ const result = validatePackage(packagePath, pkg);
39
+ result.tier = 'community';
40
+ results.push(result);
41
+ } catch (err) {
42
+ results.push({
43
+ name: entry,
44
+ packagePath,
45
+ pkg: null,
46
+ definition: null,
47
+ errors: [`Failed to read package: ${err.message}`],
48
+ warnings: [],
49
+ tier: 'community',
50
+ });
51
+ }
52
+ }
53
+
54
+ // Scan @vaicli/ scoped packages
55
+ const vaicliDir = path.join(nodeModulesDir, '@vaicli');
56
+ if (fs.existsSync(vaicliDir)) {
57
+ let scopedEntries;
58
+ try {
59
+ scopedEntries = fs.readdirSync(vaicliDir);
60
+ } catch {
61
+ scopedEntries = [];
62
+ }
63
+
64
+ for (const entry of scopedEntries) {
65
+ if (!entry.startsWith(WORKFLOW_PREFIX)) continue;
66
+
67
+ const packagePath = path.join(vaicliDir, entry);
68
+ const pkgJsonPath = path.join(packagePath, 'package.json');
69
+
70
+ if (!fs.existsSync(pkgJsonPath)) continue;
71
+
72
+ try {
73
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
74
+ const result = validatePackage(packagePath, pkg);
75
+ result.tier = 'official';
76
+ results.push(result);
77
+ } catch (err) {
78
+ results.push({
79
+ name: `${VAICLI_SCOPE}${entry}`,
80
+ packagePath,
81
+ pkg: null,
82
+ definition: null,
83
+ errors: [`Failed to read package: ${err.message}`],
84
+ warnings: [],
85
+ tier: 'official',
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+ return results;
92
+ }
93
+
94
+ /**
95
+ * Validate a community workflow package.
96
+ * @param {string} packagePath - Path to the package directory
97
+ * @param {object} [pkg] - Parsed package.json (read if not provided)
98
+ * @returns {{ name: string, packagePath: string, pkg: object, definition: object|null, errors: string[], warnings: string[] }}
99
+ */
100
+ function validatePackage(packagePath, pkg) {
101
+ const errors = [];
102
+ const warnings = [];
103
+
104
+ if (!pkg) {
105
+ try {
106
+ pkg = JSON.parse(fs.readFileSync(path.join(packagePath, 'package.json'), 'utf8'));
107
+ } catch (err) {
108
+ return { name: path.basename(packagePath), packagePath, pkg: null, definition: null, errors: [`Cannot read package.json: ${err.message}`], warnings };
109
+ }
110
+ }
111
+
112
+ const name = pkg.name || path.basename(packagePath);
113
+
114
+ // Check vai field
115
+ if (!pkg.vai || typeof pkg.vai !== 'object') {
116
+ errors.push('Missing "vai" field in package.json');
117
+ }
118
+
119
+ // Check main field points to JSON
120
+ const mainFile = pkg.main || 'workflow.json';
121
+ const workflowPath = path.join(packagePath, mainFile);
122
+
123
+ if (!mainFile.endsWith('.json')) {
124
+ errors.push(`"main" field must point to a .json file (got "${mainFile}")`);
125
+ }
126
+
127
+ if (!fs.existsSync(workflowPath)) {
128
+ errors.push(`Workflow file not found: ${mainFile}`);
129
+ return { name, packagePath, pkg, definition: null, errors, warnings };
130
+ }
131
+
132
+ // Parse and validate workflow
133
+ let definition;
134
+ try {
135
+ definition = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
136
+ } catch (err) {
137
+ errors.push(`Invalid JSON in ${mainFile}: ${err.message}`);
138
+ return { name, packagePath, pkg, definition: null, errors, warnings };
139
+ }
140
+
141
+ const workflowErrors = validateWorkflow(definition);
142
+ errors.push(...workflowErrors);
143
+
144
+ // Compatibility check
145
+ if (pkg.vai) {
146
+ if (pkg.vai.workflowVersion && pkg.vai.workflowVersion !== '1.0') {
147
+ warnings.push(`Workflow version "${pkg.vai.workflowVersion}" may not be fully compatible`);
148
+ }
149
+
150
+ if (pkg.vai.minVaiVersion) {
151
+ try {
152
+ const { version: currentVersion } = require('../../package.json');
153
+ if (compareVersions(pkg.vai.minVaiVersion, currentVersion) > 0) {
154
+ warnings.push(`Requires vai >= ${pkg.vai.minVaiVersion} (you have ${currentVersion})`);
155
+ }
156
+ } catch { /* ignore version check failures */ }
157
+ }
158
+
159
+ // Check that declared tools exist
160
+ if (Array.isArray(pkg.vai.tools)) {
161
+ const { ALL_TOOLS } = require('./workflow');
162
+ for (const tool of pkg.vai.tools) {
163
+ if (!ALL_TOOLS.has(tool)) {
164
+ warnings.push(`Declares unknown tool "${tool}" — may require a newer vai version`);
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return { name, packagePath, pkg, definition, errors, warnings };
171
+ }
172
+
173
+ /**
174
+ * Simple semver comparison. Returns >0 if a > b, <0 if a < b, 0 if equal.
175
+ * @param {string} a
176
+ * @param {string} b
177
+ * @returns {number}
178
+ */
179
+ function compareVersions(a, b) {
180
+ const pa = a.replace(/^v/, '').split('.').map(Number);
181
+ const pb = b.replace(/^v/, '').split('.').map(Number);
182
+ for (let i = 0; i < 3; i++) {
183
+ const diff = (pa[i] || 0) - (pb[i] || 0);
184
+ if (diff !== 0) return diff;
185
+ }
186
+ return 0;
187
+ }
188
+
189
+ /**
190
+ * Extract the workflow name (without prefix/scope) from a package name.
191
+ * @param {string} packageName - e.g. '@vaicli/vai-workflow-foo' or 'vai-workflow-foo'
192
+ * @returns {string} - e.g. 'foo'
193
+ */
194
+ function extractWorkflowName(packageName) {
195
+ if (packageName.startsWith(VAICLI_WORKFLOW_PREFIX)) {
196
+ return packageName.slice(VAICLI_WORKFLOW_PREFIX.length);
197
+ }
198
+ if (packageName.startsWith(WORKFLOW_PREFIX)) {
199
+ return packageName.slice(WORKFLOW_PREFIX.length);
200
+ }
201
+ return packageName;
202
+ }
203
+
204
+ /**
205
+ * Get the full workflow registry (built-in + official + community).
206
+ * Results are cached in-memory for the process duration.
207
+ * @param {{ force?: boolean }} options
208
+ * @returns {{ builtIn: Array, official: Array, community: Array }}
209
+ */
210
+ function getRegistry(options = {}) {
211
+ if (_registryCache && !options.force) return _registryCache;
212
+
213
+ // Built-in workflows
214
+ const builtIn = listBuiltinWorkflows().map(w => ({
215
+ ...w,
216
+ source: 'built-in',
217
+ }));
218
+
219
+ // Official and community workflows from local + global node_modules
220
+ const official = [];
221
+ const community = [];
222
+ const seen = new Set();
223
+
224
+ // Local first (higher priority)
225
+ const localNM = findLocalNodeModules();
226
+ if (localNM) {
227
+ for (const pkg of scanNodeModules(localNM)) {
228
+ if (!seen.has(pkg.name)) {
229
+ seen.add(pkg.name);
230
+ const target = pkg.tier === 'official' ? official : community;
231
+ target.push({ ...pkg, source: pkg.tier, scope: 'local' });
232
+ }
233
+ }
234
+ }
235
+
236
+ // Global
237
+ const globalNM = findGlobalNodeModules();
238
+ if (globalNM) {
239
+ for (const pkg of scanNodeModules(globalNM)) {
240
+ if (!seen.has(pkg.name)) {
241
+ seen.add(pkg.name);
242
+ const target = pkg.tier === 'official' ? official : community;
243
+ target.push({ ...pkg, source: pkg.tier, scope: 'global' });
244
+ }
245
+ }
246
+ }
247
+
248
+ _registryCache = { builtIn, official, community };
249
+ return _registryCache;
250
+ }
251
+
252
+ /**
253
+ * Clear the registry cache (used after install/uninstall).
254
+ */
255
+ function clearRegistryCache() {
256
+ _registryCache = null;
257
+ }
258
+
259
+ /**
260
+ * Resolve a workflow by name using the priority chain:
261
+ * 1. Local file path
262
+ * 2. Built-in template
263
+ * 3. Official package (local node_modules) — @vaicli/vai-workflow-*
264
+ * 4. Community package (local node_modules) — vai-workflow-*
265
+ * 5. Official package (global node_modules)
266
+ * 6. Community package (global node_modules)
267
+ *
268
+ * @param {string} name - Workflow name, package name, or file path
269
+ * @returns {{ definition: object, source: string, metadata: object|null }}
270
+ */
271
+ function resolveWorkflow(name) {
272
+ // 1. Try as file path (if it exists or has extension)
273
+ if (name.includes('/') || name.includes('\\') || name.endsWith('.json')) {
274
+ // But not scoped package names like @vaicli/vai-workflow-foo
275
+ if (!name.startsWith('@')) {
276
+ try {
277
+ const definition = loadWorkflow(name);
278
+ return { definition, source: 'file', metadata: null };
279
+ } catch { /* fall through */ }
280
+ }
281
+ }
282
+
283
+ // 2. Try built-in (loadWorkflow handles this) — skip for scoped names
284
+ if (!name.startsWith('@')) {
285
+ try {
286
+ const definition = loadWorkflow(name);
287
+ return { definition, source: 'built-in', metadata: null };
288
+ } catch { /* fall through */ }
289
+ }
290
+
291
+ // 3-6. Try official then community packages
292
+ const registry = getRegistry();
293
+
294
+ // Helper to find in a list by exact name or prefixed name
295
+ // Note: We allow packages with errors as long as they have a valid definition
296
+ // (errors may be warnings like "missing vai field" that don't prevent execution)
297
+ const findInList = (list, searchName) => {
298
+ // Exact match first
299
+ let match = list.find(c => c.name === searchName && c.definition);
300
+ if (match) return match;
301
+
302
+ // Try with prefix for unscoped
303
+ if (!searchName.startsWith(WORKFLOW_PREFIX) && !searchName.startsWith('@')) {
304
+ const prefixed = WORKFLOW_PREFIX + searchName;
305
+ match = list.find(c => c.name === prefixed && c.definition);
306
+ if (match) return match;
307
+ }
308
+
309
+ return null;
310
+ };
311
+
312
+ // 3. Official local
313
+ const officialLocal = registry.official.filter(c => c.scope === 'local');
314
+ let match = findInList(officialLocal, name);
315
+ if (match) return makeResult(match, 'official');
316
+
317
+ // Also try @vaicli/vai-workflow-<name> if name is a short name
318
+ if (!name.startsWith('@') && !name.startsWith(WORKFLOW_PREFIX)) {
319
+ const scopedName = `@vaicli/${WORKFLOW_PREFIX}${name}`;
320
+ match = findInList(officialLocal, scopedName);
321
+ if (match) return makeResult(match, 'official');
322
+ }
323
+
324
+ // 4. Community local
325
+ const communityLocal = registry.community.filter(c => c.scope === 'local');
326
+ match = findInList(communityLocal, name);
327
+ if (match) return makeResult(match, 'community');
328
+
329
+ // 5. Official global
330
+ const officialGlobal = registry.official.filter(c => c.scope === 'global');
331
+ match = findInList(officialGlobal, name);
332
+ if (match) return makeResult(match, 'official');
333
+
334
+ if (!name.startsWith('@') && !name.startsWith(WORKFLOW_PREFIX)) {
335
+ const scopedName = `@vaicli/${WORKFLOW_PREFIX}${name}`;
336
+ match = findInList(officialGlobal, scopedName);
337
+ if (match) return makeResult(match, 'official');
338
+ }
339
+
340
+ // 6. Community global
341
+ const communityGlobal = registry.community.filter(c => c.scope === 'global');
342
+ match = findInList(communityGlobal, name);
343
+ if (match) return makeResult(match, 'community');
344
+
345
+ throw new Error(
346
+ `Workflow not found: "${name}"\n` +
347
+ `Provide a file path, built-in template name, or installed package name.\n` +
348
+ `See: vai workflow list`
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Build a resolve result from a registry entry.
354
+ * @param {object} entry - Registry entry
355
+ * @param {string} source - 'official' or 'community'
356
+ * @returns {{ definition: object, source: string, metadata: object }}
357
+ */
358
+ function makeResult(entry, source) {
359
+ return {
360
+ definition: entry.definition,
361
+ source,
362
+ metadata: {
363
+ package: entry.pkg,
364
+ path: entry.packagePath,
365
+ scope: entry.scope,
366
+ warnings: entry.warnings,
367
+ },
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Search installed workflows (official + community) by query.
373
+ * @param {string} query
374
+ * @returns {Array}
375
+ */
376
+ function searchLocal(query) {
377
+ const registry = getRegistry();
378
+ const q = query.toLowerCase();
379
+ const all = [...registry.official, ...registry.community];
380
+ return all.filter(c => {
381
+ if (c.errors.length > 0) return false;
382
+ const name = (c.name || '').toLowerCase();
383
+ const desc = (c.pkg?.description || '').toLowerCase();
384
+ const tags = (c.pkg?.vai?.tags || []).join(' ').toLowerCase();
385
+ const cat = (c.pkg?.vai?.category || '').toLowerCase();
386
+ return name.includes(q) || desc.includes(q) || tags.includes(q) || cat.includes(q);
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Get category counts from installed workflows (official + community).
392
+ * @returns {object} { category: count }
393
+ */
394
+ function getCategories() {
395
+ const registry = getRegistry();
396
+ const counts = {};
397
+ const all = [...registry.official, ...registry.community];
398
+ for (const c of all) {
399
+ if (c.errors.length > 0) continue;
400
+ const cat = c.pkg?.vai?.category || 'utility';
401
+ counts[cat] = (counts[cat] || 0) + 1;
402
+ }
403
+ return counts;
404
+ }
405
+
406
+ module.exports = {
407
+ getRegistry,
408
+ clearRegistryCache,
409
+ resolveWorkflow,
410
+ searchLocal,
411
+ getCategories,
412
+ validatePackage,
413
+ scanNodeModules,
414
+ compareVersions,
415
+ extractWorkflowName,
416
+ };