@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.
@@ -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