@da-trollefsen/claude-wrapped 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +42 -0
- package/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +397 -0
- package/bin/cli.js +62 -0
- package/claude_code_wrapped/__init__.py +3 -0
- package/claude_code_wrapped/__main__.py +6 -0
- package/claude_code_wrapped/exporters/__init__.py +6 -0
- package/claude_code_wrapped/exporters/html_exporter.py +1032 -0
- package/claude_code_wrapped/exporters/markdown_exporter.py +462 -0
- package/claude_code_wrapped/interactive.py +166 -0
- package/claude_code_wrapped/main.py +203 -0
- package/claude_code_wrapped/pricing.py +179 -0
- package/claude_code_wrapped/reader.py +382 -0
- package/claude_code_wrapped/stats.py +520 -0
- package/claude_code_wrapped/ui.py +782 -0
- package/package.json +39 -0
- package/pyproject.toml +42 -0
- package/uv.lock +105 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Markdown export for Claude Code Wrapped."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..stats import WrappedStats, format_tokens
|
|
7
|
+
from ..pricing import format_cost
|
|
8
|
+
from ..ui import CONTRIB_COLORS, determine_personality, get_fun_facts, simplify_model_name, format_year_display
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def export_to_markdown(stats: WrappedStats, year: int | None, output_path: Path) -> None:
|
|
12
|
+
"""Export wrapped stats to a nicely formatted Markdown file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
stats: Wrapped statistics dataclass
|
|
16
|
+
year: Year being wrapped
|
|
17
|
+
output_path: Path to output Markdown file
|
|
18
|
+
"""
|
|
19
|
+
personality = determine_personality(stats)
|
|
20
|
+
fun_facts = get_fun_facts(stats)
|
|
21
|
+
|
|
22
|
+
# Calculate date range
|
|
23
|
+
if year is None:
|
|
24
|
+
# All-time: use first and last message dates
|
|
25
|
+
start_date = stats.first_message_date or datetime.now()
|
|
26
|
+
end_date = stats.last_message_date or datetime.now()
|
|
27
|
+
else:
|
|
28
|
+
start_date = datetime(year, 1, 1)
|
|
29
|
+
today = datetime.now()
|
|
30
|
+
end_date = today if year == today.year else datetime(year, 12, 31)
|
|
31
|
+
|
|
32
|
+
# Build Markdown
|
|
33
|
+
markdown = _build_markdown_document(stats, year, personality, fun_facts, start_date, end_date)
|
|
34
|
+
|
|
35
|
+
# Write to file
|
|
36
|
+
output_path.write_text(markdown, encoding='utf-8')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_markdown_document(stats: WrappedStats, year: int | None, personality: dict,
|
|
40
|
+
fun_facts: list, start_date: datetime, end_date: datetime) -> str:
|
|
41
|
+
"""Build the complete Markdown document."""
|
|
42
|
+
|
|
43
|
+
sections = [
|
|
44
|
+
_build_title_section(year),
|
|
45
|
+
_build_dramatic_reveals(stats, start_date, end_date),
|
|
46
|
+
_build_dashboard(stats, year, personality),
|
|
47
|
+
_build_contribution_graph(stats.daily_stats, year),
|
|
48
|
+
_build_charts(stats),
|
|
49
|
+
_build_tools_and_projects(stats),
|
|
50
|
+
_build_mcp_section(stats),
|
|
51
|
+
_build_monthly_costs(stats),
|
|
52
|
+
_build_fun_facts_section(fun_facts),
|
|
53
|
+
_build_credits(stats, year),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
return "\n\n".join(filter(None, sections))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_title_section(year: int | None) -> str:
|
|
60
|
+
"""Build the title section."""
|
|
61
|
+
year_display = format_year_display(year)
|
|
62
|
+
return f"""# ๐ฌ CLAUDE CODE WRAPPED
|
|
63
|
+
## Your {year_display}
|
|
64
|
+
|
|
65
|
+
*A year in review ยท Generated {datetime.now().strftime('%B %d, %Y')}*"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_dramatic_reveals(stats: WrappedStats, start_date: datetime, end_date: datetime) -> str:
|
|
69
|
+
"""Build the dramatic reveal sections."""
|
|
70
|
+
date_range = f"{start_date.strftime('%B %d')} - {end_date.strftime('%B %d, %Y')}"
|
|
71
|
+
|
|
72
|
+
sections = [
|
|
73
|
+
f"""---
|
|
74
|
+
|
|
75
|
+
## ๐ Total Messages
|
|
76
|
+
|
|
77
|
+
**{stats.total_messages:,}** messages
|
|
78
|
+
|
|
79
|
+
*{date_range}*""",
|
|
80
|
+
|
|
81
|
+
f"""---
|
|
82
|
+
|
|
83
|
+
## ๐ Your Averages
|
|
84
|
+
|
|
85
|
+
### Messages
|
|
86
|
+
- **{stats.avg_messages_per_day:.1f}** per day
|
|
87
|
+
- **{stats.avg_messages_per_week:.1f}** per week
|
|
88
|
+
- **{stats.avg_messages_per_month:.1f}** per month"""
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if stats.estimated_cost is not None:
|
|
92
|
+
sections[-1] += f"""
|
|
93
|
+
|
|
94
|
+
### Cost
|
|
95
|
+
- **{format_cost(stats.avg_cost_per_day)}** per day
|
|
96
|
+
- **{format_cost(stats.avg_cost_per_week)}** per week
|
|
97
|
+
- **{format_cost(stats.avg_cost_per_month)}** per month"""
|
|
98
|
+
|
|
99
|
+
sections.append(f"""---
|
|
100
|
+
|
|
101
|
+
## ๐ข Total Tokens
|
|
102
|
+
|
|
103
|
+
**{stats.total_tokens:,}** tokens ({format_tokens(stats.total_tokens)})
|
|
104
|
+
|
|
105
|
+
- Input: {format_tokens(stats.total_input_tokens)}
|
|
106
|
+
- Output: {format_tokens(stats.total_output_tokens)}""")
|
|
107
|
+
|
|
108
|
+
return "\n\n".join(sections)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_dashboard(stats: WrappedStats, year: int | None, personality: dict) -> str:
|
|
112
|
+
"""Build the main dashboard section."""
|
|
113
|
+
|
|
114
|
+
year_display = format_year_display(year)
|
|
115
|
+
return f"""---
|
|
116
|
+
|
|
117
|
+
## ๐ Your {year_display} Dashboard
|
|
118
|
+
|
|
119
|
+
| Messages | Sessions | Tokens | Streak |
|
|
120
|
+
|----------|----------|--------|--------|
|
|
121
|
+
| {stats.total_messages:,} | {stats.total_sessions:,} | {format_tokens(stats.total_tokens)} | {stats.streak_longest} days |
|
|
122
|
+
|
|
123
|
+
### {personality['emoji']} {personality['title']}
|
|
124
|
+
|
|
125
|
+
*{personality['description']}*"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_contribution_graph(daily_stats: dict, year: int | None) -> str:
|
|
129
|
+
"""Build ASCII contribution graph."""
|
|
130
|
+
if not daily_stats:
|
|
131
|
+
return "### ๐
Activity Graph\n\n*No activity data*"
|
|
132
|
+
|
|
133
|
+
# Calculate date range
|
|
134
|
+
if year is None:
|
|
135
|
+
# All-time: use actual date range from daily_stats
|
|
136
|
+
dates = [datetime.strptime(d, "%Y-%m-%d") for d in daily_stats.keys()]
|
|
137
|
+
start_date = min(dates) if dates else datetime.now()
|
|
138
|
+
end_date = max(dates) if dates else datetime.now()
|
|
139
|
+
else:
|
|
140
|
+
start_date = datetime(year, 1, 1)
|
|
141
|
+
today = datetime.now()
|
|
142
|
+
end_date = today if year == today.year else datetime(year, 12, 31)
|
|
143
|
+
|
|
144
|
+
# Calculate max count for color scaling
|
|
145
|
+
max_count = max(s.message_count for s in daily_stats.values()) if daily_stats else 1
|
|
146
|
+
|
|
147
|
+
# Build weeks grid
|
|
148
|
+
weeks = []
|
|
149
|
+
current = start_date - timedelta(days=start_date.weekday())
|
|
150
|
+
|
|
151
|
+
while current <= end_date + timedelta(days=7):
|
|
152
|
+
week = []
|
|
153
|
+
for day in range(7):
|
|
154
|
+
date = current + timedelta(days=day)
|
|
155
|
+
date_str = date.strftime("%Y-%m-%d")
|
|
156
|
+
|
|
157
|
+
if date < start_date or date > end_date:
|
|
158
|
+
week.append(None)
|
|
159
|
+
elif date_str in daily_stats:
|
|
160
|
+
count = daily_stats[date_str].message_count
|
|
161
|
+
level = min(4, 1 + int((count / max_count) * 3)) if count > 0 else 0
|
|
162
|
+
week.append(level)
|
|
163
|
+
else:
|
|
164
|
+
week.append(0)
|
|
165
|
+
|
|
166
|
+
weeks.append(week)
|
|
167
|
+
current += timedelta(days=7)
|
|
168
|
+
|
|
169
|
+
# Build ASCII graph
|
|
170
|
+
graph = "```\n"
|
|
171
|
+
days_labels = ["Mon", " ", "Wed", " ", "Fri", " ", " "]
|
|
172
|
+
|
|
173
|
+
for row in range(7):
|
|
174
|
+
graph += f"{days_labels[row]} "
|
|
175
|
+
for week in weeks:
|
|
176
|
+
if week[row] is None:
|
|
177
|
+
graph += " "
|
|
178
|
+
else:
|
|
179
|
+
graph += "โ "
|
|
180
|
+
graph += "\n"
|
|
181
|
+
|
|
182
|
+
graph += "\n Less โ โ โ โ โ More\n"
|
|
183
|
+
graph += "```"
|
|
184
|
+
|
|
185
|
+
# Activity count
|
|
186
|
+
active_count = len([d for d in daily_stats.values() if d.message_count > 0])
|
|
187
|
+
total_days = (end_date - start_date).days + 1
|
|
188
|
+
|
|
189
|
+
return f"""---
|
|
190
|
+
|
|
191
|
+
## ๐
Activity Graph
|
|
192
|
+
|
|
193
|
+
*{active_count} of {total_days} days active*
|
|
194
|
+
|
|
195
|
+
{graph}"""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _build_charts(stats: WrappedStats) -> str:
|
|
199
|
+
"""Build weekday and hourly charts."""
|
|
200
|
+
|
|
201
|
+
# Weekday chart
|
|
202
|
+
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
203
|
+
max_weekday = max(stats.weekday_distribution) if stats.weekday_distribution else 1
|
|
204
|
+
|
|
205
|
+
weekday_chart = "### Weekday Activity\n\n```\n"
|
|
206
|
+
for i, (day, count) in enumerate(zip(days, stats.weekday_distribution)):
|
|
207
|
+
bar_width = int((count / max_weekday) * 30)
|
|
208
|
+
bar = "โ" * bar_width
|
|
209
|
+
weekday_chart += f"{day} {bar} {count:,}\n"
|
|
210
|
+
weekday_chart += "```"
|
|
211
|
+
|
|
212
|
+
# Hourly chart
|
|
213
|
+
max_hourly = max(stats.hourly_distribution) if stats.hourly_distribution else 1
|
|
214
|
+
|
|
215
|
+
hourly_chart = "### Hourly Activity\n\n```\n"
|
|
216
|
+
for hour, count in enumerate(stats.hourly_distribution):
|
|
217
|
+
if count > 0:
|
|
218
|
+
bar_width = int((count / max_hourly) * 40)
|
|
219
|
+
bar = "โ" * bar_width
|
|
220
|
+
hourly_chart += f"{hour:02d}:00 {bar} {count:,}\n"
|
|
221
|
+
hourly_chart += "```"
|
|
222
|
+
|
|
223
|
+
return f"""---
|
|
224
|
+
|
|
225
|
+
## ๐ Activity Patterns
|
|
226
|
+
|
|
227
|
+
{weekday_chart}
|
|
228
|
+
|
|
229
|
+
{hourly_chart}"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_tools_and_projects(stats: WrappedStats) -> str:
|
|
233
|
+
"""Build tools and projects sections."""
|
|
234
|
+
|
|
235
|
+
sections = []
|
|
236
|
+
|
|
237
|
+
# Top Tools
|
|
238
|
+
if stats.top_tools:
|
|
239
|
+
max_tool = stats.top_tools[0][1]
|
|
240
|
+
tools_md = "### ๐ง Top Tools\n\n```\n"
|
|
241
|
+
for tool, count in stats.top_tools[:5]:
|
|
242
|
+
bar_width = int((count / max_tool) * 30)
|
|
243
|
+
bar = "โ" * bar_width
|
|
244
|
+
tools_md += f"{tool:<20} {bar} {count:,}\n"
|
|
245
|
+
tools_md += "```"
|
|
246
|
+
sections.append(tools_md)
|
|
247
|
+
|
|
248
|
+
# Top Projects
|
|
249
|
+
if stats.top_projects:
|
|
250
|
+
max_proj = stats.top_projects[0][1]
|
|
251
|
+
projects_md = "### ๐ Top Projects\n\n```\n"
|
|
252
|
+
for proj, count in stats.top_projects[:5]:
|
|
253
|
+
bar_width = int((count / max_proj) * 30)
|
|
254
|
+
bar = "โ" * bar_width
|
|
255
|
+
# Truncate long names
|
|
256
|
+
display_name = proj if len(proj) <= 20 else proj[:17] + "..."
|
|
257
|
+
projects_md += f"{display_name:<20} {bar} {count:,}\n"
|
|
258
|
+
projects_md += "```"
|
|
259
|
+
sections.append(projects_md)
|
|
260
|
+
|
|
261
|
+
if sections:
|
|
262
|
+
return "---\n\n## ๐ ๏ธ Tools & Projects\n\n" + "\n\n".join(sections)
|
|
263
|
+
|
|
264
|
+
return ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _build_mcp_section(stats: WrappedStats) -> str:
|
|
268
|
+
"""Build MCP servers section if any."""
|
|
269
|
+
if not stats.top_mcps:
|
|
270
|
+
return ""
|
|
271
|
+
|
|
272
|
+
max_mcp = stats.top_mcps[0][1]
|
|
273
|
+
|
|
274
|
+
mcp_md = "```\n"
|
|
275
|
+
for mcp, count in stats.top_mcps[:3]:
|
|
276
|
+
bar_width = int((count / max_mcp) * 30)
|
|
277
|
+
bar = "โ" * bar_width
|
|
278
|
+
mcp_md += f"{mcp:<20} {bar} {count:,}\n"
|
|
279
|
+
mcp_md += "```"
|
|
280
|
+
|
|
281
|
+
return f"""---
|
|
282
|
+
|
|
283
|
+
## ๐ MCP Servers
|
|
284
|
+
|
|
285
|
+
{mcp_md}"""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _build_monthly_costs(stats: WrappedStats) -> str:
|
|
289
|
+
"""Build monthly cost breakdown table."""
|
|
290
|
+
if not stats.monthly_costs:
|
|
291
|
+
return ""
|
|
292
|
+
|
|
293
|
+
# Build table
|
|
294
|
+
table = "| Month | Input | Output | Cache | Cost |\n"
|
|
295
|
+
table += "|-------|-------|--------|-------|------|\n"
|
|
296
|
+
|
|
297
|
+
total_cost = 0
|
|
298
|
+
total_input = 0
|
|
299
|
+
total_output = 0
|
|
300
|
+
total_cache = 0
|
|
301
|
+
|
|
302
|
+
for month_str in sorted(stats.monthly_costs.keys()):
|
|
303
|
+
cost = stats.monthly_costs[month_str]
|
|
304
|
+
total_cost += cost
|
|
305
|
+
|
|
306
|
+
# Get tokens for this month
|
|
307
|
+
if month_str in stats.monthly_tokens:
|
|
308
|
+
tokens = stats.monthly_tokens[month_str]
|
|
309
|
+
input_tokens = tokens.get('input', 0)
|
|
310
|
+
output_tokens = tokens.get('output', 0)
|
|
311
|
+
cache_tokens = tokens.get('cache_create', 0) + tokens.get('cache_read', 0)
|
|
312
|
+
|
|
313
|
+
total_input += input_tokens
|
|
314
|
+
total_output += output_tokens
|
|
315
|
+
total_cache += cache_tokens
|
|
316
|
+
|
|
317
|
+
# Format month
|
|
318
|
+
month_date = datetime.strptime(month_str, "%Y-%m")
|
|
319
|
+
month_label = month_date.strftime("%b %Y")
|
|
320
|
+
|
|
321
|
+
table += f"| {month_label} | {format_tokens(input_tokens)} | {format_tokens(output_tokens)} | {format_tokens(cache_tokens)} | {format_cost(cost)} |\n"
|
|
322
|
+
|
|
323
|
+
table += f"| **Total** | **{format_tokens(total_input)}** | **{format_tokens(total_output)}** | **{format_tokens(total_cache)}** | **{format_cost(total_cost)}** |\n"
|
|
324
|
+
|
|
325
|
+
return f"""---
|
|
326
|
+
|
|
327
|
+
## ๐ฐ Monthly Cost Breakdown
|
|
328
|
+
|
|
329
|
+
{table}"""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _build_fun_facts_section(fun_facts: list) -> str:
|
|
333
|
+
"""Build fun facts section."""
|
|
334
|
+
if not fun_facts:
|
|
335
|
+
return ""
|
|
336
|
+
|
|
337
|
+
facts_md = "\n".join([f"- {emoji} {fact}" for emoji, fact in fun_facts])
|
|
338
|
+
|
|
339
|
+
return f"""---
|
|
340
|
+
|
|
341
|
+
## ๐ก Insights
|
|
342
|
+
|
|
343
|
+
{facts_md}"""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _build_credits(stats: WrappedStats, year: int | None) -> str:
|
|
347
|
+
"""Build credits section."""
|
|
348
|
+
|
|
349
|
+
# Aggregate costs by simplified model name
|
|
350
|
+
display_costs = {}
|
|
351
|
+
for model, cost in stats.cost_by_model.items():
|
|
352
|
+
display_name = simplify_model_name(model)
|
|
353
|
+
display_costs[display_name] = display_costs.get(display_name, 0) + cost
|
|
354
|
+
|
|
355
|
+
sections = []
|
|
356
|
+
|
|
357
|
+
# The Numbers
|
|
358
|
+
numbers = "### ๐ต THE NUMBERS\n\n"
|
|
359
|
+
if stats.estimated_cost is not None:
|
|
360
|
+
numbers += f"**Estimated Cost:** {format_cost(stats.estimated_cost)}\n\n"
|
|
361
|
+
for model, cost in sorted(display_costs.items(), key=lambda x: -x[1]):
|
|
362
|
+
numbers += f"- {model}: {format_cost(cost)}\n"
|
|
363
|
+
numbers += "\n"
|
|
364
|
+
|
|
365
|
+
numbers += f"**Tokens:** {format_tokens(stats.total_tokens)}\n\n"
|
|
366
|
+
numbers += f"- Input: {format_tokens(stats.total_input_tokens)}\n"
|
|
367
|
+
numbers += f"- Output: {format_tokens(stats.total_output_tokens)}\n"
|
|
368
|
+
sections.append(numbers)
|
|
369
|
+
|
|
370
|
+
# Timeline
|
|
371
|
+
today = datetime.now()
|
|
372
|
+
if year is None:
|
|
373
|
+
# All-time: calculate days from first to last message
|
|
374
|
+
if stats.first_message_date and stats.last_message_date:
|
|
375
|
+
total_days = (stats.last_message_date - stats.first_message_date).days + 1
|
|
376
|
+
else:
|
|
377
|
+
total_days = stats.active_days
|
|
378
|
+
elif year == today.year:
|
|
379
|
+
total_days = (today - datetime(year, 1, 1)).days + 1
|
|
380
|
+
else:
|
|
381
|
+
total_days = 366 if year % 4 == 0 else 365
|
|
382
|
+
|
|
383
|
+
year_display = format_year_display(year)
|
|
384
|
+
# Use sentence case for "All time" in Period field
|
|
385
|
+
period_text = "All time" if year is None else year_display
|
|
386
|
+
timeline = "### ๐
TIMELINE\n\n"
|
|
387
|
+
timeline += f"- **Period:** {period_text}\n"
|
|
388
|
+
if stats.first_message_date:
|
|
389
|
+
date_str = stats.first_message_date.strftime('%B %d, %Y') if year is None else stats.first_message_date.strftime('%B %d')
|
|
390
|
+
timeline += f"- **Journey started:** {date_str}\n"
|
|
391
|
+
timeline += f"- **Active days:** {stats.active_days} of {total_days}\n"
|
|
392
|
+
if stats.most_active_hour is not None:
|
|
393
|
+
hour_label = "AM" if stats.most_active_hour < 12 else "PM"
|
|
394
|
+
hour_12 = stats.most_active_hour % 12 or 12
|
|
395
|
+
timeline += f"- **Peak hour:** {hour_12}:00 {hour_label}\n"
|
|
396
|
+
sections.append(timeline)
|
|
397
|
+
|
|
398
|
+
# Averages
|
|
399
|
+
averages = "### ๐ AVERAGES\n\n"
|
|
400
|
+
averages += "**Messages:**\n"
|
|
401
|
+
averages += f"- Per day: {stats.avg_messages_per_day:.1f}\n"
|
|
402
|
+
averages += f"- Per week: {stats.avg_messages_per_week:.1f}\n"
|
|
403
|
+
averages += f"- Per month: {stats.avg_messages_per_month:.1f}\n"
|
|
404
|
+
|
|
405
|
+
if stats.estimated_cost is not None:
|
|
406
|
+
averages += "\n**Cost:**\n"
|
|
407
|
+
averages += f"- Per day: {format_cost(stats.avg_cost_per_day)}\n"
|
|
408
|
+
averages += f"- Per week: {format_cost(stats.avg_cost_per_week)}\n"
|
|
409
|
+
averages += f"- Per month: {format_cost(stats.avg_cost_per_month)}\n"
|
|
410
|
+
sections.append(averages)
|
|
411
|
+
|
|
412
|
+
# Longest Streak (if significant)
|
|
413
|
+
if stats.streak_longest >= 3 and stats.streak_longest_start and stats.streak_longest_end:
|
|
414
|
+
streak = "### ๐ฅ LONGEST STREAK\n\n"
|
|
415
|
+
streak += f"- **{stats.streak_longest} days** of consistent coding\n"
|
|
416
|
+
streak += f"- **From:** {stats.streak_longest_start.strftime('%B %d, %Y')}\n"
|
|
417
|
+
streak += f"- **To:** {stats.streak_longest_end.strftime('%B %d, %Y')}\n"
|
|
418
|
+
streak += "\n*Consistency is the key to mastery.*"
|
|
419
|
+
if stats.streak_current > 0:
|
|
420
|
+
streak += f"\n\n*Current streak: {stats.streak_current} days*"
|
|
421
|
+
sections.append(streak)
|
|
422
|
+
|
|
423
|
+
# Longest Conversation
|
|
424
|
+
if stats.longest_conversation_messages > 0:
|
|
425
|
+
longest = "### ๐ฌ LONGEST CONVERSATION\n\n"
|
|
426
|
+
longest += f"- **Messages:** {stats.longest_conversation_messages:,}\n"
|
|
427
|
+
if stats.longest_conversation_tokens > 0:
|
|
428
|
+
longest += f"- **Tokens:** {format_tokens(stats.longest_conversation_tokens)}\n"
|
|
429
|
+
if stats.longest_conversation_date:
|
|
430
|
+
longest += f"- **Date:** {stats.longest_conversation_date.strftime('%B %d, %Y')}\n"
|
|
431
|
+
longest += "\n*That's one epic coding session!*"
|
|
432
|
+
sections.append(longest)
|
|
433
|
+
|
|
434
|
+
# Starring (Models)
|
|
435
|
+
starring = "### โญ STARRING\n\n"
|
|
436
|
+
for model, count in stats.models_used.most_common(3):
|
|
437
|
+
starring += f"- **Claude {model}** ({count:,} messages)\n"
|
|
438
|
+
sections.append(starring)
|
|
439
|
+
|
|
440
|
+
# Projects
|
|
441
|
+
if stats.top_projects:
|
|
442
|
+
projects = "### ๐ PROJECTS\n\n"
|
|
443
|
+
for proj, count in stats.top_projects[:5]:
|
|
444
|
+
projects += f"- **{proj}** ({count:,} messages)\n"
|
|
445
|
+
sections.append(projects)
|
|
446
|
+
|
|
447
|
+
# Final card
|
|
448
|
+
if year is not None:
|
|
449
|
+
final = f"""---
|
|
450
|
+
|
|
451
|
+
## ๐ See you in {year + 1}
|
|
452
|
+
|
|
453
|
+
**Created by** [Daniel Tollefsen](https://github.com/da-troll) ยท [github.com/da-troll/claude-wrapped](https://github.com/da-troll/claude-wrapped)"""
|
|
454
|
+
else:
|
|
455
|
+
final = """---
|
|
456
|
+
|
|
457
|
+
## ๐ Keep coding!
|
|
458
|
+
|
|
459
|
+
**Created by** [Daniel Tollefsen](https://github.com/da-troll) ยท [github.com/da-troll/claude-wrapped](https://github.com/da-troll/claude-wrapped)"""
|
|
460
|
+
sections.append(final)
|
|
461
|
+
|
|
462
|
+
return "---\n\n## ๐ฌ CREDITS\n\n" + "\n\n".join(sections)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Interactive mode for Claude Code Wrapped using questionary prompts."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from questionary import Style
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
# Custom styling to match the wrapped aesthetic
|
|
12
|
+
custom_style = Style([
|
|
13
|
+
('qmark', 'fg:#E67E22 bold'), # Orange question mark
|
|
14
|
+
('question', 'bold'), # Question text
|
|
15
|
+
('answer', 'fg:#9B59B6 bold'), # Purple answer
|
|
16
|
+
('pointer', 'fg:#E67E22 bold'), # Orange pointer
|
|
17
|
+
('highlighted', 'fg:#E67E22 bold'), # Orange highlighted choice
|
|
18
|
+
('selected', 'fg:#9B59B6'), # Purple selected
|
|
19
|
+
('separator', 'fg:#7F8C8D'), # Gray separator
|
|
20
|
+
('instruction', 'fg:#7F8C8D'), # Gray instructions
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_available_years() -> list[str]:
|
|
25
|
+
"""Get list of available years from the data."""
|
|
26
|
+
# For now, generate last 3 years + current year
|
|
27
|
+
current_year = datetime.now().year
|
|
28
|
+
years = [str(year) for year in range(current_year - 2, current_year + 1)]
|
|
29
|
+
years.reverse() # Most recent first
|
|
30
|
+
years.append("All time")
|
|
31
|
+
return years
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def interactive_mode() -> dict:
|
|
35
|
+
"""Run interactive mode to collect user preferences.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dictionary with user selections: {
|
|
39
|
+
'year': str,
|
|
40
|
+
'html': bool,
|
|
41
|
+
'markdown': bool,
|
|
42
|
+
'json': bool,
|
|
43
|
+
'no_animate': bool,
|
|
44
|
+
'output': str | None
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
console = Console()
|
|
48
|
+
|
|
49
|
+
# Welcome message
|
|
50
|
+
console.print()
|
|
51
|
+
console.print(" [bold #E67E22]Claude Code Wrapped[/bold #E67E22] [dim]- Interactive Mode[/dim]")
|
|
52
|
+
console.print()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# 1. Select time period
|
|
56
|
+
year_choices = get_available_years()
|
|
57
|
+
year_answer = questionary.select(
|
|
58
|
+
"Select time period:",
|
|
59
|
+
choices=year_choices,
|
|
60
|
+
style=custom_style,
|
|
61
|
+
use_shortcuts=True,
|
|
62
|
+
use_arrow_keys=True,
|
|
63
|
+
).ask()
|
|
64
|
+
|
|
65
|
+
if year_answer is None: # User pressed Ctrl+C
|
|
66
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
|
|
69
|
+
# Convert "All time" to "all" for internal use
|
|
70
|
+
year = "all" if year_answer == "All time" else year_answer
|
|
71
|
+
|
|
72
|
+
# 2. Select export format
|
|
73
|
+
export_answer = questionary.select(
|
|
74
|
+
"Export format:",
|
|
75
|
+
choices=[
|
|
76
|
+
"View in terminal only",
|
|
77
|
+
"Export to HTML",
|
|
78
|
+
"Export to Markdown",
|
|
79
|
+
"Export to both HTML & Markdown",
|
|
80
|
+
"Export to JSON",
|
|
81
|
+
],
|
|
82
|
+
style=custom_style,
|
|
83
|
+
default="View in terminal only",
|
|
84
|
+
).ask()
|
|
85
|
+
|
|
86
|
+
if export_answer is None:
|
|
87
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
88
|
+
sys.exit(0)
|
|
89
|
+
|
|
90
|
+
# Parse export selections
|
|
91
|
+
html = "HTML" in export_answer
|
|
92
|
+
markdown = "Markdown" in export_answer
|
|
93
|
+
json_export = "JSON" in export_answer
|
|
94
|
+
|
|
95
|
+
# 3. Ask about animations (only if viewing in terminal)
|
|
96
|
+
if export_answer == "View in terminal only" or html or markdown:
|
|
97
|
+
animate_answer = questionary.confirm(
|
|
98
|
+
"Show animations?",
|
|
99
|
+
default=True,
|
|
100
|
+
style=custom_style,
|
|
101
|
+
).ask()
|
|
102
|
+
|
|
103
|
+
if animate_answer is None:
|
|
104
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
105
|
+
sys.exit(0)
|
|
106
|
+
|
|
107
|
+
no_animate = not animate_answer
|
|
108
|
+
else:
|
|
109
|
+
no_animate = True # Skip animations for JSON-only export
|
|
110
|
+
|
|
111
|
+
# 4. Custom filename (only if exporting)
|
|
112
|
+
output_filename = None
|
|
113
|
+
if html or markdown or json_export:
|
|
114
|
+
use_custom = questionary.confirm(
|
|
115
|
+
"Use custom filename?",
|
|
116
|
+
default=False,
|
|
117
|
+
style=custom_style,
|
|
118
|
+
).ask()
|
|
119
|
+
|
|
120
|
+
if use_custom is None:
|
|
121
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
|
|
124
|
+
if use_custom:
|
|
125
|
+
output_filename = questionary.text(
|
|
126
|
+
"Enter filename (without extension):",
|
|
127
|
+
style=custom_style,
|
|
128
|
+
validate=lambda text: len(text) > 0 or "Filename cannot be empty",
|
|
129
|
+
).ask()
|
|
130
|
+
|
|
131
|
+
if output_filename is None:
|
|
132
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
console.print() # Add spacing before execution
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
'year': year,
|
|
139
|
+
'html': html,
|
|
140
|
+
'markdown': markdown,
|
|
141
|
+
'json': json_export,
|
|
142
|
+
'no_animate': no_animate,
|
|
143
|
+
'output': output_filename,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
except KeyboardInterrupt:
|
|
147
|
+
console.print("\n\n[yellow]Cancelled.[/yellow]")
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def should_use_interactive_mode() -> bool:
|
|
152
|
+
"""Determine if we should use interactive mode.
|
|
153
|
+
|
|
154
|
+
Interactive mode is used when:
|
|
155
|
+
- No arguments provided at all (just `claude-wrapped`)
|
|
156
|
+
- Only the --interactive flag is provided
|
|
157
|
+
"""
|
|
158
|
+
# Check if only the script name is in argv (no arguments)
|
|
159
|
+
if len(sys.argv) == 1:
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
# Check if only --interactive flag is provided
|
|
163
|
+
if len(sys.argv) == 2 and sys.argv[1] in ['--interactive', '-i']:
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
return False
|