voyageai-cli 1.29.0 → 1.30.1

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 (85) hide show
  1. package/README.md +82 -8
  2. package/package.json +1 -1
  3. package/src/cli.js +6 -0
  4. package/src/commands/benchmark.js +22 -8
  5. package/src/commands/chat.js +50 -11
  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/export.js +124 -0
  13. package/src/commands/generate.js +2 -0
  14. package/src/commands/import.js +195 -0
  15. package/src/commands/index-workspace.js +239 -0
  16. package/src/commands/ingest.js +4 -0
  17. package/src/commands/init.js +2 -0
  18. package/src/commands/mcp-server.js +115 -3
  19. package/src/commands/models.js +2 -0
  20. package/src/commands/ping.js +7 -0
  21. package/src/commands/pipeline.js +15 -0
  22. package/src/commands/playground.js +163 -9
  23. package/src/commands/query.js +16 -0
  24. package/src/commands/rerank.js +12 -0
  25. package/src/commands/scaffold.js +2 -0
  26. package/src/commands/search.js +11 -0
  27. package/src/commands/similarity.js +9 -0
  28. package/src/commands/store.js +4 -0
  29. package/src/commands/workflow.js +286 -0
  30. package/src/lib/capability-report.js +134 -0
  31. package/src/lib/chat.js +32 -1
  32. package/src/lib/config.js +2 -0
  33. package/src/lib/cost-display.js +107 -0
  34. package/src/lib/explanations.js +6 -0
  35. package/src/lib/export/contexts/benchmark-export.js +27 -0
  36. package/src/lib/export/contexts/chat-export.js +41 -0
  37. package/src/lib/export/contexts/explore-export.js +22 -0
  38. package/src/lib/export/contexts/search-export.js +54 -0
  39. package/src/lib/export/contexts/workflow-export.js +80 -0
  40. package/src/lib/export/formats/clipboard-export.js +29 -0
  41. package/src/lib/export/formats/csv-export.js +45 -0
  42. package/src/lib/export/formats/json-export.js +50 -0
  43. package/src/lib/export/formats/markdown-export.js +189 -0
  44. package/src/lib/export/formats/mermaid-export.js +274 -0
  45. package/src/lib/export/formats/pdf-export.js +117 -0
  46. package/src/lib/export/formats/png-export.js +96 -0
  47. package/src/lib/export/formats/svg-export.js +116 -0
  48. package/src/lib/export/index.js +175 -0
  49. package/src/lib/llm.js +125 -18
  50. package/src/lib/quality-audit.js +71 -0
  51. package/src/lib/security/blocked-domains.json +17 -0
  52. package/src/lib/security-audit.js +198 -0
  53. package/src/lib/telemetry.js +23 -1
  54. package/src/lib/workflow-scaffold.js +61 -0
  55. package/src/lib/workflow-test-runner.js +208 -0
  56. package/src/lib/workflow.js +333 -28
  57. package/src/mcp/install.js +280 -7
  58. package/src/mcp/schemas/index.js +40 -0
  59. package/src/mcp/server.js +2 -0
  60. package/src/mcp/tools/workspace.js +463 -0
  61. package/src/playground/announcements.md +56 -0
  62. package/src/playground/help/workflow-nodes.js +472 -0
  63. package/src/playground/index.html +13134 -8507
  64. package/src/playground/vendor/mermaid.min.js +2811 -0
  65. package/src/workflows/rag-chat.json +165 -0
  66. package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
  67. package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
  68. package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
  69. package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
  70. package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
  71. package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
  72. package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
  73. package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
  74. package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
  75. package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
  76. package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
  77. package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
  78. package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
  79. package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
  80. package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
  81. package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
  82. package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
  83. package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
  84. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
  85. package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const { renderJson, renderJsonl } = require('./formats/json-export');
