scorpion-cli 0.1.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/README.md +152 -0
- package/install.ps1 +22 -0
- package/install.sh +19 -0
- package/package.json +44 -0
- package/src/agent.js +226 -0
- package/src/commands.js +193 -0
- package/src/index.js +86 -0
- package/src/ollama.js +126 -0
- package/src/registry.js +112 -0
- package/src/tools/context.js +432 -0
- package/src/tools/deep-research.js +425 -0
- package/src/tools/documents.js +299 -0
- package/src/tools/filesystem.js +402 -0
- package/src/tools/shell.js +134 -0
- package/src/tools/system.js +379 -0
- package/src/tools/web.js +433 -0
- package/src/ui/charts.js +213 -0
- package/src/ui/export.js +141 -0
- package/src/ui/formatter.js +365 -0
- package/src/ui/images.js +153 -0
- package/src/ui/panels.js +150 -0
- package/src/ui/progress.js +131 -0
- package/src/ui/repl.js +477 -0
- package/src/ui/spinner.js +99 -0
- package/src/ui/table.js +145 -0
package/src/tools/web.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive Web Search Tools
|
|
3
|
+
* Multiple search engines for accuracy and redundancy
|
|
4
|
+
*
|
|
5
|
+
* Sources: DuckDuckGo, Bing, Google News
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as cheerio from 'cheerio';
|
|
9
|
+
import fetch from 'node-fetch'; // Explicitly use node-fetch
|
|
10
|
+
|
|
11
|
+
const USER_AGENTS = [
|
|
12
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
|
13
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
14
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function getRandomUserAgent() {
|
|
18
|
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fetchWithTimeout(url, options = {}, timeout = 6000) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timeoutMs = timeout;
|
|
24
|
+
|
|
25
|
+
const fetchPromise = fetch(url, {
|
|
26
|
+
...options,
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
redirect: 'follow',
|
|
29
|
+
headers: {
|
|
30
|
+
'User-Agent': getRandomUserAgent(),
|
|
31
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
32
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
33
|
+
...options.headers,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
controller.abort();
|
|
40
|
+
reject(new Error(`Timeout after ${timeoutMs}ms`));
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Bulletproof timeout using Promise.race
|
|
46
|
+
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
|
47
|
+
return response;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* DuckDuckGo HTML Search - Optimized
|
|
55
|
+
*/
|
|
56
|
+
async function searchDuckDuckGo(query, maxResults = 5) {
|
|
57
|
+
try {
|
|
58
|
+
const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
59
|
+
const response = await fetchWithTimeout(searchUrl, {}, 6000); // 6s timeout for search
|
|
60
|
+
const html = await response.text();
|
|
61
|
+
const $ = cheerio.load(html);
|
|
62
|
+
|
|
63
|
+
const results = [];
|
|
64
|
+
$('.result').each((i, elem) => {
|
|
65
|
+
if (results.length >= maxResults) return false;
|
|
66
|
+
|
|
67
|
+
const $link = $(elem).find('.result__a');
|
|
68
|
+
const $snippet = $(elem).find('.result__snippet');
|
|
69
|
+
let url = $link.attr('href');
|
|
70
|
+
|
|
71
|
+
if (url && url.includes('uddg=')) {
|
|
72
|
+
try { url = decodeURIComponent(url.match(/uddg=([^&]+)/)[1]); } catch (e) { }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (url && url.startsWith('http')) {
|
|
76
|
+
results.push({
|
|
77
|
+
title: $link.text().trim(),
|
|
78
|
+
url,
|
|
79
|
+
snippet: $snippet.text().trim(),
|
|
80
|
+
source: 'DuckDuckGo'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return results;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return []; // Fail silently to not block others
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bing Search - Optimized
|
|
92
|
+
*/
|
|
93
|
+
async function searchBing(query, maxResults = 5) {
|
|
94
|
+
try {
|
|
95
|
+
const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}`;
|
|
96
|
+
const response = await fetchWithTimeout(searchUrl, {}, 6000);
|
|
97
|
+
const html = await response.text();
|
|
98
|
+
const $ = cheerio.load(html);
|
|
99
|
+
|
|
100
|
+
const results = [];
|
|
101
|
+
$('li.b_algo').each((i, elem) => {
|
|
102
|
+
if (results.length >= maxResults) return false;
|
|
103
|
+
const $link = $(elem).find('h2 a');
|
|
104
|
+
const url = $link.attr('href');
|
|
105
|
+
|
|
106
|
+
if (url && url.startsWith('http')) {
|
|
107
|
+
results.push({
|
|
108
|
+
title: $link.text().trim(),
|
|
109
|
+
url,
|
|
110
|
+
snippet: $(elem).find('p').text().trim().slice(0, 300),
|
|
111
|
+
source: 'Bing'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return results;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Google News - Optimized
|
|
123
|
+
*/
|
|
124
|
+
async function searchGoogleNews(query, maxResults = 5) {
|
|
125
|
+
try {
|
|
126
|
+
const searchUrl = `https://news.google.com/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
|
|
127
|
+
const response = await fetchWithTimeout(searchUrl, {}, 6000);
|
|
128
|
+
const html = await response.text();
|
|
129
|
+
const $ = cheerio.load(html);
|
|
130
|
+
|
|
131
|
+
const results = [];
|
|
132
|
+
$('article').each((i, elem) => {
|
|
133
|
+
if (results.length >= maxResults) return false;
|
|
134
|
+
const $link = $(elem).find('a[href^="./article"]');
|
|
135
|
+
let url = $link.attr('href');
|
|
136
|
+
|
|
137
|
+
if (url) {
|
|
138
|
+
url = 'https://news.google.com' + url.slice(1);
|
|
139
|
+
results.push({
|
|
140
|
+
title: $link.text().trim(),
|
|
141
|
+
url,
|
|
142
|
+
snippet: $(elem).find('time').parent().text().trim(),
|
|
143
|
+
source: 'GoogleNews'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return results;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Multi-engine search - combines results from multiple sources
|
|
155
|
+
*/
|
|
156
|
+
async function multiSearch(query, maxResults = 8) {
|
|
157
|
+
const allResults = [];
|
|
158
|
+
const seenUrls = new Set();
|
|
159
|
+
const errors = [];
|
|
160
|
+
|
|
161
|
+
// Run searches in parallel
|
|
162
|
+
const searches = [
|
|
163
|
+
searchDuckDuckGo(query, 5).catch(e => { errors.push('DDG: ' + e.message); return []; }),
|
|
164
|
+
searchBing(query, 5).catch(e => { errors.push('Bing: ' + e.message); return []; }),
|
|
165
|
+
searchGoogleNews(query, 3).catch(e => { errors.push('GNews: ' + e.message); return []; }),
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const results = await Promise.all(searches);
|
|
169
|
+
|
|
170
|
+
// Merge and dedupe results
|
|
171
|
+
for (const sourceResults of results) {
|
|
172
|
+
for (const result of sourceResults) {
|
|
173
|
+
// Normalize URL for deduplication
|
|
174
|
+
const normalizedUrl = result.url.replace(/\/$/, '').replace(/^https?:\/\/(www\.)?/, '').slice(0, 100);
|
|
175
|
+
|
|
176
|
+
if (!seenUrls.has(normalizedUrl)) {
|
|
177
|
+
seenUrls.add(normalizedUrl);
|
|
178
|
+
allResults.push(result);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Sort by having snippet (more informative first)
|
|
184
|
+
allResults.sort((a, b) => (b.snippet?.length || 0) - (a.snippet?.length || 0));
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
results: allResults.slice(0, maxResults),
|
|
188
|
+
sourcesUsed: results.filter(r => r.length > 0).length,
|
|
189
|
+
errors: errors.length > 0 ? errors : undefined
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fetch and extract content from URL
|
|
195
|
+
*/
|
|
196
|
+
async function fetchPage(url) {
|
|
197
|
+
try {
|
|
198
|
+
if (!url.startsWith('http')) url = 'https://' + url;
|
|
199
|
+
|
|
200
|
+
// Strict 8s timeout for fetching pages
|
|
201
|
+
const response = await fetchWithTimeout(url, {}, 8000);
|
|
202
|
+
|
|
203
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
204
|
+
|
|
205
|
+
const contentType = response.headers.get('content-type') || '';
|
|
206
|
+
if (!contentType.includes('text/html') && !contentType.includes('text/plain')) {
|
|
207
|
+
return { url, title: 'Non-HTML', content: `Content: ${contentType}`, contentLength: 0 };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const html = await response.text();
|
|
211
|
+
const $ = cheerio.load(html);
|
|
212
|
+
|
|
213
|
+
// Remove junk
|
|
214
|
+
$('script, style, nav, footer, header, aside, iframe, noscript, svg, form, [class*="cookie"], [class*="popup"], [class*="modal"], [class*="banner"], [id*="ad"], [class*="advertisement"], [class*="sidebar"], [class*="menu"]').remove();
|
|
215
|
+
|
|
216
|
+
// Get title
|
|
217
|
+
const title = $('title').text().trim() ||
|
|
218
|
+
$('h1').first().text().trim() ||
|
|
219
|
+
$('meta[property="og:title"]').attr('content') || 'Untitled';
|
|
220
|
+
|
|
221
|
+
// Get meta description
|
|
222
|
+
const metaDesc = $('meta[name="description"]').attr('content') ||
|
|
223
|
+
$('meta[property="og:description"]').attr('content') || '';
|
|
224
|
+
|
|
225
|
+
// Try main content areas
|
|
226
|
+
let content = '';
|
|
227
|
+
const mainSelectors = ['article', 'main', '[role="main"]', '.post-content', '.article-content',
|
|
228
|
+
'.entry-content', '.content', '#content', '.post', '.article', '.story'];
|
|
229
|
+
|
|
230
|
+
for (const sel of mainSelectors) {
|
|
231
|
+
const $main = $(sel);
|
|
232
|
+
if ($main.length && $main.text().trim().length > 300) {
|
|
233
|
+
content = $main.text();
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Fallback to body text if no main content found
|
|
239
|
+
if (!content || content.length < 300) {
|
|
240
|
+
content = $('body').text();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Clean content
|
|
244
|
+
content = content.replace(/\s+/g, ' ').replace(/\n\s*\n/g, '\n\n').trim();
|
|
245
|
+
|
|
246
|
+
// Prepend meta description
|
|
247
|
+
if (metaDesc && content.length > 0) {
|
|
248
|
+
content = metaDesc + '\n\n' + content;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
url,
|
|
253
|
+
title: title.slice(0, 200),
|
|
254
|
+
content: content.slice(0, 8000), // Limit content size
|
|
255
|
+
contentLength: content.length
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// Return error object instead of throwing
|
|
259
|
+
return {
|
|
260
|
+
url,
|
|
261
|
+
title: 'Error',
|
|
262
|
+
content: `Failed to load: ${error.message}`,
|
|
263
|
+
contentLength: 0,
|
|
264
|
+
error: true
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============= Tool Exports =============
|
|
270
|
+
|
|
271
|
+
export const webSearch = {
|
|
272
|
+
schema: {
|
|
273
|
+
type: 'function',
|
|
274
|
+
function: {
|
|
275
|
+
name: 'web_search',
|
|
276
|
+
description: 'Search the web using multiple search engines (DuckDuckGo, Bing, Google News) for comprehensive results. Returns deduplicated results from all sources.',
|
|
277
|
+
parameters: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
required: ['query'],
|
|
280
|
+
properties: {
|
|
281
|
+
query: {
|
|
282
|
+
type: 'string',
|
|
283
|
+
description: 'Search query - be specific for best results'
|
|
284
|
+
},
|
|
285
|
+
max_results: {
|
|
286
|
+
type: 'integer',
|
|
287
|
+
description: 'Max results to return (default: 8)'
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
execute: async ({ query, max_results = 8 }) => {
|
|
294
|
+
try {
|
|
295
|
+
const { results, sourcesUsed, errors } = await multiSearch(query, max_results);
|
|
296
|
+
|
|
297
|
+
return JSON.stringify({
|
|
298
|
+
success: results.length > 0,
|
|
299
|
+
query,
|
|
300
|
+
resultCount: results.length,
|
|
301
|
+
sourcesUsed,
|
|
302
|
+
errors,
|
|
303
|
+
results
|
|
304
|
+
});
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return JSON.stringify({ success: false, error: error.message, query });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const webFetch = {
|
|
312
|
+
schema: {
|
|
313
|
+
type: 'function',
|
|
314
|
+
function: {
|
|
315
|
+
name: 'web_fetch',
|
|
316
|
+
description: 'Fetch and read content from a URL. Extracts main article content, removing ads and navigation.',
|
|
317
|
+
parameters: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
required: ['url'],
|
|
320
|
+
properties: {
|
|
321
|
+
url: {
|
|
322
|
+
type: 'string',
|
|
323
|
+
description: 'URL to fetch'
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
execute: async ({ url }) => {
|
|
330
|
+
try {
|
|
331
|
+
const result = await fetchPage(url);
|
|
332
|
+
return JSON.stringify({ success: true, ...result });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return JSON.stringify({ success: false, error: error.message, url });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export const researchTopic = {
|
|
340
|
+
schema: {
|
|
341
|
+
type: 'function',
|
|
342
|
+
function: {
|
|
343
|
+
name: 'research_topic',
|
|
344
|
+
description: 'Comprehensive research on a topic: searches multiple engines, then fetches and reads content from top sources. Best for in-depth research.',
|
|
345
|
+
parameters: {
|
|
346
|
+
type: 'object',
|
|
347
|
+
required: ['topic'],
|
|
348
|
+
properties: {
|
|
349
|
+
topic: {
|
|
350
|
+
type: 'string',
|
|
351
|
+
description: 'Topic to research'
|
|
352
|
+
},
|
|
353
|
+
depth: {
|
|
354
|
+
type: 'integer',
|
|
355
|
+
description: 'Number of sources to read in detail (default: 3, max: 5)'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
execute: async ({ topic, depth = 2 }) => {
|
|
362
|
+
// HARD TIMEOUT for entire operation: 25 seconds
|
|
363
|
+
// If it takes longer, we return whatever we have
|
|
364
|
+
const start = Date.now();
|
|
365
|
+
const TIMEOUT_MS = 25000;
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
// 1. Search (Limit to 5 seconds)
|
|
369
|
+
const searchPromise = multiSearch(topic, 5);
|
|
370
|
+
const timeoutPromise = new Promise(resolve => setTimeout(() => resolve({ results: [], sourcesUsed: 0 }), 5000));
|
|
371
|
+
|
|
372
|
+
const { results: searchResults, sourcesUsed } = await Promise.race([searchPromise, timeoutPromise]);
|
|
373
|
+
|
|
374
|
+
if (!searchResults || searchResults.length === 0) {
|
|
375
|
+
return JSON.stringify({ success: false, error: "Search timed out or found no results", topic });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const research = {
|
|
379
|
+
topic,
|
|
380
|
+
searchEnginesUsed: sourcesUsed || 1,
|
|
381
|
+
searchResults: searchResults.slice(0, 5).map(r => ({
|
|
382
|
+
title: r.title,
|
|
383
|
+
url: r.url,
|
|
384
|
+
snippet: r.snippet,
|
|
385
|
+
source: r.source
|
|
386
|
+
})),
|
|
387
|
+
detailedContent: []
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// 2. Fetch Content (Limit concurrency to 2, Max 2 sources total)
|
|
391
|
+
const maxSources = Math.min(depth, 2);
|
|
392
|
+
const topResults = searchResults.slice(0, maxSources);
|
|
393
|
+
|
|
394
|
+
const fetchPromises = topResults.map(async (result) => {
|
|
395
|
+
// Check if we're already out of time
|
|
396
|
+
if (Date.now() - start > TIMEOUT_MS) return null;
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
// Individual page fetch limit: 6s
|
|
400
|
+
const page = await fetchPage(result.url);
|
|
401
|
+
return {
|
|
402
|
+
title: page.title,
|
|
403
|
+
url: page.url,
|
|
404
|
+
searchEngine: result.source,
|
|
405
|
+
content: page.content.slice(0, 1500) // Reduced content size for faster processing
|
|
406
|
+
};
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Wait for fetches but enforce global timeout
|
|
413
|
+
const resultsPromise = Promise.all(fetchPromises);
|
|
414
|
+
const globalTimeout = new Promise(resolve => setTimeout(() => resolve([]), TIMEOUT_MS - (Date.now() - start)));
|
|
415
|
+
|
|
416
|
+
const fetchedPages = await Promise.race([resultsPromise, globalTimeout]);
|
|
417
|
+
|
|
418
|
+
research.detailedContent = (fetchedPages || []).filter(p => p !== null);
|
|
419
|
+
|
|
420
|
+
return JSON.stringify({
|
|
421
|
+
success: true,
|
|
422
|
+
...research,
|
|
423
|
+
summary: `Found ${searchResults.length} results, fetched ${research.detailedContent.length} sources in ${(Date.now() - start) / 1000}s.`
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
} catch (error) {
|
|
427
|
+
return JSON.stringify({ success: false, error: error.message, topic });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// Export for other modules
|
|
433
|
+
export { multiSearch as jinaSearch, fetchPage as jinaFetch, searchDuckDuckGo, searchBing, searchGoogleNews };
|
package/src/ui/charts.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart Rendering Utility
|
|
3
|
+
* ASCII charts and graphs for terminal
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { colors, chalk } from './formatter.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render a bar chart
|
|
10
|
+
* @param {Array} data - Data array [{label, value}]
|
|
11
|
+
* @param {Object} options - Chart options
|
|
12
|
+
*/
|
|
13
|
+
export function barChart(data, options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
maxWidth = 50,
|
|
16
|
+
showValues = true,
|
|
17
|
+
color = 'cyan',
|
|
18
|
+
title = null
|
|
19
|
+
} = options;
|
|
20
|
+
|
|
21
|
+
if (!data || data.length === 0) {
|
|
22
|
+
console.log(colors.dim(' (No data to display)'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Find max value for scaling
|
|
27
|
+
const maxValue = Math.max(...data.map(d => d.value));
|
|
28
|
+
const maxLabelLength = Math.max(...data.map(d => d.label.length));
|
|
29
|
+
|
|
30
|
+
if (title) {
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(colors.bold.white(` ${title}`));
|
|
33
|
+
console.log(colors.dim(' ─'.repeat(30)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log();
|
|
37
|
+
|
|
38
|
+
for (const item of data) {
|
|
39
|
+
const percentage = item.value / maxValue;
|
|
40
|
+
const barLength = Math.round(percentage * maxWidth);
|
|
41
|
+
const bar = '█'.repeat(barLength);
|
|
42
|
+
const paddedLabel = item.label.padEnd(maxLabelLength);
|
|
43
|
+
|
|
44
|
+
const colorFn = typeof color === 'function' ? color : chalk[color] || chalk.cyan;
|
|
45
|
+
const valueStr = showValues ? chalk.dim(` ${item.value}`) : '';
|
|
46
|
+
|
|
47
|
+
console.log(` ${chalk.dim(paddedLabel)} ${colorFn(bar)}${valueStr}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render a sparkline
|
|
55
|
+
* @param {Array} values - Array of numbers
|
|
56
|
+
* @param {Object} options - Options
|
|
57
|
+
*/
|
|
58
|
+
export function sparkline(values, options = {}) {
|
|
59
|
+
const { color = 'cyan', showMinMax = true } = options;
|
|
60
|
+
|
|
61
|
+
if (!values || values.length === 0) {
|
|
62
|
+
return colors.dim('(no data)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sparks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
66
|
+
const min = Math.min(...values);
|
|
67
|
+
const max = Math.max(...values);
|
|
68
|
+
const range = max - min;
|
|
69
|
+
|
|
70
|
+
if (range === 0) {
|
|
71
|
+
return sparks[4].repeat(values.length);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let line = values.map(v => {
|
|
75
|
+
const normalized = (v - min) / range;
|
|
76
|
+
const index = Math.min(Math.floor(normalized * sparks.length), sparks.length - 1);
|
|
77
|
+
return sparks[index];
|
|
78
|
+
}).join('');
|
|
79
|
+
|
|
80
|
+
const colorFn = chalk[color] || chalk.cyan;
|
|
81
|
+
line = colorFn(line);
|
|
82
|
+
|
|
83
|
+
if (showMinMax) {
|
|
84
|
+
line = chalk.dim(`${min} `) + line + chalk.dim(` ${max}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return line;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render a progress bar
|
|
92
|
+
* @param {number} current - Current value
|
|
93
|
+
* @param {number} total - Total value
|
|
94
|
+
* @param {Object} options - Options
|
|
95
|
+
*/
|
|
96
|
+
export function progressBar(current, total, options = {}) {
|
|
97
|
+
const {
|
|
98
|
+
width = 40,
|
|
99
|
+
complete = '█',
|
|
100
|
+
incomplete = '░',
|
|
101
|
+
showPercentage = true,
|
|
102
|
+
color = 'green'
|
|
103
|
+
} = options;
|
|
104
|
+
|
|
105
|
+
const percentage = Math.min(Math.max(current / total, 0), 1);
|
|
106
|
+
const filled = Math.round(width * percentage);
|
|
107
|
+
const empty = width - filled;
|
|
108
|
+
|
|
109
|
+
const colorFn = chalk[color] || chalk.green;
|
|
110
|
+
const bar = colorFn(complete.repeat(filled)) + colors.dim(incomplete.repeat(empty));
|
|
111
|
+
|
|
112
|
+
let result = ` ${bar}`;
|
|
113
|
+
if (showPercentage) {
|
|
114
|
+
const pct = Math.round(percentage * 100);
|
|
115
|
+
result += colors.dim(` ${pct}%`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render a distribution chart (histogram)
|
|
123
|
+
* @param {Object} distribution - {label: count}
|
|
124
|
+
* @param {Object} options - Options
|
|
125
|
+
*/
|
|
126
|
+
export function distributionChart(distribution, options = {}) {
|
|
127
|
+
const { title = 'Distribution', maxWidth = 40 } = options;
|
|
128
|
+
|
|
129
|
+
const data = Object.entries(distribution).map(([label, value]) => ({ label, value }));
|
|
130
|
+
barChart(data, { ...options, title, maxWidth });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render a simple line chart with ASCII art
|
|
135
|
+
* @param {Array} values - Array of numbers
|
|
136
|
+
* @param {Object} options - Options
|
|
137
|
+
*/
|
|
138
|
+
export function lineChart(values, options = {}) {
|
|
139
|
+
const { height = 10, width = 60, title = null, color = 'cyan' } = options;
|
|
140
|
+
|
|
141
|
+
if (!values || values.length === 0) {
|
|
142
|
+
console.log(colors.dim(' (No data to display)'));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (title) {
|
|
147
|
+
console.log();
|
|
148
|
+
console.log(colors.bold.white(` ${title}`));
|
|
149
|
+
console.log(colors.dim(' ─'.repeat(30)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const min = Math.min(...values);
|
|
153
|
+
const max = Math.max(...values);
|
|
154
|
+
const range = max - min;
|
|
155
|
+
|
|
156
|
+
console.log();
|
|
157
|
+
|
|
158
|
+
// Create grid
|
|
159
|
+
for (let row = height; row >= 0; row--) {
|
|
160
|
+
const threshold = min + (range * row / height);
|
|
161
|
+
let line = ' ';
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < values.length; i++) {
|
|
164
|
+
const value = values[i];
|
|
165
|
+
const prevValue = i > 0 ? values[i - 1] : value;
|
|
166
|
+
|
|
167
|
+
if ((value >= threshold && prevValue < threshold) ||
|
|
168
|
+
(value < threshold && prevValue >= threshold)) {
|
|
169
|
+
line += chalk[color]('╱');
|
|
170
|
+
} else if (value >= threshold) {
|
|
171
|
+
line += chalk[color]('█');
|
|
172
|
+
} else {
|
|
173
|
+
line += ' ';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(line);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render a comparison table with bars
|
|
185
|
+
* @param {Array} items - Array of {name, value, target}
|
|
186
|
+
*/
|
|
187
|
+
export function comparisonChart(items, options = {}) {
|
|
188
|
+
const { title = 'Comparison' } = options;
|
|
189
|
+
|
|
190
|
+
console.log();
|
|
191
|
+
console.log(colors.bold.white(` ${title}`));
|
|
192
|
+
console.log(colors.dim(' ─'.repeat(50)));
|
|
193
|
+
console.log();
|
|
194
|
+
|
|
195
|
+
for (const item of items) {
|
|
196
|
+
const bar = sparkline([item.value, item.target || item.value], { color: 'cyan', showMinMax: false });
|
|
197
|
+
const percentage = item.target ? Math.round((item.value / item.target) * 100) : 100;
|
|
198
|
+
const status = percentage >= 100 ? colors.success('✓') : colors.dim('○');
|
|
199
|
+
|
|
200
|
+
console.log(` ${status} ${item.name.padEnd(20)} ${bar} ${colors.dim(percentage + '%')}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default {
|
|
207
|
+
barChart,
|
|
208
|
+
sparkline,
|
|
209
|
+
progressBar,
|
|
210
|
+
distributionChart,
|
|
211
|
+
lineChart,
|
|
212
|
+
comparisonChart
|
|
213
|
+
};
|