UNPKG

voyageai-cli

Version:

CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search

1,168 lines (1,043 loc) 44.6 kB
'use strict'; const path = require('path'); const pc = require('picocolors'); const ui = require('../lib/ui'); /** * Try to get the git user name for default author. * @returns {string} */ function getGitAuthor() { try { const { execSync } = require('child_process'); return execSync('git config user.name', { encoding: 'utf8' }).trim(); } catch { return ''; } } /** * Parse repeatable --input key=value options into an object. * Used as Commander's option reducer. * * @param {string} pair - "key=value" string * @param {object} prev - Accumulated object * @returns {object} */ function collectInputs(pair, prev) { const eq = pair.indexOf('='); if (eq === -1) { throw new Error(`Invalid input format: "${pair}". Expected key=value`); } const key = pair.slice(0, eq).trim(); const value = pair.slice(eq + 1).trim(); prev[key] = value; return prev; } /** * Interactively prompt the user for missing workflow inputs using @clack/prompts. * Only prompts for inputs not already provided via --input flags. * * @param {object} definition - Workflow definition * @param {object} existingInputs - Inputs already provided via --input * @returns {Promise<object>} Merged inputs (existing + prompted) */ async function promptForInputs(definition, existingInputs) { const { buildInputSteps } = require('../lib/workflow'); const { createCLIRenderer } = require('../lib/wizard-cli'); const { runWizard } = require('../lib/wizard'); const { loadInputCache } = require('../lib/workflow-input-cache'); // Load cached inputs from last run to use as defaults const workflowName = definition.name || ''; const cachedInputs = loadInputCache(workflowName); const allSteps = buildInputSteps(definition, cachedInputs); // Only prompt for inputs not already provided const steps = allSteps.filter(s => !(s.id in existingInputs)); if (steps.length === 0) return existingInputs; const renderer = createCLIRenderer({ title: `${definition.name || 'Workflow'} inputs`, doneMessage: 'Inputs ready.', showBackHint: true, }); const { answers, cancelled } = await runWizard({ steps, config: {}, renderer, initial: {}, }); if (cancelled) { console.log(pc.dim('Cancelled.')); process.exit(0); } return { ...existingInputs, ...answers }; } /** * Register the workflow command on a Commander program. * @param {import('commander').Command} program */ function registerWorkflow(program) { const wfCmd = program .command('workflow') .alias('wf') .description('Run, validate, and manage composable RAG workflows'); // ── workflow run <file> ── wfCmd .command('run <file>') .description('Execute a workflow file or built-in template') .option('--input <key=value>', 'Set a workflow input (repeatable)', collectInputs, {}) .option('--db <name>', 'Override default database') .option('--collection <name>', 'Override default collection') .option('--json', 'Output results as JSON', false) .option('-o, --output <format>', 'Output format: json, table, markdown, text, csv, value:<path>') .option('--quiet', 'Suppress progress output', false) .option('--dry-run', 'Show execution plan without running', false) .option('--verbose', 'Show step details', false) .option('--no-interactive', 'Disable interactive input prompting') .action(async (file, opts) => { const { executeWorkflow, buildExecutionPlan, validateWorkflow } = require('../lib/workflow'); const { resolveWorkflow } = require('../lib/workflow-registry'); let definition; try { const resolved = resolveWorkflow(file); definition = resolved.definition; // Show workflow source notice if ((resolved.source === 'community' || resolved.source === 'official') && !opts.quiet) { const pkg = resolved.metadata?.package; const author = typeof pkg?.author === 'string' ? pkg.author : pkg?.author?.name || 'unknown'; const tools = (pkg?.vai?.tools || []).join(', '); const isOfficial = resolved.source === 'official'; const label = isOfficial ? 'official catalog workflow' : 'community workflow'; console.error(`${pc.dim('ℹ')} Running ${label}: ${pc.cyan(pkg?.name || file)} ${pc.dim(`v${pkg?.version || '?'}`)}`); console.error(` ${pc.dim(`by ${author}`)}${tools ? pc.dim(` | Tools: ${tools}`) : ''}`); if (!isOfficial) { console.error(` ${pc.dim('This is a community-contributed workflow, not maintained by the vai project.')}`); } console.error(); for (const w of resolved.metadata?.warnings || []) { console.error(` ${pc.yellow('⚠')} ${w}`); } } } catch (err) { console.error(ui.error(err.message)); process.exit(1); } // Validation const errors = validateWorkflow(definition); if (errors.length > 0) { console.error(ui.error('Workflow validation failed:')); for (const e of errors) console.error(` ${pc.red('-')} ${e}`); process.exit(1); } const workflowName = definition.name || file; // Interactive prompting for missing inputs if (opts.interactive !== false && process.stdin.isTTY) { opts.input = await promptForInputs(definition, opts.input); } if (opts.dryRun) { // Dry run: show plan const layers = buildExecutionPlan(definition.steps); const stepMap = new Map(definition.steps.map(s => [s.id, s])); if (opts.json) { console.log(JSON.stringify({ name: workflowName, layers, steps: definition.steps, inputs: opts.input }, null, 2)); return; } console.log(); console.log(`${pc.bold('vai workflow:')} ${workflowName} ${pc.dim('(dry run)')}`); console.log(pc.dim('═'.repeat(50))); console.log(); // Show inputs const inputKeys = Object.keys(opts.input); if (inputKeys.length > 0) { console.log(pc.bold('Inputs:')); for (const [k, v] of Object.entries(opts.input)) { console.log(` ${pc.cyan(k)}: ${JSON.stringify(v)}`); } console.log(); } // Show execution plan console.log(pc.bold('Execution plan:')); let stepNum = 1; for (let i = 0; i < layers.length; i++) { const layer = layers[i]; for (const stepId of layer) { const step = stepMap.get(stepId); const toolDesc = `${step.tool}(${summarizeInputs(step.inputs)})`; console.log(` ${pc.dim(`${stepNum}.`)} ${pc.cyan(stepId)} ${pc.dim('->')} ${toolDesc}`); stepNum++; } if (layer.length > 1) { console.log(` ${pc.dim(`(${layer.join(' and ')} run in parallel)`)}`); } } console.log(); return; } // Execute workflow const telemetry = require('../lib/telemetry'); const wfDone = telemetry.timer('cli_workflow_run', { workflowName, stepCount: definition.steps?.length || 0, isBuiltin: !!(definition._source === 'builtin'), }); try { const result = await executeWorkflow(definition, { inputs: opts.input, db: opts.db, collection: opts.collection, dryRun: false, verbose: opts.verbose, json: opts.json, onStepStart: !opts.quiet ? (stepId, step) => { process.stderr.write(` ${pc.dim('...')} ${step.name || stepId}\r`); } : undefined, onStepComplete: !opts.quiet ? (stepId, output, durationMs) => { const stepDef = definition.steps.find(s => s.id === stepId); const summary = summarizeOutput(stepDef?.tool, output); console.error(` ${pc.green('✔')} ${stepDef?.name || stepId} ${pc.dim(summary)} ${pc.dim(`[${durationMs}ms]`)}`); } : undefined, onStepSkip: !opts.quiet ? (stepId, reason) => { const stepDef = definition.steps.find(s => s.id === stepId); console.error(` ${pc.yellow('⊘')} ${stepDef?.name || stepId} ${pc.dim(`skipped: ${reason}`)}`); } : undefined, onStepError: !opts.quiet ? (stepId, err) => { const stepDef = definition.steps.find(s => s.id === stepId); console.error(` ${pc.red('✗')} ${stepDef?.name || stepId} ${pc.red(err.message)}`); } : undefined, }); if (!opts.quiet) { console.error(); console.error(`${pc.bold('vai workflow:')} ${workflowName}`); console.error(pc.dim('═'.repeat(50))); // Step summaries already printed via callbacks console.error(); console.error(`${pc.dim('Complete.')} ${result.steps.length} steps, ${result.totalTimeMs}ms total.`); console.error(); } wfDone(); // Cache inputs for next run try { const { saveInputCache } = require('../lib/workflow-input-cache'); saveInputCache(workflowName, opts.input); } catch { /* non-critical, don't fail the run */ } // Output const { formatWorkflowOutput, autoDetectFormat } = require('../lib/workflow-formatters'); const fmtHints = definition.formatters || {}; if (opts.json || opts.output === 'json') { console.log(JSON.stringify(result.output, null, 2)); } else if (opts.output) { console.log(formatWorkflowOutput(result.output, opts.output, fmtHints)); } else if (result.output) { // Auto-detect best format from output shape and workflow hints const bestFormat = autoDetectFormat(result.output, fmtHints); console.log(formatWorkflowOutput(result.output, bestFormat, fmtHints)); } } catch (err) { console.error(ui.error(err.message)); if (opts.verbose) { console.error(pc.dim(err.stack)); } process.exit(1); } }); // ── workflow check <path> ── wfCmd .command('check <path>') .description('Run validation, security, and quality checks on a workflow package') .option('--security', 'Run security checks only', false) .option('--quality', 'Run quality checks only', false) .option('--all', 'Run all check tiers', false) .option('--json', 'Output machine-readable JSON', false) .option('--ci', 'Output CI-optimized JSON with summary metadata', false) .action((pkgPath, opts) => { const { validateSchemaEnhanced, loadWorkflow } = require('../lib/workflow'); const { securityAudit, extractCapabilities } = require('../lib/security-audit'); const { qualityAudit } = require('../lib/quality-audit'); const fs = require('fs'); const resolvedPath = path.resolve(pkgPath); const runAll = opts.all || (!opts.security && !opts.quality); // Load workflow definition let definition; let pkg = {}; let workflowFile; try { // Check if it's a directory (package) or a single file const stat = fs.statSync(resolvedPath); if (stat.isDirectory()) { // Package directory const pkgJsonPath = path.join(resolvedPath, 'package.json'); if (fs.existsSync(pkgJsonPath)) { pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); } // Find workflow file workflowFile = pkg.main || 'workflow.json'; const wfPath = path.join(resolvedPath, workflowFile); if (fs.existsSync(wfPath)) { definition = JSON.parse(fs.readFileSync(wfPath, 'utf8')); } else { console.error(ui.error(`Workflow file not found: ${wfPath}`)); process.exit(1); } } else { // Single file definition = loadWorkflow(resolvedPath); } } catch (err) { console.error(ui.error(err.message)); process.exit(1); } const results = { schema: [], security: [], quality: [], capabilities: [] }; // L1: Schema validation (always runs) if (runAll || (!opts.security && !opts.quality)) { results.schema = validateSchemaEnhanced(definition); } // L2: Security if (runAll || opts.security) { const packageDir = fs.statSync(resolvedPath).isDirectory() ? resolvedPath : null; results.security = securityAudit(definition, packageDir); results.capabilities = [...extractCapabilities(definition)]; } // L3: Quality if (runAll || opts.quality) { const packageDir = fs.statSync(resolvedPath).isDirectory() ? resolvedPath : null; results.quality = qualityAudit(definition, pkg, packageDir); } // CI output (machine-readable JSON with summary metadata) if (opts.ci) { const criticalCount = results.security.filter(f => f.severity === 'critical').length; const highCount = results.security.filter(f => f.severity === 'high').length; const schemaPass = results.schema.length === 0; const securityPass = criticalCount === 0 && highCount === 0; const qualityPass = results.quality.filter(i => i.level === 'error').length === 0; const ciOutput = { schema: { pass: schemaPass, errors: results.schema }, security: { pass: securityPass, findings: results.security }, quality: { pass: qualityPass, issues: results.quality }, capabilities: results.capabilities, summary: { passAll: schemaPass && securityPass && qualityPass, criticalCount, highCount, }, }; console.log(JSON.stringify(ciOutput, null, 2)); if (criticalCount > 0) process.exit(2); if (highCount > 0) process.exit(1); return; } // JSON output if (opts.json) { console.log(JSON.stringify(results, null, 2)); // Exit code 1 if critical/high security findings const hasCritical = results.security.some(f => f.severity === 'critical' || f.severity === 'high'); if (hasCritical) process.exit(1); return; } // Pretty output console.log(); console.log(pc.bold(`Workflow Check: ${definition.name || pkgPath}`)); console.log(pc.dim('═'.repeat(50))); // Schema if (results.schema.length > 0) { console.log(); console.log(pc.bold('Schema Validation:')); for (const e of results.schema) { console.log(` ${pc.red('✗')} ${e}`); } } else if (runAll || (!opts.security && !opts.quality)) { console.log(); console.log(`${pc.bold('Schema Validation:')} ${pc.green('✔ passed')}`); } // Security if (runAll || opts.security) { console.log(); console.log(pc.bold('Security Audit:')); if (results.security.length === 0) { console.log(` ${pc.green('✔')} No security issues found`); } else { for (const f of results.security) { const color = f.severity === 'critical' ? pc.red : f.severity === 'high' ? pc.red : f.severity === 'medium' ? pc.yellow : pc.dim; const icon = f.severity === 'critical' || f.severity === 'high' ? '✗' : '⚠'; console.log(` ${color(icon)} [${f.severity.toUpperCase()}] ${f.message}`); } } // Capability flags if (results.capabilities.length > 0) { console.log(); console.log(pc.bold('Capabilities:')); const capIcons = { NETWORK: '🌐', WRITE_DB: '💾', LLM: '🤖', LOOP: '🔄', READ_DB: '📊' }; for (const cap of results.capabilities) { console.log(` ${capIcons[cap] || '•'} ${cap}`); } } } // Quality if (runAll || opts.quality) { console.log(); console.log(pc.bold('Quality Audit:')); if (results.quality.length === 0) { console.log(` ${pc.green('✔')} No quality issues found`); } else { for (const issue of results.quality) { const color = issue.level === 'error' ? pc.red : issue.level === 'warning' ? pc.yellow : pc.dim; const icon = issue.level === 'error' ? '✗' : issue.level === 'warning' ? '⚠' : 'ℹ'; console.log(` ${color(icon)} [${issue.level.toUpperCase()}] ${issue.message}`); } } } console.log(); // Exit code 1 if critical/high security findings const hasCritical = results.security.some(f => f.severity === 'critical' || f.severity === 'high'); if (hasCritical) process.exit(1); }); // ── workflow test <path> ── wfCmd .command('test <path>') .description('Run test fixtures for a workflow package') .option('--test <name>', 'Run a specific test case by name') .option('--json', 'Output machine-readable JSON', false) .action(async (pkgPath, opts) => { const { loadWorkflow } = require('../lib/workflow'); const { runAllTests, loadTestCases } = require('../lib/workflow-test-runner'); const fs = require('fs'); const resolvedPath = path.resolve(pkgPath); // Load workflow definition let definition; try { const stat = fs.statSync(resolvedPath); if (stat.isDirectory()) { const wfPath = path.join(resolvedPath, 'workflow.json'); if (fs.existsSync(wfPath)) { definition = JSON.parse(fs.readFileSync(wfPath, 'utf8')); } else { console.error(ui.error(`Workflow file not found: ${wfPath}`)); process.exit(1); } } else { console.error(ui.error('Path must be a workflow package directory')); process.exit(1); } } catch (err) { console.error(ui.error(err.message)); process.exit(1); } // Check for tests directory const testsDir = path.join(resolvedPath, 'tests'); if (!fs.existsSync(testsDir)) { console.error(ui.error('No tests/ directory found in package')); process.exit(1); } const testCases = loadTestCases(resolvedPath); if (testCases.length === 0) { console.error(ui.error('No *.test.json files found in tests/')); process.exit(1); } try { const aggregate = await runAllTests(definition, resolvedPath, { testName: opts.test, }); if (opts.json) { console.log(JSON.stringify(aggregate, null, 2)); if (aggregate.failed > 0) process.exit(1); return; } // Pretty output console.log(); console.log(pc.bold(`Workflow Tests: ${definition.name || pkgPath}`)); console.log(pc.dim('═'.repeat(50))); console.log(); for (const result of aggregate.results) { const icon = result.passed ? pc.green('✔') : pc.red('✗'); console.log(`${icon} ${result.name || result.file}`); if (result.error) { console.log(` ${pc.red(result.error)}`); } for (const assertion of result.assertions) { const aIcon = assertion.pass ? pc.green(' ✔') : pc.red(' ✗'); console.log(`${aIcon} ${assertion.message}`); } for (const err of (result.errors || [])) { console.log(` ${pc.red('Error:')} ${err}`); } } console.log(); console.log(`${pc.bold('Summary:')} ${pc.green(`${aggregate.passed} passed`)}, ${aggregate.failed > 0 ? pc.red(`${aggregate.failed} failed`) : `${aggregate.failed} failed`}`); console.log(); if (aggregate.failed > 0) process.exit(1); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); // ── workflow integration-test ── wfCmd .command('integration-test') .description('Run live integration tests against use-case domain datasets') .option('--domain <slug>', 'Test only a specific domain (devdocs, healthcare, finance, legal)') .option('--workflow <name>', 'Test only a specific workflow') .option('--no-seed', 'Skip seeding (assumes data already exists)') .option('--teardown', 'Drop test collections after running') .option('--sample-docs <path>', 'Override base path for sample documents') .option('--json', 'Output machine-readable JSON', false) .action(async (opts) => { // Delegate to the integration test runner const { execFileSync } = require('child_process'); const runnerPath = path.join(__dirname, '../../test/integration/run.js'); const args = []; if (opts.domain) args.push('--domain', opts.domain); if (opts.workflow) args.push('--workflow', opts.workflow); if (opts.seed === false) args.push('--no-seed'); if (opts.teardown) args.push('--teardown'); if (opts.sampleDocs) args.push('--sample-docs', opts.sampleDocs); if (opts.json) args.push('--json'); try { execFileSync(process.execPath, [runnerPath, ...args], { stdio: 'inherit' }); } catch (err) { if (err.status) process.exit(err.status); throw err; } }); // ── workflow validate <file> ── wfCmd .command('validate <file>') .description('Validate a workflow file without executing') .action((file) => { const { loadWorkflow, validateWorkflow, buildExecutionPlan } = require('../lib/workflow'); let definition; try { definition = loadWorkflow(file); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } const errors = validateWorkflow(definition); if (errors.length > 0) { console.error(ui.error(`Workflow has ${errors.length} error(s):`)); for (const e of errors) console.error(` ${pc.red('-')} ${e}`); process.exit(1); } const layers = buildExecutionPlan(definition.steps); console.log(ui.success(`${definition.name} is valid`)); console.log(` ${pc.dim('Steps:')} ${definition.steps.length}`); console.log(` ${pc.dim('Layers:')} ${layers.length} (${layers.map(l => l.length).join(' + ')} steps)`); if (definition.inputs) { const required = Object.entries(definition.inputs).filter(([, s]) => s.required).map(([k]) => k); if (required.length > 0) { console.log(` ${pc.dim('Required inputs:')} ${required.join(', ')}`); } } }); // ── workflow list ── wfCmd .command('list') .description('List available workflows (built-in + official + community)') .option('--built-in', 'Show only built-in workflows', false) .option('--official', 'Show only official @vaicli workflows', false) .option('--community', 'Show only community workflows', false) .option('--category <name>', 'Filter by category') .option('--tag <name>', 'Filter by tag') .option('--json', 'Output JSON', false) .action((opts) => { const { getRegistry } = require('../lib/workflow-registry'); const registry = getRegistry({ force: true }); const showBuiltIn = !opts.community && !opts.official; const showOfficial = !opts.builtIn && !opts.community; const showCommunity = !opts.builtIn && !opts.official; if (opts.json) { const out = {}; if (showBuiltIn) out.builtIn = registry.builtIn; if (showOfficial) out.official = registry.official.filter(c => c.errors.length === 0); if (showCommunity) out.community = registry.community.filter(c => c.errors.length === 0); console.log(JSON.stringify(out, null, 2)); return; } /** * Display a list of package-based workflows (official or community). */ function displayPackageList(items, label, emptyHint) { let filtered = items.filter(c => c.errors.length === 0); if (opts.category) { filtered = filtered.filter(c => (c.pkg?.vai?.category || 'utility') === opts.category); } if (opts.tag) { filtered = filtered.filter(c => (c.pkg?.vai?.tags || []).includes(opts.tag)); } console.log(); console.log(pc.bold(`${label} (${filtered.length})`)); if (filtered.length === 0) { console.log(pc.dim(` (${emptyHint})`)); } else { for (const wf of filtered) { const pkg = wf.pkg || {}; const author = typeof pkg.author === 'string' ? pkg.author : pkg.author?.name || ''; const tags = (pkg.vai?.tags || []).join(' · '); console.log(` ${pc.cyan(wf.name.padEnd(42))} ${pkg.description || ''}`); if (author || tags) { console.log(` ${pc.dim(`by ${author}`)}${pkg.version ? pc.dim(` | v${pkg.version}`) : ''}${tags ? pc.dim(` | ${tags}`) : ''}`); } } } // Show invalid packages as warnings const invalid = items.filter(c => c.errors.length > 0); if (invalid.length > 0) { console.log(); for (const inv of invalid) { console.error(` ${pc.yellow('⚠')} ${inv.name}: ${inv.errors[0]}`); } } } // Built-in if (showBuiltIn) { console.log(); console.log(pc.bold(`Built-in Workflows (${registry.builtIn.length})`)); if (registry.builtIn.length === 0) { console.log(pc.dim(' (none)')); } else { for (const wf of registry.builtIn) { console.log(` ${pc.cyan(wf.name.padEnd(28))} ${wf.description}`); } } } // Official Catalog (@vaicli) if (showOfficial) { displayPackageList(registry.official, 'Official Catalog (@vaicli)', 'none installed'); } // Community if (showCommunity) { displayPackageList(registry.community, 'Community Workflows', 'none installed — install with: vai workflow install <package-name>'); } console.log(); console.log(pc.dim('Run with: vai workflow run <name> --input key=value')); console.log(); }); // ── workflow install ── wfCmd .command('install <package>') .description('Install a workflow from npm') .option('--global', 'Install globally', false) .option('--json', 'Output JSON', false) .action(async (packageName, opts) => { const { installPackage, WORKFLOW_PREFIX, isWorkflowPackage, isOfficialPackage } = require('../lib/npm-utils'); const { validatePackage, clearRegistryCache } = require('../lib/workflow-registry'); // Auto-prefix if needed (but not for scoped packages) if (!packageName.startsWith('@') && !packageName.startsWith(WORKFLOW_PREFIX)) { packageName = WORKFLOW_PREFIX + packageName; } const telemetry = require('../lib/telemetry'); console.log(`Installing ${pc.cyan(packageName)}...`); try { telemetry.send('cli_workflow_install', { packageName }); const result = installPackage(packageName, { global: opts.global }); console.log(`${pc.green('✔')} Downloaded ${pc.cyan(packageName)}@${result.version}`); // Validate if (result.path) { const validation = validatePackage(result.path); if (validation.errors.length === 0) { const steps = validation.definition?.steps?.length || 0; const tools = (validation.pkg?.vai?.tools || []).join(', '); console.log(`${pc.green('✔')} Validated workflow definition (${steps} steps${tools ? `, tools: ${tools}` : ''})`); // Display capability flags if (validation.definition) { const { extractCapabilities } = require('../lib/security-audit'); const caps = extractCapabilities(validation.definition); if (caps.size > 0) { const capIcons = { NETWORK: '🌐', WRITE_DB: '💾', LLM: '🤖', LOOP: '🔄', READ_DB: '📊' }; const capList = [...caps].map(c => `${capIcons[c] || '•'} ${c}`).join(' '); console.log(`${pc.dim('Capabilities:')} ${capList}`); } } } else { console.log(`${pc.yellow('⚠')} Validation issues:`); for (const e of validation.errors) { console.log(` ${pc.yellow('-')} ${e}`); } console.log(); console.log(pc.dim('The package was installed but the workflow may not execute correctly.')); } for (const w of validation.warnings) { console.log(`${pc.yellow('⚠')} ${w}`); } } clearRegistryCache(); if (opts.json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(); console.log(`Installed. Run with:`); console.log(` ${pc.cyan(`vai workflow run ${packageName}`)} --input key=value`); } } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); // ── workflow uninstall ── wfCmd .command('uninstall <package>') .description('Remove a workflow package') .option('--global', 'Uninstall globally', false) .action((packageName, opts) => { const { uninstallPackage, WORKFLOW_PREFIX } = require('../lib/npm-utils'); const { clearRegistryCache } = require('../lib/workflow-registry'); if (!packageName.startsWith('@') && !packageName.startsWith(WORKFLOW_PREFIX)) { packageName = WORKFLOW_PREFIX + packageName; } console.log(`Uninstalling ${pc.cyan(packageName)}...`); try { uninstallPackage(packageName, { global: opts.global }); clearRegistryCache(); console.log(`${pc.green('✔')} Removed ${packageName}`); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); // ── workflow search ── wfCmd .command('search <query>') .description('Search npm for community workflows') .option('--limit <n>', 'Maximum results', '10') .option('--json', 'Output JSON', false) .action(async (query, opts) => { const { searchNpm } = require('../lib/npm-utils'); const telemetry = require('../lib/telemetry'); console.log(`Searching npm for vai-workflow packages matching "${query}"...`); console.log(); try { const results = await searchNpm(query, { limit: parseInt(opts.limit, 10) }); telemetry.send('cli_workflow_search', { query: query.slice(0, 50), resultCount: results.length }); if (opts.json) { console.log(JSON.stringify(results, null, 2)); return; } if (results.length === 0) { console.log(pc.dim(' No matching workflow packages found.')); console.log(); return; } for (const r of results) { const badge = r.official ? ` ${pc.green('[OFFICIAL]')}` : ''; console.log(` ${pc.cyan(r.name)} ${pc.dim(`v${r.version}`)}${badge}`); if (r.description) console.log(` ${r.description}`); console.log(` ${pc.dim(`by ${r.author}`)}${r.keywords.length ? pc.dim(` | ${r.keywords.slice(0, 5).join(', ')}`) : ''}`); console.log(); } console.log(pc.dim(`Install: vai workflow install <package-name>`)); console.log(); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); // ── workflow info ── wfCmd .command('info <name>') .description('Show detailed info about an installed workflow') .option('--json', 'Output JSON', false) .action((name, opts) => { const { resolveWorkflow, getRegistry } = require('../lib/workflow-registry'); const { WORKFLOW_PREFIX } = require('../lib/npm-utils'); try { const resolved = resolveWorkflow(name); if (opts.json) { console.log(JSON.stringify({ source: resolved.source, definition: resolved.definition, metadata: resolved.metadata }, null, 2)); return; } const def = resolved.definition; console.log(); if (resolved.source === 'community' || resolved.source === 'official') { const pkg = resolved.metadata?.package || {}; const author = typeof pkg.author === 'string' ? pkg.author : pkg.author?.name || 'unknown'; const vai = pkg.vai || {}; console.log(`${pc.bold(pc.cyan(pkg.name || name))} ${pc.dim(`v${pkg.version || '?'}`)}`); console.log(` ${pkg.description || def.description || ''}`); console.log(); console.log(` ${pc.dim('Author:')} ${author}`); console.log(` ${pc.dim('License:')} ${pkg.license || 'unknown'}`); console.log(` ${pc.dim('Category:')} ${vai.category || 'utility'}`); if (vai.tags?.length) console.log(` ${pc.dim('Tags:')} ${vai.tags.join(', ')}`); if (vai.minVaiVersion) console.log(` ${pc.dim('Min vai:')} v${vai.minVaiVersion}`); if (vai.tools?.length) console.log(` ${pc.dim('Tools:')} ${vai.tools.join(', ')}`); console.log(` ${pc.dim('Steps:')} ${def.steps?.length || 0}`); console.log(` ${pc.dim('Source:')} ${resolved.metadata?.path || 'unknown'}`); if (pkg.name) console.log(` ${pc.dim('npm:')} https://www.npmjs.com/package/${pkg.name}`); } else { console.log(`${pc.bold(pc.cyan(def.name || name))} ${pc.dim(`[${resolved.source}]`)}`); console.log(` ${def.description || ''}`); console.log(` ${pc.dim('Steps:')} ${def.steps?.length || 0}`); } // Show inputs if (def.inputs && Object.keys(def.inputs).length > 0) { console.log(); console.log(` ${pc.bold('Inputs:')}`); for (const [key, schema] of Object.entries(def.inputs)) { const req = schema.required ? pc.red('(required)') : pc.dim(`(default: ${schema.default ?? 'none'})`); const desc = schema.description || ''; console.log(` ${pc.cyan(key.padEnd(16))} ${(schema.type || 'string').padEnd(8)} ${req} ${pc.dim(desc)}`); } } console.log(); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); // ── workflow create ── wfCmd .command('create') .description('Interactive workflow builder -- scaffold a validated, publish-ready workflow package') .option('--from <file>', 'Existing workflow JSON to package') .option('--from-description <desc>', 'Generate a workflow skeleton from a text description') .option('--name <name>', 'Package name (without vai-workflow- prefix)') .option('--author <name>', 'Author name') .option('--description <desc>', 'Package description') .option('--category <cat>', 'Category (retrieval, analysis, ingestion, domain-specific, utility, integration)') .option('--scope <scope>', 'Package scope (e.g. "vaicli" for @vaicli/vai-workflow-*)') .option('--output <dir>', 'Output directory') .action(async (opts) => { const { scaffoldPackage, toPackageName, CATEGORIES } = require('../lib/workflow-scaffold'); const { loadWorkflow, validateWorkflow, buildExecutionPlan } = require('../lib/workflow'); const { runInteractiveBuilder, workflowFromDescription } = require('../lib/workflow-builder'); let definition; let name = opts.name; let author = opts.author; let description = opts.description; let category = opts.category; if (opts.from) { // Package an existing workflow try { definition = loadWorkflow(opts.from); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } if (!name) { name = definition.name || path.basename(opts.from, '.json').replace('.vai-workflow', ''); } if (!description) { description = definition.description; } } else if (opts.fromDescription) { // Generate from text description try { definition = workflowFromDescription(opts.fromDescription); name = name || definition.name; description = description || definition.description; const p = require('@clack/prompts'); p.intro(pc.bold('Generated workflow from description')); // Show what was generated p.log.info(`Name: ${pc.cyan(definition.name)}`); p.log.info(`Steps: ${definition.steps.map(s => `${pc.cyan(s.id)} (${s.tool})`).join(' -> ')}`); p.log.info(`Inputs: ${Object.keys(definition.inputs).join(', ') || 'none'}`); // Show execution plan try { const layers = buildExecutionPlan(definition.steps); p.log.info(pc.bold('Execution plan:')); for (let i = 0; i < layers.length; i++) { p.log.message(` Layer ${i + 1}: ${layers[i].join(', ')}`); } } catch (e) { /* skip */ } // Validate const errors = validateWorkflow(definition); if (errors.length > 0) { p.log.warn('Validation issues:'); for (const err of errors) { p.log.warn(` ${err}`); } } else { p.log.success('Workflow validates successfully!'); } // Ask for category if not provided if (!category) { const { guessCategory, extractTools } = require('../lib/workflow-scaffold'); category = guessCategory(extractTools(definition)); } // Ask for author if not provided if (!author) { author = getGitAuthor(); } // Confirm or edit const proceed = await p.confirm({ message: 'Scaffold this workflow as a package?', initialValue: true }); if (p.isCancel(proceed) || !proceed) { // Write just the workflow.json for manual editing const fs = require('fs'); const filename = `${definition.name}.vai-workflow.json`; fs.writeFileSync(filename, JSON.stringify(definition, null, 2) + '\n'); p.log.info(`Wrote ${pc.cyan(filename)} for manual editing.`); p.outro('Edit the file and run `vai workflow create --from <file>` when ready.'); return; } } catch (err) { console.error(ui.error(`Generation failed: ${err.message}`)); process.exit(1); } } else if (process.stdin.isTTY) { // Full interactive builder try { const result = await runInteractiveBuilder(); definition = result.definition; name = name || result.name; description = description || result.description; category = category || result.category; author = author || result.author; } catch (err) { if (err.message && err.message.includes('cancelled')) { process.exit(0); } console.error(ui.error(`Interactive mode failed: ${err.message}`)); process.exit(1); } } else { console.error(ui.error('Provide --from <file>, --from-description "text", or run interactively (TTY required).')); process.exit(1); } if (!name) { console.error(ui.error('Workflow name is required. Use --name <name>.')); process.exit(1); } try { const result = scaffoldPackage({ definition, name, author, description, category, scope: opts.scope, outputDir: opts.output, }); const pkgName = toPackageName(name, { scope: opts.scope }); console.log(); console.log(`${pc.green('✔')} Created ${pc.cyan(pkgName)}/`); for (const f of result.files) { console.log(` ${pc.dim('├──')} ${f}`); } console.log(); console.log('Next steps:'); console.log(` 1. ${opts.from ? 'Review README.md' : `cd ${pkgName} && review workflow.json`}`); console.log(` 2. ${pc.dim('vai workflow validate')} ${pkgName}/workflow.json`); console.log(` 3. npm publish`); console.log(); } catch (err) { console.error(ui.error(err.message)); process.exit(1); } }); // ── workflow init ── wfCmd .command('init') .description('Scaffold a new workflow file') .option('--name <name>', 'Workflow name') .option('--output <file>', 'Output file path') .action((opts) => { const fs = require('fs'); const name = opts.name || 'my-workflow'; const filename = opts.output || `./${name}.vai-workflow.json`; const scaffold = { $schema: 'https://vai.dev/schemas/workflow-v1.json', name: name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), description: 'TODO: describe what this workflow does', version: '1.0.0', inputs: { query: { type: 'string', description: 'The search query', required: true, }, }, defaults: {}, steps: [ { id: 'search', name: 'Search knowledge base', tool: 'query', inputs: { query: '{{ inputs.query }}', limit: 10, }, }, ], output: { results: '{{ search.output.results }}', query: '{{ inputs.query }}', }, }; fs.writeFileSync(filename, JSON.stringify(scaffold, null, 2) + '\n'); console.log(ui.success(`Created ${filename}`)); console.log(` ${pc.dim('Run with:')} vai workflow run ${filename} --input query="your question"`); console.log(` ${pc.dim('Validate:')} vai workflow validate ${filename}`); }); // ── workflow clear-cache [name] ── wfCmd .command('clear-cache [name]') .description('Clear cached workflow inputs (from previous runs)') .action((name) => { const { clearInputCache, loadInputCache, slugify, CACHE_PATH } = require('../lib/workflow-input-cache'); const fs = require('fs'); if (name) { const cached = loadInputCache(name); const keys = Object.keys(cached); if (keys.length === 0) { console.log(pc.dim(`No cached inputs for "${name}".`)); return; } clearInputCache(name); console.log(ui.success(`Cleared cached inputs for "${name}" (${keys.length} field${keys.length === 1 ? '' : 's'}).`)); } else { // Clear all let count = 0; try { const raw = fs.readFileSync(CACHE_PATH, 'utf-8'); count = Object.keys(JSON.parse(raw)).length; } catch { /* no file */ } if (count === 0) { console.log(pc.dim('No cached workflow inputs.')); return; } clearInputCache(); console.log(ui.success(`Cleared cached inputs for ${count} workflow${count === 1 ? '' : 's'}.`)); } }); } /** * Summarize step inputs for dry-run display. */ function summarizeInputs(inputs) { if (!inputs) return ''; const parts = []; for (const [k, v] of Object.entries(inputs)) { if (typeof v === 'string' && v.includes('{{')) { parts.push(`${k}=<ref>`); } else if (typeof v === 'string') { parts.push(`${k}=${v.length > 20 ? v.slice(0, 20) + '...' : v}`); } else if (typeof v === 'number' || typeof v === 'boolean') { parts.push(`${k}=${v}`); } } return parts.join(', '); } /** * Summarize step output for display. */ function summarizeOutput(tool, output) { if (!output) return ''; if (output.results && output.resultCount != null) { return `${output.resultCount} results`; } if (output.insertedCount != null) { return `${output.insertedCount} docs inserted`; } if (output.similarity != null) { return `similarity: ${output.similarity.toFixed(4)}`; } if (output.text) { return `${output.text.length} chars`; } if (output.embedding) { return `${output.dimensions}d embedding`; } if (output.totalCost != null) { return `$${output.totalCost}`; } return ''; } module.exports = { registerWorkflow, collectInputs };