voyageai-cli 1.28.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.
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const WORKFLOW_PREFIX = 'vai-workflow-';
8
+ const VAICLI_SCOPE = '@vaicli/';
9
+ const VAICLI_WORKFLOW_PREFIX = '@vaicli/vai-workflow-';
10
+ const NPM_SEARCH_URL = 'https://registry.npmjs.org/-/v1/search';
11
+
12
+ /**
13
+ * Check if a package name is an official @vaicli scoped package.
14
+ * @param {string} name
15
+ * @returns {boolean}
16
+ */
17
+ function isOfficialPackage(name) {
18
+ return name.startsWith(VAICLI_WORKFLOW_PREFIX);
19
+ }
20
+
21
+ /**
22
+ * Check if a package name is a valid vai workflow package (scoped or unscoped).
23
+ * @param {string} name
24
+ * @returns {boolean}
25
+ */
26
+ function isWorkflowPackage(name) {
27
+ return name.startsWith(VAICLI_WORKFLOW_PREFIX) || name.startsWith(WORKFLOW_PREFIX);
28
+ }
29
+
30
+ /**
31
+ * Search the npm registry for vai workflow packages.
32
+ * @param {string} query - Search terms
33
+ * @param {{ limit?: number }} options
34
+ * @returns {Promise<Array<{ name: string, version: string, description: string, author: string, date: string, keywords: string[], official: boolean }>>}
35
+ */
36
+ async function searchNpm(query, options = {}) {
37
+ const limit = options.limit || 10;
38
+ // Search for both scoped and unscoped packages
39
+ const searchText = query
40
+ ? `keywords:vai-workflow ${query}`
41
+ : `keywords:vai-workflow`;
42
+ const url = `${NPM_SEARCH_URL}?text=${encodeURIComponent(searchText)}&size=${limit * 3}`;
43
+
44
+ const res = await fetch(url, {
45
+ headers: { 'Accept': 'application/json' },
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+
49
+ if (!res.ok) {
50
+ throw new Error(`npm search failed: ${res.status} ${res.statusText}`);
51
+ }
52
+
53
+ const data = await res.json();
54
+ let results = (data.objects || [])
55
+ .filter(obj => isWorkflowPackage(obj.package.name))
56
+ .map(obj => ({
57
+ name: obj.package.name,
58
+ version: obj.package.version,
59
+ description: obj.package.description || '',
60
+ author: obj.package.author?.name || obj.package.publisher?.username || 'unknown',
61
+ date: obj.package.date,
62
+ keywords: obj.package.keywords || [],
63
+ official: isOfficialPackage(obj.package.name),
64
+ }));
65
+
66
+ // Client-side filtering: npm keyword search returns all vai-workflow packages,
67
+ // so we filter locally by matching query against name, description, and keywords
68
+ if (query) {
69
+ const q = query.toLowerCase();
70
+ const terms = q.split(/\s+/).filter(Boolean);
71
+ results = results.filter(pkg => {
72
+ const haystack = [
73
+ pkg.name,
74
+ pkg.description,
75
+ ...pkg.keywords,
76
+ ].join(' ').toLowerCase();
77
+ return terms.every(term => haystack.includes(term));
78
+ });
79
+ }
80
+
81
+ results = results.slice(0, options.limit || limit);
82
+
83
+ // Sort: official first, then by name
84
+ results.sort((a, b) => {
85
+ if (a.official !== b.official) return a.official ? -1 : 1;
86
+ return a.name.localeCompare(b.name);
87
+ });
88
+
89
+ return results;
90
+ }
91
+
92
+ /**
93
+ * Install a workflow package via npm.
94
+ * @param {string} packageName
95
+ * @param {{ global?: boolean }} options
96
+ * @returns {{ success: boolean, version: string, path: string, official: boolean }}
97
+ */
98
+ function installPackage(packageName, options = {}) {
99
+ if (!isWorkflowPackage(packageName)) {
100
+ throw new Error(
101
+ `Package name must start with "${WORKFLOW_PREFIX}" or "${VAICLI_WORKFLOW_PREFIX}". Did you mean ${WORKFLOW_PREFIX}${packageName}?`
102
+ );
103
+ }
104
+
105
+ // Determine install location: use global if explicitly requested,
106
+ // or if there's no local package.json (e.g. running inside Electron app)
107
+ const hasLocalPkg = !options.global && (() => {
108
+ try { return require('fs').existsSync(require('path').join(process.cwd(), 'package.json')); }
109
+ catch { return false; }
110
+ })();
111
+ const useGlobal = options.global || !hasLocalPkg;
112
+ const globalFlag = useGlobal ? '-g' : '';
113
+ const cmd = `npm install ${packageName} ${globalFlag} ${useGlobal ? '' : '--save'} --ignore-scripts 2>&1`;
114
+
115
+ try {
116
+ const output = execSync(cmd, {
117
+ encoding: 'utf8',
118
+ timeout: 60000,
119
+ cwd: useGlobal ? undefined : process.cwd(),
120
+ });
121
+
122
+ // Find installed version from node_modules
123
+ const pkgPath = resolvePackagePath(packageName, useGlobal);
124
+ let version = 'unknown';
125
+ if (pkgPath) {
126
+ try {
127
+ const pkg = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json'), 'utf8'));
128
+ version = pkg.version;
129
+ } catch { /* ignore */ }
130
+ }
131
+
132
+ return { success: true, version, path: pkgPath || '', official: isOfficialPackage(packageName) };
133
+ } catch (err) {
134
+ const msg = err.stderr || err.stdout || err.message;
135
+ if (msg.includes('404') || msg.includes('E404')) {
136
+ throw new Error(`Package ${packageName} not found on npm`);
137
+ }
138
+ throw new Error(`npm install failed: ${msg}`);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Uninstall a workflow package via npm.
144
+ * @param {string} packageName
145
+ * @param {{ global?: boolean }} options
146
+ * @returns {{ success: boolean }}
147
+ */
148
+ function uninstallPackage(packageName, options = {}) {
149
+ const globalFlag = options.global ? '-g' : '';
150
+ const cmd = `npm uninstall ${packageName} ${globalFlag} 2>&1`;
151
+
152
+ try {
153
+ execSync(cmd, { encoding: 'utf8', timeout: 30000 });
154
+ return { success: true };
155
+ } catch (err) {
156
+ throw new Error(`npm uninstall failed: ${err.message}`);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Get metadata for a package from the npm registry (without installing).
162
+ * @param {string} packageName
163
+ * @returns {Promise<{ name: string, version: string, description: string, author: string, vai: object|null, official: boolean }>}
164
+ */
165
+ async function getPackageInfo(packageName) {
166
+ // Scoped packages need proper encoding: @vaicli/vai-workflow-foo -> @vaicli%2fvai-workflow-foo
167
+ const encodedName = packageName.startsWith('@')
168
+ ? `@${encodeURIComponent(packageName.slice(1))}`
169
+ : encodeURIComponent(packageName);
170
+ const url = `https://registry.npmjs.org/${encodedName}/latest`;
171
+ const res = await fetch(url, {
172
+ headers: { 'Accept': 'application/json' },
173
+ signal: AbortSignal.timeout(10000),
174
+ });
175
+
176
+ if (!res.ok) {
177
+ if (res.status === 404) throw new Error(`Package ${packageName} not found on npm`);
178
+ throw new Error(`npm registry error: ${res.status}`);
179
+ }
180
+
181
+ const data = await res.json();
182
+ return {
183
+ name: data.name,
184
+ version: data.version,
185
+ description: data.description || '',
186
+ author: typeof data.author === 'string' ? data.author : data.author?.name || 'unknown',
187
+ license: data.license || 'unknown',
188
+ vai: data.vai || null,
189
+ keywords: data.keywords || [],
190
+ repository: data.repository,
191
+ official: isOfficialPackage(data.name),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Resolve the filesystem path of an installed package.
197
+ * Handles both scoped (@vaicli/vai-workflow-*) and unscoped (vai-workflow-*) packages.
198
+ * @param {string} packageName
199
+ * @param {boolean} [global]
200
+ * @returns {string|null}
201
+ */
202
+ function resolvePackagePath(packageName, global) {
203
+ // Scoped packages live at node_modules/@scope/package-name
204
+ // path.join handles this correctly since packageName includes the scope
205
+ if (!global) {
206
+ let dir = process.cwd();
207
+ while (dir !== path.dirname(dir)) {
208
+ const candidate = path.join(dir, 'node_modules', packageName);
209
+ if (fs.existsSync(candidate)) return candidate;
210
+ dir = path.dirname(dir);
211
+ }
212
+ }
213
+
214
+ // Try global
215
+ try {
216
+ const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
217
+ const candidate = path.join(globalRoot, packageName);
218
+ if (fs.existsSync(candidate)) return candidate;
219
+ } catch { /* ignore */ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Find the nearest node_modules directory.
226
+ * @returns {string|null}
227
+ */
228
+ function findLocalNodeModules() {
229
+ let dir = process.cwd();
230
+ while (dir !== path.dirname(dir)) {
231
+ const candidate = path.join(dir, 'node_modules');
232
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
233
+ return candidate;
234
+ }
235
+ dir = path.dirname(dir);
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Find the global node_modules directory.
242
+ * @returns {string|null}
243
+ */
244
+ function findGlobalNodeModules() {
245
+ try {
246
+ return execSync('npm root -g', { encoding: 'utf8' }).trim();
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ module.exports = {
253
+ searchNpm,
254
+ installPackage,
255
+ uninstallPackage,
256
+ getPackageInfo,
257
+ resolvePackagePath,
258
+ findLocalNodeModules,
259
+ findGlobalNodeModules,
260
+ isOfficialPackage,
261
+ isWorkflowPackage,
262
+ WORKFLOW_PREFIX,
263
+ VAICLI_SCOPE,
264
+ VAICLI_WORKFLOW_PREFIX,
265
+ };
@@ -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
+ };