shellwrapped 0.1.0__py3-none-any.whl
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.
- shellwrapped/__init__.py +2 -0
- shellwrapped/__main__.py +3 -0
- shellwrapped/card.py +95 -0
- shellwrapped/cli.py +145 -0
- shellwrapped/fonts/Inter-Bold.otf +0 -0
- shellwrapped/fonts/Inter-Regular.otf +0 -0
- shellwrapped/fonts/InterDisplay-Black.otf +0 -0
- shellwrapped/fonts/OFL.txt +88 -0
- shellwrapped/history.py +152 -0
- shellwrapped/show.py +108 -0
- shellwrapped/stats.py +231 -0
- shellwrapped-0.1.0.dist-info/METADATA +88 -0
- shellwrapped-0.1.0.dist-info/RECORD +17 -0
- shellwrapped-0.1.0.dist-info/WHEEL +4 -0
- shellwrapped-0.1.0.dist-info/entry_points.txt +2 -0
- shellwrapped-0.1.0.dist-info/licenses/LICENSE +21 -0
- shellwrapped-0.1.0.dist-info/licenses/src/shellwrapped/fonts/OFL.txt +88 -0
shellwrapped/__init__.py
ADDED
shellwrapped/__main__.py
ADDED
shellwrapped/card.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Render a 1200x675 share card (the size X, LinkedIn and Slack previews use)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
8
|
+
|
|
9
|
+
W, H = 1200, 675
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
|
|
13
|
+
with resources.as_file(resources.files(__package__) / "fonts" / name) as p:
|
|
14
|
+
return ImageFont.truetype(str(p), size)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _gradient(top: tuple, bottom: tuple) -> Image.Image:
|
|
18
|
+
img = Image.new("RGB", (W, H), top)
|
|
19
|
+
d = ImageDraw.Draw(img)
|
|
20
|
+
for y in range(H):
|
|
21
|
+
t = y / (H - 1)
|
|
22
|
+
d.line([(0, y), (W, y)], fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)))
|
|
23
|
+
return img
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fit(draw: ImageDraw.ImageDraw, text: str, font_name: str, size: int, max_w: int) -> ImageFont.FreeTypeFont:
|
|
27
|
+
f = _font(font_name, size)
|
|
28
|
+
while draw.textlength(text, font=f) > max_w and size > 14:
|
|
29
|
+
size -= 2
|
|
30
|
+
f = _font(font_name, size)
|
|
31
|
+
return f
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render(path: str | Path, *, brand: str, year_label: str, persona: str, tagline: str,
|
|
35
|
+
stats: list[tuple[str, str]], bars: list[tuple[str, int]], bars_title: str, footer: str,
|
|
36
|
+
accent=(255, 107, 87), top=(24, 18, 43), bottom=(10, 10, 18)) -> Path:
|
|
37
|
+
img = _gradient(top, bottom)
|
|
38
|
+
d = ImageDraw.Draw(img)
|
|
39
|
+
ink, dim = (245, 243, 255), (160, 156, 190)
|
|
40
|
+
|
|
41
|
+
# glow behind the title
|
|
42
|
+
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
43
|
+
gd = ImageDraw.Draw(glow)
|
|
44
|
+
gd.ellipse((-260, -320, 640, 360), fill=accent + (70,))
|
|
45
|
+
gd.ellipse((820, 380, 1500, 900), fill=(120, 90, 255, 40))
|
|
46
|
+
glow = glow.filter(ImageFilter.GaussianBlur(120))
|
|
47
|
+
img = Image.alpha_composite(img.convert("RGBA"), glow)
|
|
48
|
+
tiles = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
49
|
+
td = ImageDraw.Draw(tiles)
|
|
50
|
+
|
|
51
|
+
for i in range(min(len(stats), 4)):
|
|
52
|
+
x = 64 + i * (248 + 22)
|
|
53
|
+
td.rounded_rectangle((x, 300, x + 248, 432), radius=18, fill=(255, 255, 255, 14),
|
|
54
|
+
outline=(255, 255, 255, 40), width=2)
|
|
55
|
+
img = Image.alpha_composite(img, tiles).convert("RGB")
|
|
56
|
+
d = ImageDraw.Draw(img)
|
|
57
|
+
|
|
58
|
+
d.text((64, 52), brand, font=_font("Inter-Bold.otf", 26), fill=accent)
|
|
59
|
+
bw = d.textlength(brand, font=_font("Inter-Bold.otf", 26))
|
|
60
|
+
d.text((64 + bw + 14, 52), year_label, font=_font("Inter-Regular.otf", 26), fill=dim)
|
|
61
|
+
|
|
62
|
+
title_f = _fit(d, persona, "InterDisplay-Black.otf", 76, 1060)
|
|
63
|
+
d.text((62, 96), persona, font=title_f, fill=ink)
|
|
64
|
+
d.text((66, 96 + title_f.size + 14), tagline, font=_fit(d, tagline, "Inter-Regular.otf", 28, 1060), fill=dim)
|
|
65
|
+
|
|
66
|
+
# stat tiles
|
|
67
|
+
y0, tile_w, gap = 300, 248, 22
|
|
68
|
+
for i, (label, value) in enumerate(stats[:4]):
|
|
69
|
+
x = 64 + i * (tile_w + gap)
|
|
70
|
+
vf = _fit(d, value, "InterDisplay-Black.otf", 52, tile_w - 36)
|
|
71
|
+
d.text((x + 20, y0 + 18), value, font=vf, fill=ink)
|
|
72
|
+
d.text((x + 20, y0 + 90), label.upper(), font=_fit(d, label.upper(), "Inter-Bold.otf", 17, tile_w - 36),
|
|
73
|
+
fill=dim)
|
|
74
|
+
|
|
75
|
+
# bar chart
|
|
76
|
+
by = 470
|
|
77
|
+
d.text((64, by), bars_title.upper(), font=_font("Inter-Bold.otf", 17), fill=dim)
|
|
78
|
+
if bars:
|
|
79
|
+
mx = max(v for _, v in bars) or 1
|
|
80
|
+
name_f, val_f = _font("Inter-Bold.otf", 22), _font("Inter-Regular.otf", 20)
|
|
81
|
+
for i, (name, v) in enumerate(bars[:5]):
|
|
82
|
+
y = by + 36 + i * 30
|
|
83
|
+
d.text((64, y), name, font=name_f, fill=ink)
|
|
84
|
+
x0, x1 = 230, 230 + int(560 * v / mx)
|
|
85
|
+
d.rounded_rectangle((x0, y + 6, max(x1, x0 + 8), y + 22), radius=8, fill=accent)
|
|
86
|
+
d.text((max(x1, x0 + 8) + 12, y + 1), f"{v:,}", font=val_f, fill=dim)
|
|
87
|
+
|
|
88
|
+
ff = _font("Inter-Bold.otf", 22)
|
|
89
|
+
fw = d.textlength(footer, font=ff)
|
|
90
|
+
d.rounded_rectangle((W - 64 - fw - 36, H - 82, W - 64, H - 40), radius=21, fill=accent)
|
|
91
|
+
d.text((W - 64 - fw - 18, H - 74), footer, font=ff, fill=(20, 16, 34))
|
|
92
|
+
|
|
93
|
+
path = Path(path)
|
|
94
|
+
img.save(path, optimize=True)
|
|
95
|
+
return path
|
shellwrapped/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""shellwrapped: Spotify Wrapped for your shell history."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from . import __version__, card
|
|
15
|
+
from .history import history_files, load
|
|
16
|
+
from .show import Story
|
|
17
|
+
from .stats import Wrapped, compute
|
|
18
|
+
|
|
19
|
+
ACCENT = "#ff6b57"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clock(h: int) -> str:
|
|
23
|
+
return f"{(h % 12) or 12} {'am' if h < 12 else 'pm'}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def story(w: Wrapped, s: Story, label: str) -> None:
|
|
27
|
+
s.words("shellwrapped", f"Your {label} in the terminal", "Let's see what your shell remembers.", hold=1.8)
|
|
28
|
+
s.number("commands run", w.commands, f"{w.unique:,} of them unique")
|
|
29
|
+
s.bars("your top commands", w.top[:6], f"{w.top[0][0]} alone: {w.top[0][1]:,} times" if w.top else "")
|
|
30
|
+
if w.top_line:
|
|
31
|
+
s.words("your most-repeated command", w.top_line[0], f"{w.top_line[1]:,} times. Maybe it deserves an alias?")
|
|
32
|
+
if w.git:
|
|
33
|
+
s.bars("git, your way", w.git, f"{w.git[0][1]:,} × git {w.git[0][0]}")
|
|
34
|
+
if w.typos:
|
|
35
|
+
typed, meant, n = w.typos[0]
|
|
36
|
+
s.number(f"times you typed '{typed}' instead of '{meant}'", n,
|
|
37
|
+
f"{w.typo_total:,} typos in total. Your fingers are faster than your spelling.")
|
|
38
|
+
if w.has_time and w.peak_hour is not None:
|
|
39
|
+
s.words("your terminal's rush hour", clock(w.peak_hour),
|
|
40
|
+
f"{w.night_share:.0%} of your commands ran between 10pm and 5am")
|
|
41
|
+
if w.longest_session_min >= 30:
|
|
42
|
+
s.number("minutes in your longest session", w.longest_session_min,
|
|
43
|
+
f"Busiest day: {w.busiest_day:%B} {w.busiest_day.day} with {w.busiest_day_count:,} commands")
|
|
44
|
+
facts = []
|
|
45
|
+
if w.sudo:
|
|
46
|
+
facts.append(f"sudo × {w.sudo:,}" + (f" (sudo !! × {w.sudo_bang})" if w.sudo_bang else ""))
|
|
47
|
+
if w.cd_ls:
|
|
48
|
+
facts.append(f"cd then immediately ls × {w.cd_ls:,}")
|
|
49
|
+
if w.force_push:
|
|
50
|
+
facts.append(f"git push --force × {w.force_push:,}")
|
|
51
|
+
if w.rm_rf:
|
|
52
|
+
facts.append(f"rm -rf × {w.rm_rf:,}")
|
|
53
|
+
if w.clears:
|
|
54
|
+
facts.append(f"clear × {w.clears:,}")
|
|
55
|
+
if facts:
|
|
56
|
+
s.words("habits", " · ".join(facts[:3]), "No judgement.")
|
|
57
|
+
s.words("your terminal personality", w.persona[0], w.persona[1], hold=3.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def summary(w: Wrapped, label: str) -> Panel:
|
|
61
|
+
t = Table.grid(padding=(0, 3))
|
|
62
|
+
t.add_column(style="#a09cbe", justify="right")
|
|
63
|
+
t.add_column(style="bold #f5f3ff")
|
|
64
|
+
t.add_row("commands", f"{w.commands:,} [#a09cbe]({w.unique:,} unique)[/]")
|
|
65
|
+
if w.top:
|
|
66
|
+
t.add_row("top commands", " ".join(f"{p} [#a09cbe]{n:,}[/]" for p, n in w.top[:5]))
|
|
67
|
+
if w.git:
|
|
68
|
+
t.add_row("git", " ".join(f"{g} [#a09cbe]{n:,}[/]" for g, n in w.git[:4]))
|
|
69
|
+
if w.top_line:
|
|
70
|
+
t.add_row("most repeated", f"{w.top_line[0]} [#a09cbe]×{w.top_line[1]:,}[/]")
|
|
71
|
+
if w.typos:
|
|
72
|
+
t.add_row("typos", ", ".join(f"{a}→{b} ×{n}" for a, b, n in w.typos[:3]) + f" [#a09cbe]({w.typo_total} total)[/]")
|
|
73
|
+
if w.has_time and w.peak_hour is not None:
|
|
74
|
+
t.add_row("peak hour", f"{clock(w.peak_hour)} [#a09cbe]{w.night_share:.0%} after 10pm · "
|
|
75
|
+
f"{w.active_days} active days · longest session {w.longest_session_min} min[/]")
|
|
76
|
+
t.add_row("longest command", f"{w.longest:,} characters")
|
|
77
|
+
t.add_row("persona", f"[{ACCENT}]{w.persona[0]}[/] [#a09cbe]{w.persona[1]}[/]")
|
|
78
|
+
return Panel(t, title=f"[bold {ACCENT}]shellwrapped[/] [#a09cbe]{label}[/]", title_align="left",
|
|
79
|
+
border_style=ACCENT, padding=(1, 2))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def make_card(w: Wrapped, label: str, path: Path) -> Path:
|
|
83
|
+
top = w.top[0] if w.top else ("-", 0)
|
|
84
|
+
stats = [("commands", f"{w.commands:,}"), (f"× {top[0]}", f"{top[1]:,}")]
|
|
85
|
+
stats.append(("typos", f"{w.typo_total:,}") if w.typos else ("unique commands", f"{w.unique:,}"))
|
|
86
|
+
stats.append(("peak hour", clock(w.peak_hour)) if w.has_time and w.peak_hour is not None
|
|
87
|
+
else ("longest command", f"{w.longest:,} ch"))
|
|
88
|
+
return card.render(path, brand="shellwrapped", year_label=label, persona=w.persona[0], tagline=w.persona[1],
|
|
89
|
+
stats=stats, bars=w.top[:5], bars_title="top commands", footer="uvx shellwrapped")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv=None) -> int:
|
|
93
|
+
p = argparse.ArgumentParser(prog="shellwrapped", description="Spotify Wrapped for your shell history.")
|
|
94
|
+
p.add_argument("--year", type=int, default=date.today().year, help="year to wrap (default: this year)")
|
|
95
|
+
p.add_argument("--all-time", action="store_true", help="use your whole history, not just one year")
|
|
96
|
+
p.add_argument("--file", type=Path, action="append", help="history file to read (repeatable)")
|
|
97
|
+
p.add_argument("--shell", choices=["zsh", "bash", "fish", "powershell"], help="format of --file")
|
|
98
|
+
p.add_argument("--no-anim", action="store_true", help="skip the animation, just print the summary")
|
|
99
|
+
p.add_argument("--speed", type=float, default=1.0, help="animation speed multiplier")
|
|
100
|
+
p.add_argument("--card", type=Path, help="where to save the share card (default: ./shellwrapped-<year>.png)")
|
|
101
|
+
p.add_argument("--no-card", action="store_true", help="don't write a share card")
|
|
102
|
+
p.add_argument("--version", action="version", version=f"shellwrapped {__version__}")
|
|
103
|
+
a = p.parse_args(argv)
|
|
104
|
+
console = Console()
|
|
105
|
+
|
|
106
|
+
files = [(f, a.shell or ("zsh" if "zsh" in f.name else "bash")) for f in a.file] if a.file else history_files()
|
|
107
|
+
if not files:
|
|
108
|
+
console.print("No shell history found. Point me at one with --file ~/.zsh_history")
|
|
109
|
+
return 1
|
|
110
|
+
commands = load(files)
|
|
111
|
+
timed = any(c.when for c in commands)
|
|
112
|
+
year = None if a.all_time or not timed else a.year
|
|
113
|
+
w = compute(commands, year)
|
|
114
|
+
if w.commands == 0:
|
|
115
|
+
console.print(f"No commands found for {a.year}. Try --all-time.")
|
|
116
|
+
return 1
|
|
117
|
+
label = str(year) if year else "all time"
|
|
118
|
+
|
|
119
|
+
if not a.no_anim and console.is_terminal:
|
|
120
|
+
try:
|
|
121
|
+
story(w, Story(console, ACCENT, a.speed), label)
|
|
122
|
+
except KeyboardInterrupt:
|
|
123
|
+
pass
|
|
124
|
+
console.print(summary(w, label))
|
|
125
|
+
if not timed:
|
|
126
|
+
console.print("[#a09cbe]Your history has no timestamps, so this covers everything it remembers. For "
|
|
127
|
+
"per-year stats next time: `setopt EXTENDED_HISTORY` (zsh) or `export HISTTIMEFORMAT='%F %T '`"
|
|
128
|
+
" (bash).[/]")
|
|
129
|
+
if not a.no_card:
|
|
130
|
+
out = a.card or Path(f"shellwrapped-{label.replace(' ', '-')}.png")
|
|
131
|
+
try:
|
|
132
|
+
make_card(w, label, out)
|
|
133
|
+
console.print(f"\n[bold {ACCENT}]Share card saved:[/] {out} [#a09cbe](no commands or paths on it, "
|
|
134
|
+
f"just counts and program names)[/]")
|
|
135
|
+
except OSError as e:
|
|
136
|
+
console.print(f"[#a09cbe]Couldn't save the share card: {e}[/]")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run() -> None:
|
|
141
|
+
sys.exit(main())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
run()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Inter font: Copyright (c) 2016-2023 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
|
|
5
|
+
PREAMBLE
|
|
6
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
7
|
+
development of collaborative font projects, to support the font
|
|
8
|
+
creation efforts of academic and linguistic communities, and to
|
|
9
|
+
provide a free and open framework in which fonts may be shared and
|
|
10
|
+
improved in partnership with others.
|
|
11
|
+
|
|
12
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
13
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
14
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
15
|
+
redistributed and/or sold with any software provided that any
|
|
16
|
+
reserved names are not used by derivative works. The fonts and
|
|
17
|
+
derivatives, however, cannot be released under any other type of
|
|
18
|
+
license. The requirement for fonts to remain under this license
|
|
19
|
+
does not apply to any document created using the fonts or their
|
|
20
|
+
derivatives.
|
|
21
|
+
|
|
22
|
+
DEFINITIONS
|
|
23
|
+
Font Software refers to the set of files released by the
|
|
24
|
+
Copyright Holder(s) under this license and clearly marked as such.
|
|
25
|
+
This may include source files, build scripts and documentation.
|
|
26
|
+
|
|
27
|
+
Reserved Font Name refers to any names specified as such after
|
|
28
|
+
the copyright statement(s).
|
|
29
|
+
|
|
30
|
+
Original Version refers to the collection of Font Software
|
|
31
|
+
components as distributed by the Copyright Holder(s).
|
|
32
|
+
|
|
33
|
+
Modified Version refers to any derivative made by adding to,
|
|
34
|
+
deleting, or substituting in part or in whole any of the components
|
|
35
|
+
of the Original Version, by changing formats or by porting the Font
|
|
36
|
+
Software to a new environment.
|
|
37
|
+
|
|
38
|
+
Author refers to any designer, engineer, programmer, technical
|
|
39
|
+
writer or other person who contributed to the Font Software.
|
|
40
|
+
|
|
41
|
+
PERMISSION & CONDITIONS
|
|
42
|
+
Permission is hereby granted, free of charge, to any person
|
|
43
|
+
obtaining a copy of the Font Software, to use, study, copy, merge,
|
|
44
|
+
embed, modify, redistribute, and sell modified and unmodified copies
|
|
45
|
+
of the Font Software, subject to the following conditions:
|
|
46
|
+
|
|
47
|
+
1) Neither the Font Software nor any of its individual components,
|
|
48
|
+
in Original or Modified Versions, may be sold by itself.
|
|
49
|
+
|
|
50
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
51
|
+
redistributed and/or sold with any software, provided that each
|
|
52
|
+
copy contains the above copyright notice and this license. These
|
|
53
|
+
can be included either as stand-alone text files, human-readable
|
|
54
|
+
headers or in the appropriate machine-readable metadata fields
|
|
55
|
+
within text or binary files as long as those fields can be easily
|
|
56
|
+
viewed by the user.
|
|
57
|
+
|
|
58
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
59
|
+
Name(s) unless explicit written permission is granted by the
|
|
60
|
+
corresponding Copyright Holder. This restriction only applies to
|
|
61
|
+
the primary font name as presented to the users.
|
|
62
|
+
|
|
63
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the
|
|
64
|
+
Font Software shall not be used to promote, endorse or advertise
|
|
65
|
+
any Modified Version, except to acknowledge the contribution(s)
|
|
66
|
+
of the Copyright Holder(s) and the Author(s) or with their
|
|
67
|
+
explicit written permission.
|
|
68
|
+
|
|
69
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
70
|
+
must be distributed entirely under this license, and must not be
|
|
71
|
+
distributed under any other license. The requirement for fonts to
|
|
72
|
+
remain under this license does not apply to any document created
|
|
73
|
+
using the Font Software.
|
|
74
|
+
|
|
75
|
+
TERMINATION
|
|
76
|
+
This license becomes null and void if any of the above conditions
|
|
77
|
+
are not met.
|
|
78
|
+
|
|
79
|
+
DISCLAIMER
|
|
80
|
+
THE FONT SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND,
|
|
81
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
82
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
83
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
|
84
|
+
THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
85
|
+
LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR
|
|
86
|
+
CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
87
|
+
OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT
|
|
88
|
+
SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
shellwrapped/history.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Read shell history files (zsh, bash, fish, PowerShell) into timestamped commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Command:
|
|
13
|
+
text: str
|
|
14
|
+
when: datetime | None # None when the shell didn't record a timestamp
|
|
15
|
+
shell: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _home() -> Path:
|
|
19
|
+
return Path(os.environ.get("SHELLWRAPPED_HOME") or Path.home())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unmetafy(raw: bytes) -> bytes:
|
|
23
|
+
"""zsh stores bytes >= 0x80 'metafied': 0x83 followed by the byte XOR 0x20."""
|
|
24
|
+
if b"\x83" not in raw:
|
|
25
|
+
return raw
|
|
26
|
+
out = bytearray()
|
|
27
|
+
it = iter(raw)
|
|
28
|
+
for b in it:
|
|
29
|
+
if b == 0x83:
|
|
30
|
+
nxt = next(it, None)
|
|
31
|
+
if nxt is not None:
|
|
32
|
+
out.append(nxt ^ 0x20)
|
|
33
|
+
else:
|
|
34
|
+
out.append(b)
|
|
35
|
+
return bytes(out)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ts(s: str) -> datetime | None:
|
|
39
|
+
try:
|
|
40
|
+
v = int(s)
|
|
41
|
+
return datetime.fromtimestamp(v) if 0 < v < 4_102_444_800 else None
|
|
42
|
+
except (ValueError, OSError, OverflowError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ZSH_EXT = re.compile(r"^: (\d+):\d+;(.*)$", re.S)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_zsh(raw: bytes) -> list[Command]:
|
|
50
|
+
text = unmetafy(raw).decode("utf-8", errors="replace")
|
|
51
|
+
out: list[Command] = []
|
|
52
|
+
buf: list[str] = []
|
|
53
|
+
when = None
|
|
54
|
+
for line in text.split("\n"):
|
|
55
|
+
if buf: # inside a multi-line command (previous line ended with a backslash)
|
|
56
|
+
buf.append(line)
|
|
57
|
+
if not line.endswith("\\"):
|
|
58
|
+
out.append(Command("\n".join(buf), when, "zsh"))
|
|
59
|
+
buf = []
|
|
60
|
+
continue
|
|
61
|
+
m = ZSH_EXT.match(line)
|
|
62
|
+
cmd, when = (m.group(2), _ts(m.group(1))) if m else (line, None)
|
|
63
|
+
if not cmd.strip():
|
|
64
|
+
continue
|
|
65
|
+
if cmd.endswith("\\"):
|
|
66
|
+
buf = [cmd]
|
|
67
|
+
else:
|
|
68
|
+
out.append(Command(cmd, when, "zsh"))
|
|
69
|
+
if buf:
|
|
70
|
+
out.append(Command("\n".join(buf), when, "zsh"))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_bash(raw: bytes) -> list[Command]:
|
|
75
|
+
out, when = [], None
|
|
76
|
+
for line in raw.decode("utf-8", errors="replace").splitlines():
|
|
77
|
+
if line.startswith("#") and line[1:].isdigit(): # HISTTIMEFORMAT timestamps
|
|
78
|
+
when = _ts(line[1:])
|
|
79
|
+
continue
|
|
80
|
+
if line.strip():
|
|
81
|
+
out.append(Command(line, when, "bash"))
|
|
82
|
+
when = None
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_fish(raw: bytes) -> list[Command]:
|
|
87
|
+
out: list[Command] = []
|
|
88
|
+
cmd = None
|
|
89
|
+
for line in raw.decode("utf-8", errors="replace").splitlines():
|
|
90
|
+
if line.startswith("- cmd: "):
|
|
91
|
+
if cmd is not None:
|
|
92
|
+
out.append(Command(cmd, None, "fish"))
|
|
93
|
+
cmd = line[7:].replace("\\n", "\n").replace("\\\\", "\\")
|
|
94
|
+
elif line.startswith(" when: ") and cmd is not None:
|
|
95
|
+
out.append(Command(cmd, _ts(line[8:].strip()), "fish"))
|
|
96
|
+
cmd = None
|
|
97
|
+
if cmd is not None:
|
|
98
|
+
out.append(Command(cmd, None, "fish"))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_powershell(raw: bytes) -> list[Command]:
|
|
103
|
+
text = raw.decode("utf-8-sig", errors="replace")
|
|
104
|
+
out, buf = [], []
|
|
105
|
+
for line in text.splitlines():
|
|
106
|
+
buf.append(line)
|
|
107
|
+
if line.endswith("`"):
|
|
108
|
+
continue
|
|
109
|
+
cmd = "\n".join(buf)
|
|
110
|
+
buf = []
|
|
111
|
+
if cmd.strip():
|
|
112
|
+
out.append(Command(cmd, None, "powershell"))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def history_files(home: Path | None = None) -> list[tuple[Path, str]]:
|
|
117
|
+
h = home or _home()
|
|
118
|
+
appdata = Path(os.environ.get("APPDATA", h / "AppData/Roaming"))
|
|
119
|
+
candidates = []
|
|
120
|
+
if os.environ.get("HISTFILE"):
|
|
121
|
+
p = Path(os.environ["HISTFILE"])
|
|
122
|
+
candidates.append((p, "zsh" if "zsh" in p.name or "zhist" in p.name else "bash"))
|
|
123
|
+
candidates += [
|
|
124
|
+
(h / ".zsh_history", "zsh"), (h / ".zhistory", "zsh"), (h / ".histfile", "zsh"),
|
|
125
|
+
(h / ".bash_history", "bash"),
|
|
126
|
+
(h / ".local/share/fish/fish_history", "fish"),
|
|
127
|
+
(appdata / "Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt", "powershell"),
|
|
128
|
+
(h / ".local/share/powershell/PSReadLine/ConsoleHost_history.txt", "powershell"),
|
|
129
|
+
]
|
|
130
|
+
seen, out = set(), []
|
|
131
|
+
for p, shell in candidates:
|
|
132
|
+
try:
|
|
133
|
+
rp = p.resolve()
|
|
134
|
+
if rp not in seen and rp.is_file() and rp.stat().st_size > 0:
|
|
135
|
+
seen.add(rp)
|
|
136
|
+
out.append((rp, shell))
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
PARSERS = {"zsh": parse_zsh, "bash": parse_bash, "fish": parse_fish, "powershell": parse_powershell}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load(files: list[tuple[Path, str]] | None = None) -> list[Command]:
|
|
146
|
+
commands: list[Command] = []
|
|
147
|
+
for path, shell in files if files is not None else history_files():
|
|
148
|
+
try:
|
|
149
|
+
commands += PARSERS[shell](path.read_bytes())
|
|
150
|
+
except OSError:
|
|
151
|
+
continue
|
|
152
|
+
return commands
|
shellwrapped/show.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Animated terminal story: one stat per screen, numbers counting up in block digits."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from rich.align import Align
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
GLYPHS = {
|
|
13
|
+
"0": ["▄▀▀▄", "█ █", "▀▄▄▀"], "1": [" ▄█ ", " █ ", " ▄█▄"], "2": ["▄▀▀▄", " ▄▀", "▄█▄▄"],
|
|
14
|
+
"3": ["▀▀▀█", " ▀▀▄", "▄▄▄▀"], "4": ["█ █", "▀▀▀█", " █"], "5": ["█▀▀▀", "▀▀▀▄", "▄▄▄▀"],
|
|
15
|
+
"6": ["▄▀▀ ", "█▄▄ ", "▀▄▄▀"], "7": ["▀▀▀█", " █ ", " █ "], "8": ["▄▀▀▄", "▄▀▀▄", "▀▄▄▀"],
|
|
16
|
+
"9": ["▄▀▀▄", "▀▄▄█", " ▄▄▀"], ",": [" ", " ", "▄▀"], ".": [" ", " ", "▄"], ":": [" ", "▀", "▀"],
|
|
17
|
+
"%": ["▀ ▄▀", " ▄▀ ", "▄▀ ▄"], " ": [" ", " ", " "], "-": [" ", "▀▀▀", " "],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def big(text: str, style: str) -> Text:
|
|
22
|
+
rows = ["", "", ""]
|
|
23
|
+
for ch in text:
|
|
24
|
+
g = GLYPHS.get(ch)
|
|
25
|
+
if g is None:
|
|
26
|
+
continue
|
|
27
|
+
for i in range(3):
|
|
28
|
+
rows[i] += g[i] + " "
|
|
29
|
+
return Text("\n".join(r.rstrip() for r in rows), style=style, justify="center")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def headline_text(text: str, style: str, max_width: int) -> Text:
|
|
33
|
+
"""Large FIGlet lettering when it fits, plain bold text otherwise. Times/numbers use block digits."""
|
|
34
|
+
import re
|
|
35
|
+
|
|
36
|
+
m = re.fullmatch(r"([\d:,.%]+)\s*(am|pm)?", text.strip())
|
|
37
|
+
if m:
|
|
38
|
+
out = big(m.group(1), style)
|
|
39
|
+
if m.group(2):
|
|
40
|
+
out.append("\n" + m.group(2), style=style)
|
|
41
|
+
return out
|
|
42
|
+
try:
|
|
43
|
+
import pyfiglet
|
|
44
|
+
|
|
45
|
+
art = pyfiglet.figlet_format(text, font="future", width=1000).rstrip("\n")
|
|
46
|
+
if art.strip() and max(len(l) for l in art.splitlines()) <= max_width:
|
|
47
|
+
return Text(art, style=style, justify="center")
|
|
48
|
+
except Exception: # missing glyphs, missing font: fall back to plain text
|
|
49
|
+
pass
|
|
50
|
+
return Text(text, style=style, justify="center")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Story:
|
|
54
|
+
def __init__(self, console: Console, accent: str = "#ff6b57", speed: float = 1.0, animate: bool = True):
|
|
55
|
+
self.c, self.accent, self.speed, self.animate = console, accent, speed, animate
|
|
56
|
+
|
|
57
|
+
def _panel(self, label: str, body, caption: str = "") -> Panel:
|
|
58
|
+
parts = [Text(label.upper(), style="bold #a09cbe", justify="center"), Text(""), body]
|
|
59
|
+
if caption:
|
|
60
|
+
parts += [Text(""), Text(caption, style="#d8d4f0", justify="center")]
|
|
61
|
+
height = max(13, self.c.size.height - 2)
|
|
62
|
+
return Panel(Align.center(Group(*parts), vertical="middle"), border_style=self.accent, height=height)
|
|
63
|
+
|
|
64
|
+
def number(self, label: str, value: int, caption: str = "", fmt: str = "{:,}", hold: float = 2.2) -> None:
|
|
65
|
+
if not self.animate:
|
|
66
|
+
return
|
|
67
|
+
steps = 28
|
|
68
|
+
with Live(self._panel(label, big(fmt.format(0), f"bold {self.accent}")), console=self.c,
|
|
69
|
+
refresh_per_second=40, transient=True, screen=True) as live:
|
|
70
|
+
for i in range(1, steps + 1):
|
|
71
|
+
v = round(value * (1 - (1 - i / steps) ** 3)) # ease-out
|
|
72
|
+
live.update(self._panel(label, big(fmt.format(v), f"bold {self.accent}"),
|
|
73
|
+
caption if i == steps else ""))
|
|
74
|
+
time.sleep(0.9 / steps / self.speed)
|
|
75
|
+
time.sleep(hold / self.speed)
|
|
76
|
+
|
|
77
|
+
def words(self, label: str, headline: str, caption: str = "", hold: float = 2.4) -> None:
|
|
78
|
+
if not self.animate:
|
|
79
|
+
return
|
|
80
|
+
with Live(self._panel(label, Text("")), console=self.c, refresh_per_second=40, transient=True,
|
|
81
|
+
screen=True) as live:
|
|
82
|
+
shown = ""
|
|
83
|
+
for ch in headline:
|
|
84
|
+
shown += ch
|
|
85
|
+
live.update(self._panel(label, Text(shown, style=f"bold {self.accent}", justify="center")))
|
|
86
|
+
time.sleep(0.035 / self.speed)
|
|
87
|
+
live.update(self._panel(label, headline_text(headline, f"bold {self.accent}", self.c.size.width - 8),
|
|
88
|
+
caption))
|
|
89
|
+
time.sleep(hold / self.speed)
|
|
90
|
+
|
|
91
|
+
def bars(self, label: str, rows: list[tuple[str, int]], caption: str = "", hold: float = 2.6) -> None:
|
|
92
|
+
if not self.animate or not rows:
|
|
93
|
+
return
|
|
94
|
+
mx = max(v for _, v in rows) or 1
|
|
95
|
+
w = max(10, min(46, self.c.size.width - 40))
|
|
96
|
+
nw = max(len(n) for n, _ in rows)
|
|
97
|
+
with Live(self._panel(label, Text("")), console=self.c, refresh_per_second=40, transient=True,
|
|
98
|
+
screen=True) as live:
|
|
99
|
+
for step in range(1, 21):
|
|
100
|
+
t = Text(justify="left")
|
|
101
|
+
for name, v in rows:
|
|
102
|
+
n = round(w * v / mx * step / 20)
|
|
103
|
+
t.append(f"{name:>{nw}} ", style="bold #f5f3ff")
|
|
104
|
+
t.append("█" * n, style=self.accent)
|
|
105
|
+
t.append(f" {round(v * step / 20):,}\n", style="#a09cbe")
|
|
106
|
+
live.update(self._panel(label, Align.center(t), caption if step == 20 else ""))
|
|
107
|
+
time.sleep(0.025 / self.speed)
|
|
108
|
+
time.sleep(hold / self.speed)
|
shellwrapped/stats.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Turn shell history into a year in review."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import date, datetime, timedelta
|
|
11
|
+
|
|
12
|
+
from .history import Command
|
|
13
|
+
|
|
14
|
+
# Common fat-fingers -> what was meant.
|
|
15
|
+
TYPOS = {
|
|
16
|
+
"gti": "git", "got": "git", "gut": "git", "igt": "git", "tgi": "git", "gi": "git", "gitp": "git",
|
|
17
|
+
"sl": "ls", "l": "ls", "lls": "ls", "ks": "ls", "cd..": "cd ..", "cd-": "cd -", "dc": "cd",
|
|
18
|
+
"claer": "clear", "clera": "clear", "cler": "clear", "celar": "clear", "exti": "exit", "eixt": "exit",
|
|
19
|
+
"exi": "exit", "sudp": "sudo", "suod": "sudo", "sduo": "sudo", "grpe": "grep", "gerp": "grep", "rgep": "grep",
|
|
20
|
+
"pyhton": "python", "pytohn": "python", "pyton": "python", "phyton": "python", "pytho": "python",
|
|
21
|
+
"pthon": "python", "pyhton3": "python3", "pythno": "python", "nmp": "npm", "npn": "npm", "mpm": "npm",
|
|
22
|
+
"nom": "npm", "dokcer": "docker", "docekr": "docker", "dcoker": "docker", "kubeclt": "kubectl",
|
|
23
|
+
"kubctl": "kubectl", "kuebctl": "kubectl", "vmi": "vim", "ivm": "vim", "cim": "vim", "nivm": "nvim",
|
|
24
|
+
"nvmi": "nvim", "mkdri": "mkdir", "mkidr": "mkdir", "mdkir": "mkdir", "tocuh": "touch", "touhc": "touch",
|
|
25
|
+
"brwe": "brew", "berw": "brew", "carog": "cargo", "cargp": "cargo", "yran": "yarn", "ymal": "yaml",
|
|
26
|
+
"pnmp": "pnpm", "code.": "code .", "ssj": "ssh", "shh": "ssh", "mv-": "mv", "car": "cat", "cta": "cat",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
CATEGORIES = {
|
|
30
|
+
"git": "git", "gh": "git", "lazygit": "git", "tig": "git",
|
|
31
|
+
"docker": "containers", "docker-compose": "containers", "podman": "containers", "kubectl": "containers",
|
|
32
|
+
"k9s": "containers", "helm": "containers", "minikube": "containers", "kind": "containers",
|
|
33
|
+
"python": "python", "python3": "python", "pip": "python", "pip3": "python", "uv": "python", "uvx": "python",
|
|
34
|
+
"poetry": "python", "pytest": "python", "ipython": "python", "jupyter": "python", "conda": "python",
|
|
35
|
+
"node": "javascript", "npm": "javascript", "npx": "javascript", "pnpm": "javascript", "yarn": "javascript",
|
|
36
|
+
"bun": "javascript", "deno": "javascript", "tsc": "javascript",
|
|
37
|
+
"cargo": "rust", "rustc": "rust", "rustup": "rust", "go": "go",
|
|
38
|
+
"vim": "editors", "nvim": "editors", "vi": "editors", "nano": "editors", "emacs": "editors", "code": "editors",
|
|
39
|
+
"cursor": "editors", "hx": "editors", "micro": "editors", "subl": "editors",
|
|
40
|
+
"aws": "cloud", "gcloud": "cloud", "az": "cloud", "terraform": "cloud", "tofu": "cloud", "pulumi": "cloud",
|
|
41
|
+
"flyctl": "cloud", "vercel": "cloud", "wrangler": "cloud",
|
|
42
|
+
"cd": "navigation", "ls": "navigation", "ll": "navigation", "la": "navigation", "pwd": "navigation",
|
|
43
|
+
"tree": "navigation", "z": "navigation", "zoxide": "navigation", "eza": "navigation", "exa": "navigation",
|
|
44
|
+
"ssh": "remote", "scp": "remote", "rsync": "remote", "mosh": "remote",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
PERSONAS = {
|
|
48
|
+
"git": ("The Git Whisperer", "Version control is your love language."),
|
|
49
|
+
"containers": ("The Container Captain", "If it doesn't run in Docker, does it run at all?"),
|
|
50
|
+
"python": ("The Pythonista", "import antigravity, every single day."),
|
|
51
|
+
"javascript": ("The JS Juggler", "node_modules is, technically, your home directory."),
|
|
52
|
+
"rust": ("The Rustacean", "The borrow checker and you are on a first-name basis."),
|
|
53
|
+
"go": ("The Gopher", "if err != nil, you'll handle it."),
|
|
54
|
+
"editors": ("The Editor Devotee", "You live in your editor and visit the shell."),
|
|
55
|
+
"cloud": ("The Cloud Wrangler", "Your infrastructure has more commands than your apps."),
|
|
56
|
+
"navigation": ("The Navigator", "cd, ls, cd, ls. You always know where you are."),
|
|
57
|
+
"remote": ("The Remote Operator", "Your laptop is mostly a window into other machines."),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
SEGMENT = re.compile(r"\s*(?:\|\||&&|\||;)\s*")
|
|
61
|
+
WRAPPERS = {"sudo", "doas", "time", "nohup", "env", "exec", "command", "builtin", "noglob", "caffeinate", "watch"}
|
|
62
|
+
SECRETISH = re.compile(r"[A-Za-z0-9_\-+/=]{20,}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def redact(cmd: str) -> str:
|
|
66
|
+
"""Hide anything that looks like a token or password before a command is ever displayed."""
|
|
67
|
+
cmd = re.sub(r"(://[^:/\s@]+:)[^@\s]+@", r"\1•••@", cmd)
|
|
68
|
+
cmd = re.sub(r"(?i)((?:token|key|secret|password|passwd|auth)[\w-]*\s*[=:]\s*)\S+", r"\1•••", cmd)
|
|
69
|
+
|
|
70
|
+
def hide(m):
|
|
71
|
+
s = m.group(0)
|
|
72
|
+
counts = {c: s.count(c) for c in set(s)}
|
|
73
|
+
ent = -sum(n / len(s) * math.log2(n / len(s)) for n in counts.values())
|
|
74
|
+
return s[:4] + "•••" if ent > 3.6 and any(c.isdigit() for c in s) and any(c.isalpha() for c in s) else s
|
|
75
|
+
return SECRETISH.sub(hide, cmd)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def programs(cmd: str) -> list[str]:
|
|
79
|
+
"""The program run by each segment of a command line (`sudo` and friends unwrapped)."""
|
|
80
|
+
out = []
|
|
81
|
+
for seg in SEGMENT.split(cmd.strip()):
|
|
82
|
+
try:
|
|
83
|
+
words = shlex.split(seg, posix=True)
|
|
84
|
+
except ValueError:
|
|
85
|
+
words = seg.split()
|
|
86
|
+
while words: # strip FOO=bar assignments and wrappers like sudo/env/time, in any order
|
|
87
|
+
if "=" in words[0] and not words[0].startswith("="):
|
|
88
|
+
words = words[1:]
|
|
89
|
+
elif words[0] in WRAPPERS:
|
|
90
|
+
words = words[1:]
|
|
91
|
+
while words and words[0].startswith("-"):
|
|
92
|
+
words = words[1:]
|
|
93
|
+
else:
|
|
94
|
+
break
|
|
95
|
+
if words:
|
|
96
|
+
out.append(words[0].rsplit("/", 1)[-1])
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Wrapped:
|
|
102
|
+
year: int | None
|
|
103
|
+
commands: int = 0
|
|
104
|
+
unique: int = 0
|
|
105
|
+
has_time: bool = False
|
|
106
|
+
shells: list[str] = field(default_factory=list)
|
|
107
|
+
top: list[tuple[str, int]] = field(default_factory=list)
|
|
108
|
+
top_line: tuple[str, int] | None = None
|
|
109
|
+
git: list[tuple[str, int]] = field(default_factory=list)
|
|
110
|
+
categories: list[tuple[str, int]] = field(default_factory=list)
|
|
111
|
+
typos: list[tuple[str, str, int]] = field(default_factory=list) # (typed, meant, count)
|
|
112
|
+
typo_total: int = 0
|
|
113
|
+
sudo: int = 0
|
|
114
|
+
sudo_bang: int = 0
|
|
115
|
+
force_push: int = 0
|
|
116
|
+
rm_rf: int = 0
|
|
117
|
+
clears: int = 0
|
|
118
|
+
cd_ls: int = 0
|
|
119
|
+
longest: int = 0
|
|
120
|
+
pipes_record: int = 0
|
|
121
|
+
active_days: int = 0
|
|
122
|
+
busiest_day: date | None = None
|
|
123
|
+
busiest_day_count: int = 0
|
|
124
|
+
peak_hour: int | None = None
|
|
125
|
+
night_share: float = 0.0
|
|
126
|
+
longest_session_min: int = 0
|
|
127
|
+
by_hour: list[int] = field(default_factory=lambda: [0] * 24)
|
|
128
|
+
persona: tuple[str, str] = ("The Generalist", "A little bit of everything.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compute(commands: list[Command], year: int | None) -> Wrapped:
|
|
132
|
+
timed = [c for c in commands if c.when is not None]
|
|
133
|
+
if year is not None and timed:
|
|
134
|
+
commands = [c for c in commands if c.when is not None and c.when.year == year]
|
|
135
|
+
timed = commands
|
|
136
|
+
w = Wrapped(year=year if timed else None)
|
|
137
|
+
if not commands:
|
|
138
|
+
return w
|
|
139
|
+
w.commands = len(commands)
|
|
140
|
+
w.shells = sorted({c.shell for c in commands})
|
|
141
|
+
lines = [c.text.strip() for c in commands]
|
|
142
|
+
w.unique = len(set(lines))
|
|
143
|
+
|
|
144
|
+
progs: Counter[str] = Counter()
|
|
145
|
+
git_sub: Counter[str] = Counter()
|
|
146
|
+
typo_hits: Counter[str] = Counter()
|
|
147
|
+
prev_cd = False
|
|
148
|
+
for line in lines:
|
|
149
|
+
ps = programs(line)
|
|
150
|
+
progs.update(ps)
|
|
151
|
+
low = line.lower()
|
|
152
|
+
first = line.split()[0] if line.split() else ""
|
|
153
|
+
if first in TYPOS and shutil.which(first) is None:
|
|
154
|
+
typo_hits[first] += 1
|
|
155
|
+
elif low.strip() in TYPOS: # e.g. "cd.." typed literally, without the space
|
|
156
|
+
typo_hits[low.strip()] += 1
|
|
157
|
+
if re.match(r"^\s*(sudo|doas)\s", line):
|
|
158
|
+
w.sudo += 1
|
|
159
|
+
if re.match(r"^\s*sudo\s+!!", line):
|
|
160
|
+
w.sudo_bang += 1
|
|
161
|
+
if re.search(r"\bgit\s+push\b.*(--force\b|-f\b|--force-with-lease)", line):
|
|
162
|
+
w.force_push += 1
|
|
163
|
+
if re.search(r"\brm\s+-(rf|fr|Rf|fR)\b", line):
|
|
164
|
+
w.rm_rf += 1
|
|
165
|
+
if ps[:1] == ["clear"] or line in ("cls", "reset"):
|
|
166
|
+
w.clears += 1
|
|
167
|
+
if prev_cd and ps[:1] in (["ls"], ["ll"], ["la"], ["eza"], ["exa"]):
|
|
168
|
+
w.cd_ls += 1
|
|
169
|
+
prev_cd = ps[:1] == ["cd"]
|
|
170
|
+
if "git" in ps:
|
|
171
|
+
m = re.search(r"\bgit\s+(?:-C\s+\S+\s+)?([a-z][a-z-]*)", line)
|
|
172
|
+
if m:
|
|
173
|
+
git_sub[m.group(1)] += 1
|
|
174
|
+
w.longest = max(w.longest, len(line))
|
|
175
|
+
w.pipes_record = max(w.pipes_record, line.count("|") - 2 * line.count("||"))
|
|
176
|
+
|
|
177
|
+
w.top = progs.most_common(10)
|
|
178
|
+
line_counts = Counter(l for l in lines if len(l) > 2)
|
|
179
|
+
if line_counts:
|
|
180
|
+
top_line, n = line_counts.most_common(1)[0]
|
|
181
|
+
w.top_line = (redact(top_line), n)
|
|
182
|
+
w.git = git_sub.most_common(5)
|
|
183
|
+
cats: Counter[str] = Counter()
|
|
184
|
+
for p, n in progs.items():
|
|
185
|
+
if p in CATEGORIES:
|
|
186
|
+
cats[CATEGORIES[p]] += n
|
|
187
|
+
w.categories = cats.most_common()
|
|
188
|
+
w.typos = [(t, TYPOS[t], n) for t, n in typo_hits.most_common(5)]
|
|
189
|
+
w.typo_total = sum(typo_hits.values())
|
|
190
|
+
|
|
191
|
+
if timed:
|
|
192
|
+
w.has_time = True
|
|
193
|
+
per_day = Counter(c.when.date() for c in timed)
|
|
194
|
+
w.active_days = len(per_day)
|
|
195
|
+
w.busiest_day, w.busiest_day_count = max(per_day.items(), key=lambda kv: (kv[1], kv[0]))
|
|
196
|
+
for c in timed:
|
|
197
|
+
w.by_hour[c.when.hour] += 1
|
|
198
|
+
w.peak_hour = w.by_hour.index(max(w.by_hour))
|
|
199
|
+
w.night_share = sum(w.by_hour[h] for h in (22, 23, 0, 1, 2, 3, 4)) / len(timed)
|
|
200
|
+
w.longest_session_min = longest_session(sorted(c.when for c in timed))
|
|
201
|
+
|
|
202
|
+
w.persona = persona(w)
|
|
203
|
+
return w
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def longest_session(times: list[datetime], gap: timedelta = timedelta(minutes=30)) -> int:
|
|
207
|
+
if not times:
|
|
208
|
+
return 0
|
|
209
|
+
best = 0
|
|
210
|
+
start = prev = times[0]
|
|
211
|
+
for t in times[1:]:
|
|
212
|
+
if t - prev > gap:
|
|
213
|
+
start = t
|
|
214
|
+
prev = t
|
|
215
|
+
best = max(best, int((t - start).total_seconds() // 60))
|
|
216
|
+
return best
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def persona(w: Wrapped) -> tuple[str, str]:
|
|
220
|
+
if w.has_time and w.night_share >= 0.3:
|
|
221
|
+
return ("The Night Shell", f"{w.night_share:.0%} of your commands ran between 10pm and 5am.")
|
|
222
|
+
if w.commands and w.typo_total / w.commands >= 0.02:
|
|
223
|
+
return ("The Speed Typist", f"{w.typo_total} typos. You type faster than you spell.")
|
|
224
|
+
if w.categories:
|
|
225
|
+
total = sum(n for _, n in w.categories)
|
|
226
|
+
cat, n = w.categories[0]
|
|
227
|
+
if cat == "navigation" and len(w.categories) > 1 and n / total < 0.5:
|
|
228
|
+
cat = w.categories[1][0] # everyone cd's; prefer a more telling trait unless it's extreme
|
|
229
|
+
if cat in PERSONAS:
|
|
230
|
+
return PERSONAS[cat]
|
|
231
|
+
return ("The Generalist", "A little bit of everything.")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shellwrapped
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spotify Wrapped for your terminal: your shell history's year in review, with a shareable card
|
|
5
|
+
Project-URL: Homepage, https://github.com/ArkaKhorchidian/shellwrapped
|
|
6
|
+
Project-URL: Issues, https://github.com/ArkaKhorchidian/shellwrapped/issues
|
|
7
|
+
Author: Arka Khorchidian
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: src/shellwrapped/fonts/OFL.txt
|
|
11
|
+
Keywords: bash,cli,fish,fun,history,shell,terminal,wrapped,year-in-review,zsh
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: System :: Shells
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: pillow>=9.2
|
|
21
|
+
Requires-Dist: pyfiglet>=0.8
|
|
22
|
+
Requires-Dist: rich>=13
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<h1 align="center">shellwrapped</h1>
|
|
28
|
+
|
|
29
|
+
<p align="center"><b>Spotify Wrapped for your terminal.</b><br>
|
|
30
|
+
Your shell history's year in review: top commands, git habits, typos, 2am sessions, and your terminal personality. Plus a card to share.</p>
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<a href="https://pypi.org/project/shellwrapped/"><img src="https://img.shields.io/pypi/v/shellwrapped.svg" alt="PyPI"></a>
|
|
34
|
+
<a href="https://github.com/ArkaKhorchidian/shellwrapped/actions/workflows/ci.yml"><img src="https://github.com/ArkaKhorchidian/shellwrapped/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
35
|
+
<img src="https://img.shields.io/pypi/pyversions/shellwrapped.svg" alt="Python">
|
|
36
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uvx shellwrapped # or: pipx run shellwrapped
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<img src="docs/demo.gif" alt="shellwrapped playing an animated year in review in the terminal" width="720">
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
When it's done, you get a card like this saved next to you:
|
|
48
|
+
|
|
49
|
+
<p align="center"><img src="docs/card.png" alt="shellwrapped share card: The Night Shell" width="720"></p>
|
|
50
|
+
|
|
51
|
+
<sub>Demo and card generated from a synthetic year of history.</sub>
|
|
52
|
+
|
|
53
|
+
## What it finds
|
|
54
|
+
|
|
55
|
+
- **Your top commands**, counted properly through pipes, `&&`, `sudo`, `env` and `VAR=value` prefixes
|
|
56
|
+
- **Your git habits**: how many `git status` it takes you to feel safe
|
|
57
|
+
- **Your most-repeated command**: a strong hint it deserves an alias
|
|
58
|
+
- **Your typos**: `gti`, `sl`, `claer`, `pyhton`, `cd..`, and friends
|
|
59
|
+
- **When you work**: your rush hour, your longest session, your busiest day, how much happens after 10pm
|
|
60
|
+
- **Habits**: `sudo !!`, `rm -rf`, `git push --force`, `cd` immediately followed by `ls`
|
|
61
|
+
- **Your terminal personality**: The Night Shell, The Git Whisperer, The Container Captain, The Pythonista, The Speed Typist, ...
|
|
62
|
+
|
|
63
|
+
Works with **zsh, bash, fish and PowerShell** on macOS, Linux and Windows. It finds your history files
|
|
64
|
+
automatically, including zsh's extended format with multi-line commands.
|
|
65
|
+
|
|
66
|
+
## Private by design
|
|
67
|
+
|
|
68
|
+
- Everything runs locally. No network requests.
|
|
69
|
+
- The share card shows only counts and program names (`git`, `docker`), never full commands, paths or arguments.
|
|
70
|
+
- Anything token-shaped is masked before a command is shown in your terminal.
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
shellwrapped --all-time # your whole history instead of this year
|
|
76
|
+
shellwrapped --year 2025
|
|
77
|
+
shellwrapped --no-anim # skip the animation, just the summary
|
|
78
|
+
shellwrapped --file ~/.zsh_history --shell zsh
|
|
79
|
+
shellwrapped --card wrapped.png # where to save the card (or --no-card)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Year-by-year stats need timestamps in your history. zsh's `EXTENDED_HISTORY` and fish record
|
|
83
|
+
them automatically. In bash, add `export HISTTIMEFORMAT='%F %T '` to your `.bashrc`. Without timestamps,
|
|
84
|
+
shellwrapped covers everything your history remembers.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT. Bundles the [Inter](https://github.com/rsms/inter) typeface under the SIL Open Font License.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
shellwrapped/__init__.py,sha256=SSoOXvswayx3ItLi20JKsun8u3NQUW59JNLN5RPRGYQ,82
|
|
2
|
+
shellwrapped/__main__.py,sha256=yQP0sf-rkTO-3MOotv4blvukvNGwbJFz10hQhMsxcJM,28
|
|
3
|
+
shellwrapped/card.py,sha256=ri9X-h1C9lqdeaQdyXAfpuAjScu01JXSPgEzKNdnNcI,3944
|
|
4
|
+
shellwrapped/cli.py,sha256=8KuOSLU953AgCpk09GXEiPKzMhRa44RkSfDZKalVZ0Q,6936
|
|
5
|
+
shellwrapped/history.py,sha256=uJVsNOaLOeJNUJFjaAStWUi4w_yjaoj-EWkuMV9CcSw,4818
|
|
6
|
+
shellwrapped/show.py,sha256=Vn8bh-UW34jBMoeyoOqU6RsVqsmJUBanC7gqcU7B9HQ,5206
|
|
7
|
+
shellwrapped/stats.py,sha256=9lEhYINhDw0m7stS0g_v-LwS8H-Bi9DRIsZyHkZ7yuc,10489
|
|
8
|
+
shellwrapped/fonts/Inter-Bold.otf,sha256=AP1vRpHuiISjO0a_RYUbNBknw3wwBk9VjhvvskKXHEk,626056
|
|
9
|
+
shellwrapped/fonts/Inter-Regular.otf,sha256=vm1wnctzDdrwUMva5v6JvVbV0U3GaIVjm79vBr0DUhs,605092
|
|
10
|
+
shellwrapped/fonts/InterDisplay-Black.otf,sha256=wOu2dpM6Rdc9Fv6nDIdfnytxG1hcabFZYrfyqJC1-Og,617468
|
|
11
|
+
shellwrapped/fonts/OFL.txt,sha256=DxohhtfdVQS2KAiP-zWeMnb-CGS_A5qE4QwLedvy0vo,4163
|
|
12
|
+
shellwrapped-0.1.0.dist-info/METADATA,sha256=XpqUxihJw27i-jCeI_IoB3bGP7lXhdn2it60b4oOmEo,3979
|
|
13
|
+
shellwrapped-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
shellwrapped-0.1.0.dist-info/entry_points.txt,sha256=QsSaEqYCcPcVEfsUBLQLrUE2Q1WqKghqHNjbtiq382Y,54
|
|
15
|
+
shellwrapped-0.1.0.dist-info/licenses/LICENSE,sha256=mxFQpL6plnvuVMkJNW-M1_rxIlAzORcfDhnP1UETuKI,1073
|
|
16
|
+
shellwrapped-0.1.0.dist-info/licenses/src/shellwrapped/fonts/OFL.txt,sha256=DxohhtfdVQS2KAiP-zWeMnb-CGS_A5qE4QwLedvy0vo,4163
|
|
17
|
+
shellwrapped-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arka Khorchidian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Inter font: Copyright (c) 2016-2023 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
|
|
5
|
+
PREAMBLE
|
|
6
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
7
|
+
development of collaborative font projects, to support the font
|
|
8
|
+
creation efforts of academic and linguistic communities, and to
|
|
9
|
+
provide a free and open framework in which fonts may be shared and
|
|
10
|
+
improved in partnership with others.
|
|
11
|
+
|
|
12
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
13
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
14
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
15
|
+
redistributed and/or sold with any software provided that any
|
|
16
|
+
reserved names are not used by derivative works. The fonts and
|
|
17
|
+
derivatives, however, cannot be released under any other type of
|
|
18
|
+
license. The requirement for fonts to remain under this license
|
|
19
|
+
does not apply to any document created using the fonts or their
|
|
20
|
+
derivatives.
|
|
21
|
+
|
|
22
|
+
DEFINITIONS
|
|
23
|
+
Font Software refers to the set of files released by the
|
|
24
|
+
Copyright Holder(s) under this license and clearly marked as such.
|
|
25
|
+
This may include source files, build scripts and documentation.
|
|
26
|
+
|
|
27
|
+
Reserved Font Name refers to any names specified as such after
|
|
28
|
+
the copyright statement(s).
|
|
29
|
+
|
|
30
|
+
Original Version refers to the collection of Font Software
|
|
31
|
+
components as distributed by the Copyright Holder(s).
|
|
32
|
+
|
|
33
|
+
Modified Version refers to any derivative made by adding to,
|
|
34
|
+
deleting, or substituting in part or in whole any of the components
|
|
35
|
+
of the Original Version, by changing formats or by porting the Font
|
|
36
|
+
Software to a new environment.
|
|
37
|
+
|
|
38
|
+
Author refers to any designer, engineer, programmer, technical
|
|
39
|
+
writer or other person who contributed to the Font Software.
|
|
40
|
+
|
|
41
|
+
PERMISSION & CONDITIONS
|
|
42
|
+
Permission is hereby granted, free of charge, to any person
|
|
43
|
+
obtaining a copy of the Font Software, to use, study, copy, merge,
|
|
44
|
+
embed, modify, redistribute, and sell modified and unmodified copies
|
|
45
|
+
of the Font Software, subject to the following conditions:
|
|
46
|
+
|
|
47
|
+
1) Neither the Font Software nor any of its individual components,
|
|
48
|
+
in Original or Modified Versions, may be sold by itself.
|
|
49
|
+
|
|
50
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
51
|
+
redistributed and/or sold with any software, provided that each
|
|
52
|
+
copy contains the above copyright notice and this license. These
|
|
53
|
+
can be included either as stand-alone text files, human-readable
|
|
54
|
+
headers or in the appropriate machine-readable metadata fields
|
|
55
|
+
within text or binary files as long as those fields can be easily
|
|
56
|
+
viewed by the user.
|
|
57
|
+
|
|
58
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
59
|
+
Name(s) unless explicit written permission is granted by the
|
|
60
|
+
corresponding Copyright Holder. This restriction only applies to
|
|
61
|
+
the primary font name as presented to the users.
|
|
62
|
+
|
|
63
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the
|
|
64
|
+
Font Software shall not be used to promote, endorse or advertise
|
|
65
|
+
any Modified Version, except to acknowledge the contribution(s)
|
|
66
|
+
of the Copyright Holder(s) and the Author(s) or with their
|
|
67
|
+
explicit written permission.
|
|
68
|
+
|
|
69
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
70
|
+
must be distributed entirely under this license, and must not be
|
|
71
|
+
distributed under any other license. The requirement for fonts to
|
|
72
|
+
remain under this license does not apply to any document created
|
|
73
|
+
using the Font Software.
|
|
74
|
+
|
|
75
|
+
TERMINATION
|
|
76
|
+
This license becomes null and void if any of the above conditions
|
|
77
|
+
are not met.
|
|
78
|
+
|
|
79
|
+
DISCLAIMER
|
|
80
|
+
THE FONT SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND,
|
|
81
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
82
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
83
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
|
84
|
+
THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
85
|
+
LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR
|
|
86
|
+
CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
87
|
+
OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT
|
|
88
|
+
SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|