4
+ const { renderCsv } = require('./formats/csv-export');
5
+ const { renderMarkdown } = require('./formats/markdown-export');
6
+ const { renderMermaid } = require('./formats/mermaid-export');
7
+ const { copyToClipboard } = require('./formats/clipboard-export');
8
+ const { renderSvgAsync } = require('./formats/svg-export');
9
+ const { renderPng } = require('./formats/png-export');
10
+ const { renderPdf } = require('./formats/pdf-export');
11
+
12
+ const { normalizeWorkflow, WORKFLOW_FORMATS } = require('./contexts/workflow-export');
13
+ const { normalizeSearch, SEARCH_FORMATS } = require('./contexts/search-export');
14
+ const { normalizeChat, CHAT_FORMATS } = require('./contexts/chat-export');
15
+ const { normalizeBenchmark, BENCHMARK_FORMATS } = require('./contexts/benchmark-export');
16
+ const { normalizeExplore, EXPLORE_FORMATS } = require('./contexts/explore-export');
17
+
18
+ // ════════════════════════════════════════════════════════════════════
19
+ // Context → Formats mapping
20
+ // ════════════════════════════════════════════════════════════════════
21
+
22
+ const FORMAT_MAP = {
23
+ workflow: WORKFLOW_FORMATS,
24
+ search: SEARCH_FORMATS,
25
+ chat: CHAT_FORMATS,
26
+ benchmark: BENCHMARK_FORMATS,
27
+ explore: EXPLORE_FORMATS,
28
+ };
29
+
30
+ const TRANSFORMERS = {
31
+ workflow: normalizeWorkflow,
32
+ search: normalizeSearch,
33
+ chat: normalizeChat,
34
+ benchmark: normalizeBenchmark,
35
+ explore: normalizeExplore,
36
+ };
37
+
38
+ const RENDERERS = {
39
+ json: renderJson,
40
+ jsonl: renderJsonl,
41
+ csv: renderCsv,
42
+ markdown: renderMarkdown,
43
+ mermaid: renderMermaid,
44
+ svg: renderSvgAsync,
45
+ png: renderPng,
46
+ pdf: renderPdf,
47
+ };
48
+
49
+ // Formats that produce binary (Buffer) output
50
+ const BINARY_FORMATS = new Set(['png', 'pdf']);
51
+
52
+ const EXT_MAP = {
53
+ json: '.json',
54
+ jsonl: '.jsonl',
55
+ csv: '.csv',
56
+ markdown: '.md',
57
+ mermaid: '.mmd',
58
+ svg: '.svg',
59
+ png: '.png',
60
+ pdf: '.pdf',
61
+ };
62
+
63
+ // ════════════════════════════════════════════════════════════════════
64
+ // Public API
65
+ // ════════════════════════════════════════════════════════════════════
66
+
67
+ /**
68
+ * Get supported formats for a given context.
69
+ * @param {string} context
70
+ * @returns {string[]}
71
+ */
72
+ function getFormatsForContext(context) {
73
+ return FORMAT_MAP[context] || [];
74
+ }
75
+
76
+ /**
77
+ * Build a deterministic filename for the export.
78
+ * Pattern: {context}-{name}-{timestamp}.{ext}
79
+ * @param {string} context
80
+ * @param {object} data
81
+ * @param {string} format
82
+ * @returns {string}
83
+ */
84
+ function buildFilename(context, data, format) {
85
+ const name = (data.name || data.sessionId || data.query || context)
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, '-')
88
+ .replace(/^-|-$/g, '')
89
+ .slice(0, 40);
90
+ const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
91
+ const ext = EXT_MAP[format] || '.txt';
92
+ return `${context}-${name}-${ts}${ext}`;
93
+ }
94
+
95
+ /**
96
+ * Main export entry point.
97
+ *
98
+ * @param {object} params
99
+ * @param {string} params.context - 'workflow' | 'search' | 'chat' | 'benchmark' | 'explore'
100
+ * @param {string} params.format - 'json' | 'jsonl' | 'csv' | 'markdown' | 'mermaid' | 'clipboard'
101
+ * @param {object} params.data - Raw source data
102
+ * @param {object} [params.options] - Format & context specific options
103
+ * @returns {{ content: string, mimeType: string, suggestedFilename: string, format: string }}
104
+ */
105
+ async function exportArtifact({ context, format, data, options = {} }) {
106
+ // Handle clipboard as a meta-format: pick the best underlying format and copy
107
+ const isClipboard = format === 'clipboard';
108
+ const effectiveFormat = isClipboard ? pickClipboardFormat(context) : format;
109
+
110
+ // Validate
111
+ const supported = getFormatsForContext(context);
112
+ if (!supported.includes(format)) {
113
+ throw new ExportError(
114
+ `Format "${format}" not supported for ${context}. Supported: ${supported.join(', ')}`
115
+ );
116
+ }
117
+
118
+ // Transform
119
+ const transformer = TRANSFORMERS[context];
120
+ if (!transformer) {
121
+ throw new ExportError(`Unknown export context: "${context}"`);
122
+ }
123
+ const normalized = transformer(data, options);
124
+
125
+ // Render
126
+ const renderer = RENDERERS[effectiveFormat];
127
+ if (!renderer) {
128
+ throw new ExportError(`No renderer for format: "${effectiveFormat}"`);
129
+ }
130
+ const output = await renderer(normalized, options);
131
+ const isBinary = BINARY_FORMATS.has(effectiveFormat);
132
+
133
+ // Clipboard side-effect (text only — binary clipboard handled by Electron)
134
+ if (isClipboard) {
135
+ const ok = copyToClipboard(typeof output.content === 'string' ? output.content : output.content.toString());
136
+ if (!ok) {
137
+ throw new ExportError('Failed to copy to clipboard — unsupported platform or missing clipboard tool');
138
+ }
139
+ }
140
+
141
+ return {
142
+ content: output.content,
143
+ mimeType: output.mimeType,
144
+ suggestedFilename: buildFilename(context, data, effectiveFormat),
145
+ format: effectiveFormat,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Pick the best text format for clipboard based on context.
151
+ */
152
+ function pickClipboardFormat(context) {
153
+ switch (context) {
154
+ case 'workflow': return 'mermaid';
155
+ case 'search': return 'json';
156
+ case 'chat': return 'markdown';
157
+ case 'benchmark': return 'json';
158
+ case 'explore': return 'json';
159
+ default: return 'json';
160
+ }
161
+ }
162
+
163
+ class ExportError extends Error {
164
+ constructor(message) {
165
+ super(message);
166
+ this.name = 'ExportError';
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ exportArtifact,
172
+ getFormatsForContext,
173
+ buildFilename,
174
+ ExportError,
175
+ };
package/src/lib/llm.js CHANGED
@@ -147,15 +147,24 @@ class AnthropicProvider {
147
147
  const json = await res.json();
148
148
  const text = json.content?.[0]?.text || '';
149
149
  yield text;
150
+ // Yield usage sentinel
151
+ const usage = json.usage || {};
152
+ yield { __usage: { inputTokens: usage.input_tokens || 0, outputTokens: usage.output_tokens || 0 } };
150
153
  return;
151
154
  }
152
155
 
153
- yield* parseSSE(res.body, (event, data) => {
154
- if (event === 'content_block_delta' && data.delta?.text) {
155
- return data.delta.text;
156
+ // Manual SSE loop to capture usage from streaming events
157
+ const usage = { inputTokens: 0, outputTokens: 0 };
158
+ for await (const chunk of parseSSEWithMeta(res.body)) {
159
+ if (chunk.__event === 'message_start' && chunk.__data?.message?.usage) {
160
+ usage.inputTokens = chunk.__data.message.usage.input_tokens || 0;
161
+ } else if (chunk.__event === 'message_delta' && chunk.__data?.usage) {
162
+ usage.outputTokens = chunk.__data.usage.output_tokens || 0;
163
+ } else if (chunk.__event === 'content_block_delta' && chunk.__data?.delta?.text) {
164
+ yield chunk.__data.delta.text;
156
165
  }
157
- return null;
158
- });
166
+ }
167
+ yield { __usage: usage };
159
168
  }
160
169
 
161
170
  /**
@@ -163,7 +172,7 @@ class AnthropicProvider {
163
172
  * @param {Array} messages - Conversation messages
164
173
  * @param {Array} tools - Tool definitions in Anthropic format
165
174
  * @param {object} [options]
166
- * @returns {Promise<{type: 'text'|'tool_calls', content?: string, calls?: Array, stopReason: string}>}
175
+ * @returns {Promise<{type: 'text'|'tool_calls', content?: string, calls?: Array, stopReason: string, usage: object}>}
167
176
  */
168
177
  async chatWithTools(messages, tools, options = {}) {
169
178
  const model = options.model || this.model;
@@ -200,6 +209,8 @@ class AnthropicProvider {
200
209
 
201
210
  const json = await res.json();
202
211
  const stopReason = json.stop_reason || 'end_turn';
212
+ const apiUsage = json.usage || {};
213
+ const usage = { inputTokens: apiUsage.input_tokens || 0, outputTokens: apiUsage.output_tokens || 0 };
203
214
 
204
215
  // Check for tool_use blocks
205
216
  const toolBlocks = (json.content || []).filter(b => b.type === 'tool_use');
@@ -212,6 +223,7 @@ class AnthropicProvider {
212
223
  arguments: b.input,
213
224
  })),
214
225
  stopReason,
226
+ usage,
215
227
  _raw: json.content,
216
228
  };
217
229
  }
@@ -222,6 +234,7 @@ class AnthropicProvider {
222
234
  type: 'text',
223
235
  content: textBlocks.map(b => b.text).join(''),
224
236
  stopReason,
237
+ usage,
225
238
  };
226
239
  }
