shellwecount 1.0.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.
Files changed (2) hide show
  1. package/package.json +30 -0
  2. package/shellwecount.js +516 -0
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "shellwecount",
3
+ "version": "1.0.0",
4
+ "description": "A terminal-based command history timeline visualizer with color-coded categories, frequency ranking, and density heatmap. / 基于终端的命令历史时间线可视化工具,支持命令分类着色、使用频率排名和密度热力图。",
5
+ "main": "shellwecount.js",
6
+ "bin": {
7
+ "shellwecount": "shellwecount.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "terminal",
14
+ "cli",
15
+ "history",
16
+ "timeline",
17
+ "visualization",
18
+ "shell",
19
+ "count",
20
+ "命令历史",
21
+ "时间线",
22
+ "可视化"
23
+ ],
24
+ "author": "yangsta",
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=14.0.0"
28
+ },
29
+ "preferGlobal": true
30
+ }
@@ -0,0 +1,516 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ // ─── i18n ──────────────────────────────────────────────────────
10
+ const i18n = {
11
+ en: {
12
+ title: 'Command History Timeline',
13
+ totalCmds: 'Total',
14
+ cmdsLabel: 'commands',
15
+ topCommands: 'Top Commands',
16
+ times: 'times',
17
+ categories: 'Categories',
18
+ density: 'Density Heatmap',
19
+ legend: 'Legend',
20
+ low: 'Low',
21
+ medium: 'Med',
22
+ high: 'High',
23
+ extreme: 'Extreme',
24
+ cmdList: 'Command List',
25
+ noRecords: 'No commands found for this time period.',
26
+ using: 'Using',
27
+ errorNoHistory: 'Error: No history file found',
28
+ usage: `
29
+ Command History Timeline Visualizer
30
+
31
+ Usage: shellwecount [options]
32
+
33
+ Options:
34
+ -r, --range <range> Time range (today|yesterday|week|month) [default: today]
35
+ -f, --format <format> Display format:
36
+ both - All views (default)
37
+ top - Top commands ranking
38
+ stats - Ranking + Category breakdown
39
+ density - Density heatmap
40
+ list - Command list
41
+ -t, --top <count> Show top N commands [default: 10]
42
+ -l, --lang <lang> Language (en|zh) [default: auto-detect]
43
+ -h, --help Show help
44
+
45
+ Examples:
46
+ shellwecount --range today # Today's full view
47
+ shellwecount -r week -f top # This week's top 10
48
+ shellwecount -r month -t 20 -f top # This month's top 20
49
+ shellwecount -r week -f stats # This week's stats + ranking
50
+ `,
51
+ },
52
+ zh: {
53
+ title: '命令历史时间线',
54
+ totalCmds: '共',
55
+ cmdsLabel: '条命令',
56
+ topCommands: '常用命令 TOP',
57
+ times: '次',
58
+ categories: '命令分类',
59
+ density: '密度热力图',
60
+ legend: '图例',
61
+ low: '低',
62
+ medium: '中',
63
+ high: '高',
64
+ extreme: '极高',
65
+ cmdList: '命令列表',
66
+ noRecords: '该时间段内没有命令记录',
67
+ using: '使用',
68
+ errorNoHistory: '错误: 未找到历史文件',
69
+ usage: `
70
+ 命令历史时间线可视化工具
71
+
72
+ 用法: shellwecount [选项]
73
+
74
+ 选项:
75
+ -r, --range <范围> 时间范围 (today|yesterday|week|month) [默认: today]
76
+ -f, --format <格式> 显示格式:
77
+ both - 全部 (默认)
78
+ top - 常用命令排名
79
+ stats - 排名 + 分类统计
80
+ density - 密度热力图
81
+ list - 命令列表
82
+ -t, --top <数量> 显示排名前N的命令 [默认: 10]
83
+ -l, --lang <语言> 语言 (en|zh) [默认: 自动检测]
84
+ -h, --help 显示帮助
85
+
86
+ 示例:
87
+ shellwecount --range today # 今天的完整视图
88
+ shellwecount -r week -f top # 本周常用命令TOP10
89
+ shellwecount -r month -t 20 -f top # 本月TOP20
90
+ shellwecount -r week -f stats # 本周统计+排名
91
+ `,
92
+ },
93
+ };
94
+
95
+ function detectLang() {
96
+ const locale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '';
97
+ return locale.includes('zh') || locale.includes('CN') ? 'zh' : 'en';
98
+ }
99
+
100
+ let lang = detectLang();
101
+ function t(key) { return i18n[lang]?.[key] || i18n.en[key] || key; }
102
+
103
+ // ─── ANSI Colors ───────────────────────────────────────────────
104
+ const colors = {
105
+ reset: '\x1b[0m',
106
+ bold: '\x1b[1m',
107
+ dim: '\x1b[2m',
108
+ cyan: '\x1b[36m',
109
+ green: '\x1b[32m',
110
+ yellow: '\x1b[33m',
111
+ blue: '\x1b[34m',
112
+ magenta: '\x1b[35m',
113
+ red: '\x1b[31m',
114
+ white: '\x1b[37m',
115
+ gray: '\x1b[90m',
116
+ };
117
+
118
+ // ─── Command Category Classifier ───────────────────────────────
119
+ const categories = [
120
+ { name: 'git', patterns: [/^git\s/, /^gh\s/], color: colors.magenta },
121
+ { name: 'npm', patterns: [/^npm\s/, /^yarn\s/, /^pnpm\s/, /^bun\s/], color: colors.red },
122
+ { name: 'docker', patterns: [/^docker\s/, /^docker-compose\s/, /^kubectl\s/], color: colors.blue },
123
+ { name: 'python', patterns: [/^python[3]?\s/, /^pip[3]?\s/, /^uv\s/], color: colors.yellow },
124
+ { name: 'node', patterns: [/^node\s/, /^npx\s/, /^deno\s/], color: colors.green },
125
+ { name: 'editor', patterns: [/^vim\s/, /^nvim\s/, /^nano\s/, /^code\s/, /^emacs\s/], color: colors.cyan },
126
+ { name: 'build', patterns: [/^make\s/, /^cmake\s/, /^cargo\s/, /^go\s/], color: colors.yellow },
127
+ { name: 'system', patterns: [/^sudo\s/, /^apt\s/, /^dnf\s/, /^pacman\s/, /^systemctl\s/, /^service\s/], color: colors.gray },
128
+ { name: 'network', patterns: [/^curl\s/, /^wget\s/, /^ssh\s/, /^scp\s/, /^rsync\s/], color: colors.blue },
129
+ ];
130
+
131
+ function classifyCommand(cmd) {
132
+ for (const cat of categories) {
133
+ for (const pattern of cat.patterns) {
134
+ if (pattern.test(cmd)) return cat;
135
+ }
136
+ }
137
+ return { name: 'other', color: colors.white };
138
+ }
139
+
140
+ function getCommandBase(cmd) {
141
+ return cmd.split(/\s+/)[0] || cmd;
142
+ }
143
+
144
+ // ─── History File Detection & Parsing ─────────────────────────
145
+ function findHistoryFile() {
146
+ const shell = process.env.SHELL || '';
147
+ const home = os.homedir();
148
+
149
+ if (shell.includes('zsh')) {
150
+ const zshHist = path.join(home, '.zsh_history');
151
+ if (fs.existsSync(zshHist)) return { path: zshHist, type: 'zsh' };
152
+ }
153
+ if (shell.includes('bash')) {
154
+ const bashHist = path.join(home, '.bash_history');
155
+ if (fs.existsSync(bashHist)) return { path: bashHist, type: 'bash' };
156
+ }
157
+
158
+ const zshHist = path.join(home, '.zsh_history');
159
+ if (fs.existsSync(zshHist)) return { path: zshHist, type: 'zsh' };
160
+ const bashHist = path.join(home, '.bash_history');
161
+ if (fs.existsSync(bashHist)) return { path: bashHist, type: 'bash' };
162
+
163
+ return null;
164
+ }
165
+
166
+ function parseZshHistory(filePath) {
167
+ const content = fs.readFileSync(filePath, 'utf8');
168
+ const lines = content.split('\n');
169
+ const entries = [];
170
+
171
+ for (const line of lines) {
172
+ if (!line.startsWith(': ')) continue;
173
+ const match = line.match(/^:\s+(\d+):(\d+);(.*)$/);
174
+ if (match) {
175
+ entries.push({
176
+ timestamp: parseInt(match[1], 10),
177
+ duration: parseInt(match[2], 10),
178
+ command: match[3].trim(),
179
+ });
180
+ }
181
+ }
182
+ return entries;
183
+ }
184
+
185
+ function parseBashHistory(filePath) {
186
+ const content = fs.readFileSync(filePath, 'utf8');
187
+ const lines = content.split('\n');
188
+ const entries = [];
189
+
190
+ for (let i = 0; i < lines.length; i++) {
191
+ if (lines[i].startsWith('#')) {
192
+ const timestamp = parseInt(lines[i].slice(1), 10);
193
+ const command = lines[i + 1];
194
+ if (!isNaN(timestamp) && command && command.trim()) {
195
+ entries.push({
196
+ timestamp,
197
+ duration: 0,
198
+ command: command.trim(),
199
+ });
200
+ i++;
201
+ }
202
+ }
203
+ }
204
+ return entries;
205
+ }
206
+
207
+ function parseHistory() {
208
+ const histFile = findHistoryFile();
209
+ if (!histFile) {
210
+ console.error(`${colors.red}${t('errorNoHistory')}${colors.reset}`);
211
+ process.exit(1);
212
+ }
213
+ console.error(`${colors.dim}${t('using')}: ${histFile.path} (${histFile.type})${colors.reset}`);
214
+
215
+ if (histFile.type === 'zsh') return parseZshHistory(histFile.path);
216
+ return parseBashHistory(histFile.path);
217
+ }
218
+
219
+ // ─── Time Filtering & Binning ──────────────────────────────────
220
+ function filterByRange(entries, range) {
221
+ const now = Date.now() / 1000;
222
+ let start;
223
+
224
+ switch (range) {
225
+ case 'today':
226
+ start = new Date();
227
+ start.setHours(0, 0, 0, 0);
228
+ start = start.getTime() / 1000;
229
+ break;
230
+ case 'yesterday':
231
+ start = new Date();
232
+ start.setDate(start.getDate() - 1);
233
+ start.setHours(0, 0, 0, 0);
234
+ start = start.getTime() / 1000;
235
+ break;
236
+ case 'week':
237
+ start = now - 7 * 24 * 60 * 60;
238
+ break;
239
+ case 'month':
240
+ start = now - 30 * 24 * 60 * 60;
241
+ break;
242
+ default:
243
+ start = now - 24 * 60 * 60;
244
+ }
245
+
246
+ return entries.filter(e => e.timestamp >= start && e.timestamp <= now);
247
+ }
248
+
249
+ function getTimeBucketSize(range) {
250
+ switch (range) {
251
+ case 'today':
252
+ case 'yesterday': return 15 * 60;
253
+ case 'week': return 2 * 60 * 60;
254
+ case 'month': return 6 * 60 * 60;
255
+ default: return 15 * 60;
256
+ }
257
+ }
258
+
259
+ function binByTime(entries, range) {
260
+ if (entries.length === 0) return { bins: [] };
261
+
262
+ const bucketSize = getTimeBucketSize(range);
263
+ const minTime = Math.floor(entries[0].timestamp / bucketSize) * bucketSize;
264
+ const maxTime = Math.ceil(entries[entries.length - 1].timestamp / bucketSize) * bucketSize;
265
+
266
+ const bins = [];
267
+ for (let t = minTime; t <= maxTime; t += bucketSize) {
268
+ const bucketEntries = entries.filter(e => e.timestamp >= t && e.timestamp < t + bucketSize);
269
+ bins.push({ start: t, count: bucketEntries.length });
270
+ }
271
+
272
+ return { bins, bucketSize };
273
+ }
274
+
275
+ // ─── Command Ranking ───────────────────────────────────────────
276
+ function rankCommands(entries, topN = 10) {
277
+ const freq = new Map();
278
+
279
+ for (const entry of entries) {
280
+ const base = getCommandBase(entry.command);
281
+ const cat = classifyCommand(entry.command);
282
+ const key = `${cat.name}:${base}`;
283
+
284
+ if (!freq.has(key)) {
285
+ freq.set(key, { command: base, category: cat, count: 0 });
286
+ }
287
+ freq.get(key).count++;
288
+ }
289
+
290
+ return Array.from(freq.values())
291
+ .sort((a, b) => b.count - a.count)
292
+ .slice(0, topN);
293
+ }
294
+
295
+ // ─── Rendering ─────────────────────────────────────────────────
296
+ function formatTime(timestamp) {
297
+ const d = new Date(timestamp * 1000);
298
+ return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
299
+ }
300
+
301
+ function formatDate(timestamp) {
302
+ const d = new Date(timestamp * 1000);
303
+ return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
304
+ }
305
+
306
+ function renderTopCommands(entries, topN = 10) {
307
+ const ranked = rankCommands(entries, topN);
308
+ if (ranked.length === 0) return [];
309
+
310
+ const maxCount = ranked[0].count;
311
+ const barMaxWidth = 30;
312
+
313
+ const lines = [];
314
+ lines.push(`${colors.bold}${colors.cyan}▎${t('topCommands')} ${topN}${colors.reset}`);
315
+
316
+ for (let i = 0; i < ranked.length; i++) {
317
+ const { command, category, count } = ranked[i];
318
+ const rank = (i + 1).toString().padStart(2);
319
+ const barWidth = Math.max(1, Math.round((count / maxCount) * barMaxWidth));
320
+ const bar = `${category.color}${'█'.repeat(barWidth)}${colors.reset}`;
321
+ const num = i < 3 ? `${colors.bold}${colors.yellow}${rank}${colors.reset}` : `${colors.dim}${rank}${colors.reset}`;
322
+ lines.push(` ${num} ${category.color}${command.padEnd(20)}${colors.reset} ${bar} ${colors.green}${count}${colors.reset} ${t('times')}`);
323
+ }
324
+
325
+ return lines;
326
+ }
327
+
328
+ function renderCategoryBreakdown(entries) {
329
+ const catFreq = new Map();
330
+ for (const entry of entries) {
331
+ const cat = classifyCommand(entry.command);
332
+ catFreq.set(cat.name, (catFreq.get(cat.name) || 0) + 1);
333
+ }
334
+
335
+ const sorted = Array.from(catFreq.entries()).sort((a, b) => b[1] - a[1]);
336
+ const total = entries.length;
337
+ const barMax = 25;
338
+
339
+ const lines = [];
340
+ lines.push(`${colors.bold}${colors.cyan}▎${t('categories')}${colors.reset}`);
341
+
342
+ for (const [name, count] of sorted) {
343
+ const cat = categories.find(c => c.name === name) || { color: colors.white };
344
+ const pct = ((count / total) * 100).toFixed(1);
345
+ const barLen = Math.round((count / total) * barMax);
346
+ const bar = `${cat.color}${'█'.repeat(Math.max(1, barLen))}${colors.reset}`;
347
+ lines.push(` ${cat.color}${name.padEnd(10)}${colors.reset} ${bar} ${colors.green}${count}${colors.reset} (${pct}%)`);
348
+ }
349
+
350
+ return lines;
351
+ }
352
+
353
+ function renderDensity(bins) {
354
+ const maxCount = Math.max(...bins.map(b => b.count), 1);
355
+
356
+ function densityColor(level) {
357
+ if (level === 0) return colors.dim;
358
+ if (level < 0.25) return colors.green;
359
+ if (level < 0.5) return colors.yellow;
360
+ if (level < 0.75) return colors.red;
361
+ return colors.magenta;
362
+ }
363
+
364
+ const terminalWidth = process.stdout.columns || 100;
365
+ const barWidth = Math.max(20, terminalWidth - 6);
366
+ const binsPerCol = Math.max(1, Math.ceil(bins.length / barWidth));
367
+ const columns = [];
368
+
369
+ for (let i = 0; i < bins.length; i += binsPerCol) {
370
+ const group = bins.slice(i, i + binsPerCol);
371
+ const total = group.reduce((sum, b) => sum + b.count, 0);
372
+ columns.push({ start: group[0]?.start, total });
373
+ }
374
+
375
+ let bar = ' ';
376
+ for (const col of columns) {
377
+ const level = col.total / maxCount;
378
+ bar += `${densityColor(level)}█${colors.reset}`;
379
+ }
380
+
381
+ const lines = [];
382
+ lines.push(`${colors.bold}${colors.cyan}▎${t('density')}${colors.reset}`);
383
+ lines.push(bar);
384
+
385
+ const timeMarkers = [];
386
+ const markerInterval = Math.max(1, Math.floor(columns.length / 6));
387
+ for (let i = 0; i < columns.length; i += markerInterval) {
388
+ timeMarkers.push({ pos: i + 2, time: formatTime(columns[i].start) });
389
+ }
390
+
391
+ let markerLine = ' ';
392
+ let lastEnd = 0;
393
+ for (const m of timeMarkers) {
394
+ const spaces = m.pos - lastEnd;
395
+ markerLine += ' '.repeat(Math.max(0, spaces)) + colors.dim + m.time + colors.reset;
396
+ lastEnd = m.pos + m.time.length;
397
+ }
398
+ lines.push(markerLine);
399
+ lines.push(` ${colors.dim}${t('legend')}: ${colors.green}${t('low')}${colors.reset} ${colors.yellow}${t('medium')}${colors.reset} ${colors.red}${t('high')}${colors.reset} ${colors.magenta}${t('extreme')}${colors.reset}`);
400
+
401
+ return lines;
402
+ }
403
+
404
+ function renderList(entries) {
405
+ const lines = [];
406
+ lines.push(`${colors.bold}${colors.cyan}▎${t('cmdList')}${colors.reset}`);
407
+
408
+ const terminalWidth = process.stdout.columns || 100;
409
+ const cmdMaxWidth = terminalWidth - 12;
410
+ for (const entry of entries) {
411
+ const cat = classifyCommand(entry.command);
412
+ const timeStr = formatTime(entry.timestamp);
413
+ const cmdStr = entry.command.length > cmdMaxWidth ? entry.command.slice(0, cmdMaxWidth - 3) + '...' : entry.command;
414
+ lines.push(` ${colors.dim}${timeStr}${colors.reset} ${cat.color}${cmdStr}${colors.reset}`);
415
+ }
416
+
417
+ return lines;
418
+ }
419
+
420
+ function renderTimeline(entries, range, format) {
421
+ const filtered = filterByRange(entries, range);
422
+
423
+ if (filtered.length === 0) {
424
+ console.log(`${colors.yellow}${t('noRecords')}${colors.reset}`);
425
+ return;
426
+ }
427
+
428
+ const { bins } = binByTime(filtered, range);
429
+ const dateStr = formatDate(filtered[0].timestamp);
430
+ const lastDateStr = formatDate(filtered[filtered.length - 1].timestamp);
431
+ const dateRange = dateStr === lastDateStr ? dateStr : `${dateStr} → ${lastDateStr}`;
432
+ const totalCmds = filtered.length;
433
+ const histInfo = findHistoryFile();
434
+
435
+ // Header
436
+ const lines = [];
437
+ lines.push('');
438
+ lines.push(`${colors.bold}${colors.cyan}${t('title')}${colors.reset} ${colors.dim}${dateRange}${colors.reset}`);
439
+ lines.push(`${t('totalCmds')} ${colors.green}${totalCmds}${colors.reset} ${t('cmdsLabel')} ${colors.dim}·${colors.reset} ${colors.dim}${histInfo?.type || 'N/A'}${colors.reset}`);
440
+ lines.push('');
441
+
442
+ if (format === 'top' || format === 'both' || format === 'stats') {
443
+ lines.push(...renderTopCommands(filtered));
444
+ lines.push('');
445
+ }
446
+
447
+ if (format === 'stats' || format === 'both') {
448
+ lines.push(...renderCategoryBreakdown(filtered));
449
+ lines.push('');
450
+ }
451
+
452
+ if (format === 'density' || format === 'both') {
453
+ lines.push(...renderDensity(bins));
454
+ lines.push('');
455
+ }
456
+
457
+ if (format === 'list' || format === 'both') {
458
+ lines.push(...renderList(filtered));
459
+ lines.push('');
460
+ }
461
+
462
+ console.log(lines.join('\n'));
463
+ }
464
+
465
+ // ─── CLI Arguments ─────────────────────────────────────────────
466
+ function parseArgs() {
467
+ const args = process.argv.slice(2);
468
+ let range = 'today';
469
+ let format = 'both';
470
+ let topN = 10;
471
+ let langArg = null;
472
+
473
+ for (let i = 0; i < args.length; i++) {
474
+ switch (args[i]) {
475
+ case '--range':
476
+ case '-r':
477
+ range = args[++i] || 'today';
478
+ break;
479
+ case '--format':
480
+ case '-f':
481
+ format = args[++i] || 'both';
482
+ break;
483
+ case '--top':
484
+ case '-t':
485
+ topN = parseInt(args[++i], 10) || 10;
486
+ break;
487
+ case '--lang':
488
+ case '-l':
489
+ langArg = args[++i];
490
+ break;
491
+ case '--help':
492
+ case '-h':
493
+ printHelp();
494
+ process.exit(0);
495
+ }
496
+ }
497
+
498
+ if (langArg) {
499
+ lang = langArg === 'zh' ? 'zh' : 'en';
500
+ }
501
+
502
+ return { range, format, topN };
503
+ }
504
+
505
+ function printHelp() {
506
+ console.log(t('usage'));
507
+ }
508
+
509
+ // ─── Main ──────────────────────────────────────────────────────
510
+ function main() {
511
+ const { range, format, topN } = parseArgs();
512
+ const entries = parseHistory();
513
+ renderTimeline(entries, range, format);
514
+ }
515
+
516
+ main();