wrapped-claude 0.1.0__tar.gz

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,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kafui Hounkpatin (original "My Claude Wrapped")
4
+ Copyright (c) 2026 Salem Gnandi (neo-brutalist adaptation)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: wrapped-claude
3
+ Version: 0.1.0
4
+ Summary: Ton année Claude Code, wrappée en néo-brutalisme (CLI). Adaptation de My Claude Wrapped (@HKafuiEPI).
5
+ Author: Salem Gnandi
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Salemgnk/claude-wrapped
8
+ Project-URL: Original, https://github.com/HKafuiEPI/my_claude_wrapped
9
+ Keywords: claude,claude-code,wrapped,cli,stats,recap
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ <div align="center">
21
+
22
+ # ◆ Claude Wrapped — Neo-Brutalist Edition
23
+
24
+ **Ton année [Claude Code](https://claude.com/claude-code), wrappée en néo-brutalisme.**
25
+ **Your Claude Code year, wrapped — neo-brutalist style.**
26
+
27
+ Lit tes transcripts Claude Code **en local** et génère une page HTML unique,
28
+ partageable et autonome — blocs à ombre dure, accents francs, polices mono, dans
29
+ l'esprit de [Git Wrapped](https://github.com/Salemgnk/git-wrapped).
30
+
31
+ `🇫🇷 Français` · `🇬🇧 English` · MIT
32
+
33
+ </div>
34
+
35
+ ---
36
+
37
+ > **Adaptation néo-brutaliste** de **[My Claude Wrapped — Arcade Edition](https://github.com/HKafuiEPI/my_claude_wrapped)**
38
+ > par **[@HKafuiEPI](https://github.com/HKafuiEPI)** (👏 tout le crédit de l'idée et de l'analyse des
39
+ > transcripts lui revient). Ici on garde son moteur, on change la peau : direction artistique
40
+ > néo-brutaliste au lieu de l'arcade rétro. / Neo-brutalist re-skin of **@HKafuiEPI**'s
41
+ > *My Claude Wrapped*; same engine, different art direction.
42
+
43
+ ## 🇫🇷 Français
44
+
45
+ ### C'est quoi ?
46
+
47
+ Un générateur qui transforme ton historique **local** Claude Code
48
+ (`~/.claude/projects/**/*.jsonl`) en une page « Wrapped » à la Spotify, look
49
+ **néo-brutaliste**. Aucune donnée n'est envoyée nulle part : tout tourne en local
50
+ et la page ne contient **que des chiffres agrégés** (jamais le contenu des conversations).
51
+
52
+ ### Installation & utilisation
53
+
54
+ Aucune dépendance — juste **Python 3.8+**.
55
+
56
+ ```bash
57
+ pip install wrapped-claude
58
+ wrapped-claude # FR -> claude_wrapped.html (commande installée)
59
+ wrapped-claude --video --lang en # toutes les options, ex.
60
+ ```
61
+
62
+ Ou depuis les sources — `python3 generate.py` équivaut à la commande `wrapped-claude` :
63
+
64
+ ```bash
65
+ git clone git@github.com:Salemgnk/claude-wrapped.git
66
+ cd claude-wrapped
67
+ python3 generate.py # FR -> claude_wrapped.html
68
+ python3 generate.py --lang en # EN -> claude_wrapped.en.html
69
+ python3 generate.py --days 30 # 30 derniers jours
70
+ python3 generate.py --images # exporte 1 PNG par écran -> images/
71
+ python3 generate.py --video # vidéo diaporama (fondus) -> claude_wrapped.mp4
72
+ python3 generate.py --video --video-music song.mp3 # + musique
73
+ ```
74
+
75
+ > **`--images`** capture chaque écran en PNG partageable ; **`--video`** les assemble en
76
+ > **mp4** (fondus enchaînés, ~3 s/écran, musique optionnelle). Nécessite **Chrome/Chromium**
77
+ > (et **ffmpeg** pour `--video`) ; aucune dépendance Python ajoutée, ignoré proprement si absent.
78
+ > **Exécutable uniquement en CLI** (données locales).
79
+
80
+ Puis ouvre le `.html` — navigation `←` `→`, `Espace`, clic ou swipe.
81
+
82
+ ## 🇬🇧 English
83
+
84
+ ### What is it?
85
+
86
+ A generator that turns your **local** Claude Code history
87
+ (`~/.claude/projects/**/*.jsonl`) into a Spotify-Wrapped-style page, **neo-brutalist**
88
+ look. Nothing is sent anywhere: everything runs locally and the page contains
89
+ **only aggregate numbers** (never conversation content).
90
+
91
+ ### Install & usage
92
+
93
+ No dependencies — just **Python 3.8+**.
94
+
95
+ ```bash
96
+ pip install wrapped-claude
97
+ wrapped-claude # FR -> claude_wrapped.html (installed command)
98
+ wrapped-claude --video --lang en # all options, e.g.
99
+ ```
100
+
101
+ Or from source — `python3 generate.py` is equivalent to the `wrapped-claude` command:
102
+
103
+ ```bash
104
+ git clone git@github.com:Salemgnk/claude-wrapped.git
105
+ cd claude-wrapped
106
+ python3 generate.py # FR -> claude_wrapped.html
107
+ python3 generate.py --lang en # EN -> claude_wrapped.en.html
108
+ python3 generate.py --images # export one PNG per screen -> images/
109
+ python3 generate.py --video # slideshow video (fades) -> claude_wrapped.mp4
110
+ python3 generate.py --video --video-music song.mp3 # + music
111
+ ```
112
+
113
+ > **`--images`** captures each screen as a shareable PNG; **`--video`** stitches them into an
114
+ > **mp4** (crossfades, ~3s/screen, optional music). Needs **Chrome/Chromium** (and **ffmpeg**
115
+ > for `--video`); no Python dependency added, skipped gracefully if missing. **CLI-only** (local
116
+ > data). Then open the `.html` and scroll.
117
+
118
+ ## Crédits / Credits
119
+
120
+ - Idée & moteur d'analyse / idea & parsing engine : **[@HKafuiEPI](https://github.com/HKafuiEPI)** — [My Claude Wrapped](https://github.com/HKafuiEPI/my_claude_wrapped)
121
+ - Direction artistique néo-brutaliste / neo-brutalist art direction : reprise de [Git Wrapped](https://github.com/Salemgnk/git-wrapped)
122
+ - Polices / fonts : Space Grotesk & Space Mono (OFL)
@@ -0,0 +1,103 @@
1
+ <div align="center">
2
+
3
+ # ◆ Claude Wrapped — Neo-Brutalist Edition
4
+
5
+ **Ton année [Claude Code](https://claude.com/claude-code), wrappée en néo-brutalisme.**
6
+ **Your Claude Code year, wrapped — neo-brutalist style.**
7
+
8
+ Lit tes transcripts Claude Code **en local** et génère une page HTML unique,
9
+ partageable et autonome — blocs à ombre dure, accents francs, polices mono, dans
10
+ l'esprit de [Git Wrapped](https://github.com/Salemgnk/git-wrapped).
11
+
12
+ `🇫🇷 Français` · `🇬🇧 English` · MIT
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ > **Adaptation néo-brutaliste** de **[My Claude Wrapped — Arcade Edition](https://github.com/HKafuiEPI/my_claude_wrapped)**
19
+ > par **[@HKafuiEPI](https://github.com/HKafuiEPI)** (👏 tout le crédit de l'idée et de l'analyse des
20
+ > transcripts lui revient). Ici on garde son moteur, on change la peau : direction artistique
21
+ > néo-brutaliste au lieu de l'arcade rétro. / Neo-brutalist re-skin of **@HKafuiEPI**'s
22
+ > *My Claude Wrapped*; same engine, different art direction.
23
+
24
+ ## 🇫🇷 Français
25
+
26
+ ### C'est quoi ?
27
+
28
+ Un générateur qui transforme ton historique **local** Claude Code
29
+ (`~/.claude/projects/**/*.jsonl`) en une page « Wrapped » à la Spotify, look
30
+ **néo-brutaliste**. Aucune donnée n'est envoyée nulle part : tout tourne en local
31
+ et la page ne contient **que des chiffres agrégés** (jamais le contenu des conversations).
32
+
33
+ ### Installation & utilisation
34
+
35
+ Aucune dépendance — juste **Python 3.8+**.
36
+
37
+ ```bash
38
+ pip install wrapped-claude
39
+ wrapped-claude # FR -> claude_wrapped.html (commande installée)
40
+ wrapped-claude --video --lang en # toutes les options, ex.
41
+ ```
42
+
43
+ Ou depuis les sources — `python3 generate.py` équivaut à la commande `wrapped-claude` :
44
+
45
+ ```bash
46
+ git clone git@github.com:Salemgnk/claude-wrapped.git
47
+ cd claude-wrapped
48
+ python3 generate.py # FR -> claude_wrapped.html
49
+ python3 generate.py --lang en # EN -> claude_wrapped.en.html
50
+ python3 generate.py --days 30 # 30 derniers jours
51
+ python3 generate.py --images # exporte 1 PNG par écran -> images/
52
+ python3 generate.py --video # vidéo diaporama (fondus) -> claude_wrapped.mp4
53
+ python3 generate.py --video --video-music song.mp3 # + musique
54
+ ```
55
+
56
+ > **`--images`** capture chaque écran en PNG partageable ; **`--video`** les assemble en
57
+ > **mp4** (fondus enchaînés, ~3 s/écran, musique optionnelle). Nécessite **Chrome/Chromium**
58
+ > (et **ffmpeg** pour `--video`) ; aucune dépendance Python ajoutée, ignoré proprement si absent.
59
+ > **Exécutable uniquement en CLI** (données locales).
60
+
61
+ Puis ouvre le `.html` — navigation `←` `→`, `Espace`, clic ou swipe.
62
+
63
+ ## 🇬🇧 English
64
+
65
+ ### What is it?
66
+
67
+ A generator that turns your **local** Claude Code history
68
+ (`~/.claude/projects/**/*.jsonl`) into a Spotify-Wrapped-style page, **neo-brutalist**
69
+ look. Nothing is sent anywhere: everything runs locally and the page contains
70
+ **only aggregate numbers** (never conversation content).
71
+
72
+ ### Install & usage
73
+
74
+ No dependencies — just **Python 3.8+**.
75
+
76
+ ```bash
77
+ pip install wrapped-claude
78
+ wrapped-claude # FR -> claude_wrapped.html (installed command)
79
+ wrapped-claude --video --lang en # all options, e.g.
80
+ ```
81
+
82
+ Or from source — `python3 generate.py` is equivalent to the `wrapped-claude` command:
83
+
84
+ ```bash
85
+ git clone git@github.com:Salemgnk/claude-wrapped.git
86
+ cd claude-wrapped
87
+ python3 generate.py # FR -> claude_wrapped.html
88
+ python3 generate.py --lang en # EN -> claude_wrapped.en.html
89
+ python3 generate.py --images # export one PNG per screen -> images/
90
+ python3 generate.py --video # slideshow video (fades) -> claude_wrapped.mp4
91
+ python3 generate.py --video --video-music song.mp3 # + music
92
+ ```
93
+
94
+ > **`--images`** captures each screen as a shareable PNG; **`--video`** stitches them into an
95
+ > **mp4** (crossfades, ~3s/screen, optional music). Needs **Chrome/Chromium** (and **ffmpeg**
96
+ > for `--video`); no Python dependency added, skipped gracefully if missing. **CLI-only** (local
97
+ > data). Then open the `.html` and scroll.
98
+
99
+ ## Crédits / Credits
100
+
101
+ - Idée & moteur d'analyse / idea & parsing engine : **[@HKafuiEPI](https://github.com/HKafuiEPI)** — [My Claude Wrapped](https://github.com/HKafuiEPI/my_claude_wrapped)
102
+ - Direction artistique néo-brutaliste / neo-brutalist art direction : reprise de [Git Wrapped](https://github.com/Salemgnk/git-wrapped)
103
+ - Polices / fonts : Space Grotesk & Space Mono (OFL)
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wrapped-claude"
7
+ version = "0.1.0"
8
+ description = "Ton année Claude Code, wrappée en néo-brutalisme (CLI). Adaptation de My Claude Wrapped (@HKafuiEPI)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Salem Gnandi" }]
13
+ keywords = ["claude", "claude-code", "wrapped", "cli", "stats", "recap"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Environment :: Console",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Utilities",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/Salemgnk/claude-wrapped"
24
+ Original = "https://github.com/HKafuiEPI/my_claude_wrapped"
25
+
26
+ [project.scripts]
27
+ wrapped-claude = "wrapped_claude.cli:main"
28
+
29
+ [tool.setuptools]
30
+ packages = ["wrapped_claude"]
31
+
32
+ [tool.setuptools.package-data]
33
+ wrapped_claude = ["template.html", "fonts/*.woff2"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """wrapped-claude — ton année Claude Code, wrappée en néo-brutalisme (CLI)."""
2
+ from .cli import main
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["main"]
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ My Claude Wrapped — generate a shareable retro-arcade HTML page from your local
4
+ Claude Code transcripts (~/.claude/projects/**/*.jsonl).
5
+
6
+ My Claude Wrapped — génère une page HTML partageable (style arcade rétro) à
7
+ partir de tes transcripts locaux Claude Code.
8
+
9
+ Usage:
10
+ python3 generate.py # full history, French (default)
11
+ python3 generate.py --lang en # English version
12
+ python3 generate.py --days 30 # last 30 days only
13
+ python3 generate.py --out wrapped.html # custom output name
14
+ python3 generate.py --projects-dir /path/to/projects
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import base64
20
+ import glob
21
+ import json
22
+ import os
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+ from collections import Counter
28
+ from datetime import datetime, timedelta, timezone
29
+ from pathlib import Path
30
+
31
+ WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
32
+ WEEKDAY_ABBR = {
33
+ "fr": {"Monday": "LUN", "Tuesday": "MAR", "Wednesday": "MER", "Thursday": "JEU",
34
+ "Friday": "VEN", "Saturday": "SAM", "Sunday": "DIM"},
35
+ "en": {"Monday": "MON", "Tuesday": "TUE", "Wednesday": "WED", "Thursday": "THU",
36
+ "Friday": "FRI", "Saturday": "SAT", "Sunday": "SUN"},
37
+ }
38
+
39
+
40
+ def parse_ts(ts: str):
41
+ try:
42
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ def pretty_project(raw: str) -> str:
48
+ """'-home-pr1nx3-Frisz-frisz-flutter' -> 'Frisz/frisz/flutter'."""
49
+ s = raw
50
+ for prefix in ("-home-pr1nx3-", "-home-pr1nx3", "-Users-", "-home-"):
51
+ if s.startswith(prefix):
52
+ s = s[len(prefix):]
53
+ break
54
+ s = s.lstrip("-")
55
+ return s.replace("-", "/") or raw
56
+
57
+
58
+ def is_real_prompt(msg: dict) -> bool:
59
+ """A user message that is not just a tool_result / meta entry."""
60
+ c = msg.get("content")
61
+ if isinstance(c, str):
62
+ return bool(c.strip())
63
+ if isinstance(c, list):
64
+ for b in c:
65
+ if isinstance(b, str) and b.strip():
66
+ return True
67
+ if isinstance(b, dict) and b.get("type") == "text" and (b.get("text") or "").strip():
68
+ return True
69
+ return False
70
+
71
+
72
+ def collect(projects_dir: str, since: datetime | None, lang: str):
73
+ files = glob.glob(os.path.join(projects_dir, "**", "*.jsonl"), recursive=True)
74
+
75
+ models = Counter()
76
+ tools = Counter()
77
+ hours = [0] * 24
78
+ weekdays = Counter()
79
+ by_project_prompts = Counter()
80
+ by_day = Counter()
81
+ sessions = set()
82
+ projects_seen = set()
83
+
84
+ tok_in = tok_out = cache_read = cache_create = 0
85
+ user_prompts = 0
86
+ assistant_msgs = 0
87
+ first_ts = last_ts = None
88
+
89
+ for f in files:
90
+ proj = f.split(os.sep + "projects" + os.sep)[-1].split(os.sep)[0]
91
+ try:
92
+ fh = open(f, encoding="utf-8", errors="ignore")
93
+ except OSError:
94
+ continue
95
+ with fh:
96
+ for line in fh:
97
+ line = line.strip()
98
+ if not line:
99
+ continue
100
+ try:
101
+ o = json.loads(line)
102
+ except json.JSONDecodeError:
103
+ continue
104
+
105
+ ts = o.get("timestamp")
106
+ dt = parse_ts(ts) if ts else None
107
+ if since and dt and dt < since:
108
+ continue
109
+
110
+ t = o.get("type")
111
+ if o.get("sessionId"):
112
+ sessions.add(o["sessionId"])
113
+
114
+ if dt:
115
+ if first_ts is None or dt < first_ts:
116
+ first_ts = dt
117
+ if last_ts is None or dt > last_ts:
118
+ last_ts = dt
119
+ hours[dt.hour] += 1
120
+ weekdays[dt.strftime("%A")] += 1
121
+ by_day[dt.date().isoformat()] += 1
122
+
123
+ if t == "user":
124
+ msg = o.get("message", {}) or {}
125
+ if is_real_prompt(msg):
126
+ user_prompts += 1
127
+ by_project_prompts[proj] += 1
128
+ projects_seen.add(proj)
129
+ elif t == "assistant":
130
+ projects_seen.add(proj)
131
+ msg = o.get("message", {}) or {}
132
+ m = msg.get("model")
133
+ if m and m != "<synthetic>":
134
+ models[m] += 1
135
+ assistant_msgs += 1
136
+ u = msg.get("usage", {}) or {}
137
+ tok_in += u.get("input_tokens", 0) or 0
138
+ tok_out += u.get("output_tokens", 0) or 0
139
+ cache_read += u.get("cache_read_input_tokens", 0) or 0
140
+ cache_create += u.get("cache_creation_input_tokens", 0) or 0
141
+ for b in (msg.get("content") or []):
142
+ if isinstance(b, dict) and b.get("type") == "tool_use":
143
+ tools[b.get("name", "?")] += 1
144
+
145
+ tokens_total = tok_in + tok_out + cache_read + cache_create
146
+ days_active = len(by_day)
147
+ busiest_day = by_day.most_common(1)[0] if by_day else ["-", 0]
148
+ peak_hour = max(range(24), key=lambda h: hours[h]) if any(hours) else 0
149
+
150
+ night = sum(hours[h] for h in list(range(22, 24)) + list(range(0, 5)))
151
+ total_h = sum(hours) or 1
152
+ night_pct = round(100 * night / total_h)
153
+
154
+ abbr = WEEKDAY_ABBR[lang]
155
+ weekday_list = [[abbr[d], weekdays.get(d, 0)] for d in WEEKDAYS]
156
+ busiest_weekday = max(WEEKDAYS, key=lambda d: weekdays.get(d, 0)) if weekdays else "Monday"
157
+
158
+ top_projects = [[pretty_project(p), n] for p, n in by_project_prompts.most_common(8)]
159
+ top_model = models.most_common(1)[0][0] if models else "-"
160
+ top_tool = tools.most_common(1)[0][0] if tools else "-"
161
+
162
+ persona = build_persona(top_model, top_tool, peak_hour, night_pct, lang)
163
+ achievements = build_achievements(
164
+ user_prompts, tokens_total, len(projects_seen), peak_hour,
165
+ top_tool, len(sessions), len(models), busiest_day[1], lang,
166
+ )
167
+
168
+ return {
169
+ "meta": {
170
+ "lang": lang,
171
+ "generated": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
172
+ "start": first_ts.date().isoformat() if first_ts else "-",
173
+ "end": last_ts.date().isoformat() if last_ts else "-",
174
+ "days_active": days_active,
175
+ "player": os.environ.get("USER", "PLAYER 1"),
176
+ },
177
+ "totals": {
178
+ "sessions": len(sessions),
179
+ "projects": len(projects_seen),
180
+ "prompts": user_prompts,
181
+ "tokens_total": tokens_total,
182
+ "tokens_in": tok_in,
183
+ "tokens_out": tok_out,
184
+ "cache_read": cache_read,
185
+ "cache_create": cache_create,
186
+ "tool_calls": sum(tools.values()),
187
+ "assistant_msgs": assistant_msgs,
188
+ },
189
+ "models": models.most_common(8),
190
+ "tools": tools.most_common(6),
191
+ "hours": hours,
192
+ "weekdays": weekday_list,
193
+ "busiest_weekday": abbr[busiest_weekday],
194
+ "projects": top_projects,
195
+ "busiest_day": busiest_day,
196
+ "peak_hour": peak_hour,
197
+ "night_pct": night_pct,
198
+ "top_model": top_model,
199
+ "top_tool": top_tool,
200
+ "persona": persona,
201
+ "achievements": achievements,
202
+ }
203
+
204
+
205
+ def build_persona(top_model, top_tool, peak_hour, night_pct, lang):
206
+ model_short = top_model.replace("claude-", "").replace("-", " ").upper()
207
+ variants = {
208
+ "night": ("THE NIGHT CODER", "Tu codes quand le reste du monde dort.",
209
+ "You code while the rest of the world sleeps."),
210
+ "early": ("THE EARLY GRINDER", "Premier au clavier, café en main.",
211
+ "First to the keyboard, coffee in hand."),
212
+ "bash": ("THE TERMINAL GLADIATOR", "Le shell est ton arène.",
213
+ "The shell is your arena."),
214
+ "smith": ("THE CODE SMITH", "Tu forges du code, ligne par ligne.",
215
+ "You forge code, line by line."),
216
+ "builder":("THE BUILDER", "Tu construis, sans relâche.",
217
+ "You build, relentlessly."),
218
+ }
219
+ if night_pct >= 35:
220
+ key = "night"
221
+ elif 5 <= peak_hour <= 9:
222
+ key = "early"
223
+ elif top_tool == "Bash":
224
+ key = "bash"
225
+ elif top_tool in ("Edit", "Write"):
226
+ key = "smith"
227
+ else:
228
+ key = "builder"
229
+ title, sub_fr, sub_en = variants[key]
230
+ return {"title": title, "sub": sub_fr if lang == "fr" else sub_en, "main_model": model_short}
231
+
232
+
233
+ def build_achievements(prompts, tokens, projects, peak_hour, top_tool,
234
+ sessions, n_models, busy_day, lang):
235
+ fr = lang == "fr"
236
+ a = []
237
+
238
+ def add(icon, title_fr, title_en, desc_fr, desc_en):
239
+ a.append({"icon": icon,
240
+ "title": title_fr if fr else title_en,
241
+ "desc": desc_fr if fr else desc_en})
242
+
243
+ if prompts >= 500:
244
+ add("💬", "PROLIFIQUE", "PROLIFIC", f"{prompts} prompts envoyés", f"{prompts} prompts sent")
245
+ if tokens >= 1_000_000_000:
246
+ add("🪙", "TOKEN BILLIONAIRE", "TOKEN BILLIONAIRE",
247
+ f"{tokens/1e9:.1f}B tokens traités", f"{tokens/1e9:.1f}B tokens processed")
248
+ elif tokens >= 100_000_000:
249
+ add("🪙", "TOKEN TYCOON", "TOKEN TYCOON",
250
+ f"{tokens/1e6:.0f}M tokens traités", f"{tokens/1e6:.0f}M tokens processed")
251
+ if projects >= 20:
252
+ add("🗺️", "EXPLORATEUR", "EXPLORER", f"{projects} projets visités", f"{projects} projects visited")
253
+ if peak_hour >= 22 or peak_hour < 5:
254
+ add("🦉", "NOCTAMBULE", "NIGHT OWL", f"Pic d'activité à {peak_hour}h", f"Peak activity at {peak_hour}:00")
255
+ elif 5 <= peak_hour <= 9:
256
+ add("🌅", "LÈVE-TÔT", "EARLY BIRD", f"Pic d'activité à {peak_hour}h", f"Peak activity at {peak_hour}:00")
257
+ if top_tool == "Bash":
258
+ add("⚔️", "TERMINAL MASTER", "TERMINAL MASTER",
259
+ "Bash, ton arme de prédilection", "Bash, your weapon of choice")
260
+ if sessions >= 40:
261
+ add("🏃", "MARATHONIEN", "MARATHONER", f"{sessions} sessions jouées", f"{sessions} sessions played")
262
+ if n_models >= 5:
263
+ add("🎭", "COLLECTIONNEUR", "COLLECTOR", f"{n_models} modèles utilisés", f"{n_models} models used")
264
+ if busy_day >= 200:
265
+ add("🔥", "JOURNÉE DE FEU", "DAY ON FIRE", f"{busy_day} events en 1 jour", f"{busy_day} events in 1 day")
266
+ return a
267
+
268
+
269
+ def _font_faces() -> str:
270
+ """@font-face en base64 (polices néo-brutalistes) pour un HTML 100% autonome."""
271
+ fonts_dir = Path(__file__).parent / "fonts"
272
+ faces = [("Grotesk", 700, "SpaceGrotesk-700.woff2"),
273
+ ("Grotesk", 500, "SpaceGrotesk-500.woff2"),
274
+ ("Mono", 700, "SpaceMono-700.woff2")]
275
+ css = ""
276
+ for family, weight, fn in faces:
277
+ p = fonts_dir / fn
278
+ if not p.exists():
279
+ continue
280
+ b64 = base64.b64encode(p.read_bytes()).decode()
281
+ css += (f"@font-face{{font-family:'{family}';font-weight:{weight};font-display:block;"
282
+ f"src:url(data:font/woff2;base64,{b64}) format('woff2');}}")
283
+ return css
284
+
285
+
286
+ def render_html(data: dict, template_path: str) -> str:
287
+ tpl = Path(template_path).read_text(encoding="utf-8")
288
+ tpl = tpl.replace("/*__FONTS__*/", _font_faces())
289
+ return tpl.replace("/*__WRAPPED_DATA__*/", json.dumps(data, ensure_ascii=False))
290
+
291
+
292
+ # --- Optional PNG export (one image per screen) via a headless browser ---
293
+ # No Python dependency added: uses a system Chrome/Chromium if available.
294
+
295
+ CHROME_CANDIDATES = ["google-chrome-stable", "google-chrome", "chromium",
296
+ "chromium-browser", "chrome"]
297
+
298
+
299
+ def find_chrome() -> str | None:
300
+ for c in CHROME_CANDIDATES:
301
+ p = shutil.which(c)
302
+ if p:
303
+ return p
304
+ return None
305
+
306
+
307
+ def _run(chrome: str, args: list, **kw):
308
+ return subprocess.run([chrome, "--headless", "--no-sandbox", "--disable-gpu",
309
+ "--hide-scrollbars"] + args,
310
+ capture_output=True, text=True, timeout=90, **kw)
311
+
312
+
313
+ def slide_count(chrome: str, url: str) -> int | None:
314
+ """Read data-slides exposed by the page (falls back gracefully)."""
315
+ try:
316
+ out = _run(chrome, ["--virtual-time-budget=2500", "--dump-dom", url]).stdout
317
+ m = re.search(r'data-slides="(\d+)"', out)
318
+ if m:
319
+ return int(m.group(1))
320
+ except Exception:
321
+ pass
322
+ return None
323
+
324
+
325
+ def _render_screens(chrome: str, base: str, out_dir: str, width: int, height: int) -> int:
326
+ """Capture chaque écran en PNG (mode ?shot). Retourne le nombre d'écrans."""
327
+ n = slide_count(chrome, base + "?shot=1") or 10
328
+ os.makedirs(out_dir, exist_ok=True)
329
+ for i in range(1, n + 1):
330
+ _run(chrome, [
331
+ "--force-device-scale-factor=2",
332
+ f"--window-size={width},{height}",
333
+ "--virtual-time-budget=3000",
334
+ f"--screenshot={os.path.join(out_dir, f'slide_{i:02d}.png')}",
335
+ f"{base}?slide={i}&shot=1",
336
+ ])
337
+ return n
338
+
339
+
340
+ def render_images(html_path: str, out_dir: str, width: int, height: int) -> None:
341
+ chrome = find_chrome()
342
+ if not chrome:
343
+ print("⚠️ --images needs Chrome/Chromium (none found on PATH). Skipping image export.")
344
+ return
345
+ base = "file://" + os.path.abspath(html_path)
346
+ n = _render_screens(chrome, base, out_dir, width, height)
347
+ print(f"🖼️ {n} images ({width}x{height} @2x) → {out_dir}/")
348
+
349
+
350
+ def render_video(html_path: str, out_path: str, width: int, height: int,
351
+ dur: float, music: str | None) -> None:
352
+ """Diaporama vidéo (fondus enchaînés) : écrans PNG (Chrome) assemblés par ffmpeg."""
353
+ chrome, ffmpeg = find_chrome(), shutil.which("ffmpeg")
354
+ if not chrome or not ffmpeg:
355
+ miss = " + ".join(m for m, ok in [("Chrome/Chromium", chrome), ("ffmpeg", ffmpeg)] if not ok)
356
+ print(f"⚠️ --video needs {miss} (not found on PATH). Skipping video export.")
357
+ return
358
+ base = "file://" + os.path.abspath(html_path)
359
+ tmp = tempfile.mkdtemp(prefix="claude-wrapped-video-")
360
+ try:
361
+ n = _render_screens(chrome, base, tmp, width, height)
362
+ T = 0.5 # durée du fondu enchaîné
363
+ inputs = []
364
+ for i in range(1, n + 1):
365
+ inputs += ["-loop", "1", "-framerate", "30", "-t", str(dur),
366
+ "-i", os.path.join(tmp, f"slide_{i:02d}.png")]
367
+ has_music = bool(music) and os.path.exists(music or "")
368
+ if has_music:
369
+ inputs += ["-i", music]
370
+ if n == 1:
371
+ filt = "[0]format=yuv420p[v]"
372
+ else:
373
+ parts, prev = [], "[0]"
374
+ for k in range(1, n):
375
+ out = "[vx]" if k == n - 1 else f"[v{k}]"
376
+ parts.append(f"{prev}[{k}]xfade=transition=fade:duration={T}:offset={k*(dur-T):.3f}{out}")
377
+ prev = out
378
+ filt = ";".join(parts) + ";[vx]format=yuv420p[v]"
379
+ cmd = [ffmpeg, "-y", *inputs, "-filter_complex", filt, "-map", "[v]"]
380
+ if has_music:
381
+ cmd += ["-map", f"{n}:a", "-shortest", "-c:a", "aac"]
382
+ cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", out_path]
383
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
384
+ if r.returncode != 0:
385
+ print("⚠️ ffmpeg a échoué :", (r.stderr or "").strip().splitlines()[-1:] or "?")
386
+ return
387
+ print(f"🎬 vidéo ({n} écrans, ~{n * dur - (n - 1) * T:.0f}s"
388
+ f"{', musique' if has_music else ''}) → {out_path}")
389
+ finally:
390
+ shutil.rmtree(tmp, ignore_errors=True)
391
+
392
+
393
+ def main():
394
+ here = Path(__file__).parent
395
+ ap = argparse.ArgumentParser(description="Generate your Claude Code Wrapped.")
396
+ ap.add_argument("--projects-dir", default=os.path.expanduser("~/.claude/projects"))
397
+ ap.add_argument("--lang", choices=["fr", "en"], default="fr")
398
+ ap.add_argument("--days", type=int, default=None, help="Limit to the last N days")
399
+ ap.add_argument("--out", default=None)
400
+ ap.add_argument("--template", default=str(here / "template.html"))
401
+ ap.add_argument("--images", action="store_true",
402
+ help="Also export one PNG per screen (needs Chrome/Chromium)")
403
+ ap.add_argument("--images-dir", default=None,
404
+ help="Output folder for --images (default: <out folder>/images)")
405
+ ap.add_argument("--image-size", default="448x858",
406
+ help="WxH of exported images/video (default: 448x858, fills the phone frame)")
407
+ ap.add_argument("--video", action="store_true",
408
+ help="Also render a slideshow video (needs Chrome/Chromium + ffmpeg)")
409
+ ap.add_argument("--video-dur", type=float, default=3.0,
410
+ help="Seconds per screen in --video (default: 3)")
411
+ ap.add_argument("--video-music", default=None, help="Optional audio file for --video")
412
+ args = ap.parse_args()
413
+
414
+ out = args.out or ("claude_wrapped.html" if args.lang == "fr"
415
+ else "claude_wrapped.en.html")
416
+
417
+ since = None
418
+ if args.days:
419
+ since = datetime.now(timezone.utc) - timedelta(days=args.days)
420
+
421
+ print(f"⏳ Analyzing transcripts… (lang={args.lang})")
422
+ data = collect(args.projects_dir, since, args.lang)
423
+ html = render_html(data, args.template)
424
+ Path(out).write_text(html, encoding="utf-8")
425
+
426
+ t = data["totals"]
427
+ print("✅ Wrapped generated!")
428
+ print(f" → {out}")
429
+ print(f" Sessions: {t['sessions']} | Projects: {t['projects']} | Prompts: {t['prompts']}")
430
+ print(f" Tokens: {t['tokens_total']:,} | Top model: {data['top_model']} | Top tool: {data['top_tool']}")
431
+ print(f" Persona: {data['persona']['title']}")
432
+
433
+ if args.images or args.video:
434
+ try:
435
+ w, h = (int(x) for x in args.image_size.lower().split("x"))
436
+ except ValueError:
437
+ w, h = 448, 858
438
+ if args.images:
439
+ img_dir = args.images_dir or str(Path(out).resolve().parent / "images")
440
+ render_images(out, img_dir, w, h)
441
+ if args.video:
442
+ render_video(out, str(Path(out).with_suffix(".mp4")), w, h,
443
+ args.video_dur, args.video_music)
444
+
445
+
446
+ if __name__ == "__main__":
447
+ main()
@@ -0,0 +1,270 @@
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Claude Wrapped</title>
7
+ <style>
8
+ /*__FONTS__*/
9
+ :root{
10
+ --bg:#0C0C0E; --panel:#141417; --line:#2A2A31; --ink:#F4F3EE;
11
+ --dim:rgba(244,243,238,.52); --faint:rgba(244,243,238,.10);
12
+ --acc:#D6FF3D; --mono:"Mono",ui-monospace,Menlo,monospace;
13
+ --sans:"Grotesk","Helvetica Neue",Helvetica,Arial,sans-serif;
14
+ }
15
+ *{margin:0;padding:0;box-sizing:border-box}
16
+ html,body{height:100%}
17
+ body{background:#050506;color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;
18
+ display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:100vh}
19
+ .stage{position:relative;width:min(100vw,448px);height:min(100dvh,858px);container-type:size;
20
+ overflow:hidden;background:var(--bg);border:2px solid var(--line);
21
+ box-shadow:0 40px 120px rgba(0,0,0,.7);user-select:none}
22
+ @media(max-width:500px){.stage{width:100vw;height:100dvh;border:none}}
23
+ .chrome{position:absolute;top:0;left:0;right:0;z-index:20;padding:2.6cqh 5.5cqw 0}
24
+ .sysbar{display:flex;align-items:center;justify-content:space-between;font-family:var(--mono);
25
+ font-size:1.9cqh;font-weight:700;letter-spacing:.06em;color:var(--dim);text-transform:uppercase}
26
+ .sysbar .dot{color:var(--acc)}
27
+ .prog{display:flex;gap:1.4cqw;margin-top:1.6cqh}
28
+ .prog i{flex:1;height:6px;border:1.5px solid var(--line);overflow:hidden}
29
+ .prog i b{display:block;height:100%;width:0;background:var(--acc)}
30
+ .ctrl{position:absolute;bottom:2.6cqh;right:5cqw;z-index:25;display:flex;gap:2cqw}
31
+ .cbtn{width:6.4cqh;height:6.4cqh;border:1.5px solid var(--line);background:rgba(10,9,8,.5);
32
+ color:var(--ink);font-size:2.6cqh;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
33
+ .deck{position:absolute;inset:0;z-index:5}
34
+ .card{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:flex-end;
35
+ gap:3cqh;padding:12cqh 6cqw 13cqh;overflow:hidden;opacity:0;pointer-events:none;transition:opacity .28s ease;
36
+ background:radial-gradient(82cqw 60cqh at 82% 4%,color-mix(in srgb,var(--acc) 22%,transparent),transparent 60%),
37
+ radial-gradient(60cqw 48cqh at 6% 102%,color-mix(in srgb,var(--acc) 9%,transparent),transparent 55%),var(--bg)}
38
+ .card.active{opacity:1;pointer-events:auto}
39
+ .card.top{justify-content:flex-start;padding:12cqh 6cqw 6cqh;gap:2.6cqh}
40
+ .card::after{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;opacity:.35;mix-blend-mode:soft-light;
41
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E")}
42
+ .card>*{position:relative;z-index:2}
43
+ .viz{position:absolute;top:15cqh;left:6cqw;right:6cqw;z-index:2}
44
+ .chart{display:flex;gap:.5cqw;height:10cqh;align-items:flex-end}
45
+ .chart i{flex:1;background:color-mix(in srgb,var(--acc) 72%,transparent);height:0;transition:height .9s cubic-bezier(.2,.8,.2,1)}
46
+ .icon{font-family:var(--mono);font-weight:700;font-size:7.5cqh;line-height:1;color:var(--acc)}
47
+ .eyebrow{font-family:var(--mono);font-size:2cqh;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--acc)}
48
+ .block{align-self:flex-start;border:2.5px solid var(--ink);box-shadow:8px 8px 0 var(--acc);background:var(--panel);padding:1cqh 3cqw}
49
+ .year{font-family:var(--sans);font-weight:700;line-height:.8;letter-spacing:-.04em;font-size:min(15cqh,20cqw);color:var(--ink);white-space:nowrap}
50
+ .num{font-family:var(--sans);font-weight:700;line-height:.82;letter-spacing:-.03em;font-size:min(15cqh,23cqw);color:var(--ink);font-variant-numeric:tabular-nums;white-space:nowrap}
51
+ .unit{font-family:var(--mono);font-size:3cqh;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--acc)}
52
+ .joke{font-family:var(--mono);font-size:2.15cqh;line-height:1.4;color:rgba(244,243,238,.82);max-width:32ch}
53
+ .joke::before{content:"# ";color:var(--acc);font-weight:700}
54
+ .note{font-family:var(--mono);font-size:2cqh;line-height:1.4;color:var(--dim);max-width:34ch}
55
+ .title{font-family:var(--sans);font-weight:700;font-size:5.2cqh;letter-spacing:-.02em;line-height:1.02;max-width:15ch}
56
+ .title em{font-style:normal;color:var(--acc)}
57
+ .name{font-family:var(--sans);font-weight:700;line-height:.92;letter-spacing:-.02em;font-size:min(8.5cqh,12cqw);color:var(--acc);word-break:break-word}
58
+ .vtag{font-family:var(--sans);font-size:3.1cqh;font-weight:500;line-height:1.3;color:var(--ink);max-width:24ch;opacity:.92}
59
+ .badges{display:flex;flex-wrap:wrap;gap:1.6cqw}
60
+ .badge{font-family:var(--mono);font-size:1.9cqh;font-weight:700;letter-spacing:.02em;padding:.7cqh 2.4cqw;border:2px solid var(--acc);color:var(--acc)}
61
+ .rank{display:flex;flex-direction:column;gap:1.15cqh;font-family:var(--mono);font-size:2.35cqh;font-weight:700}
62
+ .rank .r{display:flex;align-items:baseline;gap:2.6cqw}
63
+ .rank .r .i{color:var(--dim)}
64
+ .rank .r.top{font-size:3.1cqh;margin-bottom:.6cqh}
65
+ .rank .r.top .i,.rank .r.top .n{color:var(--acc)}
66
+ .rank .r .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
67
+ .rank .r .c{color:var(--dim);font-variant-numeric:tabular-nums}
68
+ .receipt{align-self:stretch;border:2.5px solid var(--ink);box-shadow:8px 8px 0 var(--acc);background:var(--panel);padding:2.4cqh 5cqw}
69
+ .receipt .rh{display:flex;justify-content:space-between;font-family:var(--mono);font-size:2cqh;font-weight:700;
70
+ letter-spacing:.1em;text-transform:uppercase;color:var(--acc);border-bottom:1px solid var(--line);padding-bottom:1.3cqh;margin-bottom:1.1cqh}
71
+ .receipt .row{display:flex;justify-content:space-between;gap:3cqw;font-family:var(--mono);font-size:2.3cqh;padding:.7cqh 0}
72
+ .receipt .row .k{color:var(--dim);text-transform:uppercase;letter-spacing:.06em;font-size:.82em;align-self:center}
73
+ .receipt .row .v{font-weight:700;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
74
+ .chip{align-self:flex-start;font-family:var(--mono);font-size:1.95cqh;font-weight:700;letter-spacing:.08em;
75
+ text-transform:uppercase;color:var(--ink);border:2px solid var(--line);padding:.7cqh 2.6cqw}
76
+ .chip .k{color:var(--acc)}
77
+ .cursor{display:inline-block;width:.09em;height:.74em;margin-left:.04em;background:var(--acc);vertical-align:baseline;animation:blink 1s steps(1) infinite}
78
+ @keyframes blink{50%{opacity:0}}
79
+ body.shot .chrome,body.shot .ctrl{display:none!important}
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <div class="stage" id="stage">
84
+ <div class="chrome"><div class="sysbar"><span><span class="dot">●</span> claude-wrapped</span>
85
+ <span id="counter">01 / 01</span></div><div class="prog" id="prog"></div></div>
86
+ <div class="deck" id="deck"></div>
87
+ <div class="ctrl"><button class="cbtn" id="prev">◄</button><button class="cbtn" id="next">►</button></div>
88
+ </div>
89
+ <script>
90
+ const DATA = /*__WRAPPED_DATA__*/;
91
+ const STRINGS = {
92
+ fr:{eyebrowIntro:"// ton année avec claude",titleA:"Tes ",titleB:"prompts",titleC:", wrappés.",
93
+ prompts:"prompts envoyés",promptsNote:(s,p,d)=>`sur ${s} sessions, ${p} projets, ${d} jours actifs.`,
94
+ tokensEye:"// tu as fait tourner",tokens:"tokens traités",tokensNote:(i,o,c)=>`${o} écrits par Claude · ${c} lus en cache`,
95
+ modelEye:"// ton modèle fétiche",toolEye:"// ton arme de prédilection",tool:"appels d'outils",
96
+ rhythmEye:"// ton rythme",rhythmTitleA:"Tu prompt surtout à ",rhythmTitleB:"h",
97
+ nightNote:(n)=>`${n}% de ton activité entre 22h et 5h.`,projEye:"// tes terrains de jeu",
98
+ verdictEye:"// verdict",recapEye:"// le récap",recap:"sessions · prompts · tokens",
99
+ wrap:"c'est ton année claude",genBy:"généré par claude-wrapped",chip:"▸ appuie pour démarrer"},
100
+ en:{eyebrowIntro:"// your year with claude",titleA:"Your ",titleB:"prompts",titleC:", wrapped.",
101
+ prompts:"prompts sent",promptsNote:(s,p,d)=>`across ${s} sessions, ${p} projects, ${d} active days.`,
102
+ tokensEye:"// you pushed",tokens:"tokens processed",tokensNote:(i,o,c)=>`${o} written by Claude · ${c} read from cache`,
103
+ modelEye:"// your main character",toolEye:"// your weapon of choice",tool:"tool calls",
104
+ rhythmEye:"// your rhythm",rhythmTitleA:"You mostly prompt at ",rhythmTitleB:":00",
105
+ nightNote:(n)=>`${n}% of your activity between 10pm and 5am.`,projEye:"// your playgrounds",
106
+ verdictEye:"// verdict",recapEye:"// the recap",recap:"sessions · prompts · tokens",
107
+ wrap:"that's your claude year",genBy:"generated by claude-wrapped",chip:"▸ tap to start"},
108
+ };
109
+ const S = STRINGS[(DATA.meta && DATA.meta.lang) || "fr"];
110
+ // --- punchlines sarcastiques ---
111
+ const JOKES = {
112
+ fr:{
113
+ prompts:n=> n<100?"Claude t'a à peine vu passer.":
114
+ n<500?"Le minimum syndical pour dire « j'utilise l'IA » en soirée.":
115
+ n<1500?"Tu as un problème. Un vrai. Et Claude n'est pas médecin.":
116
+ n<3000?"Tu ne codes plus, tu délègues ta vie. Et tu relis même pas.":
117
+ "À ce stade, Claude mérite un salaire, des congés, et une part de ton diplôme.",
118
+ tokens:n=> n>=1e9?"De quoi réécrire la bibliothèque d'Alexandrie. Toi, t'as pondu un CRUD.":
119
+ n>=1e8?"Des millions de tokens, et le bug est toujours là. Bravo l'équipe.":
120
+ "Radin en tokens. Ou alors t'abandonnes au premier « Réfléchissons ».",
121
+ tool:t=>({Bash:"rm -rf en confiance aveugle. Ton /home vit dangereusement.",
122
+ Edit:"Claude écrit, tu signes. Le CDI le plus tranquille de l'open space.",
123
+ Write:"Claude écrit, tu signes. Le CDI le plus tranquille de l'open space.",
124
+ Read:"Tu fais lire Claude et tu hoches la tête. Manager né."}[t]
125
+ || (t+" non-stop. Ta seule vraie constance.")),
126
+ rhythm:(np,ph)=> np>=35?"3h du mat, toi et Claude, seuls contre le linter et le bon sens.":
127
+ ph<=9?"Lève-tôt. Tes prompts sentent le café et le désespoir matinal.":
128
+ "Actif en journée. Le strict minimum pour faire semblant.",
129
+ model:m=> (m||"Ton modèle")+", ton bras droit. Et ton bras gauche. Et ton cerveau.",
130
+ projects:"Autant de projets ouverts. Combien finis ? On ne posera pas la question.",
131
+ recap:"Ton année Claude, résumée. Ne montre pas ça à un recruteur.",
132
+ },
133
+ en:{
134
+ prompts:n=> n<100?"Claude barely noticed you.":
135
+ n<500?"The bare minimum to say 'I use AI' at parties.":
136
+ n<1500?"You have a problem. A real one. Claude isn't a doctor.":
137
+ n<3000?"You don't code anymore, you delegate your life. And you don't even read it.":
138
+ "At this point, Claude deserves a salary, PTO, and a cut of your degree.",
139
+ tokens:n=> n>=1e9?"Enough tokens to rewrite the Library of Alexandria. You shipped a CRUD.":
140
+ n>=1e8?"Millions of tokens, and the bug is still there. Great teamwork.":
141
+ "Stingy with tokens. Or you bail at the first 'Let's think'.",
142
+ tool:t=>({Bash:"rm -rf on blind faith. Your /home lives dangerously.",
143
+ Edit:"Claude writes, you sign. The comfiest job in the office.",
144
+ Write:"Claude writes, you sign. The comfiest job in the office.",
145
+ Read:"You make Claude read and you nod along. Born manager."}[t]
146
+ || (t+" non-stop. Your only real consistency.")),
147
+ rhythm:(np,ph)=> np>=35?"3am, you and Claude, alone against the linter and common sense.":
148
+ ph<=9?"Early bird. Your prompts smell of coffee and morning despair.":
149
+ "Active by day. The bare minimum to look busy.",
150
+ model:m=> (m||"Your model")+", your right hand. And your left. And your brain.",
151
+ projects:"So many open projects. How many finished? We won't ask.",
152
+ recap:"Your Claude year, wrapped. Don't show this to a recruiter.",
153
+ },
154
+ };
155
+ const J = JOKES[(DATA.meta && DATA.meta.lang) || "fr"];
156
+ const ACCENTS = ["#D6FF3D","#FF5CA8","#4DE1FF","#FF7A3C","#9B7CFF","#4CE6A0"];
157
+ const T = DATA.totals || {}, M = DATA.meta || {};
158
+
159
+ function fmt(n){ n=+n||0;
160
+ if(n>=1e9) return (n/1e9).toFixed(n<1e10?1:0)+"B";
161
+ if(n>=1e6) return (n/1e6).toFixed(n<1e7?1:0)+"M";
162
+ if(n>=1e3) return (n/1e3).toFixed(n<1e4?1:0)+"k";
163
+ return String(n); }
164
+ function fmtFull(n){ return (+n||0).toLocaleString("fr-FR"); }
165
+ function el(t,c,h){ const e=document.createElement(t); if(c)e.className=c; if(h!=null)e.innerHTML=h; return e; }
166
+ function shortModel(m){ return (m||"-").replace("claude-","").replace(/-/g," ").toUpperCase(); }
167
+
168
+ const deck=document.getElementById("deck"), slides=[];
169
+ function card(ai){ const c=el("div","card"); c.style.setProperty("--acc",ACCENTS[ai%ACCENTS.length]); return c; }
170
+ function add(builder){ const c=builder(); deck.appendChild(c); slides.push(c); }
171
+ function chart(vals){ const max=Math.max(1,...vals); const v=el("div","viz"),ch=el("div","chart");
172
+ vals.forEach(val=>{ const b=el("i"); b.dataset.h=Math.max(3,Math.round(val/max*100)); ch.appendChild(b); });
173
+ v.appendChild(ch); return v; }
174
+ function rankList(items){ const box=el("div","rank");
175
+ items.slice(0,5).forEach((it,i)=>{ const r=el("div","r"+(i===0?" top":""));
176
+ r.appendChild(el("span","i",String(i+1).padStart(2,"0")));
177
+ r.appendChild(el("span","n",it[0])); r.appendChild(el("span","c",fmtFull(it[1]))); box.appendChild(r); });
178
+ return box; }
179
+
180
+ // --- slides ---
181
+ add(()=>{ const c=card(0); c.appendChild(el("div","eyebrow",S.eyebrowIntro));
182
+ const b=el("div","block"); const y=el("div","year","CLAUDE"); b.appendChild(y); c.appendChild(b);
183
+ c.appendChild(el("div","title",S.titleA+"<em>WRAPPED</em>"));
184
+ c.appendChild(el("div","note",`${(M.player||"PLAYER").toUpperCase()} · ${M.start||"?"} → ${M.end||"?"}`));
185
+ c.appendChild(el("div","chip",`<span class="k">▸</span> ${S.chip.replace("▸ ","")}`)); return c; });
186
+
187
+ add(()=>{ const c=card(1); c.appendChild(el("div","eyebrow","// tu as envoyé"));
188
+ const b=el("div","block"); const n=el("div","num",fmtFull(T.prompts||0)); b.appendChild(n); c.appendChild(b);
189
+ c.appendChild(el("div","unit",S.prompts));
190
+ c.appendChild(el("div","joke",J.prompts(T.prompts||0)));
191
+ c.appendChild(el("div","note",S.promptsNote(fmtFull(T.sessions||0),fmtFull(T.projects||0),fmtFull(M.days_active||0)))); return c; });
192
+
193
+ add(()=>{ const c=card(2); c.appendChild(el("div","eyebrow",S.tokensEye));
194
+ const b=el("div","block"); b.appendChild(el("div","num",fmt(T.tokens_total||0))); c.appendChild(b);
195
+ c.appendChild(el("div","unit",S.tokens));
196
+ c.appendChild(el("div","joke",J.tokens(T.tokens_total||0)));
197
+ c.appendChild(el("div","note",S.tokensNote(fmt(T.tokens_in),fmt(T.tokens_out),fmt(T.cache_read)))); return c; });
198
+
199
+ add(()=>{ const c=card(3); c.classList.add("top"); c.appendChild(el("div","icon","✦"));
200
+ c.appendChild(el("div","eyebrow",S.modelEye));
201
+ c.appendChild(el("div","name",shortModel(DATA.top_model)));
202
+ c.appendChild(rankList((DATA.models||[]).map(m=>[shortModel(m[0]),m[1]])));
203
+ c.appendChild(el("div","joke",J.model(shortModel(DATA.top_model)))); return c; });
204
+
205
+ add(()=>{ const c=card(4); c.classList.add("top"); c.appendChild(el("div","icon","⚔"));
206
+ c.appendChild(el("div","eyebrow",S.toolEye));
207
+ c.appendChild(el("div","name",DATA.top_tool||"-"));
208
+ c.appendChild(rankList((DATA.tools||[]).map(t=>[t[0],t[1]])));
209
+ c.appendChild(el("div","joke",J.tool(DATA.top_tool||""))); return c; });
210
+
211
+ add(()=>{ const c=card(5); c.appendChild(chart(DATA.hours||new Array(24).fill(0)));
212
+ c.appendChild(el("div","eyebrow",S.rhythmEye));
213
+ const t=el("div","title"); t.innerHTML=S.rhythmTitleA+`<em>${DATA.peak_hour||0}${S.rhythmTitleB}</em>.`;
214
+ c.appendChild(t); c.appendChild(el("div","joke",J.rhythm(DATA.night_pct||0,DATA.peak_hour||0)));
215
+ c.appendChild(el("div","note",S.nightNote(DATA.night_pct||0))); return c; });
216
+
217
+ if((DATA.projects||[]).length){ add(()=>{ const c=card(2); c.classList.add("top");
218
+ c.appendChild(el("div","icon","▚")); c.appendChild(el("div","eyebrow",S.projEye));
219
+ c.appendChild(rankList(DATA.projects));
220
+ c.appendChild(el("div","joke",J.projects)); return c; }); }
221
+
222
+ add(()=>{ const c=card(4); c.classList.add("top"); c.appendChild(el("div","icon","❖"));
223
+ c.appendChild(el("div","eyebrow",S.verdictEye));
224
+ c.appendChild(el("div","name",(DATA.persona&&DATA.persona.title)||"-"));
225
+ c.appendChild(el("div","vtag","« "+((DATA.persona&&DATA.persona.sub)||"")+" »"));
226
+ if((DATA.achievements||[]).length){ const b=el("div","badges");
227
+ DATA.achievements.slice(0,6).forEach(a=>b.appendChild(el("span","badge",(a.icon||"")+" "+a.title)));
228
+ c.appendChild(b); } return c; });
229
+
230
+ add(()=>{ const c=card(3); c.appendChild(el("div","eyebrow",S.recapEye));
231
+ const r=el("div","receipt"); const h=el("div","rh");
232
+ h.appendChild(el("span",null,(M.player||"claude").toUpperCase())); h.appendChild(el("span",null,(M.end||"").slice(0,4)));
233
+ r.appendChild(h);
234
+ [["sessions",fmtFull(T.sessions||0)],["prompts",fmtFull(T.prompts||0)],["tokens",fmt(T.tokens_total||0)],
235
+ ["modèle",shortModel(DATA.top_model)],["outil",DATA.top_tool||"-"],["profil",(DATA.persona&&DATA.persona.title)||"-"]]
236
+ .forEach(([k,v])=>{ const row=el("div","row"); row.appendChild(el("span","k",k)); row.appendChild(el("span","v",v)); r.appendChild(row); });
237
+ c.appendChild(r); c.appendChild(el("div","unit",S.wrap));
238
+ c.appendChild(el("div","joke",J.recap));
239
+ c.appendChild(el("div","note",S.genBy)); return c; });
240
+
241
+ // --- navigation ---
242
+ const prog=document.getElementById("prog"), counter=document.getElementById("counter");
243
+ slides.forEach(()=>{ const i=el("i"); i.appendChild(el("b")); prog.appendChild(i); });
244
+ const dots=[...prog.children], pad=n=>String(n).padStart(2,"0");
245
+ let idx=0;
246
+ function show(i){ i=Math.max(0,Math.min(slides.length-1,i)); idx=i;
247
+ slides.forEach((s,j)=>s.classList.toggle("active",j===i));
248
+ counter.textContent=pad(i+1)+" / "+pad(slides.length);
249
+ slides[i].querySelectorAll("[data-h]").forEach(b=>b.style.height=b.dataset.h+"%");
250
+ dots.forEach((d,j)=>{ d.firstChild.style.width=j<=i?"100%":"0%"; }); }
251
+ document.getElementById("next").onclick=e=>{e.stopPropagation();show(idx+1);};
252
+ document.getElementById("prev").onclick=e=>{e.stopPropagation();show(idx-1);};
253
+ document.getElementById("stage").addEventListener("click",e=>{ if(e.target.closest("button"))return;
254
+ const r=e.currentTarget.getBoundingClientRect(); show(idx+((e.clientX-r.left)<r.width*0.32?-1:1)); });
255
+ addEventListener("keydown",e=>{ if(e.key==="ArrowRight"||e.key===" "){show(idx+1);e.preventDefault();}
256
+ else if(e.key==="ArrowLeft")show(idx-1); });
257
+ let sx=null; const st=document.getElementById("stage");
258
+ st.addEventListener("touchstart",e=>sx=e.touches[0].clientX,{passive:true});
259
+ st.addEventListener("touchend",e=>{ if(sx==null)return; const dx=e.changedTouches[0].clientX-sx;
260
+ if(dx<-42)show(idx+1); else if(dx>42)show(idx-1); sx=null; });
261
+ show(0);
262
+
263
+ // mode capture (generate.py --images) : ?shot=1 masque le chrome, ?slide=N cible un écran
264
+ document.documentElement.dataset.slides = slides.length;
265
+ { const q=new URLSearchParams(location.search);
266
+ if(q.get("shot")){ document.body.classList.add("shot");
267
+ show(Math.max(1,Math.min(slides.length,+(q.get("slide")||1)))-1); } }
268
+ </script>
269
+ </body>
270
+ </html>
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: wrapped-claude
3
+ Version: 0.1.0
4
+ Summary: Ton année Claude Code, wrappée en néo-brutalisme (CLI). Adaptation de My Claude Wrapped (@HKafuiEPI).
5
+ Author: Salem Gnandi
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Salemgnk/claude-wrapped
8
+ Project-URL: Original, https://github.com/HKafuiEPI/my_claude_wrapped
9
+ Keywords: claude,claude-code,wrapped,cli,stats,recap
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ <div align="center">
21
+
22
+ # ◆ Claude Wrapped — Neo-Brutalist Edition
23
+
24
+ **Ton année [Claude Code](https://claude.com/claude-code), wrappée en néo-brutalisme.**
25
+ **Your Claude Code year, wrapped — neo-brutalist style.**
26
+
27
+ Lit tes transcripts Claude Code **en local** et génère une page HTML unique,
28
+ partageable et autonome — blocs à ombre dure, accents francs, polices mono, dans
29
+ l'esprit de [Git Wrapped](https://github.com/Salemgnk/git-wrapped).
30
+
31
+ `🇫🇷 Français` · `🇬🇧 English` · MIT
32
+
33
+ </div>
34
+
35
+ ---
36
+
37
+ > **Adaptation néo-brutaliste** de **[My Claude Wrapped — Arcade Edition](https://github.com/HKafuiEPI/my_claude_wrapped)**
38
+ > par **[@HKafuiEPI](https://github.com/HKafuiEPI)** (👏 tout le crédit de l'idée et de l'analyse des
39
+ > transcripts lui revient). Ici on garde son moteur, on change la peau : direction artistique
40
+ > néo-brutaliste au lieu de l'arcade rétro. / Neo-brutalist re-skin of **@HKafuiEPI**'s
41
+ > *My Claude Wrapped*; same engine, different art direction.
42
+
43
+ ## 🇫🇷 Français
44
+
45
+ ### C'est quoi ?
46
+
47
+ Un générateur qui transforme ton historique **local** Claude Code
48
+ (`~/.claude/projects/**/*.jsonl`) en une page « Wrapped » à la Spotify, look
49
+ **néo-brutaliste**. Aucune donnée n'est envoyée nulle part : tout tourne en local
50
+ et la page ne contient **que des chiffres agrégés** (jamais le contenu des conversations).
51
+
52
+ ### Installation & utilisation
53
+
54
+ Aucune dépendance — juste **Python 3.8+**.
55
+
56
+ ```bash
57
+ pip install wrapped-claude
58
+ wrapped-claude # FR -> claude_wrapped.html (commande installée)
59
+ wrapped-claude --video --lang en # toutes les options, ex.
60
+ ```
61
+
62
+ Ou depuis les sources — `python3 generate.py` équivaut à la commande `wrapped-claude` :
63
+
64
+ ```bash
65
+ git clone git@github.com:Salemgnk/claude-wrapped.git
66
+ cd claude-wrapped
67
+ python3 generate.py # FR -> claude_wrapped.html
68
+ python3 generate.py --lang en # EN -> claude_wrapped.en.html
69
+ python3 generate.py --days 30 # 30 derniers jours
70
+ python3 generate.py --images # exporte 1 PNG par écran -> images/
71
+ python3 generate.py --video # vidéo diaporama (fondus) -> claude_wrapped.mp4
72
+ python3 generate.py --video --video-music song.mp3 # + musique
73
+ ```
74
+
75
+ > **`--images`** capture chaque écran en PNG partageable ; **`--video`** les assemble en
76
+ > **mp4** (fondus enchaînés, ~3 s/écran, musique optionnelle). Nécessite **Chrome/Chromium**
77
+ > (et **ffmpeg** pour `--video`) ; aucune dépendance Python ajoutée, ignoré proprement si absent.
78
+ > **Exécutable uniquement en CLI** (données locales).
79
+
80
+ Puis ouvre le `.html` — navigation `←` `→`, `Espace`, clic ou swipe.
81
+
82
+ ## 🇬🇧 English
83
+
84
+ ### What is it?
85
+
86
+ A generator that turns your **local** Claude Code history
87
+ (`~/.claude/projects/**/*.jsonl`) into a Spotify-Wrapped-style page, **neo-brutalist**
88
+ look. Nothing is sent anywhere: everything runs locally and the page contains
89
+ **only aggregate numbers** (never conversation content).
90
+
91
+ ### Install & usage
92
+
93
+ No dependencies — just **Python 3.8+**.
94
+
95
+ ```bash
96
+ pip install wrapped-claude
97
+ wrapped-claude # FR -> claude_wrapped.html (installed command)
98
+ wrapped-claude --video --lang en # all options, e.g.
99
+ ```
100
+
101
+ Or from source — `python3 generate.py` is equivalent to the `wrapped-claude` command:
102
+
103
+ ```bash
104
+ git clone git@github.com:Salemgnk/claude-wrapped.git
105
+ cd claude-wrapped
106
+ python3 generate.py # FR -> claude_wrapped.html
107
+ python3 generate.py --lang en # EN -> claude_wrapped.en.html
108
+ python3 generate.py --images # export one PNG per screen -> images/
109
+ python3 generate.py --video # slideshow video (fades) -> claude_wrapped.mp4
110
+ python3 generate.py --video --video-music song.mp3 # + music
111
+ ```
112
+
113
+ > **`--images`** captures each screen as a shareable PNG; **`--video`** stitches them into an
114
+ > **mp4** (crossfades, ~3s/screen, optional music). Needs **Chrome/Chromium** (and **ffmpeg**
115
+ > for `--video`); no Python dependency added, skipped gracefully if missing. **CLI-only** (local
116
+ > data). Then open the `.html` and scroll.
117
+
118
+ ## Crédits / Credits
119
+
120
+ - Idée & moteur d'analyse / idea & parsing engine : **[@HKafuiEPI](https://github.com/HKafuiEPI)** — [My Claude Wrapped](https://github.com/HKafuiEPI/my_claude_wrapped)
121
+ - Direction artistique néo-brutaliste / neo-brutalist art direction : reprise de [Git Wrapped](https://github.com/Salemgnk/git-wrapped)
122
+ - Polices / fonts : Space Grotesk & Space Mono (OFL)
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ wrapped_claude/__init__.py
5
+ wrapped_claude/cli.py
6
+ wrapped_claude/template.html
7
+ wrapped_claude.egg-info/PKG-INFO
8
+ wrapped_claude.egg-info/SOURCES.txt
9
+ wrapped_claude.egg-info/dependency_links.txt
10
+ wrapped_claude.egg-info/entry_points.txt
11
+ wrapped_claude.egg-info/top_level.txt
12
+ wrapped_claude/fonts/SpaceGrotesk-500.woff2
13
+ wrapped_claude/fonts/SpaceGrotesk-700.woff2
14
+ wrapped_claude/fonts/SpaceMono-700.woff2
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wrapped-claude = wrapped_claude.cli:main
@@ -0,0 +1 @@
1
+ wrapped_claude