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.
@@ -0,0 +1,2 @@
1
+ """shellwrapped: Spotify Wrapped for your shell history."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import run
2
+
3
+ run()
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
@@ -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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shellwrapped = shellwrapped.cli:run
@@ -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.