@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,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)}")
|