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.
- package/package.json +2 -1
- package/src/commands/app.js +15 -0
- package/src/commands/playground.js +638 -7
- package/src/commands/workflow.js +417 -14
- package/src/lib/explanations.js +88 -0
- package/src/lib/npm-utils.js +265 -0
- package/src/lib/workflow-registry.js +416 -0
- package/src/lib/workflow-scaffold.js +319 -0
- package/src/lib/workflow.js +433 -7
- package/src/playground/announcements.md +71 -0
- package/src/playground/icons/V.png +0 -0
- package/src/playground/index.html +2204 -94
- package/src/workflows/consistency-check.json +4 -0
- package/src/workflows/cost-analysis.json +4 -0
- package/src/workflows/enrich-and-ingest.json +56 -0
- package/src/workflows/intelligent-ingest.json +66 -0
- package/src/workflows/kb-health-report.json +45 -0
- package/src/workflows/multi-collection-search.json +4 -0
- package/src/workflows/research-and-summarize.json +4 -0
- package/src/workflows/search-with-fallback.json +66 -0
- package/src/workflows/smart-ingest.json +4 -0
|
@@ -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
|
+
};
|