claude-code-wrapped 0.1.2

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,604 @@
1
+ """Rich-based terminal UI for Claude Code Wrapped."""
2
+
3
+ import sys
4
+ import time
5
+ from datetime import datetime, timedelta
6
+
7
+ from rich.align import Align
8
+ from rich.console import Console, Group
9
+ from rich.live import Live
10
+ from rich.panel import Panel
11
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
12
+ from rich.style import Style
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from .stats import WrappedStats, format_tokens
17
+
18
+ # Minimal color palette
19
+ COLORS = {
20
+ "orange": "#E67E22",
21
+ "purple": "#9B59B6",
22
+ "blue": "#3498DB",
23
+ "green": "#27AE60",
24
+ "white": "#ECF0F1",
25
+ "gray": "#7F8C8D",
26
+ "dark": "#2C3E50",
27
+ }
28
+
29
+ # GitHub-style contribution colors (light to dark green)
30
+ CONTRIB_COLORS = ["#161B22", "#0E4429", "#006D32", "#26A641", "#39D353"]
31
+
32
+
33
+ def wait_for_keypress():
34
+ """Wait for user to press Enter or Space."""
35
+ try:
36
+ import termios
37
+ import tty
38
+ fd = sys.stdin.fileno()
39
+ old_settings = termios.tcgetattr(fd)
40
+ try:
41
+ tty.setraw(fd)
42
+ ch = sys.stdin.read(1)
43
+ # Also handle escape sequences for special keys
44
+ if ch == '\x1b':
45
+ sys.stdin.read(2) # consume rest of escape sequence
46
+ return ch
47
+ finally:
48
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
49
+ except (ImportError, AttributeError, OSError):
50
+ # Fallback for non-Unix systems (Windows) or piped input
51
+ input()
52
+ return '\n'
53
+
54
+
55
+ def create_dramatic_stat(value: str, label: str, subtitle: str = "", color: str = COLORS["orange"]) -> Text:
56
+ """Create a dramatic full-screen stat reveal."""
57
+ text = Text()
58
+ text.append("\n\n\n\n\n")
59
+ text.append(f"{value}\n", style=Style(color=color, bold=True))
60
+ text.append(f"{label}\n\n", style=Style(color=COLORS["white"], bold=True))
61
+ if subtitle:
62
+ text.append(subtitle, style=Style(color=COLORS["gray"]))
63
+ text.append("\n\n\n\n")
64
+ text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"]))
65
+ return text
66
+
67
+
68
+ def create_title_slide(year: int) -> Text:
69
+ """Create the opening title."""
70
+ title = Text()
71
+ title.append("\n\n\n")
72
+ title.append(" ░█████╗░██╗░░░░░░█████╗░██╗░░░██╗██████╗░███████╗\n", style=COLORS["orange"])
73
+ title.append(" ██╔══██╗██║░░░░░██╔══██╗██║░░░██║██╔══██╗██╔════╝\n", style=COLORS["orange"])
74
+ title.append(" ██║░░╚═╝██║░░░░░███████║██║░░░██║██║░░██║█████╗░░\n", style=COLORS["orange"])
75
+ title.append(" ██║░░██╗██║░░░░░██╔══██║██║░░░██║██║░░██║██╔══╝░░\n", style=COLORS["orange"])
76
+ title.append(" ╚█████╔╝███████╗██║░░██║╚██████╔╝██████╔╝███████╗\n", style=COLORS["orange"])
77
+ title.append(" ░╚════╝░╚══════╝╚═╝░░╚═╝░╚═════╝░╚═════╝░╚══════╝\n", style=COLORS["orange"])
78
+ title.append("\n")
79
+ title.append(" C O D E W R A P P E D\n", style=Style(color=COLORS["white"], bold=True))
80
+ title.append(f" {year}\n\n", style=Style(color=COLORS["purple"], bold=True))
81
+ title.append(" by ", style=Style(color=COLORS["gray"]))
82
+ title.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
83
+ title.append("\n\n\n")
84
+ title.append(" press [ENTER] to begin", style=Style(color=COLORS["dark"]))
85
+ title.append("\n\n")
86
+ return title
87
+
88
+
89
+ def create_big_stat(value: str, label: str, color: str = COLORS["orange"]) -> Text:
90
+ """Create a big statistic display."""
91
+ text = Text()
92
+ text.append(f"{value}\n", style=Style(color=color, bold=True))
93
+ text.append(label, style=Style(color=COLORS["gray"]))
94
+ return text
95
+
96
+
97
+ def create_contribution_graph(daily_stats: dict, year: int) -> Panel:
98
+ """Create a GitHub-style contribution graph."""
99
+ if not daily_stats:
100
+ return Panel("No activity data", title="Activity", border_style=COLORS["gray"])
101
+
102
+ dates = sorted(daily_stats.keys())
103
+ start_date = datetime.strptime(dates[0], "%Y-%m-%d")
104
+ end_date = datetime.strptime(dates[-1], "%Y-%m-%d")
105
+
106
+ max_count = max(s.message_count for s in daily_stats.values()) if daily_stats else 1
107
+
108
+ weeks = []
109
+ current = start_date - timedelta(days=start_date.weekday())
110
+
111
+ while current <= end_date + timedelta(days=7):
112
+ week = []
113
+ for day in range(7):
114
+ date = current + timedelta(days=day)
115
+ date_str = date.strftime("%Y-%m-%d")
116
+ if date_str in daily_stats:
117
+ count = daily_stats[date_str].message_count
118
+ level = min(4, 1 + int((count / max_count) * 3)) if count > 0 else 0
119
+ else:
120
+ level = 0
121
+ week.append(level)
122
+ weeks.append(week)
123
+ current += timedelta(days=7)
124
+
125
+ graph = Text()
126
+ days_labels = ["Mon", " ", "Wed", " ", "Fri", " ", " "]
127
+
128
+ for row in range(7):
129
+ graph.append(f"{days_labels[row]} ", style=Style(color=COLORS["gray"]))
130
+ for week in weeks:
131
+ color = CONTRIB_COLORS[week[row]]
132
+ graph.append("■ ", style=Style(color=color))
133
+ graph.append("\n")
134
+
135
+ legend = Text()
136
+ legend.append("\n Less ", style=Style(color=COLORS["gray"]))
137
+ for color in CONTRIB_COLORS:
138
+ legend.append("■ ", style=Style(color=color))
139
+ legend.append("More", style=Style(color=COLORS["gray"]))
140
+
141
+ content = Group(graph, Align.center(legend))
142
+
143
+ return Panel(
144
+ Align.center(content),
145
+ title=f"Activity · {len([d for d in daily_stats.values() if d.message_count > 0])} active days",
146
+ border_style=Style(color=COLORS["green"]),
147
+ padding=(0, 2),
148
+ )
149
+
150
+
151
+ def create_hour_chart(distribution: list[int]) -> Panel:
152
+ """Create a clean hourly distribution chart."""
153
+ max_val = max(distribution) if any(distribution) else 1
154
+ chars = "▁▂▃▄▅▆▇█"
155
+
156
+ content = Text()
157
+ for i, val in enumerate(distribution):
158
+ idx = int((val / max_val) * (len(chars) - 1)) if max_val > 0 else 0
159
+ if 6 <= i < 12:
160
+ color = COLORS["orange"]
161
+ elif 12 <= i < 18:
162
+ color = COLORS["blue"]
163
+ elif 18 <= i < 22:
164
+ color = COLORS["purple"]
165
+ else:
166
+ color = COLORS["gray"]
167
+ content.append(chars[idx], style=Style(color=color))
168
+
169
+ content.append("\n")
170
+ content.append("0 6 12 18 24", style=Style(color=COLORS["gray"]))
171
+
172
+ return Panel(
173
+ Align.center(content),
174
+ title="Hours",
175
+ border_style=Style(color=COLORS["purple"]),
176
+ padding=(0, 1),
177
+ )
178
+
179
+
180
+ def create_weekday_chart(distribution: list[int]) -> Panel:
181
+ """Create a clean weekday distribution chart."""
182
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
183
+ max_val = max(distribution) if any(distribution) else 1
184
+
185
+ content = Text()
186
+ for i, (day, count) in enumerate(zip(days, distribution)):
187
+ bar_len = int((count / max_val) * 12) if max_val > 0 else 0
188
+ bar = "█" * bar_len + "░" * (12 - bar_len)
189
+ content.append(f"{day} ", style=Style(color=COLORS["gray"]))
190
+ content.append(bar, style=Style(color=COLORS["blue"]))
191
+ content.append(f" {count:,}\n", style=Style(color=COLORS["gray"]))
192
+
193
+ return Panel(
194
+ content,
195
+ title="Days",
196
+ border_style=Style(color=COLORS["blue"]),
197
+ padding=(0, 1),
198
+ )
199
+
200
+
201
+ def create_top_list(items: list[tuple[str, int]], title: str, color: str) -> Panel:
202
+ """Create a clean top items list."""
203
+ content = Text()
204
+ max_val = max(v for _, v in items) if items else 1
205
+
206
+ for i, (name, count) in enumerate(items[:5], 1):
207
+ content.append(f"{i}. ", style=Style(color=COLORS["gray"]))
208
+ content.append(f"{name[:12]:<12} ", style=Style(color=COLORS["white"]))
209
+ bar_len = int((count / max_val) * 8)
210
+ content.append("▓" * bar_len, style=Style(color=color))
211
+ content.append("░" * (8 - bar_len), style=Style(color=COLORS["dark"]))
212
+ content.append(f" {count:,}\n", style=Style(color=COLORS["gray"]))
213
+
214
+ return Panel(
215
+ content,
216
+ title=title,
217
+ border_style=Style(color=color),
218
+ padding=(0, 1),
219
+ )
220
+
221
+
222
+ def create_personality_card(stats: WrappedStats) -> Panel:
223
+ """Create the personality card."""
224
+ personality = determine_personality(stats)
225
+
226
+ content = Text()
227
+ content.append(f"\n {personality['emoji']} ", style=Style(bold=True))
228
+ content.append(f"{personality['title']}\n\n", style=Style(color=COLORS["purple"], bold=True))
229
+ content.append(f" {personality['description']}\n", style=Style(color=COLORS["gray"]))
230
+
231
+ return Panel(
232
+ content,
233
+ title="Your Type",
234
+ border_style=Style(color=COLORS["purple"]),
235
+ padding=(0, 1),
236
+ )
237
+
238
+
239
+ def determine_personality(stats: WrappedStats) -> dict:
240
+ """Determine user's coding personality based on stats."""
241
+ night_hours = sum(stats.hourly_distribution[22:]) + sum(stats.hourly_distribution[:6])
242
+ day_hours = sum(stats.hourly_distribution[6:22])
243
+ top_tool = stats.top_tools[0][0] if stats.top_tools else None
244
+ weekend_msgs = stats.weekday_distribution[5] + stats.weekday_distribution[6]
245
+ weekday_msgs = sum(stats.weekday_distribution[:5])
246
+
247
+ if night_hours > day_hours * 0.4:
248
+ return {"emoji": "🦉", "title": "Night Owl", "description": "The quiet hours are your sanctuary."}
249
+ elif stats.streak_longest >= 14:
250
+ return {"emoji": "🔥", "title": "Streak Master", "description": f"{stats.streak_longest} days. Unstoppable."}
251
+ elif top_tool == "Edit":
252
+ return {"emoji": "🎨", "title": "The Refactorer", "description": "You see beauty in clean code."}
253
+ elif top_tool == "Bash":
254
+ return {"emoji": "⚡", "title": "Terminal Warrior", "description": "Command line is your domain."}
255
+ elif stats.total_projects >= 5:
256
+ return {"emoji": "🚀", "title": "Empire Builder", "description": f"{stats.total_projects} projects. Legend."}
257
+ elif weekend_msgs > weekday_msgs * 0.5:
258
+ return {"emoji": "🌙", "title": "Weekend Warrior", "description": "Passion fuels your weekends."}
259
+ elif stats.models_used.get("Opus", 0) > stats.models_used.get("Sonnet", 0):
260
+ return {"emoji": "🎯", "title": "Perfectionist", "description": "Only the best will do."}
261
+ else:
262
+ return {"emoji": "💻", "title": "Dedicated Dev", "description": "Steady and reliable."}
263
+
264
+
265
+ def get_fun_facts(stats: WrappedStats) -> list[tuple[str, str]]:
266
+ """Generate fun facts / bloopers based on stats."""
267
+ facts = []
268
+
269
+ # Late night coding
270
+ late_night = sum(stats.hourly_distribution[0:5])
271
+ if late_night > 100:
272
+ facts.append(("🌙", f"You coded after midnight {late_night:,} times. Sleep is overrated."))
273
+
274
+ # Most active day insight
275
+ if stats.most_active_day:
276
+ day_name = stats.most_active_day[0].strftime("%A")
277
+ facts.append(("📅", f"Your biggest day was a {day_name}. {stats.most_active_day[1]:,} messages. Epic."))
278
+
279
+ # Tool obsession
280
+ if stats.top_tools:
281
+ top_tool, count = stats.top_tools[0]
282
+ facts.append(("🔧", f"You used {top_tool} {count:,} times. It's basically muscle memory now."))
283
+
284
+ # If they use Opus a lot
285
+ opus_count = stats.models_used.get("Opus", 0)
286
+ if opus_count > 1000:
287
+ facts.append(("🎭", f"You summoned Opus {opus_count:,} times. Only the best for you."))
288
+
289
+ # Streak fact
290
+ if stats.streak_longest >= 7:
291
+ facts.append(("🔥", f"Your {stats.streak_longest}-day streak was legendary. Consistency wins."))
292
+
293
+ # Multi-project
294
+ if stats.total_projects >= 3:
295
+ facts.append(("🏗️", f"You juggled {stats.total_projects} projects. Multitasking champion."))
296
+
297
+ # Token usage perspective
298
+ if stats.total_tokens > 1_000_000_000:
299
+ books = stats.total_tokens // 100_000 # ~100k tokens per book
300
+ facts.append(("📚", f"You processed enough tokens for ~{books:,} books. Wow."))
301
+
302
+ # Weekend warrior
303
+ weekend = stats.weekday_distribution[5] + stats.weekday_distribution[6]
304
+ if weekend > 1000:
305
+ facts.append(("🏖️", f"Even weekends weren't safe. {weekend:,} weekend messages."))
306
+
307
+ return facts[:5] # Limit to 5 facts
308
+
309
+
310
+ def create_fun_facts_slide(facts: list[tuple[str, str]]) -> Text:
311
+ """Create a fun facts slide."""
312
+ text = Text()
313
+ text.append("\n\n")
314
+ text.append(" B L O O P E R S & F U N F A C T S\n\n", style=Style(color=COLORS["purple"], bold=True))
315
+
316
+ for emoji, fact in facts:
317
+ text.append(f" {emoji} ", style=Style(bold=True))
318
+ text.append(f"{fact}\n\n", style=Style(color=COLORS["white"]))
319
+
320
+ text.append("\n")
321
+ text.append(" press [ENTER] for credits", style=Style(color=COLORS["dark"]))
322
+ text.append("\n")
323
+ return text
324
+
325
+
326
+ def simplify_model_name(model: str) -> str:
327
+ """Simplify a full model ID to a display name."""
328
+ model_lower = model.lower()
329
+ if 'opus-4-5' in model_lower or 'opus-4.5' in model_lower:
330
+ return 'Opus 4.5'
331
+ elif 'opus-4-1' in model_lower or 'opus-4.1' in model_lower:
332
+ return 'Opus 4.1'
333
+ elif 'opus' in model_lower:
334
+ return 'Opus'
335
+ elif 'sonnet-4-5' in model_lower or 'sonnet-4.5' in model_lower:
336
+ return 'Sonnet 4.5'
337
+ elif 'sonnet' in model_lower:
338
+ return 'Sonnet'
339
+ elif 'haiku-4-5' in model_lower or 'haiku-4.5' in model_lower:
340
+ return 'Haiku 4.5'
341
+ elif 'haiku' in model_lower:
342
+ return 'Haiku'
343
+ return model
344
+
345
+
346
+ def create_credits_roll(stats: WrappedStats) -> list[Text]:
347
+ """Create end credits content."""
348
+ from .pricing import format_cost
349
+
350
+ frames = []
351
+
352
+ # Aggregate costs by simplified model name for display
353
+ display_costs: dict[str, float] = {}
354
+ for model, cost in stats.cost_by_model.items():
355
+ display_name = simplify_model_name(model)
356
+ display_costs[display_name] = display_costs.get(display_name, 0) + cost
357
+
358
+ # Frame 1: The Numbers (cost + tokens)
359
+ numbers = Text()
360
+ numbers.append("\n\n\n")
361
+ numbers.append(" T H E N U M B E R S\n\n", style=Style(color=COLORS["green"], bold=True))
362
+ if stats.estimated_cost is not None:
363
+ numbers.append(f" Estimated Cost ", style=Style(color=COLORS["white"], bold=True))
364
+ numbers.append(f"{format_cost(stats.estimated_cost)}\n", style=Style(color=COLORS["green"], bold=True))
365
+ for model, cost in sorted(display_costs.items(), key=lambda x: -x[1]):
366
+ numbers.append(f" {model}: {format_cost(cost)}\n", style=Style(color=COLORS["gray"]))
367
+ numbers.append(f"\n Tokens ", style=Style(color=COLORS["white"], bold=True))
368
+ numbers.append(f"{format_tokens(stats.total_tokens)}\n", style=Style(color=COLORS["orange"], bold=True))
369
+ numbers.append(f" Input: {format_tokens(stats.total_input_tokens)}\n", style=Style(color=COLORS["gray"]))
370
+ numbers.append(f" Output: {format_tokens(stats.total_output_tokens)}\n", style=Style(color=COLORS["gray"]))
371
+ numbers.append("\n\n")
372
+ numbers.append(" [ENTER]", style=Style(color=COLORS["dark"]))
373
+ frames.append(numbers)
374
+
375
+ # Frame 2: Timeline
376
+ timeline = Text()
377
+ timeline.append("\n\n\n")
378
+ timeline.append(" T I M E L I N E\n\n", style=Style(color=COLORS["orange"], bold=True))
379
+ if stats.first_message_date:
380
+ timeline.append(" First message ", style=Style(color=COLORS["white"], bold=True))
381
+ timeline.append(f"{stats.first_message_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
382
+ if stats.last_message_date:
383
+ timeline.append(" Last message ", style=Style(color=COLORS["white"], bold=True))
384
+ timeline.append(f"{stats.last_message_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
385
+ timeline.append(f"\n Active days ", style=Style(color=COLORS["white"], bold=True))
386
+ timeline.append(f"{stats.active_days}\n", style=Style(color=COLORS["orange"], bold=True))
387
+ if stats.most_active_hour is not None:
388
+ hour_label = "AM" if stats.most_active_hour < 12 else "PM"
389
+ hour_12 = stats.most_active_hour % 12 or 12
390
+ timeline.append(f" Peak hour ", style=Style(color=COLORS["white"], bold=True))
391
+ timeline.append(f"{hour_12}:00 {hour_label}\n", style=Style(color=COLORS["purple"], bold=True))
392
+ timeline.append("\n\n")
393
+ timeline.append(" [ENTER]", style=Style(color=COLORS["dark"]))
394
+ frames.append(timeline)
395
+
396
+ # Frame 3: Cast (models)
397
+ cast = Text()
398
+ cast.append("\n\n\n")
399
+ cast.append(" S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True))
400
+ for model, count in stats.models_used.most_common(3):
401
+ cast.append(f" Claude {model}", style=Style(color=COLORS["white"], bold=True))
402
+ cast.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"]))
403
+ cast.append("\n\n\n")
404
+ cast.append(" [ENTER]", style=Style(color=COLORS["dark"]))
405
+ frames.append(cast)
406
+
407
+ # Frame 4: Projects
408
+ if stats.top_projects:
409
+ projects = Text()
410
+ projects.append("\n\n\n")
411
+ projects.append(" P R O J E C T S\n\n", style=Style(color=COLORS["blue"], bold=True))
412
+ for proj, count in stats.top_projects[:5]:
413
+ projects.append(f" {proj}", style=Style(color=COLORS["white"], bold=True))
414
+ projects.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"]))
415
+ projects.append("\n\n\n")
416
+ projects.append(" [ENTER]", style=Style(color=COLORS["dark"]))
417
+ frames.append(projects)
418
+
419
+ # Frame 5: Final card
420
+ final = Text()
421
+ final.append("\n\n\n\n")
422
+ final.append(" See you in ", style=Style(color=COLORS["gray"]))
423
+ final.append(f"{stats.year + 1}", style=Style(color=COLORS["orange"], bold=True))
424
+ final.append("\n\n\n\n", style=Style(color=COLORS["gray"]))
425
+ final.append(" ", style=Style())
426
+ final.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
427
+ final.append(" presents\n\n", style=Style(color=COLORS["gray"]))
428
+ final.append(" [ENTER] to exit", style=Style(color=COLORS["dark"]))
429
+ frames.append(final)
430
+
431
+ return frames
432
+
433
+
434
+ def render_wrapped(stats: WrappedStats, console: Console | None = None, animate: bool = True):
435
+ """Render the complete wrapped experience."""
436
+ if console is None:
437
+ console = Console()
438
+
439
+ # === CINEMATIC MODE ===
440
+ if animate:
441
+ # Loading
442
+ console.clear()
443
+ with Progress(
444
+ SpinnerColumn(style=COLORS["orange"]),
445
+ TextColumn("[bold]Unwrapping your year...[/bold]"),
446
+ BarColumn(complete_style=COLORS["orange"], finished_style=COLORS["green"]),
447
+ console=console,
448
+ transient=True,
449
+ ) as progress:
450
+ task = progress.add_task("", total=100)
451
+ for _ in range(100):
452
+ time.sleep(0.012)
453
+ progress.update(task, advance=1)
454
+
455
+ console.clear()
456
+
457
+ # Title slide - wait for keypress
458
+ console.print(Align.center(create_title_slide(stats.year)))
459
+ wait_for_keypress()
460
+ console.clear()
461
+
462
+ # Dramatic stat reveals
463
+ slides = [
464
+ (f"{stats.total_messages:,}", "MESSAGES", "conversations with Claude", COLORS["orange"]),
465
+ (str(stats.total_sessions), "SESSIONS", "coding adventures", COLORS["purple"]),
466
+ (format_tokens(stats.total_tokens), "TOKENS", "processed through the AI", COLORS["green"]),
467
+ (f"{stats.streak_longest}", "DAY STREAK", "your longest run", COLORS["blue"]),
468
+ ]
469
+
470
+ for value, label, subtitle, color in slides:
471
+ console.print(Align.center(create_dramatic_stat(value, label, subtitle, color)))
472
+ wait_for_keypress()
473
+ console.clear()
474
+
475
+ # Personality reveal
476
+ personality = determine_personality(stats)
477
+ personality_text = Text()
478
+ personality_text.append("\n\n\n\n")
479
+ personality_text.append(f" {personality['emoji']}\n\n", style=Style(bold=True))
480
+ personality_text.append(f" You are\n", style=Style(color=COLORS["gray"]))
481
+ personality_text.append(f" {personality['title']}\n\n", style=Style(color=COLORS["purple"], bold=True))
482
+ personality_text.append(f" {personality['description']}\n\n\n", style=Style(color=COLORS["white"]))
483
+ personality_text.append(" [ENTER]", style=Style(color=COLORS["dark"]))
484
+ console.print(Align.center(personality_text))
485
+ wait_for_keypress()
486
+ console.clear()
487
+
488
+ # === DASHBOARD VIEW ===
489
+ console.print()
490
+
491
+ # Header
492
+ header = Text()
493
+ header.append("═" * 60 + "\n", style=Style(color=COLORS["orange"]))
494
+ header.append(" CLAUDE CODE WRAPPED ", style=Style(color=COLORS["white"], bold=True))
495
+ header.append(str(stats.year), style=Style(color=COLORS["orange"], bold=True))
496
+ header.append("\n" + "═" * 60, style=Style(color=COLORS["orange"]))
497
+ console.print(Align.center(header))
498
+ console.print()
499
+
500
+ # Big stats row
501
+ stats_table = Table(show_header=False, box=None, padding=(0, 3), expand=True)
502
+ stats_table.add_column(justify="center")
503
+ stats_table.add_column(justify="center")
504
+ stats_table.add_column(justify="center")
505
+ stats_table.add_column(justify="center")
506
+
507
+ stats_table.add_row(
508
+ create_big_stat(f"{stats.total_messages:,}", "messages", COLORS["orange"]),
509
+ create_big_stat(str(stats.total_sessions), "sessions", COLORS["purple"]),
510
+ create_big_stat(format_tokens(stats.total_tokens), "tokens", COLORS["green"]),
511
+ create_big_stat(f"{stats.streak_longest}d", "best streak", COLORS["blue"]),
512
+ )
513
+ console.print(Align.center(stats_table))
514
+ console.print()
515
+
516
+ # Contribution graph
517
+ console.print(create_contribution_graph(stats.daily_stats, stats.year))
518
+
519
+ # Charts row
520
+ charts = Table(show_header=False, box=None, padding=(0, 1), expand=True)
521
+ charts.add_column(ratio=1)
522
+ charts.add_column(ratio=2)
523
+ charts.add_row(
524
+ create_personality_card(stats),
525
+ create_weekday_chart(stats.weekday_distribution),
526
+ )
527
+ console.print(charts)
528
+
529
+ # Hour chart
530
+ console.print(create_hour_chart(stats.hourly_distribution))
531
+
532
+ # Top lists
533
+ lists = Table(show_header=False, box=None, padding=(0, 1), expand=True)
534
+ lists.add_column(ratio=1)
535
+ lists.add_column(ratio=1)
536
+ lists.add_row(
537
+ create_top_list(stats.top_tools[:5], "Top Tools", COLORS["orange"]),
538
+ create_top_list(stats.top_projects, "Top Projects", COLORS["green"]),
539
+ )
540
+ console.print(lists)
541
+
542
+ # Insights
543
+ insights = Text()
544
+ if stats.most_active_day:
545
+ insights.append(" Peak day: ", style=Style(color=COLORS["gray"]))
546
+ insights.append(f"{stats.most_active_day[0].strftime('%b %d')}", style=Style(color=COLORS["orange"], bold=True))
547
+ insights.append(f" ({stats.most_active_day[1]:,} msgs)", style=Style(color=COLORS["gray"]))
548
+ if stats.most_active_hour is not None:
549
+ insights.append(" • Peak hour: ", style=Style(color=COLORS["gray"]))
550
+ insights.append(f"{stats.most_active_hour}:00", style=Style(color=COLORS["purple"], bold=True))
551
+ if stats.primary_model:
552
+ insights.append(" • Favorite: ", style=Style(color=COLORS["gray"]))
553
+ insights.append(f"Claude {stats.primary_model}", style=Style(color=COLORS["blue"], bold=True))
554
+
555
+ console.print()
556
+ console.print(Align.center(insights))
557
+
558
+ # === CREDITS SEQUENCE ===
559
+ if animate:
560
+ console.print()
561
+ continue_text = Text()
562
+ continue_text.append("\n press [ENTER] for fun facts & credits", style=Style(color=COLORS["dark"]))
563
+ console.print(Align.center(continue_text))
564
+ wait_for_keypress()
565
+ console.clear()
566
+
567
+ # Fun facts
568
+ facts = get_fun_facts(stats)
569
+ if facts:
570
+ console.print(Align.center(create_fun_facts_slide(facts)))
571
+ wait_for_keypress()
572
+ console.clear()
573
+
574
+ # Credits roll
575
+ for frame in create_credits_roll(stats):
576
+ console.print(Align.center(frame))
577
+ wait_for_keypress()
578
+ console.clear()
579
+
580
+ # Final footer
581
+ console.print()
582
+ footer = Text()
583
+ footer.append("─" * 60 + "\n\n", style=Style(color=COLORS["dark"]))
584
+ footer.append("Thanks for building with Claude ", style=Style(color=COLORS["gray"]))
585
+ footer.append("✨\n\n", style=Style(color=COLORS["orange"]))
586
+ footer.append("Built by ", style=Style(color=COLORS["gray"]))
587
+ footer.append("Mert Deveci", style=Style(color=COLORS["white"], bold=True, link="https://x.com/gm_mertd"))
588
+ footer.append(" · ", style=Style(color=COLORS["dark"]))
589
+ footer.append("@gm_mertd", style=Style(color=COLORS["blue"], link="https://x.com/gm_mertd"))
590
+ footer.append(" · ", style=Style(color=COLORS["dark"]))
591
+ footer.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
592
+ footer.append("\n")
593
+ console.print(Align.center(footer))
594
+
595
+
596
+ if __name__ == "__main__":
597
+ from .reader import load_all_messages
598
+ from .stats import aggregate_stats
599
+
600
+ print("Loading data...")
601
+ messages = load_all_messages(year=2025)
602
+ stats = aggregate_stats(messages, 2025)
603
+
604
+ render_wrapped(stats)
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "claude-code-wrapped",
3
+ "version": "0.1.2",
4
+ "description": "Your year with Claude Code - Spotify Wrapped style terminal experience",
5
+ "bin": {
6
+ "claude-code-wrapped": "./bin/cli.js"
7
+ },
8
+ "scripts": {
9
+ "start": "node bin/cli.js"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/The-Money-Company-Limited/claudecodewrapped.git"
14
+ },
15
+ "keywords": [
16
+ "claude",
17
+ "anthropic",
18
+ "cli",
19
+ "wrapped",
20
+ "stats",
21
+ "terminal",
22
+ "claude-code"
23
+ ],
24
+ "author": "Mert Deveci",
25
+ "license": "MIT",
26
+ "bugs": {
27
+ "url": "https://github.com/The-Money-Company-Limited/claudecodewrapped/issues"
28
+ },
29
+ "homepage": "https://github.com/The-Money-Company-Limited/claudecodewrapped#readme",
30
+ "engines": {
31
+ "node": ">=16.0.0"
32
+ }
33
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "claude-code-wrapped"
3
+ version = "0.1.2"
4
+ description = "Your year with Claude Code - Spotify Wrapped style terminal experience"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "Mert Deveci" }
10
+ ]
11
+ keywords = ["claude", "anthropic", "cli", "wrapped", "stats", "terminal"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+ dependencies = [
23
+ "rich>=13.0.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/The-Money-Company-Limited/claudecodewrapped"
28
+ Repository = "https://github.com/The-Money-Company-Limited/claudecodewrapped"
29
+
30
+ [project.scripts]
31
+ claude-code-wrapped = "claude_code_wrapped.main:main"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"