@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,203 @@
1
+ """Claude Code Wrapped - Main entry point."""
2
+
3
+ import argparse
4
+ import sys
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from .reader import get_claude_dir, load_all_messages
11
+ from .stats import aggregate_stats
12
+ from .ui import render_wrapped
13
+ from .exporters import export_to_html, export_to_markdown
14
+ from .interactive import interactive_mode, should_use_interactive_mode
15
+
16
+
17
+ def main():
18
+ """Main entry point for Claude Code Wrapped."""
19
+ # Check if we should use interactive mode
20
+ if should_use_interactive_mode():
21
+ # Get user selections through interactive prompts
22
+ selections = interactive_mode()
23
+
24
+ # Create args namespace from interactive selections
25
+ args = argparse.Namespace(
26
+ year=selections['year'],
27
+ no_animate=selections['no_animate'],
28
+ json=selections['json'],
29
+ html=selections['html'],
30
+ markdown=selections['markdown'],
31
+ output=selections['output'],
32
+ )
33
+ else:
34
+ # Use traditional CLI argument parsing
35
+ parser = argparse.ArgumentParser(
36
+ description="Claude Code Wrapped - Your year with Claude Code",
37
+ formatter_class=argparse.RawDescriptionHelpFormatter,
38
+ epilog="""
39
+ Examples:
40
+ claude-wrapped Interactive mode (prompts for all options)
41
+ claude-wrapped 2025 Show your 2025 wrapped
42
+ claude-wrapped all Show your all-time wrapped
43
+ claude-wrapped --no-animate Skip animations
44
+ claude-wrapped --html Export to HTML file
45
+ claude-wrapped --markdown Export to Markdown file
46
+ claude-wrapped all --html --markdown Export all-time stats to both formats
47
+ """,
48
+ )
49
+ parser.add_argument(
50
+ "year",
51
+ type=str,
52
+ nargs="?",
53
+ default=str(datetime.now().year),
54
+ help="Year to analyze or 'all' for all-time stats (default: current year)",
55
+ )
56
+ parser.add_argument(
57
+ "--no-animate",
58
+ action="store_true",
59
+ help="Disable animations for faster display",
60
+ )
61
+ parser.add_argument(
62
+ "--json",
63
+ action="store_true",
64
+ help="Output raw stats as JSON",
65
+ )
66
+ parser.add_argument(
67
+ "--html",
68
+ action="store_true",
69
+ help="Export to HTML file",
70
+ )
71
+ parser.add_argument(
72
+ "--markdown",
73
+ action="store_true",
74
+ help="Export to Markdown file",
75
+ )
76
+ parser.add_argument(
77
+ "--output",
78
+ type=str,
79
+ help="Custom output filename (without extension)",
80
+ )
81
+ parser.add_argument(
82
+ "-i", "--interactive",
83
+ action="store_true",
84
+ help="Launch interactive mode (default when no arguments provided)",
85
+ )
86
+
87
+ args = parser.parse_args()
88
+ console = Console()
89
+
90
+ # Parse year argument
91
+ if args.year.lower() == "all":
92
+ year_filter = None
93
+ year_display = "all-time"
94
+ year_label = "All time"
95
+ else:
96
+ try:
97
+ year_filter = int(args.year)
98
+ year_display = str(year_filter)
99
+ year_label = str(year_filter)
100
+ except ValueError:
101
+ console.print(f"[red]Error:[/red] Invalid year '{args.year}'. Use a year (e.g., 2025) or 'all'.")
102
+ sys.exit(1)
103
+
104
+ # Check for Claude directory
105
+ try:
106
+ claude_dir = get_claude_dir()
107
+ except FileNotFoundError as e:
108
+ console.print(f"[red]Error:[/red] {e}")
109
+ console.print("\nMake sure you have Claude Code installed and have used it at least once.")
110
+ sys.exit(1)
111
+
112
+ # Load messages
113
+ if not args.json:
114
+ console.print(f"\n[dim]Loading your Claude Code history for {year_label}...[/dim]\n")
115
+
116
+ messages = load_all_messages(claude_dir, year=year_filter)
117
+
118
+ if not messages:
119
+ console.print(f"[yellow]No Claude Code activity found for {year_label}.[/yellow]")
120
+ console.print("\nTry a different year or make sure you've used Claude Code.")
121
+ sys.exit(0)
122
+
123
+ # Calculate stats
124
+ stats = aggregate_stats(messages, year_filter)
125
+
126
+ # Generate timestamp for filenames
127
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M")
128
+
129
+ # Export to HTML if requested
130
+ if args.html:
131
+ if args.output:
132
+ output_name = args.output
133
+ else:
134
+ output_name = f"claude-wrapped-{year_display}-{timestamp}"
135
+ html_path = Path(f"{output_name}.html")
136
+ export_to_html(stats, year_filter, html_path)
137
+ console.print(f"\n[green]✓[/green] Exported to [bold]{html_path}[/bold]")
138
+
139
+ # Export to Markdown if requested
140
+ if args.markdown:
141
+ if args.output:
142
+ output_name = args.output
143
+ else:
144
+ output_name = f"claude-wrapped-{year_display}-{timestamp}"
145
+ md_path = Path(f"{output_name}.md")
146
+ export_to_markdown(stats, year_filter, md_path)
147
+ console.print(f"\n[green]✓[/green] Exported to [bold]{md_path}[/bold]")
148
+
149
+ # Output
150
+ if args.json:
151
+ import json
152
+ output = {
153
+ "year": stats.year,
154
+ "total_messages": stats.total_messages,
155
+ "total_user_messages": stats.total_user_messages,
156
+ "total_assistant_messages": stats.total_assistant_messages,
157
+ "total_sessions": stats.total_sessions,
158
+ "total_projects": stats.total_projects,
159
+ "total_tokens": stats.total_tokens,
160
+ "total_input_tokens": stats.total_input_tokens,
161
+ "total_output_tokens": stats.total_output_tokens,
162
+ "active_days": stats.active_days,
163
+ "late_night_days": stats.late_night_days,
164
+ "streak_longest": stats.streak_longest,
165
+ "streak_current": stats.streak_current,
166
+ "most_active_hour": stats.most_active_hour,
167
+ "most_active_day": stats.most_active_day[0].isoformat() if stats.most_active_day else None,
168
+ "most_active_day_messages": stats.most_active_day[1] if stats.most_active_day else None,
169
+ "primary_model": stats.primary_model,
170
+ "top_tools": dict(stats.top_tools),
171
+ "top_mcps": dict(stats.top_mcps),
172
+ "top_projects": dict(stats.top_projects),
173
+ "hourly_distribution": stats.hourly_distribution,
174
+ "weekday_distribution": stats.weekday_distribution,
175
+ "estimated_cost_usd": stats.estimated_cost,
176
+ "cost_by_model": stats.cost_by_model,
177
+ # Averages
178
+ "avg_messages_per_day": round(stats.avg_messages_per_day, 1),
179
+ "avg_messages_per_week": round(stats.avg_messages_per_week, 1),
180
+ "avg_messages_per_month": round(stats.avg_messages_per_month, 1),
181
+ "avg_cost_per_day": round(stats.avg_cost_per_day, 2) if stats.avg_cost_per_day else None,
182
+ "avg_cost_per_week": round(stats.avg_cost_per_week, 2) if stats.avg_cost_per_week else None,
183
+ "avg_cost_per_month": round(stats.avg_cost_per_month, 2) if stats.avg_cost_per_month else None,
184
+ # Code activity
185
+ "total_edits": stats.total_edits,
186
+ "total_writes": stats.total_writes,
187
+ "avg_code_changes_per_day": round(stats.avg_edits_per_day, 1),
188
+ "avg_code_changes_per_week": round(stats.avg_edits_per_week, 1),
189
+ # Monthly breakdown
190
+ "monthly_costs": stats.monthly_costs,
191
+ "monthly_tokens": stats.monthly_tokens,
192
+ # Longest conversation
193
+ "longest_conversation_messages": stats.longest_conversation_messages,
194
+ "longest_conversation_tokens": stats.longest_conversation_tokens,
195
+ "longest_conversation_date": stats.longest_conversation_date.isoformat() if stats.longest_conversation_date else None,
196
+ }
197
+ print(json.dumps(output, indent=2))
198
+ else:
199
+ render_wrapped(stats, console, animate=not args.no_animate)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
@@ -0,0 +1,179 @@
1
+ """Pricing data and cost calculation for Claude models.
2
+
3
+ Based on official Anthropic pricing as of December 2025.
4
+ Source: https://docs.anthropic.com/en/docs/about-claude/models
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class ModelPricing:
12
+ """Pricing for a Claude model in USD per million tokens."""
13
+ input_cost: float # Base input tokens
14
+ output_cost: float # Output tokens
15
+ cache_write_cost: float # 5-minute cache writes (1.25x input)
16
+ cache_read_cost: float # Cache hits & refreshes (0.1x input)
17
+
18
+
19
+ # Official Anthropic pricing as of December 2025 (USD per million tokens)
20
+ # Source: https://platform.claude.com/docs/en/about-claude/pricing
21
+ MODEL_PRICING: dict[str, ModelPricing] = {
22
+ # Claude 4.x models
23
+ "claude-opus-4-5-20251101": ModelPricing(5.0, 25.0, 6.25, 0.50),
24
+ "claude-opus-4-20250514": ModelPricing(15.0, 75.0, 18.75, 1.50),
25
+ "claude-opus-4-1-20250620": ModelPricing(15.0, 75.0, 18.75, 1.50),
26
+ "claude-sonnet-4-5-20250514": ModelPricing(3.0, 15.0, 3.75, 0.30),
27
+ "claude-sonnet-4-20250514": ModelPricing(3.0, 15.0, 3.75, 0.30),
28
+ "claude-haiku-4-5-20251101": ModelPricing(1.0, 5.0, 1.25, 0.10),
29
+
30
+ # Claude 3.x models
31
+ "claude-3-5-sonnet-20241022": ModelPricing(3.0, 15.0, 3.75, 0.30),
32
+ "claude-3-5-sonnet-20240620": ModelPricing(3.0, 15.0, 3.75, 0.30),
33
+ "claude-3-5-haiku-20241022": ModelPricing(0.80, 4.0, 1.0, 0.08),
34
+ "claude-3-opus-20240229": ModelPricing(15.0, 75.0, 18.75, 1.50),
35
+ "claude-3-sonnet-20240229": ModelPricing(3.0, 15.0, 3.75, 0.30),
36
+ "claude-3-haiku-20240307": ModelPricing(0.25, 1.25, 0.30, 0.03),
37
+ }
38
+
39
+ # Simplified model name mappings for display
40
+ MODEL_FAMILY_PRICING: dict[str, ModelPricing] = {
41
+ "opus": ModelPricing(15.0, 75.0, 18.75, 1.50), # Conservative estimate
42
+ "sonnet": ModelPricing(3.0, 15.0, 3.75, 0.30),
43
+ "haiku": ModelPricing(0.80, 4.0, 1.0, 0.08), # Use 3.5 Haiku as default
44
+ }
45
+
46
+
47
+ def get_model_pricing(model_name: str | None) -> ModelPricing | None:
48
+ """Get pricing for a model by name.
49
+
50
+ Handles both full model IDs and simplified names like 'Opus', 'Sonnet', 'Haiku'.
51
+ """
52
+ if not model_name:
53
+ return None
54
+
55
+ model_lower = model_name.lower()
56
+
57
+ # Try exact match first
58
+ if model_lower in MODEL_PRICING:
59
+ return MODEL_PRICING[model_lower]
60
+
61
+ # For simple names like "Opus", "Sonnet", "Haiku", use family pricing
62
+ # This is what we get from the stats module's simplified model names
63
+ for family, pricing in MODEL_FAMILY_PRICING.items():
64
+ if model_lower == family:
65
+ return pricing
66
+
67
+ # Try partial match on full model IDs (for full model names like "claude-3-5-sonnet-...")
68
+ for model_id, pricing in MODEL_PRICING.items():
69
+ if model_lower in model_id or model_id in model_lower:
70
+ return pricing
71
+
72
+ # Fall back to family-based pricing for partial matches
73
+ for family, pricing in MODEL_FAMILY_PRICING.items():
74
+ if family in model_lower:
75
+ return pricing
76
+
77
+ return None
78
+
79
+
80
+ def calculate_cost(
81
+ input_tokens: int,
82
+ output_tokens: int,
83
+ cache_creation_tokens: int = 0,
84
+ cache_read_tokens: int = 0,
85
+ model_name: str | None = None,
86
+ ) -> float | None:
87
+ """Calculate cost in USD for a given token usage.
88
+
89
+ Args:
90
+ input_tokens: Number of input tokens (excluding cache)
91
+ output_tokens: Number of output tokens
92
+ cache_creation_tokens: Number of cache write tokens
93
+ cache_read_tokens: Number of cache read tokens
94
+ model_name: Model name or ID for pricing lookup
95
+
96
+ Returns:
97
+ Cost in USD, or None if pricing unavailable
98
+ """
99
+ pricing = get_model_pricing(model_name)
100
+ if not pricing:
101
+ return None
102
+
103
+ # Convert to millions and calculate
104
+ cost = (
105
+ (input_tokens / 1_000_000) * pricing.input_cost +
106
+ (output_tokens / 1_000_000) * pricing.output_cost +
107
+ (cache_creation_tokens / 1_000_000) * pricing.cache_write_cost +
108
+ (cache_read_tokens / 1_000_000) * pricing.cache_read_cost
109
+ )
110
+
111
+ return cost
112
+
113
+
114
+ def calculate_total_cost_by_model(
115
+ model_usage: dict[str, dict[str, int]]
116
+ ) -> tuple[float, dict[str, float]]:
117
+ """Calculate total cost across all models.
118
+
119
+ Args:
120
+ model_usage: Dict mapping model names to token counts:
121
+ {
122
+ "Sonnet": {"input": 1000, "output": 500, "cache_create": 0, "cache_read": 0},
123
+ "Opus": {"input": 500, "output": 200, ...},
124
+ }
125
+
126
+ Returns:
127
+ Tuple of (total_cost, per_model_costs)
128
+ """
129
+ total = 0.0
130
+ per_model: dict[str, float] = {}
131
+
132
+ for model_name, tokens in model_usage.items():
133
+ cost = calculate_cost(
134
+ input_tokens=tokens.get("input", 0),
135
+ output_tokens=tokens.get("output", 0),
136
+ cache_creation_tokens=tokens.get("cache_create", 0),
137
+ cache_read_tokens=tokens.get("cache_read", 0),
138
+ model_name=model_name,
139
+ )
140
+ if cost is not None:
141
+ per_model[model_name] = cost
142
+ total += cost
143
+
144
+ return total, per_model
145
+
146
+
147
+ def format_cost(cost: float | None) -> str:
148
+ """Format cost for display."""
149
+ if cost is None:
150
+ return "N/A"
151
+ if cost < 0.01:
152
+ return f"${cost:.4f}"
153
+ if cost < 1:
154
+ return f"${cost:.2f}"
155
+ if cost < 100:
156
+ return f"${cost:.2f}"
157
+ if cost < 1000:
158
+ return f"${cost:.0f}"
159
+ return f"${cost:,.0f}"
160
+
161
+
162
+ if __name__ == "__main__":
163
+ # Test pricing lookups
164
+ print("Testing pricing lookups:")
165
+ test_models = ["Opus", "Sonnet", "Haiku", "claude-3-5-sonnet-20241022", "unknown"]
166
+ for model in test_models:
167
+ pricing = get_model_pricing(model)
168
+ print(f" {model}: {pricing}")
169
+
170
+ # Test cost calculation
171
+ print("\nTesting cost calculation:")
172
+ cost = calculate_cost(
173
+ input_tokens=1_000_000,
174
+ output_tokens=500_000,
175
+ cache_creation_tokens=100_000,
176
+ cache_read_tokens=50_000,
177
+ model_name="Sonnet"
178
+ )
179
+ print(f" 1M input + 500K output + 100K cache create + 50K cache read (Sonnet): {format_cost(cost)}")