227
240
 
@@ -324,6 +337,11 @@ class OpenAIProvider {
324
337
  messages,
325
338
  };
326
339
 
340
+ // Request usage data in streaming mode
341
+ if (stream) {
342
+ body.stream_options = { include_usage: true };
343
+ }
344
+
327
345
  const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
328
346
  method: 'POST',
329
347
  headers: {
@@ -342,14 +360,25 @@ class OpenAIProvider {
342
360
  const json = await res.json();
343
361
  const text = json.choices?.[0]?.message?.content || '';
344
362
  yield text;
363
+ const apiUsage = json.usage || {};
364
+ yield { __usage: { inputTokens: apiUsage.prompt_tokens || 0, outputTokens: apiUsage.completion_tokens || 0 } };
345
365
  return;
346
366
  }
347
367
 
348
- yield* parseSSE(res.body, (_event, data) => {
349
- if (data === '[DONE]') return null;
350
- const content = data.choices?.[0]?.delta?.content;
351
- return content || null;
352
- });
368
+ // Manual SSE loop to capture usage from final streaming chunk
369
+ const usage = { inputTokens: 0, outputTokens: 0 };
370
+ for await (const chunk of parseSSEWithMeta(res.body)) {
371
+ const data = chunk.__data;
372
+ if (data === '[DONE]') continue;
373
+ // Final chunk with usage stats (stream_options: include_usage)
374
+ if (data?.usage) {
375
+ usage.inputTokens = data.usage.prompt_tokens || 0;
376
+ usage.outputTokens = data.usage.completion_tokens || 0;
377
+ }
378
+ const content = data?.choices?.[0]?.delta?.content;
379
+ if (content) yield content;
380
+ }
381
+ yield { __usage: usage };
353
382
  }
