-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwrapped.py
More file actions
1618 lines (1450 loc) · 76.9 KB
/
Copy pathwrapped.py
File metadata and controls
1618 lines (1450 loc) · 76.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""claude-code-wrapped - spotify wrapped for your claude code usage.
claude code shows you this session's cost and forgets it. wrapped reads your
local ~/.claude transcripts (jsonl on disk, no api, no network, all local) and
turns a whole month (or all time) into one shareable card: total tokens, real
cost, busiest day, most-used tool, longest session, and the meme stat - how many
times claude told you "you're absolutely right".
every number is computed the honest way: assistant records that share a
requestId are one api turn with cumulative usage, so they are collapsed and
counted once (otherwise cost and tool counts inflate); cache reads bill at 0.1x
the input rate and cache writes at 1.25x. it proves itself on a bundled fixture
corpus, so the numbers are deterministic.
usage:
python3 wrapped.py interactive setup (pick profile, format, ...)
python3 wrapped.py profiles list the claude profiles it can read
python3 wrapped.py demo the text wrapped, on the fixtures
python3 wrapped.py wrap [dir] the text wrapped for a transcripts dir
python3 wrapped.py card [dir] --out <f> the shareable card, rendered to PNG
python3 wrapped.py selftest prove the stats on the fixtures
profiles: claude code stores transcripts per profile under $CLAUDE_CONFIG_DIR
(default ~/.claude). wrapped reads the ACTIVE profile by default, which
is what makes it agree with `/usage` when you have several. `profiles`
lists them; pass a dir to `wrap`/`card` to read a specific one.
card options:
--format square|wide aspect ratio (default square: universal, safe anywhere)
--share also print ready-to-paste X / LinkedIn / Slack captions
--html write the HTML instead of rasterizing to PNG
the card auto-renders to PNG via a headless chromium browser if one is found
(override with $CLAUDE_WRAPPED_BROWSER); otherwise it falls back to HTML.
windows: --month YYYY-MM (just that month) --all-time (default)
privacy: the card never shows repo names or paths. nothing leaves the machine.
output: --json emits the structured stats (on wrap).
point it at your own history:
python3 wrapped.py wrap ~/.claude/projects
python3 wrapped.py card ~/.claude/projects --month 2026-07 --format wide --share
"""
import contextlib
import glob
import io
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import urllib.parse
import zlib
from datetime import date, datetime, timedelta, timezone
from html import escape
# per-model price per 1,000,000 tokens: (input, output). cache reads bill at 0.1x
# the input rate, cache writes at 1.25x. unknown models fall back to opus-tier.
PRICING = {
"claude-fable-5": (10.0, 50.0),
"claude-opus-4-8": (5.0, 25.0),
"claude-opus-4-7": (5.0, 25.0),
"claude-opus-4-6": (5.0, 25.0),
"claude-opus-4-5": (5.0, 25.0),
"claude-sonnet-4-6": (3.0, 15.0),
"claude-sonnet-4-5": (3.0, 15.0),
"claude-haiku-4-5": (1.0, 5.0),
}
_DEFAULT_PRICE = (5.0, 25.0)
# the meme. counts every time an assistant turn concedes the point: "you're
# absolutely right" / "you are absolutely right", any case, straight or curly
# apostrophe. per-occurrence, so a turn that says it twice counts twice.
_ABSOLUTELY_RIGHT = re.compile(r"\byou(?:['’])?re\s+absolutely\s+right\b"
r"|\byou\s+are\s+absolutely\s+right\b", re.IGNORECASE)
_MONTHS = ["jan", "feb", "mar", "apr", "may", "jun",
"jul", "aug", "sep", "oct", "nov", "dec"]
# the "get your own" the card + text wrapped show so a reshare drives people back.
# one place to change it.
REPO = "github.com/adamentwistle/claude-code-wrapped"
def _price(model):
if model:
for key, price in PRICING.items():
if model == key or model.startswith(key):
return price
return _DEFAULT_PRICE
def _int(v):
try:
return int(v or 0)
except (TypeError, ValueError):
return 0
def _ts(raw):
if not raw:
return None
try:
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except ValueError:
return None
# normalize to tz-aware (assume UTC when a stamp carries no offset) so a
# transcript mixing naive and aware timestamps can still be min/max'd.
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
def _cost(model, inp_t, out_t, cache_r, cache_c):
inp, out = _price(model)
return (inp_t * inp + out_t * out + cache_r * (inp * 0.1)
+ cache_c * (inp * 1.25)) / 1e6
def _usage_total(u):
"""Sum of the four token fields in a usage dict (0 for an empty/none dict)."""
if not isinstance(u, dict):
return 0
return (_int(u.get("input_tokens")) + _int(u.get("output_tokens"))
+ _int(u.get("cache_read_input_tokens")) + _int(u.get("cache_creation_input_tokens")))
def parse_run(path):
"""Parse one transcript jsonl into a normalized per-session record. Assistant
records that share a requestId are one api turn with cumulative usage, so they
are collapsed (counted once) - otherwise cost and tool counts inflate."""
records = []
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if isinstance(rec, dict):
records.append(rec)
groups, order = {}, []
seen_tool_ids, times, absolutely_right, cwds = set(), [], 0, []
for r in records:
ts = _ts(r.get("timestamp"))
if ts:
times.append(ts)
msg = r.get("message")
if not isinstance(msg, dict):
msg = {}
content = msg.get("content")
if not isinstance(content, list):
content = []
if r.get("type") != "assistant" or not r.get("uuid"):
continue
if r.get("cwd"):
cwds.append(r["cwd"])
# the meme is what claude told YOU - count main-thread turns only, not
# subagent (sidechain) self-talk you never saw.
if not r.get("isSidechain"):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
absolutely_right += len(_ABSOLUTELY_RIGHT.findall(block.get("text") or ""))
rid = r.get("requestId") or r["uuid"]
if rid not in groups:
groups[rid] = {"usage": {}, "model": None, "tools": []}
order.append(rid)
g = groups[rid]
# records sharing a requestId repeat cumulative usage; keep the one with
# the most tokens so an empty or partial trailing usage cannot zero it out.
u = msg.get("usage")
if isinstance(u, dict) and _usage_total(u) >= _usage_total(g["usage"]):
g["usage"] = u
if msg.get("model"):
g["model"] = msg["model"]
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
bid = block.get("id")
if bid in seen_tool_ids:
continue
seen_tool_ids.add(bid)
g["tools"].append(block.get("name") or "?")
input_t = output_t = cache_r = cache_c = 0
cost = 0.0
model_counts, tools = {}, {}
for rid in order:
g = groups[rid]
u = g["usage"]
it, ot = _int(u.get("input_tokens")), _int(u.get("output_tokens"))
cr, cc = _int(u.get("cache_read_input_tokens")), _int(u.get("cache_creation_input_tokens"))
input_t += it
output_t += ot
cache_r += cr
cache_c += cc
cost += _cost(g["model"], it, ot, cr, cc)
if g["model"]:
model_counts[g["model"]] = model_counts.get(g["model"], 0) + 1
for name in g["tools"]:
tools[name] = tools.get(name, 0) + 1
started, ended = (min(times), max(times)) if times else (None, None)
span_ms = int((ended - started).total_seconds() * 1000) if times and ended > started else 0
model = max(model_counts, key=model_counts.get) if model_counts else None
run_id = next((r.get("sessionId") for r in records if r.get("sessionId")),
os.path.splitext(os.path.basename(path))[0])
# a real transcript has at least one conversational turn; a bare metadata /
# sidecar .jsonl has none and is not a session.
is_transcript = any(r.get("type") in ("user", "assistant") for r in records)
# the project is the working directory's basename - the only name-bearing
# field wrapped touches. it stays in the local --json output only; the card
# and the terminal wrapped never show it.
project = os.path.basename(max(set(cwds), key=cwds.count)) if cwds else None
return {
"run_id": run_id, "project": project, "model": model,
"is_transcript": is_transcript,
"input_tokens": input_t, "output_tokens": output_t,
"cache_read": cache_r, "cache_creation": cache_c,
# total throughput: every token the model actually processed, cache
# included - cache reads are real context, and they are most of the bill.
"tokens": input_t + output_t + cache_r + cache_c, "cost_usd": round(cost, 6),
"tools": tools, "tool_calls": sum(tools.values()),
"absolutely_right": absolutely_right,
"started": started.isoformat() if started else None,
"ended": ended.isoformat() if ended else None, "span_ms": span_ms,
}
def ingest_dir(directory):
"""Parse every *.jsonl transcript under a directory into per-session records,
skipping non-transcript sidecars, sorted by start time. Searches recursively,
so a projects/ root that nests one dir per project works without naming each.
Dedup is within a file: claude code writes one session per jsonl and does not
repeat a requestId across files (verified on real history), so this is correct
for the on-disk format. One unparseable file is skipped, not fatal."""
paths = glob.glob(os.path.join(directory, "*.jsonl"))
paths += glob.glob(os.path.join(directory, "**", "*.jsonl"), recursive=True)
runs = []
for p in sorted(set(paths)):
try:
r = parse_run(p)
except Exception as e: # a corrupt file must not take down the whole wrapped
print("skipped %s (%s)" % (os.path.basename(p), e), file=sys.stderr)
continue
if r["is_transcript"]:
runs.append(r)
return sorted(runs, key=lambda r: r["started"] or "")
def filter_month(runs, month):
"""Keep only sessions that started in `month` (a YYYY-MM string)."""
if not month:
return runs
return [r for r in runs if (r["started"] or "")[:7] == month]
def default_transcript_dir():
"""The transcripts of the ACTIVE claude profile. Claude Code stores config +
transcripts under $CLAUDE_CONFIG_DIR (default ~/.claude), so honoring it is
what makes wrapped agree with `/usage` when several profiles exist on one box."""
cfg = os.environ.get("CLAUDE_CONFIG_DIR")
if cfg and os.path.isdir(os.path.join(cfg, "projects")):
return os.path.join(cfg, "projects")
return os.path.expanduser("~/.claude/projects")
def discover_profiles():
"""Every claude config dir on this machine that has transcripts, most-populated
first, with the active one flagged. Claude Code keeps each profile in its own
~/.claude* directory; wrapped only ever reads one unless you point it at more."""
home = os.path.expanduser("~")
active = os.environ.get("CLAUDE_CONFIG_DIR")
active_real = os.path.realpath(active) if active else None
bases = ([active] if active else []) + sorted(glob.glob(os.path.join(home, ".claude*")))
profiles, seen = [], set()
for base in bases:
if not base or not os.path.isdir(base):
continue
pdir = os.path.join(base, "projects")
if not os.path.isdir(pdir):
continue
real = os.path.realpath(pdir)
if real in seen:
continue
seen.add(real)
files = len(glob.glob(os.path.join(pdir, "*.jsonl")))
files += len(glob.glob(os.path.join(pdir, "**", "*.jsonl"), recursive=True))
profiles.append({"name": os.path.basename(base.rstrip("/")), "dir": pdir,
"files": files, "active": os.path.realpath(base) == active_real})
profiles.sort(key=lambda p: (not p["active"], -p["files"]))
return profiles
def _tool_histogram(runs):
hist = {}
for r in runs:
for name, count in r["tools"].items():
hist[name] = hist.get(name, 0) + count
return dict(sorted(hist.items(), key=lambda kv: (-kv[1], kv[0])))
def _by_day(runs):
days = {}
for r in runs:
day = (r["started"] or "")[:10] or "unknown"
d = days.setdefault(day, {"tokens": 0, "sessions": 0})
d["tokens"] += r["tokens"]
d["sessions"] += 1
return days
def _by_project(runs):
projects = {}
for r in runs:
name = r["project"] or "unknown"
projects[name] = projects.get(name, 0) + r["tokens"]
return projects
def wrapped_stats(runs, window="all time"):
"""The whole card in one structured dict: the headline stats, an honest token
breakdown (cache reads are most of it), a daily activity series for the viz,
and the ranked data-driven insights. `top_project` never reaches the card or
the terminal output; it lives in the local --json only."""
hist = _tool_histogram(runs)
top_tool = next(iter(hist.items()), None)
days = _by_day(runs)
busiest = max(days.items(), key=lambda kv: (kv[1]["tokens"], kv[0])) if days else None
longest = max(runs, key=lambda r: r["span_ms"]) if runs else None
projects = _by_project(runs)
top_project = max(projects.items(), key=lambda kv: (kv[1], kv[0])) if projects else None
inp = sum(r["input_tokens"] for r in runs)
out = sum(r["output_tokens"] for r in runs)
cache_r = sum(r["cache_read"] for r in runs)
cache_c = sum(r["cache_creation"] for r in runs)
total = inp + out + cache_r + cache_c
stats = {
"window": window,
"sessions": len(runs),
# tokens processed = everything the model handled (cache is ~90%+ of it).
# the breakdown keeps it honest: "from cache" (re-read context) vs "new".
"total_tokens": total,
"tokens_from_cache": cache_r,
"tokens_new": inp + out + cache_c,
"input_tokens": inp, "output_tokens": out,
"cache_read": cache_r, "cache_creation": cache_c,
# cost is an api-EQUIVALENT: what these tokens would bill at api rates.
# on a flat subscription it is not what you paid.
"cost_usd": round(sum(r["cost_usd"] for r in runs), 6),
"tool_calls": sum(r["tool_calls"] for r in runs),
"distinct_tools": len(hist),
"top_tool": {"name": top_tool[0], "count": top_tool[1]} if top_tool else None,
"absolutely_right": sum(r["absolutely_right"] for r in runs),
"longest_session": {"run_id": longest["run_id"], "span_ms": longest["span_ms"],
"human": fmt_duration(longest["span_ms"])} if longest else None,
"busiest_day": {"date": busiest[0], "tokens": busiest[1]["tokens"],
"label": fmt_day(busiest[0])} if busiest else None,
"top_project": {"name": top_project[0], "tokens": top_project[1]} if top_project else None,
"activity": [{"date": d, "tokens": v["tokens"]} for d, v in sorted(days.items())],
}
stats["insights"] = insights(stats)
return stats
# ---- the insight engine (data-driven, deterministic, offline) ---------------
# each rule fires on a condition and renders a dry one-liner; the highest-scoring
# firing rule is the card's punchline. this is what makes every card different -
# your data picks your roast. no model, no network.
def _cache_share(s):
return s["tokens_from_cache"] / s["total_tokens"] if s["total_tokens"] else 0.0
def _insight_rules():
"""(id, fires(s)->bool, score(s)->float, line(s)->str). scores decide the pick;
a few scale with how extreme the number is, so the sharpest stat wins. lines
stay short (one punchline per card) so the insight band never truncates."""
return [
("absolutely-right-zero", lambda s: s["absolutely_right"] == 0, lambda s: 6.0,
lambda s: "claude never once said \"you're absolutely right\". respect, or fear."),
("absolutely-right", lambda s: s["absolutely_right"] > 0,
lambda s: 5.0 + min(s["absolutely_right"], 20) * 0.1,
lambda s: "claude said \"you're absolutely right\" %s times. it meant it maybe %s."
% (format(s["absolutely_right"], ","), format(s["absolutely_right"] // 2, ","))),
("cache-heavy", lambda s: _cache_share(s) >= 0.85,
lambda s: 5.5 + (_cache_share(s) - 0.85) * 8,
lambda s: "%d%% of your tokens were cache reads, not fresh work." % round(100 * _cache_share(s))),
("marathon", lambda s: s["longest_session"] and s["longest_session"]["span_ms"] >= 4 * 3600000,
lambda s: 5.2,
lambda s: "your longest session ran %s. that is a hostage situation." % s["longest_session"]["human"]),
("big-spend", lambda s: s["cost_usd"] >= 50,
lambda s: 4.5 + min(s["cost_usd"] / 100, 4),
lambda s: "$%s at api-equivalent rates. your plan is either a bargain or a problem." % format(round(s["cost_usd"]), ",")),
("bash-brain", lambda s: s["top_tool"] and s["top_tool"]["name"] == "Bash"
and s["tool_calls"] and s["top_tool"]["count"] / s["tool_calls"] >= 0.4,
lambda s: 4.2,
lambda s: "you reached for bash %s times. every problem is a shell command now." % format(s["top_tool"]["count"], ",")),
("many-sessions", lambda s: s["sessions"] >= 40,
lambda s: 4.0,
lambda s: "%s sessions. claude is your most-contacted coworker." % format(s["sessions"], ",")),
("tool-variety", lambda s: s["distinct_tools"] >= 8,
lambda s: 3.5,
lambda s: "%d different tools this year. renaissance developer, or just indecisive." % s["distinct_tools"]),
("busiest", lambda s: s["busiest_day"],
lambda s: 3.0,
lambda s: "busiest day: %s. the git log knows what happened." % s["busiest_day"]["label"]),
("fallback", lambda s: True, lambda s: 1.0,
lambda s: "%s tokens, %s sessions, one you. that was the year." % (fmt_tokens(s["total_tokens"]), format(s["sessions"], ","))),
]
def insights(s):
"""Every firing rule, ranked by score (highest first). The card takes the top
line as its punchline and the next as a secondary; ties break by rule order."""
fired = []
for i, (rid, fires, score, line) in enumerate(_insight_rules()):
try:
if fires(s):
fired.append({"id": rid, "score": round(score(s), 3), "line": line(s), "_o": i})
except Exception:
continue
fired.sort(key=lambda x: (-x["score"], x["_o"]))
for f in fired:
del f["_o"]
return fired
# ---- formatting -------------------------------------------------------------
def fmt_tokens(n):
"""Compact token count: 128400000 -> 128.4M, 14200 -> 14.2K."""
n = int(n)
for unit, size in (("B", 1e9), ("M", 1e6), ("K", 1e3)):
if n >= size:
return "%.1f%s" % (n / size, unit)
return str(n)
def fmt_dollars(v):
"""$1,284.57 - two decimals, thousands separators."""
return "$%s" % format(round(float(v), 2), ",.2f")
def fmt_duration(ms):
"""Wall-clock span: 1200000 -> 20m, 11520000 -> 3h 12m, 1019880000 -> 11d 19h.
A "session" is one transcript file's first-to-last timestamp, so a resumed
conversation can legitimately span days."""
total_m = int(ms) // 60000
d, rem = divmod(total_m, 1440)
h, m = divmod(rem, 60)
if d:
return "%dd %dh" % (d, h)
return "%dh %dm" % (h, m) if h else "%dm" % m
def fmt_day(date_str):
"""2026-06-30 -> jun 30."""
d = _ts(date_str + "T00:00:00")
return "%s %d" % (_MONTHS[d.month - 1], d.day) if d else date_str
def _pretty_tool(name):
"""Shorten a tool name for display: mcp__server__tool -> tool. Real transcripts
carry mcp tool names ~40 chars long that would overflow a fixed card. Counting
always uses the full name; this is display only."""
if name.startswith("mcp__"):
return name.split("__")[-1] or name
return name
def _clip(text, n):
return text if len(text) <= n else text[:n - 1] + "…"
# ---- the text wrapped (m1 end-to-end deliverable) ---------------------------
def _wrap(text, w):
"""Greedy word-wrap into lines of at most w chars."""
lines, cur = [], ""
for word in text.split():
if cur and len(cur) + 1 + len(word) > w:
lines.append(cur)
cur = word
else:
cur = (cur + " " + word).strip()
if cur:
lines.append(cur)
return lines
def _color_on():
"""Colour + rich ascii when writing to a real terminal; plain when piped."""
if os.environ.get("CLAUDE_WRAPPED_FORCE_COLOR"):
return True
return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
and os.environ.get("TERM") != "dumb")
_RESET, _BOLD = "\x1b[0m", "\x1b[1m"
def _rgb(r, g, b):
return "\x1b[38;2;%d;%d;%dm" % (r, g, b)
_AMBER, _ABRIGHT = _rgb(255, 180, 84), _rgb(255, 206, 133)
_INK, _MUTED, _FAINT = _rgb(238, 224, 202), _rgb(150, 133, 108), _rgb(112, 98, 80)
_CACHE = _rgb(140, 102, 55)
def _c(text, *codes):
return ("".join(codes) + text + _RESET) if _color_on() else text
# a 5-row block font for the hero number (the centrepiece of the terminal view).
_BIGFONT = {
"0": [" ██ ", "█ █", "█ █", "█ █", " ██ "], "1": [" █ ", " ██ ", " █ ", " █ ", "████"],
"2": ["███ ", " █", " ██ ", "█ ", "████"], "3": ["███ ", " █", " ██ ", " █", "███ "],
"4": ["█ █", "█ █", "████", " █", " █"], "5": ["████", "█ ", "███ ", " █", "███ "],
"6": [" ██ ", "█ ", "███ ", "█ █", " ██ "], "7": ["████", " █", " █ ", " █ ", " █ "],
"8": [" ██ ", "█ █", " ██ ", "█ █", " ██ "], "9": [" ██ ", "█ █", " ███", " █", " ██ "],
".": [" ", " ", " ", " ", "██"], "M": ["█ █", "██ ██", "█ █ █", "█ █", "█ █"],
"K": ["█ █", "█ █ ", "██ ", "█ █ ", "█ █"], "B": ["███ ", "█ █", "███ ", "█ █", "███ "],
}
def _bignum(text):
rows = ["", "", "", "", ""]
for ch in text:
g = _BIGFONT.get(ch)
if g:
for i in range(5):
rows[i] += g[i] + " "
return rows
_SPARK = "▁▂▃▄▅▆▇█"
def _calendar_series(activity, limit):
"""The activity series made honest for a chart: zero-fill the active-days-only
series to the full calendar span (a day off is a gap, not a missing bar), then
when the span is longer than `limit` days, bucket consecutive days so the whole
window still fits. Returns (buckets, days_per_bucket); each bucket is
{"date": <start date iso>, "tokens": <sum>}. days_per_bucket == 1 means daily."""
days = {d["date"]: d["tokens"] for d in activity
if re.match(r"^\d{4}-\d{2}-\d{2}$", d["date"] or "")}
if not days:
return [], 1
d0, d1 = date.fromisoformat(min(days)), date.fromisoformat(max(days))
span = (d1 - d0).days + 1
k = max(1, -(-span // limit)) # ceil(span / limit)
buckets, cur = [], d0
while cur <= d1:
tokens = sum(days.get((cur + timedelta(days=i)).isoformat(), 0) for i in range(k))
buckets.append({"date": cur.isoformat(), "tokens": tokens})
cur += timedelta(days=k)
return buckets, k
def _sparkline(activity, limit=56):
"""A coloured sparkline over the FULL window (peak bright), + its date span.
Zero-filled and bucketed via _calendar_series so a long window compresses
instead of silently dropping its start."""
series, k = _calendar_series(activity, limit)
if not series:
return "", ""
peak = max(d["tokens"] for d in series) or 1
peak_i = max(range(len(series)), key=lambda i: series[i]["tokens"])
wide = 2 if len(series) <= 28 else 1 # chunky bars while they fit
bar = ""
for i, d in enumerate(series):
ch = _SPARK[min(7, int(round(7 * d["tokens"] / peak)))] * wide
bar += _c(ch, _ABRIGHT, _BOLD) if i == peak_i else _c(ch, _CACHE)
dates = sorted(d["date"] for d in activity if re.match(r"^\d{4}-\d{2}-\d{2}$", d["date"] or ""))
span = (fmt_day(dates[0]) + " to " + fmt_day(dates[-1])
if len(dates) > 1 else fmt_day(dates[0]))
if k > 1:
span += " (%dd per bar)" % k
return bar, span
def print_wrapped(s):
"""The wrapped in the terminal: a block-font hero number, a colour-coded stat
grid, a sparkline of daily activity, and the punchline. Colour + rich ascii on
a tty; a clean plain version when piped (NO_COLOR / not a terminal)."""
P, W = " ", 60
print()
print(P + _c("●", _FAINT) + _c(" ●", _MUTED) + _c(" ●", _AMBER) + " "
+ _c("claude code", _INK, _BOLD) + _c(" · ", _FAINT) + _c("wrapped", _AMBER, _BOLD)
+ " " + _c("[ %s ]" % s["window"], _FAINT))
print(P + _c("─" * W, _FAINT))
print()
print(P + _c("TOKENS PROCESSED", _FAINT))
num, unit = _split_compact(fmt_tokens(s["total_tokens"]))
for row in _bignum(num + unit):
print(P + _c(row, _AMBER, _BOLD))
total = s["total_tokens"] or 1
fill = max(0, min(30, int(round(30 * s["tokens_from_cache"] / total))))
print(P + _c("cache included ", _FAINT) + "["
+ _c("█" * fill, _CACHE) + _c("█" * (30 - fill), _ABRIGHT) + "] "
+ _c(fmt_tokens(s["tokens_from_cache"]), _INK) + _c(" cache", _FAINT)
+ _c(" · ", _FAINT) + _c(fmt_tokens(s["tokens_new"]), _INK) + _c(" new", _FAINT))
print()
tool = "-"
if s["top_tool"]:
tool = "%s · %s" % (_clip(_pretty_tool(s["top_tool"]["name"]), 12), format(s["top_tool"]["count"], ","))
cells = [
(fmt_dollars(s["cost_usd"]), "cost (api-equiv)"),
(format(s["sessions"], ","), "sessions"),
(format(s["tool_calls"], ","), "tool calls"),
(tool, "top tool"),
(s["longest_session"]["human"] if s["longest_session"] else "-", "longest session"),
(s["busiest_day"]["label"] if s["busiest_day"] else "-", "busiest day"),
]
col = 20
for triad in (cells[0:3], cells[3:6]):
print(P + "".join(_c(v.ljust(col), _ABRIGHT, _BOLD) for v, _ in triad))
print(P + "".join(_c(l.ljust(col), _FAINT) for _, l in triad))
print()
bar, span = _sparkline(s["activity"])
if bar and s["busiest_day"]:
note = _c("peak · %s · %s" % (s["busiest_day"]["label"], fmt_tokens(s["busiest_day"]["tokens"])), _AMBER)
print(P + _c("DAILY ACTIVITY", _FAINT) + " " + note)
print(P + bar)
print(P + _c(span, _FAINT))
print()
if s["insights"]:
for i, ln in enumerate(_wrap(s["insights"][0]["line"], W - 2)):
print(P + (_c("› ", _AMBER, _BOLD) if i == 0 else " ") + _c(ln, _INK))
print()
print(P + _c("run your own ", _FAINT) + _c(REPO, _AMBER, _BOLD))
print()
# ---- the html share-card (v3, three formats, pinned CTA) --------------------
# a self-contained dark CRT-terminal card in three formats (square/portrait/wide),
# no network / no external assets / no javascript. the chart is pure css/html bars.
# the card is a flex column: a grid of stats that can shrink, and a CTA footer bar
# that is pinned to the bottom and can never be squeezed off (the v2 bug).
CARD_STYLE = """
:root{
--bg-0:#0d0a07;--bg-1:#080605;--surface:#141009;--surface-2:#1b150c;
--line:#2c2115;--line-2:#3a2c1a;
--amber:#ffb454;--amber-bright:#ffce85;--amber-dim:#8a5e2a;
--ink:#f3e6d1;--ink-2:#b7a488;--ink-3:#7c6c57;--glow:rgba(255,180,84,.5);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg-1);overflow:hidden}
body{display:flex;align-items:center;justify-content:center;min-height:100vh;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;-webkit-font-smoothing:antialiased}
.card{position:relative;overflow:hidden;color:var(--ink);
background:radial-gradient(120% 78% at 50% -12%,rgba(255,180,84,.11),transparent 58%),
linear-gradient(158deg,var(--bg-0) 0%,var(--bg-1) 100%);
display:flex;flex-direction:column;isolation:isolate}
.card::before{content:"";position:absolute;inset:0;z-index:5;pointer-events:none;
background:repeating-linear-gradient(0deg,rgba(0,0,0,.15) 0 1px,transparent 1px 3px);
mix-blend-mode:multiply;opacity:.55}
.card::after{content:"";position:absolute;inset:0;z-index:6;pointer-events:none;
background:radial-gradient(135% 105% at 50% 42%,transparent 58%,rgba(0,0,0,.5) 100%)}
.card > *{position:relative;z-index:2;min-width:0}
.grid{flex:1 1 auto;min-height:0;overflow:hidden;display:grid}
.tabnum{font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1;letter-spacing:-.01em}
.head{grid-area:head;display:flex;align-items:center;gap:20px;border-bottom:1px solid var(--line)}
.dots{display:flex;gap:10px;flex:none}
.dots i{width:14px;height:14px;border-radius:50%;display:block}
.dots i:nth-child(1){background:#6b4a24}
.dots i:nth-child(2){background:#a9712f}
.dots i:nth-child(3){background:var(--amber)}
.head .title{color:var(--ink-2);letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.head .title b{color:var(--ink);font-weight:600}
.head .win{margin-left:auto;flex:none;color:var(--amber);border:1px solid var(--line-2);
border-radius:999px;background:rgba(255,180,84,.06);letter-spacing:.06em}
.hero{grid-area:hero;display:flex;flex-direction:column;justify-content:center}
.hero .kicker{color:var(--ink-3);letter-spacing:.09em;text-transform:uppercase}
.hero .num{font-weight:700;line-height:.84;color:var(--amber);text-shadow:0 0 34px var(--glow);white-space:nowrap}
.hero .num .unit{color:var(--amber-bright);font-weight:600}
.hero .lead{color:var(--ink-2)}
.hero .lead b{color:var(--ink);font-weight:600}
.cache{display:flex;flex-direction:column}
.cache .track{width:100%;display:flex;border-radius:6px;overflow:hidden;background:var(--bg-1);border:1px solid var(--line)}
.cache .seg-cache{background:linear-gradient(180deg,#7a5326,#573b1c);height:100%}
.cache .seg-new{background:linear-gradient(180deg,var(--amber-bright),var(--amber));height:100%;box-shadow:0 0 16px var(--glow)}
.cache .legend{display:flex;gap:26px;color:var(--ink-2);flex-wrap:wrap}
.cache .legend b{color:var(--ink);font-weight:600}
.cache .legend .mk{display:inline-block;width:11px;height:11px;border-radius:3px;vertical-align:middle;margin-right:9px}
.cache .legend .mk.c{background:#6b4a24}
.cache .legend .mk.n{background:var(--amber);box-shadow:0 0 9px var(--glow)}
.activity{grid-area:activity;display:flex;flex-direction:column;justify-content:center}
.activity .lbl{color:var(--ink-3);letter-spacing:.09em;text-transform:uppercase;display:flex;justify-content:space-between;align-items:baseline;gap:12px}
.activity .lbl .peaknote{color:var(--amber);text-transform:none;letter-spacing:0;white-space:nowrap;flex:none}
.plot{position:relative;display:flex;align-items:flex-end;gap:2.4%;border-bottom:1px solid var(--line-2)}
.plot .grid-line{position:absolute;left:0;right:0;border-top:1px dashed var(--line);opacity:.55}
.bar{position:relative;flex:1 1 0;min-width:0;display:flex;align-items:flex-end;justify-content:center;height:100%}
.bar .fill{width:100%;border-radius:5px 5px 0 0;background:linear-gradient(180deg,#8a6636,#4f3a20)}
.bar.peak .fill{background:linear-gradient(180deg,var(--amber-bright),var(--amber) 58%,#b9812f);box-shadow:0 0 26px var(--glow),inset 0 1px 0 rgba(255,255,255,.35)}
.bar .cap{position:absolute;top:0;transform:translateY(-135%);color:var(--amber);white-space:nowrap;font-weight:600}
.bar.peak:first-child .cap{left:0}
.bar.peak:last-child .cap{right:0}
.axis{display:flex;gap:2.4%;color:var(--ink-3)}
.axis span{flex:1 1 0;min-width:0;display:flex;justify-content:center;white-space:nowrap}
.axis span.lft{justify-content:flex-start}
.axis span.rgt{justify-content:flex-end}
.axis span.on{color:var(--amber)}
.stats{grid-area:stats;display:grid;gap:14px}
.tile{background:linear-gradient(180deg,var(--surface-2),var(--surface));border:1px solid var(--line);
border-radius:12px;padding:16px 22px;display:flex;flex-direction:column;gap:7px;justify-content:center}
.tile .v{color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tile .v .mut{color:var(--ink-3);font-weight:400}
.tile .k{color:var(--ink-3);text-transform:uppercase;letter-spacing:.07em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tile.hot{border-color:var(--line-2);background:linear-gradient(180deg,#241a0e,#1a130a)}
.tile.hot .v{color:var(--amber)}
.insight{grid-area:insight;background:linear-gradient(180deg,rgba(255,180,84,.055),rgba(255,180,84,.02));
border:1px solid var(--line-2);border-left:3px solid var(--amber);border-radius:12px;
display:flex;flex-direction:column;justify-content:center;gap:11px;overflow:hidden}
.insight .p{color:var(--ink);line-height:1.32;letter-spacing:.004em}
.insight .p .em{color:var(--amber);text-shadow:0 0 18px var(--glow)}
.insight .p::before{content:"> ";color:var(--amber);font-weight:700}
.insight .s{color:var(--ink-2);line-height:1.3}
.insight .s .em{color:var(--amber)}
.cta{flex:none;display:flex;align-items:center;gap:14px;color:var(--ink);
background:linear-gradient(180deg,var(--surface-2),var(--surface));
border:1px solid var(--line-2);border-radius:12px}
.cta .p{color:var(--amber);flex:none;font-weight:700}
.cta .u{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cta .u b{color:var(--amber-bright);font-weight:600}
.cta .caret{margin-left:auto;flex:none;color:var(--amber)}
.card.square{width:1080px;height:1080px;padding:44px 54px 40px;gap:18px}
.card.square .grid{gap:20px 46px;grid-template-columns:minmax(0,1.04fr) minmax(0,0.96fr);
grid-template-rows:auto auto auto auto;align-content:space-between;
grid-template-areas:"head head" "hero activity" "stats stats" "insight insight"}
.card.square .head{padding-bottom:20px;font-size:23px}
.card.square .head .win{padding:7px 18px;font-size:19px}
.card.square .hero{gap:16px;justify-content:center}
.card.square .hero .kicker{font-size:20px}
.card.square .hero .num{font-size:132px}
.card.square .hero .num .unit{font-size:74px}
.card.square .hero .lead{font-size:21px;margin-top:-4px}
.card.square .cache{gap:12px;margin-top:6px}
.card.square .cache .track{height:20px}
.card.square .cache .legend{font-size:19px}
.card.square .activity .lbl{font-size:18px;margin-bottom:30px}
.card.square .plot{height:184px;gap:2.6%}
.card.square .bar .cap{font-size:20px}
.card.square .axis{font-size:16px;margin-top:12px}
.card.square .stats{grid-template-columns:repeat(3,1fr);grid-template-rows:1fr 1fr}
.card.square .tile .v{font-size:32px}
.card.square .tile.wide-v .v{font-size:28px}
.card.square .tile .k{font-size:15px}
.card.square .insight{padding:22px 30px}
.card.square .insight .p{font-size:27px}
.card.square .insight .s{font-size:19px}
.card.square .cta{padding:18px 26px;font-size:24px}
.card.wide{width:1600px;height:900px;padding:40px 54px 38px;gap:16px}
.card.wide .grid{gap:22px 54px;grid-template-columns:minmax(0,1fr) minmax(0,1fr);
grid-template-rows:auto 1fr 1fr;
grid-template-areas:"head head" "hero activity" "insight stats"}
.card.wide .head{padding-bottom:20px;font-size:22px}
.card.wide .head .win{padding:6px 18px;font-size:19px}
.card.wide .hero{gap:18px}
.card.wide .hero .kicker{font-size:20px}
.card.wide .hero .num{font-size:148px}
.card.wide .hero .num .unit{font-size:84px}
.card.wide .hero .lead{font-size:22px;margin-top:-4px}
.card.wide .cache{gap:12px;margin-top:14px}
.card.wide .cache .track{height:22px}
.card.wide .cache .legend{font-size:20px}
.card.wide .activity{justify-content:flex-start}
.card.wide .activity .lbl{font-size:18px;margin-bottom:24px}
.card.wide .plot{height:196px}
.card.wide .bar .cap{font-size:18px}
.card.wide .axis{font-size:15px;margin-top:11px}
.card.wide .stats{grid-template-columns:repeat(3,1fr);grid-template-rows:1fr 1fr;gap:14px}
.card.wide .tile{padding:16px 22px;gap:6px}
.card.wide .tile .v{font-size:30px}
.card.wide .tile.wide-v .v{font-size:26px}
.card.wide .tile .k{font-size:15px}
.card.wide .insight{padding:24px 30px}
.card.wide .insight .p{font-size:25px}
.card.wide .insight .s{font-size:18px}
.card.wide .cta{padding:16px 26px;font-size:22px}
"""
_CARD_BODY = """<div class="card {fmt}">
<div class="grid">
<div class="head">
<span class="dots"><i></i><i></i><i></i></span>
<span class="title"><b>claude code</b> · wrapped</span>
<span class="win tabnum">{window}</span>
</div>
<div class="hero">
<div class="kicker">tokens processed</div>
<div class="num tabnum">{hero_num}<span class="unit">{hero_unit}</span></div>
<div class="lead">cache included</div>
<div class="cache">
<div class="track">
<div class="seg-cache" style="width:{cache_pct}%"></div>
<div class="seg-new" style="width:{new_pct}%"></div>
</div>
<div class="legend tabnum">
<span><span class="mk c"></span><b>{from_cache}</b> from cache</span>
<span><span class="mk n"></span><b>{new_tokens}</b> new</span>
</div>
</div>
</div>
<div class="activity">
<div class="lbl"><span>{act_label}</span><span class="peaknote tabnum">{peaknote}</span></div>
<div class="plot">
<div class="grid-line" style="bottom:50%"></div>
<div class="grid-line" style="bottom:75%"></div>
{bars}
</div>
<div class="axis tabnum">{axis}</div>
</div>
<div class="stats">
<div class="tile hot"><div class="v tabnum">{cost}</div><div class="k">api-equivalent cost</div></div>
<div class="tile"><div class="v tabnum">{sessions}</div><div class="k">sessions</div></div>
<div class="tile"><div class="v tabnum">{tool_calls}</div><div class="k">tool calls</div></div>
<div class="tile wide-v"><div class="v tabnum">{top_tool}</div><div class="k">top tool</div></div>
<div class="tile"><div class="v tabnum">{longest}</div><div class="k">longest session</div></div>
<div class="tile"><div class="v tabnum">{busiest}</div><div class="k">busiest day</div></div>
</div>
<div class="insight">
<div class="p">{primary}</div>
{secondary_block}
</div>
</div>
<div class="cta">
<span class="p">$</span>
<span class="u">run your own: <b>{repo}</b></span>
<span class="caret">▍</span>
</div>
</div>"""
def _split_compact(text):
"""'128.4M' -> ('128.4','M'); '342' -> ('342','')."""
if text and text[-1] in "KMB":
return text[:-1], text[-1]
return text, ""
def _emphasize(text):
"""Escape an insight line, then amber-highlight quoted phrases and percentages.
The inserted markup is fixed (not from data), so this stays injection-safe."""
esc = escape(text)
esc = re.sub(r"(".*?")", r'<span class="em">\1</span>', esc)
esc = re.sub(r"(?<![\w>])(\d[\d,]*%)", r'<span class="em">\1</span>', esc)
return esc
def _axis_label_indices(n, peak_i):
"""Which bars get a date label: the peak, the ends, and a few evenly spaced
fillers, each kept >=2 bars from an already-chosen one (a label is wider than
a bar, so labelling every bar overflows the card, and adjacent labels overlap).
The peak wins over an end label when they collide - it is the amber one."""
chosen = {peak_i}
for end in (0, n - 1):
if all(abs(end - c) >= 2 for c in chosen):
chosen.add(end)
stride = max(2, -(-n // 4))
for i in range(0, n, stride):
if all(abs(i - c) >= 2 for c in chosen):
chosen.add(i)
return chosen
def _activity_html(activity, limit=14):
"""The activity bar chart (bars + axis) over the FULL window: zero-filled
calendar days, bucketed to at most `limit` bars (so an all-time card shows
all time, not its last two weeks). Returns (bars, axis, peaknote, label)."""
series, k = _calendar_series(activity, limit)
if not series:
return "", "", "", "daily activity"
peak_tokens = max(d["tokens"] for d in series) or 1
peak_i = max(range(len(series)), key=lambda i: series[i]["tokens"])
labeled = _axis_label_indices(len(series), peak_i)
last_day = max(d["date"] for d in activity
if re.match(r"^\d{4}-\d{2}-\d{2}$", d["date"] or ""))
bars = axis = ""
for i, d in enumerate(series):
pct = max(4, round(100 * d["tokens"] / peak_tokens))
is_peak = i == peak_i
cap = '<span class="cap tabnum">%s</span>' % fmt_tokens(d["tokens"]) if is_peak else ""
bars += ('<div class="bar%s">%s<div class="fill" style="height:%d%%"></div></div>'
% (" peak" if is_peak else "", cap, pct))
cls = (["on"] if is_peak else []) + (["lft"] if i == 0 else []) \
+ (["rgt"] if i == len(series) - 1 else [])
# the end tick shows the true last day of the window, not the last
# bucket's start, so the axis reads as the real date range.
text = fmt_day(last_day) if i == len(series) - 1 else fmt_day(d["date"])
axis += ('<span class="%s">%s</span>'
% (" ".join(cls), escape(text) if i in labeled else ""))
if k == 1:
label = "daily activity"
peaknote = "peak · %s · %s" % (escape(fmt_day(series[peak_i]["date"])),
fmt_tokens(series[peak_i]["tokens"]))
else: # bucketed: say so where there is room (the label row wraps if too long)
label = "activity"
peaknote = "%dd bars · peak %s · %s" % (k, escape(fmt_day(series[peak_i]["date"])),
fmt_tokens(series[peak_i]["tokens"]))
return bars, axis, peaknote, label
def html_card(s, fmt="square"):
"""Render wrapped_stats() into one self-contained dark HTML share-card in the
chosen format (square/wide). Every dynamic value is escaped, and no project
name ever reaches the card. The CTA is a pinned footer bar (flex:none)."""
if fmt not in FORMATS:
fmt = "square"
total = s["total_tokens"] or 1
hero_num, hero_unit = _split_compact(fmt_tokens(s["total_tokens"]))
cache_pct = 100.0 * s["tokens_from_cache"] / total
new_pct = max(0.0, 100.0 - cache_pct)
if s["top_tool"]:
top_tool = ('%s <span class="mut">· %s</span>'
% (escape(_clip(_pretty_tool(s["top_tool"]["name"]), 16)),
format(s["top_tool"]["count"], ",")))
else:
top_tool = "none"
longest = escape(s["longest_session"]["human"]) if s["longest_session"] else "0m"
busiest = escape(s["busiest_day"]["label"]) if s["busiest_day"] else "-"
bars, axis, peaknote, act_label = _activity_html(s["activity"])
# one punchline on the card (the top-ranked insight), kept short by design so
# the band never truncates; the clamp is a safety net only. the rest of the
# ranked insights are still in `wrap --json` for anyone who wants them.
ins = s["insights"]
primary = _emphasize(_clip(ins[0]["line"], 96)) if ins else ""
secondary_block = ""
body = _CARD_BODY.format(
fmt=fmt, window=escape(s["window"]),
hero_num=escape(hero_num), hero_unit=escape(hero_unit),
cache_pct="%.1f" % cache_pct, new_pct="%.1f" % new_pct,
from_cache=fmt_tokens(s["tokens_from_cache"]), new_tokens=fmt_tokens(s["tokens_new"]),
peaknote=peaknote, bars=bars, axis=axis, act_label=act_label,
cost=escape(fmt_dollars(s["cost_usd"])), sessions=format(s["sessions"], ","),
tool_calls=format(s["tool_calls"], ","), top_tool=top_tool,
longest=longest, busiest=busiest, primary=primary, secondary_block=secondary_block,
repo=escape(REPO))
return ('<!doctype html>\n<html><head><meta charset="utf-8">'
'<title>claude code wrapped</title>\n<style>%s</style></head>\n'
'<body>%s</body></html>\n' % (CARD_STYLE, body))
# ---- rasterizing + sharing (m4) ---------------------------------------------
# aspect ratios. square is the default and the universal one (safe on X, LinkedIn,
# anywhere); wide suits X-landscape and blogs. (portrait was dropped: it cropped.)
FORMATS = {"square": (1080, 1080), "wide": (1600, 900)}
# where a headless browser might live, so `card` can emit a PNG directly instead
# of leaving the user to screenshot an HTML file by hand.
_BROWSER_CANDIDATES = [
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
"microsoft-edge", "brave-browser",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]
def find_browser():
"""A path to a chromium-family browser for headless rendering, or None.
Honors $CLAUDE_WRAPPED_BROWSER / $CHROME_PATH first."""
for env in ("CLAUDE_WRAPPED_BROWSER", "CHROME_PATH"):
p = os.environ.get(env)
if p and (os.path.exists(p) or shutil.which(p)):
return p
for cand in _BROWSER_CANDIDATES:
found = shutil.which(cand) if os.path.basename(cand) == cand else (cand if os.path.exists(cand) else None)
if found:
return found
return None
def _png_unfilter(ftype, row, prev, bpp):
"""Reverse one PNG scanline filter (types 0-4) in place."""
if ftype == 0:
return
if ftype == 1: # sub
for i in range(bpp, len(row)):
row[i] = (row[i] + row[i - bpp]) & 0xFF
elif ftype == 2: # up
for i in range(len(row)):
row[i] = (row[i] + prev[i]) & 0xFF
elif ftype == 3: # average
for i in range(len(row)):
left = row[i - bpp] if i >= bpp else 0
row[i] = (row[i] + ((left + prev[i]) >> 1)) & 0xFF
elif ftype == 4: # paeth
for i in range(len(row)):
a = row[i - bpp] if i >= bpp else 0