354
383
 
355
384
  /**
@@ -357,7 +386,7 @@ class OpenAIProvider {
357
386
  * @param {Array} messages - Conversation messages
358
387
  * @param {Array} tools - Tool definitions in OpenAI format
359
388
  * @param {object} [options]
360
- * @returns {Promise<{type: 'text'|'tool_calls', content?: string, calls?: Array, stopReason: string}>}
389
+ * @returns {Promise<{type: 'text'|'tool_calls', content?: string, calls?: Array, stopReason: string, usage: object}>}
361
390
  */
362
391
  async chatWithTools(messages, tools, options = {}) {
363
392
  const model = options.model || this.model;
@@ -389,6 +418,8 @@ class OpenAIProvider {
389
418
  const choice = json.choices?.[0] || {};
390
419
  const msg = choice.message || {};
391
420
  const stopReason = choice.finish_reason || 'stop';
421
+ const apiUsage = json.usage || {};
422
+ const usage = { inputTokens: apiUsage.prompt_tokens || 0, outputTokens: apiUsage.completion_tokens || 0 };
392
423
 
393
424
  if (msg.tool_calls && msg.tool_calls.length > 0) {
394
425
  return {
@@ -401,6 +432,7 @@ class OpenAIProvider {
401
432
  : tc.function.arguments,
402
433
  })),
403
434
  stopReason,
435
+ usage,
404
436
  _raw: msg,
405
437
  };
406
438
  }
@@ -409,6 +441,7 @@ class OpenAIProvider {
409
441
  type: 'text',
410
442
  content: msg.content || '',
411
443
  stopReason,
444
+ usage,
412
445
  };
413
446
  }
414
447
 
@@ -502,14 +535,25 @@ class OllamaProvider {
502
535
  const json = await res.json();
503
536
  const text = json.choices?.[0]?.message?.content || '';
504
537
  yield text;
538
+ // Ollama may not return usage, default to 0
539
+ const apiUsage = json.usage || {};
540
+ yield { __usage: { inputTokens: apiUsage.prompt_tokens || 0, outputTokens: apiUsage.completion_tokens || 0 } };
505
541
  return;
506
542
  }
507
543
 
508
- yield* parseSSE(res.body, (_event, data) => {
509
- if (data === '[DONE]') return null;
510
- const content = data.choices?.[0]?.delta?.content;
511
- return content || null;
512
- });
544
+ // Manual SSE loop (Ollama may not support stream_options)
545
+ const usage = { inputTokens: 0, outputTokens: 0 };
546
+ for await (const chunk of parseSSEWithMeta(res.body)) {
547
+ const data = chunk.__data;
548
+ if (data === '[DONE]') continue;
549
+ if (data?.usage) {
550
+ usage.inputTokens = data.usage.prompt_tokens || 0;
551
+ usage.outputTokens = data.usage.completion_tokens || 0;
552
+ }
553
+ const content = data?.choices?.[0]?.delta?.content;
554
+ if (content) yield content;
555
+ }
556
+ yield { __usage: usage };
513
557
  }
514
558
 
515
559
  /**
@@ -517,7 +561,7 @@ class OllamaProvider {
517
561
  * @param {Array} messages - Conversation messages
518
562
  * @param {Array} tools - Tool definitions in OpenAI format
519
563
  * @param {object} [options]
520
- * @returns {Promise<{type: 'text'|'tool_calls', content?: string, calls?: Array, stopReason: string}>}
564
+ * @returns {Promise<{type: 'text'|'tool_calls', content?: string, calls?: Array, stopReason: string, usage: object}>}
521
565
  */
522
566
  async chatWithTools(messages, tools, options = {}) {
523
567
  const model = options.model || this.model;
@@ -544,6 +588,9 @@ class OllamaProvider {
544
588
  const choice = json.choices?.[0] || {};
545
589
  const msg = choice.message || {};
546
590
  const stopReason = choice.finish_reason || 'stop';
591
+ // Ollama may not return usage, default to 0
592
+ const apiUsage = json.usage || {};
593
+ const usage = { inputTokens: apiUsage.prompt_tokens || 0, outputTokens: apiUsage.completion_tokens || 0 };
547
594
 
548
595
  if (msg.tool_calls && msg.tool_calls.length > 0) {
549
596
  return {
@@ -556,6 +603,7 @@ class OllamaProvider {
556
603
  : tc.function.arguments,
557
604
  })),
558
605
  stopReason,
606
+ usage,
559
607
  _raw: msg,
560
608
  };
561
609
  }
@@ -564,6 +612,7 @@ class OllamaProvider {
564
612
  type: 'text',
565
613
  content: msg.content || '',
566
614
  stopReason,
615
+ usage,
567
616
  };
568
617
  }
569
618
 
@@ -622,6 +671,64 @@ class OllamaProvider {
622
671
  // SSE Stream Parser
623
672
  // ============================================
624
673
 
674
+ /**
675
+ * Parse a Server-Sent Events stream, yielding raw event+data pairs.
676
+ * Unlike parseSSE, this preserves event types and full data objects
677
+ * so callers can extract both content and metadata (e.g. usage stats).
678
+ *
679
+ * @param {ReadableStream} body - Response body stream
680
+ * @yields {{ __event: string|null, __data: object|string }} Parsed SSE events
681
+ */
682
+ async function* parseSSEWithMeta(body) {
683
+ const decoder = new TextDecoder();
684
+ let buffer = '';
685
+ let currentEvent = null;
686
+
687
+ for await (const chunk of body) {
688
+ buffer += decoder.decode(chunk, { stream: true });
689
+
690
+ const lines = buffer.split('\n');
691
+ buffer = lines.pop() || '';
692
+
693
+ for (const line of lines) {
694
+ if (line.startsWith('event: ')) {
695
+ currentEvent = line.slice(7).trim();
696
+ continue;
697
+ }
698
+
699
+ if (line.startsWith('data: ')) {
700
+ const rawData = line.slice(6);
701
+
702
+ if (rawData === '[DONE]') {
703
+ yield { __event: currentEvent, __data: '[DONE]' };
704
+ return;
705
+ }
706
+
707
+ let parsed;
708
+ try {
709
+ parsed = JSON.parse(rawData);
710
+ } catch {
711
+ continue;
712
+ }
713
+
714
+ yield { __event: currentEvent, __data: parsed };
715
+ currentEvent = null;
716
+ }
717
+ }
718
+ }
719
+
720
+ // Process remaining buffer
721
+ if (buffer.trim() && buffer.startsWith('data: ')) {
722
+ const rawData = buffer.slice(6);
723
+ if (rawData !== '[DONE]') {
724
+ try {
725
+ const parsed = JSON.parse(rawData);
726
+ yield { __event: currentEvent, __data: parsed };
727
+ } catch { /* skip */ }
728
+ }
729
+ }
730
+ }
731
+
625
732
  /**
626
733
  * Parse a Server-Sent Events stream.
627
734
  * @param {ReadableStream} body - Response body stream
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const CATEGORIES = ['retrieval', 'analysis', 'ingestion', 'domain-specific', 'utility', 'integration'];
7
+
8
+ /**
9
+ * Run quality audit on a workflow definition and its package.
10
+ * @param {object} definition - Parsed workflow JSON
11
+ * @param {object} pkg - Parsed package.json
12
+ * @param {string} [packagePath] - Path to the package directory
13
+ * @returns {Array<{level: string, message: string}>}
14
+ */
15
+ function qualityAudit(definition, pkg, packagePath) {
16
+ const issues = [];
17
+
18
+ // Package metadata checks
19
+ if (!pkg.description || pkg.description.length < 20) {
20
+ issues.push({ level: 'error', message: 'Package description too short (min 20 chars)' });
21
+ }
22
+ if (!pkg.author) {
23
+ issues.push({ level: 'error', message: 'Package must have an author' });
24
+ }
25
+ if (!pkg.license) {
26
+ issues.push({ level: 'warning', message: 'No license specified' });
27
+ }
28
+ if (!pkg.vai?.category || !CATEGORIES.includes(pkg.vai.category)) {
29
+ issues.push({ level: 'error', message: 'Invalid or missing vai.category' });
30
+ }
31
+
32
+ // README checks
33
+ if (packagePath) {
34
+ const readmePath = path.join(packagePath, 'README.md');
35
+ if (!fs.existsSync(readmePath)) {
36
+ issues.push({ level: 'error', message: 'Missing README.md' });
37
+ } else {
38
+ const readme = fs.readFileSync(readmePath, 'utf8');
39
+ if (readme.length < 200) {
40
+ issues.push({ level: 'warning', message: 'README is very short (< 200 chars)' });
41
+ }
42
+ if (!readme.includes('## Usage') && !readme.includes('## Install')) {
43
+ issues.push({ level: 'warning', message: 'README should include Usage or Install section' });
44
+ }
45
+ if (readme.includes('TODO')) {
46
+ issues.push({ level: 'warning', message: 'README contains TODO placeholders' });
47
+ }
48
+ }
49
+ }
50
+
51
+ // Workflow definition quality
52
+ if (definition && Array.isArray(definition.steps)) {
53
+ if (definition.steps.length === 1) {
54
+ issues.push({ level: 'suggestion', message: 'Single-step workflows may not warrant a package — consider documenting as a CLI example instead' });
55
+ }
56
+ }
57
+
58
+ // Branding
59
+ if (!definition?.branding?.icon) {
60
+ issues.push({ level: 'suggestion', message: 'Consider adding branding.icon for store display' });
61
+ }
62
+
63
+ // Naming — should be descriptive, not generic
64
+ if (definition?.name && /^(test|my|workflow|demo|example)/i.test(definition.name)) {
65
+ issues.push({ level: 'warning', message: `Workflow name "${definition.name}" is too generic` });
66
+ }
67
+
68
+ return issues;
69
+ }
70
+
71
+ module.exports = { qualityAudit, CATEGORIES };
@@ -0,0 +1,17 @@
1
+ [
2
+ "evil.com",
3
+ "malware.com",
4
+ "requestbin.com",
5
+ "webhook.site",
6
+ "pipedream.net",
7
+ "hookbin.com",
8
+ "requestcatcher.com",
9
+ "canarytokens.com",
10
+ "burpcollaborator.net",
11
+ "interact.sh",
12
+ "oastify.com",
13
+ "dnslog.cn",
14
+ "ceye.io",
15
+ "bxss.me",
16
+ "xss.ht"
17
+ ]