@uxcontinuum/ccwrapped 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/index.js +749 -0
- package/package.json +33 -0
- package/wrapped.py +1171 -0
package/wrapped.py
ADDED
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code Wrapped — Spotify Wrapped but for your Claude sessions.
|
|
4
|
+
Reads ~/.claude/projects/**/*.jsonl locally. Nothing leaves your machine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import argparse
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from collections import Counter, defaultdict
|
|
15
|
+
import textwrap
|
|
16
|
+
|
|
17
|
+
def _find_claude_dir() -> Path:
|
|
18
|
+
candidates = [Path.home() / ".claude" / "projects"]
|
|
19
|
+
# also check /home/* when running as root (sudo / devbox setups)
|
|
20
|
+
if Path.home() == Path("/root"):
|
|
21
|
+
for p in Path("/home").iterdir():
|
|
22
|
+
alt = p / ".claude" / "projects"
|
|
23
|
+
if alt not in candidates:
|
|
24
|
+
candidates.append(alt)
|
|
25
|
+
for c in candidates:
|
|
26
|
+
if c.exists() and any(c.rglob("*.jsonl")):
|
|
27
|
+
return c
|
|
28
|
+
return candidates[0]
|
|
29
|
+
|
|
30
|
+
CLAUDE_DIR = _find_claude_dir()
|
|
31
|
+
|
|
32
|
+
# ANSI colors
|
|
33
|
+
BOLD = "\033[1m"
|
|
34
|
+
DIM = "\033[2m"
|
|
35
|
+
RESET = "\033[0m"
|
|
36
|
+
GREEN = "\033[92m"
|
|
37
|
+
CYAN = "\033[96m"
|
|
38
|
+
YELLOW = "\033[93m"
|
|
39
|
+
MAGENTA = "\033[95m"
|
|
40
|
+
RED = "\033[91m"
|
|
41
|
+
BLUE = "\033[94m"
|
|
42
|
+
WHITE = "\033[97m"
|
|
43
|
+
BG_DARK = "\033[48;5;235m"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def color_disabled():
|
|
47
|
+
global BOLD, DIM, RESET, GREEN, CYAN, YELLOW, MAGENTA, RED, BLUE, WHITE, BG_DARK
|
|
48
|
+
BOLD = DIM = RESET = GREEN = CYAN = YELLOW = MAGENTA = RED = BLUE = WHITE = BG_DARK = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_sessions(days: int) -> list[dict]:
|
|
52
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
53
|
+
sessions = []
|
|
54
|
+
if not CLAUDE_DIR.exists():
|
|
55
|
+
return sessions
|
|
56
|
+
for path in CLAUDE_DIR.rglob("*.jsonl"):
|
|
57
|
+
if "subagents" in str(path):
|
|
58
|
+
continue
|
|
59
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
60
|
+
if mtime < cutoff:
|
|
61
|
+
continue
|
|
62
|
+
s = parse_session(path, cutoff)
|
|
63
|
+
if s:
|
|
64
|
+
sessions.append(s)
|
|
65
|
+
return sessions
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_session(path: Path, cutoff: datetime) -> dict | None:
|
|
69
|
+
lines = []
|
|
70
|
+
try:
|
|
71
|
+
lines = path.read_text(errors="replace").splitlines()
|
|
72
|
+
except Exception:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
tool_calls = Counter()
|
|
76
|
+
user_prompts = []
|
|
77
|
+
timestamps = []
|
|
78
|
+
input_tokens = 0
|
|
79
|
+
output_tokens = 0
|
|
80
|
+
message_count = 0
|
|
81
|
+
title = None
|
|
82
|
+
|
|
83
|
+
for raw in lines:
|
|
84
|
+
try:
|
|
85
|
+
msg = json.loads(raw)
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if msg.get("type") == "custom-title":
|
|
90
|
+
title = msg.get("title", "")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if msg.get("type") not in ("user", "assistant"):
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
ts_str = msg.get("timestamp", "")
|
|
97
|
+
if ts_str:
|
|
98
|
+
try:
|
|
99
|
+
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
100
|
+
if ts < cutoff:
|
|
101
|
+
continue
|
|
102
|
+
timestamps.append(ts)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
content = msg.get("message", {}).get("content", [])
|
|
107
|
+
role = msg.get("type")
|
|
108
|
+
message_count += 1
|
|
109
|
+
|
|
110
|
+
if role == "assistant":
|
|
111
|
+
usage = msg.get("message", {}).get("usage", {})
|
|
112
|
+
input_tokens += usage.get("input_tokens", 0)
|
|
113
|
+
output_tokens += usage.get("output_tokens", 0)
|
|
114
|
+
if isinstance(content, list):
|
|
115
|
+
for block in content:
|
|
116
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
117
|
+
tool_calls[block["name"]] += 1
|
|
118
|
+
|
|
119
|
+
if role == "user":
|
|
120
|
+
if isinstance(content, list):
|
|
121
|
+
for block in content:
|
|
122
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
123
|
+
text = block.get("text", "").strip()
|
|
124
|
+
if text:
|
|
125
|
+
user_prompts.append(text)
|
|
126
|
+
|
|
127
|
+
if not timestamps:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
duration_mins = 0
|
|
131
|
+
if len(timestamps) >= 2:
|
|
132
|
+
delta = max(timestamps) - min(timestamps)
|
|
133
|
+
duration_mins = int(delta.total_seconds() / 60)
|
|
134
|
+
# cap at 8h — anything longer is a multi-day file, not a real session duration
|
|
135
|
+
duration_mins = min(duration_mins, 480)
|
|
136
|
+
|
|
137
|
+
project = path.parts
|
|
138
|
+
proj_name = "unknown"
|
|
139
|
+
for i, part in enumerate(project):
|
|
140
|
+
if part == "projects" and i + 1 < len(project):
|
|
141
|
+
raw_proj = project[i + 1]
|
|
142
|
+
# format: -home-coder-project-name → project-name
|
|
143
|
+
home_prefix = f"-home-{Path.home().name}-"
|
|
144
|
+
if raw_proj.startswith(home_prefix):
|
|
145
|
+
raw_proj = raw_proj[len(home_prefix):]
|
|
146
|
+
elif raw_proj.startswith("-home-"):
|
|
147
|
+
raw_proj = raw_proj.lstrip("-")
|
|
148
|
+
proj_name = raw_proj or "(home)"
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"path": str(path),
|
|
153
|
+
"project": proj_name,
|
|
154
|
+
"title": title or "",
|
|
155
|
+
"tool_calls": tool_calls,
|
|
156
|
+
"user_prompts": user_prompts,
|
|
157
|
+
"timestamps": timestamps,
|
|
158
|
+
"duration_mins": duration_mins,
|
|
159
|
+
"input_tokens": input_tokens,
|
|
160
|
+
"output_tokens": output_tokens,
|
|
161
|
+
"message_count": message_count,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def aggregate(sessions: list[dict]) -> dict:
|
|
166
|
+
if not sessions:
|
|
167
|
+
return {}
|
|
168
|
+
|
|
169
|
+
total_tools = Counter()
|
|
170
|
+
all_prompts = []
|
|
171
|
+
all_timestamps = []
|
|
172
|
+
projects = Counter()
|
|
173
|
+
longest = None
|
|
174
|
+
longest_mins = 0
|
|
175
|
+
|
|
176
|
+
for s in sessions:
|
|
177
|
+
total_tools.update(s["tool_calls"])
|
|
178
|
+
all_prompts.extend(s["user_prompts"])
|
|
179
|
+
all_timestamps.extend(s["timestamps"])
|
|
180
|
+
projects[s["project"]] += 1
|
|
181
|
+
if s["duration_mins"] > longest_mins:
|
|
182
|
+
longest_mins = s["duration_mins"]
|
|
183
|
+
longest = s
|
|
184
|
+
|
|
185
|
+
total_tool_calls = sum(total_tools.values())
|
|
186
|
+
tool_pct = {t: round(100 * c / total_tool_calls) for t, c in total_tools.items()} if total_tool_calls else {}
|
|
187
|
+
|
|
188
|
+
hours = Counter(ts.hour for ts in all_timestamps)
|
|
189
|
+
peak_hour = hours.most_common(1)[0][0] if hours else 12
|
|
190
|
+
night_count = sum(hours[h] for h in range(22, 24)) + sum(hours[h] for h in range(0, 5))
|
|
191
|
+
night_pct = round(100 * night_count / len(all_timestamps)) if all_timestamps else 0
|
|
192
|
+
|
|
193
|
+
total_output = sum(s["output_tokens"] for s in sessions)
|
|
194
|
+
total_input = sum(s["input_tokens"] for s in sessions)
|
|
195
|
+
|
|
196
|
+
prompt_words = Counter()
|
|
197
|
+
for p in all_prompts:
|
|
198
|
+
for word in re.findall(r'\b[a-z]{4,}\b', p.lower()):
|
|
199
|
+
if word not in {"this", "that", "with", "from", "have", "will", "just", "your",
|
|
200
|
+
"what", "when", "then", "also", "some", "into", "make", "more",
|
|
201
|
+
"here", "they", "them", "been", "were", "need", "want", "code",
|
|
202
|
+
"file", "like", "very", "only", "about", "would", "should",
|
|
203
|
+
"could", "there", "their", "these", "those", "such", "both"}:
|
|
204
|
+
prompt_words[word] += 1
|
|
205
|
+
|
|
206
|
+
top_word = prompt_words.most_common(1)[0] if prompt_words else ("", 0)
|
|
207
|
+
|
|
208
|
+
polite = sum(1 for p in all_prompts if re.search(r'\b(please|thank|thanks|sorry)\b', p.lower()))
|
|
209
|
+
just_count = sum(p.lower().count("just") for p in all_prompts)
|
|
210
|
+
avg_len = int(sum(len(p) for p in all_prompts) / len(all_prompts)) if all_prompts else 0
|
|
211
|
+
|
|
212
|
+
untitled = sum(1 for s in sessions if not s["title"])
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
"total_sessions": len(sessions),
|
|
216
|
+
"total_tool_calls": total_tool_calls,
|
|
217
|
+
"total_output_tokens": total_output,
|
|
218
|
+
"total_input_tokens": total_input,
|
|
219
|
+
"tool_pct": tool_pct,
|
|
220
|
+
"top_tools": total_tools.most_common(5),
|
|
221
|
+
"top_project": projects.most_common(1)[0] if projects else ("none", 0),
|
|
222
|
+
"project_count": len(projects),
|
|
223
|
+
"peak_hour": peak_hour,
|
|
224
|
+
"night_pct": night_pct,
|
|
225
|
+
"longest": longest,
|
|
226
|
+
"longest_mins": longest_mins,
|
|
227
|
+
"avg_prompt_len": avg_len,
|
|
228
|
+
"polite_count": polite,
|
|
229
|
+
"just_count": just_count,
|
|
230
|
+
"untitled_count": untitled,
|
|
231
|
+
"top_word": top_word,
|
|
232
|
+
"total_sessions_list": sessions,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def fmt_hour(h: int) -> str:
|
|
237
|
+
if h == 0: return "midnight"
|
|
238
|
+
if h < 12: return f"{h}am"
|
|
239
|
+
if h == 12: return "noon"
|
|
240
|
+
return f"{h - 12}pm"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def fmt_tokens(n: int) -> str:
|
|
244
|
+
if n >= 1_000_000: return f"{n / 1_000_000:.1f}M"
|
|
245
|
+
if n >= 1_000: return f"{n // 1_000}K"
|
|
246
|
+
return str(n)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def fmt_duration(mins: int) -> str:
|
|
250
|
+
if mins < 60: return f"{mins}m"
|
|
251
|
+
h = mins // 60
|
|
252
|
+
m = mins % 60
|
|
253
|
+
return f"{h}h {m}m" if m else f"{h}h"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
ARCHETYPES = {
|
|
257
|
+
"THE ORCHESTRATOR": {
|
|
258
|
+
"color": MAGENTA,
|
|
259
|
+
"short": "Why do one thing when you can spawn five agents in parallel? You delegate like a CEO who just discovered hiring.",
|
|
260
|
+
"full": "You've internalized the most important insight in AI-native development: Claude is a fleet, not a tool. While others prompt one thing at a time, you parallelize. Your sessions look like org charts.",
|
|
261
|
+
"famous_for": "Spawning subagents to research while you're already writing the implementation",
|
|
262
|
+
"strength": "You get compound leverage out of Claude that most users never access.",
|
|
263
|
+
"watch_out": "Orchestration overhead can exceed the task cost. Sometimes one good prompt beats five parallel ones.",
|
|
264
|
+
},
|
|
265
|
+
"THE BASH HAMMER": {
|
|
266
|
+
"color": RED,
|
|
267
|
+
"short": "You don't read docs. You run things and see what breaks. The terminal is your native tongue.",
|
|
268
|
+
"full": "Your debugging strategy is empirical: run it, watch it fail, learn from the output. Documentation is for people who have time. You'd rather have stderr than a man page. The feedback loop between you and a running process is tighter than most engineers get in a full week.",
|
|
269
|
+
"famous_for": "Running a command to see if it works before asking why it might not",
|
|
270
|
+
"strength": "Speed. Nothing you ship has been theorized to death.",
|
|
271
|
+
"watch_out": "\"It worked on my machine\" is a philosophy, not a workaround.",
|
|
272
|
+
},
|
|
273
|
+
"THE DETECTIVE": {
|
|
274
|
+
"color": CYAN,
|
|
275
|
+
"short": "You investigate before you act. The codebase has no secrets from you. You find things first.",
|
|
276
|
+
"full": "You Grep before you guess. You Read before you rewrite. Your sessions start with an investigation phase that most developers skip entirely, which is why you break fewer things. The codebase isn't a mystery to you — it's a crime scene you've already mapped.",
|
|
277
|
+
"famous_for": "Knowing exactly where the bug is before touching a single file",
|
|
278
|
+
"strength": "Your changes are targeted. Your blast radius is minimal.",
|
|
279
|
+
"watch_out": "Sometimes you read so much the fix is obvious in your head but still not in the code.",
|
|
280
|
+
},
|
|
281
|
+
"THE FILE SURGEON": {
|
|
282
|
+
"color": GREEN,
|
|
283
|
+
"short": "Precise, deliberate, minimal blast radius. You fix what needs fixing and leave the rest alone.",
|
|
284
|
+
"full": "Your PRs are small and your diffs are clean. While others rewrite files to fix a function, you cut with precision. Edit + Write dominate your sessions because you're not exploring — you already know what needs to change. You operate like a surgeon: in, fix it, close.",
|
|
285
|
+
"famous_for": "The smallest diff that completely solves the problem",
|
|
286
|
+
"strength": "Clean git history. Low regression risk. Reviews that take 5 minutes.",
|
|
287
|
+
"watch_out": "Sometimes the right fix actually is a full rewrite. Precision can become avoidance.",
|
|
288
|
+
},
|
|
289
|
+
"THE NIGHT OWL": {
|
|
290
|
+
"color": BLUE,
|
|
291
|
+
"short": "Your best code ships after midnight. Sleep is optional. The dark hours are yours.",
|
|
292
|
+
"full": "You've found the only time slot with no meetings, no Slack pings, and no one asking for status updates. Late-night coding sessions have a different quality — longer, deeper, fewer interruptions. The commit timestamps tell the story. You're not burning out. You're just on a different schedule.",
|
|
293
|
+
"famous_for": "Pushing commits at 2am that somehow work in standup",
|
|
294
|
+
"strength": "Uninterrupted focus blocks that most developers can only dream about.",
|
|
295
|
+
"watch_out": "The merge conflicts waiting in the morning. And the sleep debt.",
|
|
296
|
+
},
|
|
297
|
+
"THE ESSAYIST": {
|
|
298
|
+
"color": YELLOW,
|
|
299
|
+
"short": "You explain everything. Context, edge cases, constraints, the history. Claude always knows exactly what you want.",
|
|
300
|
+
"full": "Your prompts read like engineering specs. You front-load context, enumerate constraints, and explain what you've already tried. The result is that Claude's first output hits close to correct more often than average. You've traded prompt time for iteration time, and it's a good trade.",
|
|
301
|
+
"famous_for": "3-paragraph prompts that get first-pass outputs most people take 5 attempts to reach",
|
|
302
|
+
"strength": "High hit rate on first output. Claude is never guessing with you.",
|
|
303
|
+
"watch_out": "Occasionally the prompt takes longer to write than the task would have taken to do manually.",
|
|
304
|
+
},
|
|
305
|
+
"THE COMMANDER": {
|
|
306
|
+
"color": RED,
|
|
307
|
+
"short": "Short, direct, no ceremony. \"Fix it.\" \"Add auth.\" \"Make it faster.\" Claude figures out the rest.",
|
|
308
|
+
"full": "You prompt like someone who's been doing this long enough to trust the model. You don't over-explain because you've learned that Claude fills in context reasonably well — and when it doesn't, one correction is faster than a paragraph of upfront specification. Your sessions move fast.",
|
|
309
|
+
"famous_for": "Two-word prompts that somehow produce exactly the right thing",
|
|
310
|
+
"strength": "Tight iteration loops. High sessions-per-hour. No wasted setup.",
|
|
311
|
+
"watch_out": "The context that lives only in your head sometimes doesn't make it into the output.",
|
|
312
|
+
},
|
|
313
|
+
"THE GENERALIST": {
|
|
314
|
+
"color": WHITE,
|
|
315
|
+
"short": "No single tool dominates. You read the problem and pick the right approach. That's rarer than it sounds.",
|
|
316
|
+
"full": "Your tool usage is balanced because your problems are varied. You're not locked into a pattern — you Grep when you need to investigate, Edit when you know what to change, run Bash when you need feedback. The diversity in your sessions reflects the diversity of what you actually build.",
|
|
317
|
+
"famous_for": "Knowing which tool to reach for before you open the file",
|
|
318
|
+
"strength": "You don't have a hammer, so nothing looks like a nail.",
|
|
319
|
+
"watch_out": "Balance can read as indecisiveness. Sometimes a bias toward one approach is a feature.",
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_archetype(stats: dict) -> tuple[str, str, str]:
|
|
325
|
+
profile = get_archetype_profile(stats)
|
|
326
|
+
return (profile["name"], profile["color"], profile["short"])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_archetype_profile(stats: dict) -> dict:
|
|
330
|
+
tp = stats["tool_pct"]
|
|
331
|
+
bash_pct = tp.get("Bash", 0)
|
|
332
|
+
read_pct = tp.get("Read", 0) + tp.get("Glob", 0) + tp.get("Grep", 0)
|
|
333
|
+
edit_pct = tp.get("Edit", 0) + tp.get("Write", 0)
|
|
334
|
+
agent_pct = tp.get("Agent", 0)
|
|
335
|
+
night_pct = stats["night_pct"]
|
|
336
|
+
avg_len = stats["avg_prompt_len"]
|
|
337
|
+
|
|
338
|
+
if agent_pct > 10:
|
|
339
|
+
name = "THE ORCHESTRATOR"
|
|
340
|
+
elif bash_pct > 55:
|
|
341
|
+
name = "THE BASH HAMMER"
|
|
342
|
+
elif read_pct > 50:
|
|
343
|
+
name = "THE DETECTIVE"
|
|
344
|
+
elif edit_pct > 40:
|
|
345
|
+
name = "THE FILE SURGEON"
|
|
346
|
+
elif night_pct > 35:
|
|
347
|
+
name = "THE NIGHT OWL"
|
|
348
|
+
elif avg_len > 600:
|
|
349
|
+
name = "THE ESSAYIST"
|
|
350
|
+
elif avg_len < 80:
|
|
351
|
+
name = "THE COMMANDER"
|
|
352
|
+
else:
|
|
353
|
+
name = "THE GENERALIST"
|
|
354
|
+
|
|
355
|
+
profile = dict(ARCHETYPES[name])
|
|
356
|
+
profile["name"] = name
|
|
357
|
+
|
|
358
|
+
# Build 3 supporting stats specific to this archetype
|
|
359
|
+
supporting = []
|
|
360
|
+
if name == "THE BASH HAMMER":
|
|
361
|
+
supporting = [
|
|
362
|
+
f"Bash: {bash_pct}% of all tool calls",
|
|
363
|
+
f"Sessions: {stats['total_sessions']:,} total",
|
|
364
|
+
f"Peak hour: {fmt_hour(stats['peak_hour'])}",
|
|
365
|
+
]
|
|
366
|
+
elif name == "THE DETECTIVE":
|
|
367
|
+
supporting = [
|
|
368
|
+
f"Grep + Read + Glob: {read_pct}% of tool calls",
|
|
369
|
+
f"Projects explored: {stats['project_count']}",
|
|
370
|
+
f"Avg prompt length: {avg_len} chars",
|
|
371
|
+
]
|
|
372
|
+
elif name == "THE FILE SURGEON":
|
|
373
|
+
supporting = [
|
|
374
|
+
f"Edit + Write: {edit_pct}% of tool calls",
|
|
375
|
+
f"Sessions: {stats['total_sessions']:,} total",
|
|
376
|
+
f"Tokens generated: {fmt_tokens(stats['total_output_tokens'])}",
|
|
377
|
+
]
|
|
378
|
+
elif name == "THE ORCHESTRATOR":
|
|
379
|
+
supporting = [
|
|
380
|
+
f"Agent calls: {agent_pct}% of tool calls",
|
|
381
|
+
f"Total tool calls: {stats['total_tool_calls']:,}",
|
|
382
|
+
f"Projects active: {stats['project_count']}",
|
|
383
|
+
]
|
|
384
|
+
elif name == "THE NIGHT OWL":
|
|
385
|
+
supporting = [
|
|
386
|
+
f"After-10pm sessions: {night_pct}%",
|
|
387
|
+
f"Peak hour: {fmt_hour(stats['peak_hour'])}",
|
|
388
|
+
f"Total sessions: {stats['total_sessions']:,}",
|
|
389
|
+
]
|
|
390
|
+
elif name == "THE ESSAYIST":
|
|
391
|
+
supporting = [
|
|
392
|
+
f"Avg prompt: {avg_len} characters",
|
|
393
|
+
f"Sessions: {stats['total_sessions']:,}",
|
|
394
|
+
f"Top tool: {stats['top_tools'][0][0] if stats['top_tools'] else 'Bash'}",
|
|
395
|
+
]
|
|
396
|
+
elif name == "THE COMMANDER":
|
|
397
|
+
supporting = [
|
|
398
|
+
f"Avg prompt: {avg_len} characters",
|
|
399
|
+
f"Sessions: {stats['total_sessions']:,}",
|
|
400
|
+
f"Peak hour: {fmt_hour(stats['peak_hour'])}",
|
|
401
|
+
]
|
|
402
|
+
else:
|
|
403
|
+
t5 = stats["top_tools"][:3]
|
|
404
|
+
supporting = [f"{t}: {c} calls" for t, c in t5]
|
|
405
|
+
|
|
406
|
+
profile["supporting"] = supporting
|
|
407
|
+
return profile
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_secondary_trait(stats: dict) -> tuple[str, str] | None:
|
|
411
|
+
just = stats["just_count"]
|
|
412
|
+
polite = stats["polite_count"]
|
|
413
|
+
untitled = stats["untitled_count"]
|
|
414
|
+
total = stats["total_sessions"]
|
|
415
|
+
|
|
416
|
+
if just > 30:
|
|
417
|
+
return ("The Just-er", f"You said \"just\" {just} times. Just do it. Just fix it. Just make it work.")
|
|
418
|
+
if polite > 20:
|
|
419
|
+
return ("The Polite Prompter", f"You said please or thank you {polite} times. Claude appreciates it.")
|
|
420
|
+
if untitled > total * 0.5:
|
|
421
|
+
pct = round(100 * untitled / total)
|
|
422
|
+
return ("The Untitled", f"{pct}% of your sessions have no title. Living dangerously.")
|
|
423
|
+
if stats["night_pct"] > 25 and stats.get("tool_pct", {}).get("Bash", 0) > 40:
|
|
424
|
+
return ("The 2am Deployer", "You know what you're doing. You just don't know when to stop.")
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def get_prompt_dna(stats: dict) -> dict:
|
|
429
|
+
avg_len = stats["avg_prompt_len"]
|
|
430
|
+
just = stats["just_count"]
|
|
431
|
+
polite = stats["polite_count"]
|
|
432
|
+
total = stats["total_sessions"]
|
|
433
|
+
tp = stats["tool_pct"]
|
|
434
|
+
bash = tp.get("Bash", 0)
|
|
435
|
+
night = stats["night_pct"]
|
|
436
|
+
top_word = stats["top_word"]
|
|
437
|
+
|
|
438
|
+
# Formality: polite → formal, terse → informal
|
|
439
|
+
if polite > total * 0.3:
|
|
440
|
+
formality = ("formal", "You treat Claude like a colleague. Please and thank you included.")
|
|
441
|
+
elif avg_len < 80:
|
|
442
|
+
formality = ("blunt", "No preamble. No ceremony. You get to the point faster than most people think.")
|
|
443
|
+
else:
|
|
444
|
+
formality = ("direct", "Professional but not stiff. You explain what you need without small talk.")
|
|
445
|
+
|
|
446
|
+
# Verbosity
|
|
447
|
+
if avg_len > 700:
|
|
448
|
+
verbosity = ("verbose", f"Avg {avg_len} chars per prompt. You front-load context, enumerate constraints, explain edge cases. Claude rarely has to ask a follow-up.")
|
|
449
|
+
elif avg_len > 300:
|
|
450
|
+
verbosity = ("moderate", f"Avg {avg_len} chars. Enough context to be useful, not so much that you're writing a spec instead of a prompt.")
|
|
451
|
+
elif avg_len > 100:
|
|
452
|
+
verbosity = ("terse", f"Avg {avg_len} chars. Short and functional. You've learned what Claude needs to get there.")
|
|
453
|
+
else:
|
|
454
|
+
verbosity = ("minimal", f"Avg {avg_len} chars. You prompt in fragments. \"Fix it.\" \"Add tests.\" Claude fills in the rest.")
|
|
455
|
+
|
|
456
|
+
# Command style
|
|
457
|
+
if bash > 55:
|
|
458
|
+
style = ("executor", "Your prompts are task orders. You want Claude to run something, not think about running something.")
|
|
459
|
+
elif just > 40:
|
|
460
|
+
style = ("minimizer", f"You say \"just\" a lot ({just}x). You minimize the ask in the prompt. It's a habit.")
|
|
461
|
+
elif avg_len > 600:
|
|
462
|
+
style = ("specifier", "You write prompts like requirements. Comprehensive. Claude gets it right because you leave nothing out.")
|
|
463
|
+
else:
|
|
464
|
+
style = ("collaborator", "You prompt back and forth. Claude is a thinking partner, not an executor.")
|
|
465
|
+
|
|
466
|
+
# Time pattern
|
|
467
|
+
if night > 35:
|
|
468
|
+
timing = ("nocturnal", "Most of your prompts land after 10pm. Your creative output happens in the dark.")
|
|
469
|
+
elif stats["peak_hour"] < 9:
|
|
470
|
+
timing = ("early riser", "You're at it before most people open Slack.")
|
|
471
|
+
elif stats["peak_hour"] > 17:
|
|
472
|
+
timing = ("after-hours", "Your peak is late afternoon into evening. You warm up slowly.")
|
|
473
|
+
else:
|
|
474
|
+
timing = ("office hours", f"Peak hour: {fmt_hour(stats['peak_hour'])}. You work when the world expects you to.")
|
|
475
|
+
|
|
476
|
+
# One-liner summary
|
|
477
|
+
summaries = {
|
|
478
|
+
("formal", "verbose", "specifier"): "You prompt like a senior engineer writing an RFC. Thorough, polite, leaves nothing to interpretation.",
|
|
479
|
+
("formal", "verbose", "collaborator"): "You prompt like a thoughtful manager delegating to a smart report. Context-first, measured tone.",
|
|
480
|
+
("blunt", "minimal", "executor"): "You prompt like a tired sysadmin at 2am. Fast, functional, zero ceremony. It works.",
|
|
481
|
+
("blunt", "terse", "executor"): "Short commands, high trust. You've calibrated Claude to your shorthand.",
|
|
482
|
+
("direct", "moderate", "collaborator"): "Clear and efficient. You prompt like someone who respects both their time and Claude's ability.",
|
|
483
|
+
("direct", "verbose", "specifier"): "You prompt like a product manager who's also a developer. Structured, specific, outcome-focused.",
|
|
484
|
+
("direct", "terse", "executor"): "Functional and fast. You know what you want and you say it.",
|
|
485
|
+
("direct", "moderate", "executor"): "You prompt like a developer who's done this before. Not terse, not long-winded. Just clear.",
|
|
486
|
+
("direct", "verbose", "executor"): "You write long prompts that end in short commands. The context is the care; the task is the punch.",
|
|
487
|
+
("direct", "terse", "collaborator"): "Efficient back-and-forth. Short prompts, quick corrections, good signal.",
|
|
488
|
+
("blunt", "verbose", "executor"): "Dense task orders. You write a lot but you're still clearly telling Claude what to do, not asking.",
|
|
489
|
+
("blunt", "moderate", "executor"): "You front-load just enough context to get the output right, then ship it. Clean loop.",
|
|
490
|
+
("formal", "terse", "specifier"): "Polite but demanding. You know exactly what you want and you ask for it nicely.",
|
|
491
|
+
("formal", "moderate", "collaborator"): "You work with Claude like a trusted colleague. Respectful, collaborative, clear.",
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
key = (formality[0], verbosity[0], style[0])
|
|
495
|
+
summary = summaries.get(key, f"You prompt {formality[0]}, {verbosity[0]}, and {style[0]}. That combination is distinctly yours.")
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
"formality": formality,
|
|
499
|
+
"verbosity": verbosity,
|
|
500
|
+
"style": style,
|
|
501
|
+
"timing": timing,
|
|
502
|
+
"summary": summary,
|
|
503
|
+
"top_word": top_word,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def print_dna(stats: dict, days: int, no_color: bool = False):
|
|
508
|
+
if no_color:
|
|
509
|
+
color_disabled()
|
|
510
|
+
|
|
511
|
+
dna = get_prompt_dna(stats)
|
|
512
|
+
archetype_name, arch_color, _ = get_archetype(stats)
|
|
513
|
+
W = 46
|
|
514
|
+
|
|
515
|
+
print()
|
|
516
|
+
print(f" {BOLD}{MAGENTA}{'━' * W}{RESET}")
|
|
517
|
+
print(f" {BOLD}{MAGENTA} YOUR PROMPT STYLE DNA{RESET}")
|
|
518
|
+
print(f" {DIM} how you actually talk to Claude{RESET}")
|
|
519
|
+
print(f" {BOLD}{MAGENTA}{'━' * W}{RESET}")
|
|
520
|
+
print()
|
|
521
|
+
|
|
522
|
+
traits = [
|
|
523
|
+
("Formality", dna["formality"][0].upper(), dna["formality"][1], CYAN),
|
|
524
|
+
("Verbosity", dna["verbosity"][0].upper(), dna["verbosity"][1], GREEN),
|
|
525
|
+
("Style", dna["style"][0].upper(), dna["style"][1], YELLOW),
|
|
526
|
+
("Timing", dna["timing"][0].upper(), dna["timing"][1], BLUE),
|
|
527
|
+
]
|
|
528
|
+
|
|
529
|
+
for label, value, desc, color in traits:
|
|
530
|
+
print(f" {BOLD}{color}{label}: {value}{RESET}")
|
|
531
|
+
for line in textwrap.wrap(desc, W - 2):
|
|
532
|
+
print(f" {DIM}{line}{RESET}")
|
|
533
|
+
print()
|
|
534
|
+
|
|
535
|
+
print(f" {DIM}{'─' * W}{RESET}")
|
|
536
|
+
print()
|
|
537
|
+
print(f" {BOLD}The one-liner:{RESET}")
|
|
538
|
+
for line in textwrap.wrap(dna["summary"], W):
|
|
539
|
+
print(f" {line}")
|
|
540
|
+
print()
|
|
541
|
+
|
|
542
|
+
if dna["top_word"][0]:
|
|
543
|
+
print(f" {DIM}Most used word: \"{dna['top_word'][0]}\" ({dna['top_word'][1]}x){RESET}")
|
|
544
|
+
print()
|
|
545
|
+
|
|
546
|
+
print(f" {DIM}Archetype: {arch_color}{BOLD}{archetype_name}{RESET}")
|
|
547
|
+
print()
|
|
548
|
+
print(f" {BOLD}{MAGENTA}{'━' * W}{RESET}")
|
|
549
|
+
print()
|
|
550
|
+
print(f" {DIM}Try it: github.com/turleydesigns/claude-wrapped{RESET}")
|
|
551
|
+
print(f" {DIM}python3 wrapped.py --dna{RESET}")
|
|
552
|
+
print(f" {DIM}What's your prompt style?{RESET}")
|
|
553
|
+
print()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def generate_claude_md_block(stats: dict) -> list[tuple[str, str]]:
|
|
557
|
+
"""Returns list of (rule, reason) tuples based on actual session patterns."""
|
|
558
|
+
tp = stats["tool_pct"]
|
|
559
|
+
bash = tp.get("Bash", 0)
|
|
560
|
+
read = tp.get("Read", 0) + tp.get("Glob", 0) + tp.get("Grep", 0)
|
|
561
|
+
edit = tp.get("Edit", 0) + tp.get("Write", 0)
|
|
562
|
+
agent = tp.get("Agent", 0)
|
|
563
|
+
night = stats["night_pct"]
|
|
564
|
+
peak_h = stats["peak_hour"]
|
|
565
|
+
avg_len = stats["avg_prompt_len"]
|
|
566
|
+
just = stats["just_count"]
|
|
567
|
+
polite = stats["polite_count"]
|
|
568
|
+
total = stats["total_sessions"]
|
|
569
|
+
untitled = stats["untitled_count"]
|
|
570
|
+
|
|
571
|
+
rules = []
|
|
572
|
+
|
|
573
|
+
# Bash-first preference
|
|
574
|
+
if bash > 55:
|
|
575
|
+
rules.append((
|
|
576
|
+
f"When I ask you to run or check something, use Bash first. Don't suggest reading docs or alternative approaches unless I ask.",
|
|
577
|
+
f"You use Bash {bash}% of the time — this matches your actual workflow."
|
|
578
|
+
))
|
|
579
|
+
|
|
580
|
+
# Don't ask clarifying questions upfront
|
|
581
|
+
if avg_len > 500:
|
|
582
|
+
rules.append((
|
|
583
|
+
"I provide full context in my prompts. Start working with what I give you. Don't ask clarifying questions before attempting the task.",
|
|
584
|
+
f"Your avg prompt is {avg_len} chars — you front-load context well."
|
|
585
|
+
))
|
|
586
|
+
|
|
587
|
+
# Just = not simple
|
|
588
|
+
if just > 25:
|
|
589
|
+
rules.append((
|
|
590
|
+
"When I use the word 'just' in a prompt, ignore it as a signal of simplicity. The task is rarely as simple as that word implies.",
|
|
591
|
+
f"You said 'just' {just} times. It's a consistent pattern."
|
|
592
|
+
))
|
|
593
|
+
|
|
594
|
+
# Minimal diffs
|
|
595
|
+
if edit > 40:
|
|
596
|
+
rules.append((
|
|
597
|
+
"Prefer targeted edits over full rewrites. Keep diffs minimal unless I explicitly ask for a rewrite.",
|
|
598
|
+
f"Edit + Write is {edit}% of your tool calls — you work surgically."
|
|
599
|
+
))
|
|
600
|
+
|
|
601
|
+
# Read before touching
|
|
602
|
+
if read > 45:
|
|
603
|
+
rules.append((
|
|
604
|
+
"Before making changes to unfamiliar code, read the relevant files first. Don't assume structure from filenames.",
|
|
605
|
+
f"You use Read/Grep/Glob {read}% of the time — you investigate before acting."
|
|
606
|
+
))
|
|
607
|
+
|
|
608
|
+
# Late night: no preamble
|
|
609
|
+
if night > 30:
|
|
610
|
+
rules.append((
|
|
611
|
+
"Keep responses direct and skip lengthy preambles. I often work late and don't need warmup text before the answer.",
|
|
612
|
+
f"{night}% of your sessions are after 10pm."
|
|
613
|
+
))
|
|
614
|
+
|
|
615
|
+
# Early morning: same
|
|
616
|
+
if peak_h <= 7:
|
|
617
|
+
rules.append((
|
|
618
|
+
f"My peak hour is {fmt_hour(peak_h)}. Get straight to the work. Skip introductory context I already know.",
|
|
619
|
+
"Early sessions suggest you prefer efficiency over explanation."
|
|
620
|
+
))
|
|
621
|
+
|
|
622
|
+
# Subagents
|
|
623
|
+
if agent > 8:
|
|
624
|
+
rules.append((
|
|
625
|
+
"When a task can be parallelized, suggest spawning subagents rather than doing things sequentially.",
|
|
626
|
+
f"You use Agent calls {agent}% of the time — you think in parallel."
|
|
627
|
+
))
|
|
628
|
+
|
|
629
|
+
# Formal register
|
|
630
|
+
if polite > total * 0.2:
|
|
631
|
+
rules.append((
|
|
632
|
+
"Match my communication register. I'm direct but professional. Skip informal phrases.",
|
|
633
|
+
f"You use polite language in {polite} sessions — you communicate formally."
|
|
634
|
+
))
|
|
635
|
+
|
|
636
|
+
# Short prompts: need more inference
|
|
637
|
+
if avg_len < 80:
|
|
638
|
+
rules.append((
|
|
639
|
+
"My prompts are often short. Use context from recent conversation history to fill in what I haven't explicitly stated.",
|
|
640
|
+
f"Avg prompt is {avg_len} chars — you trust Claude to infer context."
|
|
641
|
+
))
|
|
642
|
+
|
|
643
|
+
# High untitled — suggest titling behavior
|
|
644
|
+
if untitled > total * 0.6:
|
|
645
|
+
pct = round(100 * untitled / total)
|
|
646
|
+
rules.append((
|
|
647
|
+
"If we've done something worth remembering, suggest a session title at the end.",
|
|
648
|
+
f"{pct}% of your sessions are untitled."
|
|
649
|
+
))
|
|
650
|
+
|
|
651
|
+
return rules
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def get_spirit_model(stats: dict) -> tuple[str, str, str, list[str]]:
|
|
655
|
+
tp = stats["tool_pct"]
|
|
656
|
+
bash_pct = tp.get("Bash", 0)
|
|
657
|
+
read_pct = tp.get("Read", 0) + tp.get("Glob", 0) + tp.get("Grep", 0)
|
|
658
|
+
avg_len = stats["avg_prompt_len"]
|
|
659
|
+
|
|
660
|
+
is_opus = avg_len > 500 or read_pct > 40
|
|
661
|
+
is_haiku = avg_len < 120 or (bash_pct > 60 and stats["untitled_count"] > stats["total_sessions"] * 0.5)
|
|
662
|
+
|
|
663
|
+
if is_opus and not is_haiku:
|
|
664
|
+
name = "Claude Opus"
|
|
665
|
+
color = MAGENTA
|
|
666
|
+
tagline = "Methodical, first-principles, thorough."
|
|
667
|
+
quip = "You could write a thesis before writing the code."
|
|
668
|
+
data = [
|
|
669
|
+
f"Avg prompt: {avg_len} chars",
|
|
670
|
+
f"Read/Grep/Glob: {read_pct}% of tool calls",
|
|
671
|
+
]
|
|
672
|
+
elif is_haiku and not is_opus:
|
|
673
|
+
name = "Claude Haiku"
|
|
674
|
+
color = CYAN
|
|
675
|
+
tagline = "Fast, minimal, no ceremony."
|
|
676
|
+
quip = "You prompt like you're billed by the token."
|
|
677
|
+
data = [
|
|
678
|
+
f"Avg prompt: {avg_len} chars",
|
|
679
|
+
f"Bash: {bash_pct}% of tool calls",
|
|
680
|
+
]
|
|
681
|
+
else:
|
|
682
|
+
name = "Claude Sonnet"
|
|
683
|
+
color = GREEN
|
|
684
|
+
tagline = "Balanced, pragmatic, gets it done."
|
|
685
|
+
quip = "You've found the balance and you know it."
|
|
686
|
+
data = [
|
|
687
|
+
f"Avg prompt: {avg_len} chars",
|
|
688
|
+
f"Top tool: {stats['top_tools'][0][0] if stats['top_tools'] else 'Bash'}",
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
return (name, color, f"{tagline}\n{quip}", data)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def print_spirit(stats: dict, days: int, no_color: bool = False):
|
|
695
|
+
if no_color:
|
|
696
|
+
color_disabled()
|
|
697
|
+
|
|
698
|
+
name, color, description, data = get_spirit_model(stats)
|
|
699
|
+
lines = description.split("\n")
|
|
700
|
+
tagline = lines[0]
|
|
701
|
+
quip = lines[1] if len(lines) > 1 else ""
|
|
702
|
+
W = 46
|
|
703
|
+
|
|
704
|
+
print()
|
|
705
|
+
print(f" {BOLD}{'━' * W}{RESET}")
|
|
706
|
+
print(f" {BOLD} YOUR SPIRIT MODEL{RESET}")
|
|
707
|
+
print(f" {BOLD}{'━' * W}{RESET}")
|
|
708
|
+
print()
|
|
709
|
+
name_pad = (W - len(name)) // 2
|
|
710
|
+
print(f" {' ' * name_pad}{BOLD}{color}{name}{RESET}")
|
|
711
|
+
print()
|
|
712
|
+
print(f" {tagline}")
|
|
713
|
+
print(f" {DIM}{quip}{RESET}")
|
|
714
|
+
print()
|
|
715
|
+
for item in data:
|
|
716
|
+
print(f" {BOLD}·{RESET} {item}")
|
|
717
|
+
print()
|
|
718
|
+
print(f" {BOLD}{'━' * W}{RESET}")
|
|
719
|
+
print(f" {DIM}python3 wrapped.py --spirit{RESET}")
|
|
720
|
+
print(f" {DIM}What's your spirit model?{RESET}")
|
|
721
|
+
print()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def print_tune_up(stats: dict, days: int, no_color: bool = False, write: bool = False, target_path: str | None = None):
|
|
725
|
+
if no_color:
|
|
726
|
+
color_disabled()
|
|
727
|
+
|
|
728
|
+
rules = generate_claude_md_block(stats)
|
|
729
|
+
W = 46
|
|
730
|
+
|
|
731
|
+
print()
|
|
732
|
+
print(f" {BOLD}{GREEN}{'━' * W}{RESET}")
|
|
733
|
+
print(f" {BOLD}{GREEN} CLAUDE.md TUNE-UP{RESET}")
|
|
734
|
+
print(f" {DIM} based on {days} days of your actual sessions{RESET}")
|
|
735
|
+
print(f" {BOLD}{GREEN}{'━' * W}{RESET}")
|
|
736
|
+
print()
|
|
737
|
+
|
|
738
|
+
# Find existing CLAUDE.md
|
|
739
|
+
cwd_claude = Path.cwd() / "CLAUDE.md"
|
|
740
|
+
home_claude = Path.home() / "CLAUDE.md"
|
|
741
|
+
found_path = None
|
|
742
|
+
if cwd_claude.exists():
|
|
743
|
+
found_path = cwd_claude
|
|
744
|
+
elif home_claude.exists():
|
|
745
|
+
found_path = home_claude
|
|
746
|
+
|
|
747
|
+
if found_path:
|
|
748
|
+
print(f" {DIM}Found: {found_path}{RESET}")
|
|
749
|
+
else:
|
|
750
|
+
print(f" {DIM}No CLAUDE.md found in current directory or home.{RESET}")
|
|
751
|
+
print()
|
|
752
|
+
|
|
753
|
+
if not rules:
|
|
754
|
+
print(f" {DIM}No strong patterns detected yet. Try running after 7+ active days.{RESET}")
|
|
755
|
+
print()
|
|
756
|
+
return
|
|
757
|
+
|
|
758
|
+
print(f" {BOLD}{len(rules)} suggestions based on your patterns:{RESET}")
|
|
759
|
+
print()
|
|
760
|
+
|
|
761
|
+
# Show proposed block
|
|
762
|
+
block_lines = ["## How I work (from session patterns)\n"]
|
|
763
|
+
for rule, reason in rules:
|
|
764
|
+
block_lines.append(f"- {rule}")
|
|
765
|
+
|
|
766
|
+
for i, (rule, reason) in enumerate(rules):
|
|
767
|
+
print(f" {BOLD}{GREEN}{i+1}.{RESET} {DIM}{reason}{RESET}")
|
|
768
|
+
for line in textwrap.wrap(rule, W - 4):
|
|
769
|
+
print(f" {line}")
|
|
770
|
+
print()
|
|
771
|
+
|
|
772
|
+
print(f" {DIM}{'─' * W}{RESET}")
|
|
773
|
+
print()
|
|
774
|
+
print(f" {BOLD}Proposed CLAUDE.md block:{RESET}")
|
|
775
|
+
print()
|
|
776
|
+
print(f" {DIM}┌{'─' * (W - 2)}┐{RESET}")
|
|
777
|
+
for line in block_lines:
|
|
778
|
+
wrapped = textwrap.wrap(line, W - 6) if line.strip() else [""]
|
|
779
|
+
for wl in wrapped:
|
|
780
|
+
pad = W - 4 - len(wl)
|
|
781
|
+
print(f" {DIM}│{RESET} {wl}{' ' * pad}{DIM}│{RESET}")
|
|
782
|
+
print(f" {DIM}└{'─' * (W - 2)}┘{RESET}")
|
|
783
|
+
print()
|
|
784
|
+
|
|
785
|
+
if write:
|
|
786
|
+
dest = Path(target_path) if target_path else (found_path or cwd_claude)
|
|
787
|
+
existing = dest.read_text() if dest.exists() else ""
|
|
788
|
+
marker = "\n## How I work (from session patterns)\n"
|
|
789
|
+
if marker.strip() in existing:
|
|
790
|
+
print(f" {YELLOW}CLAUDE.md already has a session-patterns block. Skipping write.{RESET}")
|
|
791
|
+
else:
|
|
792
|
+
with open(dest, "a") as f:
|
|
793
|
+
f.write("\n" + "\n".join(block_lines) + "\n")
|
|
794
|
+
print(f" {BOLD}{GREEN}Written to {dest}{RESET}")
|
|
795
|
+
else:
|
|
796
|
+
print(f" {DIM}To write this to CLAUDE.md:{RESET}")
|
|
797
|
+
print(f" {DIM} python3 wrapped.py --tune-up --write{RESET}")
|
|
798
|
+
|
|
799
|
+
print()
|
|
800
|
+
print(f" {BOLD}{GREEN}{'━' * W}{RESET}")
|
|
801
|
+
print()
|
|
802
|
+
print(f" {DIM}Try it: github.com/turleydesigns/claude-wrapped{RESET}")
|
|
803
|
+
print(f" {DIM}python3 wrapped.py --tune-up{RESET}")
|
|
804
|
+
print()
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def build_roast(stats: dict, days: int) -> list[str]:
|
|
808
|
+
lines = []
|
|
809
|
+
total = stats["total_sessions"]
|
|
810
|
+
tp = stats["tool_pct"]
|
|
811
|
+
bash = tp.get("Bash", 0)
|
|
812
|
+
grep = tp.get("Grep", 0)
|
|
813
|
+
read = tp.get("Read", 0)
|
|
814
|
+
edit = tp.get("Edit", 0)
|
|
815
|
+
write = tp.get("Write", 0)
|
|
816
|
+
agent = tp.get("Agent", 0)
|
|
817
|
+
night = stats["night_pct"]
|
|
818
|
+
peak_h = stats["peak_hour"]
|
|
819
|
+
avg_len = stats["avg_prompt_len"]
|
|
820
|
+
just = stats["just_count"]
|
|
821
|
+
polite = stats["polite_count"]
|
|
822
|
+
untitled = stats["untitled_count"]
|
|
823
|
+
out_tok = stats["total_output_tokens"]
|
|
824
|
+
proj_ct = stats["project_count"]
|
|
825
|
+
longest = stats["longest_mins"]
|
|
826
|
+
top_word = stats["top_word"]
|
|
827
|
+
|
|
828
|
+
# Roast 1 — sessions volume
|
|
829
|
+
if total > 10000:
|
|
830
|
+
lines.append(f"You opened {total:,} Claude sessions in {days} days. At some point that stops being productivity and starts being a coping mechanism.")
|
|
831
|
+
elif total > 1000:
|
|
832
|
+
lines.append(f"{total:,} sessions in {days} days. Claude is basically your main relationship at this point.")
|
|
833
|
+
elif total > 100:
|
|
834
|
+
lines.append(f"{total} sessions in {days} days. Healthy. Worrying about you less now.")
|
|
835
|
+
else:
|
|
836
|
+
lines.append(f"Only {total} sessions in {days} days. Either you have a very healthy work-life balance or Claude kept crashing.")
|
|
837
|
+
|
|
838
|
+
# Roast 2 — Bash hammer
|
|
839
|
+
if bash > 60:
|
|
840
|
+
lines.append(f"Bash is {bash}% of your tool calls. You are not using Claude to help you think. You are using Claude to hold your terminal history.")
|
|
841
|
+
elif bash > 45:
|
|
842
|
+
lines.append(f"You run Bash {bash}% of the time. The docs exist. They are right there.")
|
|
843
|
+
|
|
844
|
+
# Roast 3 — Agent
|
|
845
|
+
if agent > 15:
|
|
846
|
+
lines.append(f"You spawn subagents {agent}% of the time. That's not workflow optimization, that's just hiring Claude to manage Claude.")
|
|
847
|
+
elif agent > 5:
|
|
848
|
+
lines.append(f"You've discovered subagents. Give it two weeks.")
|
|
849
|
+
|
|
850
|
+
# Roast 4 — read-heavy but doesn't act
|
|
851
|
+
if grep + read > 50 and edit + write < 15:
|
|
852
|
+
lines.append(f"You spend {grep + read}% of your time reading the codebase and {edit + write}% changing it. At some point reading becomes procrastinating.")
|
|
853
|
+
|
|
854
|
+
# Roast 5 — night owl
|
|
855
|
+
if night > 40:
|
|
856
|
+
lines.append(f"{night}% of your sessions started after 10pm. You have found a way to be consistently online during the hours when judgment is lowest. This is fine.")
|
|
857
|
+
elif night > 25:
|
|
858
|
+
lines.append(f"{night}% night sessions. Peak hour was {fmt_hour(peak_h)}. You're not a night owl, you're just avoiding something.")
|
|
859
|
+
|
|
860
|
+
# Roast 6 — prompt length extremes
|
|
861
|
+
if avg_len > 800:
|
|
862
|
+
lines.append(f"Your average prompt is {avg_len} characters. You write more context for Claude than you do for your teammates. This is probably correct.")
|
|
863
|
+
elif avg_len < 60:
|
|
864
|
+
lines.append(f"Average prompt length: {avg_len} characters. \"fix it\" is a complete sentence to you. Claude is doing a lot of inference work on your behalf.")
|
|
865
|
+
|
|
866
|
+
# Roast 7 — just count
|
|
867
|
+
if just > 50:
|
|
868
|
+
lines.append(f"You said \"just\" {just} times. Just fix it. Just add auth. Just make it work. The word \"just\" implies simplicity. Nothing you're asking Claude to do is simple.")
|
|
869
|
+
elif just > 20:
|
|
870
|
+
lines.append(f"You said \"just\" {just} times. It's a tell. You say it when you know the task is bigger than you're admitting.")
|
|
871
|
+
|
|
872
|
+
# Roast 8 — polite
|
|
873
|
+
if polite > 30:
|
|
874
|
+
lines.append(f"You said please or thank you {polite} times. Claude is not going to remember this. But it says something about you.")
|
|
875
|
+
elif polite == 0 and total > 50:
|
|
876
|
+
lines.append("You've never once said please or thank you to Claude. Not once. That's a choice.")
|
|
877
|
+
|
|
878
|
+
# Roast 9 — untitled sessions
|
|
879
|
+
if untitled > total * 0.7:
|
|
880
|
+
pct = round(100 * untitled / total)
|
|
881
|
+
lines.append(f"{pct}% of your sessions have no title. You are living in a sea of \"New Session\" and \"New Session (2)\" and you have made peace with this.")
|
|
882
|
+
elif untitled > 20:
|
|
883
|
+
lines.append(f"{untitled} sessions with no title. Future you is going to hate current you for this.")
|
|
884
|
+
|
|
885
|
+
# Roast 10 — tokens
|
|
886
|
+
books = round(out_tok / 5 / 90_000, 1)
|
|
887
|
+
if books > 100:
|
|
888
|
+
lines.append(f"Claude has generated {fmt_tokens(out_tok)} output tokens for you. That's {books}x the word count of Lord of the Rings. You've read all of it and remembered approximately none of it.")
|
|
889
|
+
elif books > 10:
|
|
890
|
+
lines.append(f"{fmt_tokens(out_tok)} output tokens. You've received more words from Claude than exist in most novels. Some of them were probably useful.")
|
|
891
|
+
|
|
892
|
+
# Roast 11 — project count
|
|
893
|
+
if proj_ct > 500:
|
|
894
|
+
lines.append(f"You have {proj_ct} active projects. That's not a portfolio. That's avoidance with version control.")
|
|
895
|
+
elif proj_ct > 50:
|
|
896
|
+
lines.append(f"{proj_ct} different projects in {days} days. You start things.")
|
|
897
|
+
|
|
898
|
+
# Roast 12 — longest session
|
|
899
|
+
if longest >= 480:
|
|
900
|
+
lines.append(f"Your longest session hit the 8-hour cap on our tracker. We stopped counting. You didn't stop working.")
|
|
901
|
+
elif longest > 120:
|
|
902
|
+
lines.append(f"Your longest session was {fmt_duration(longest)}. You got into it.")
|
|
903
|
+
|
|
904
|
+
# Roast 13 — top word
|
|
905
|
+
if top_word[0] and top_word[1] > 20:
|
|
906
|
+
lines.append(f"Your most common word in prompts was \"{top_word[0]}\" at {top_word[1]} times. This is either your main domain or your main problem.")
|
|
907
|
+
|
|
908
|
+
# Closer
|
|
909
|
+
lines.append(f"None of this is criticism. {total:,} sessions in {days} days means you're actually using the tool. That puts you ahead of most people who installed it and forgot about it.")
|
|
910
|
+
|
|
911
|
+
return lines
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def print_roast(stats: dict, days: int, no_color: bool = False):
|
|
915
|
+
if no_color:
|
|
916
|
+
color_disabled()
|
|
917
|
+
|
|
918
|
+
lines = build_roast(stats, days)
|
|
919
|
+
W = 46
|
|
920
|
+
archetype_name, arch_color, _ = get_archetype(stats)
|
|
921
|
+
|
|
922
|
+
print()
|
|
923
|
+
print(f" {BOLD}{YELLOW}{'━' * W}{RESET}")
|
|
924
|
+
print(f" {BOLD}{YELLOW} THE ROAST{RESET}")
|
|
925
|
+
print(f" {DIM} {days}-day Claude Code session review{RESET}")
|
|
926
|
+
print(f" {BOLD}{YELLOW}{'━' * W}{RESET}")
|
|
927
|
+
print()
|
|
928
|
+
print(f" {DIM}Archetype: {arch_color}{BOLD}{archetype_name}{RESET}")
|
|
929
|
+
print()
|
|
930
|
+
|
|
931
|
+
for i, line in enumerate(lines):
|
|
932
|
+
# Wrap each roast item
|
|
933
|
+
wrapped = textwrap.wrap(line, W)
|
|
934
|
+
for wl in wrapped:
|
|
935
|
+
print(f" {wl}")
|
|
936
|
+
print()
|
|
937
|
+
|
|
938
|
+
print(f" {BOLD}{YELLOW}{'━' * W}{RESET}")
|
|
939
|
+
print()
|
|
940
|
+
print(f" {DIM}Try it: github.com/turleydesigns/claude-wrapped{RESET}")
|
|
941
|
+
print(f" {DIM}python3 wrapped.py --roast{RESET}")
|
|
942
|
+
print(f" {DIM}What does yours say?{RESET}")
|
|
943
|
+
print()
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def print_archetype(stats: dict, days: int, no_color: bool = False):
|
|
947
|
+
if no_color:
|
|
948
|
+
color_disabled()
|
|
949
|
+
|
|
950
|
+
profile = get_archetype_profile(stats)
|
|
951
|
+
secondary = get_secondary_trait(stats)
|
|
952
|
+
W = 46
|
|
953
|
+
|
|
954
|
+
name = profile["name"]
|
|
955
|
+
color = profile["color"]
|
|
956
|
+
|
|
957
|
+
print()
|
|
958
|
+
print(f" {BOLD}{color}{'▓' * W}{RESET}")
|
|
959
|
+
print(f" {BOLD}{color}{'▓':1}{'':^{W-2}}{'▓':1}{RESET}")
|
|
960
|
+
label = "YOUR CLAUDE CODE ARCHETYPE"
|
|
961
|
+
pad = (W - 2 - len(label)) // 2
|
|
962
|
+
print(f" {BOLD}{color}▓{' ' * pad}{label}{' ' * (W - 2 - pad - len(label))}▓{RESET}")
|
|
963
|
+
print(f" {BOLD}{color}{'▓':1}{'':^{W-2}}{'▓':1}{RESET}")
|
|
964
|
+
print(f" {BOLD}{color}{'▓' * W}{RESET}")
|
|
965
|
+
print()
|
|
966
|
+
|
|
967
|
+
# Archetype name — big
|
|
968
|
+
name_pad = (W - len(name)) // 2
|
|
969
|
+
print(f" {' ' * name_pad}{BOLD}{color}{name}{RESET}")
|
|
970
|
+
print()
|
|
971
|
+
|
|
972
|
+
# Full description
|
|
973
|
+
for line in textwrap.wrap(profile["full"], W):
|
|
974
|
+
print(f" {line}")
|
|
975
|
+
print()
|
|
976
|
+
|
|
977
|
+
# 3 supporting stats
|
|
978
|
+
print(f" {DIM}{'─' * W}{RESET}")
|
|
979
|
+
print()
|
|
980
|
+
for stat in profile["supporting"]:
|
|
981
|
+
print(f" {BOLD}·{RESET} {stat}")
|
|
982
|
+
print()
|
|
983
|
+
|
|
984
|
+
# Famous for / strength / watch out
|
|
985
|
+
print(f" {DIM}{'─' * W}{RESET}")
|
|
986
|
+
print()
|
|
987
|
+
print(f" {BOLD}{color}Famous for:{RESET}")
|
|
988
|
+
for line in textwrap.wrap(profile["famous_for"], W - 4):
|
|
989
|
+
print(f" {line}")
|
|
990
|
+
print()
|
|
991
|
+
print(f" {BOLD}{GREEN}Strength:{RESET}")
|
|
992
|
+
for line in textwrap.wrap(profile["strength"], W - 4):
|
|
993
|
+
print(f" {line}")
|
|
994
|
+
print()
|
|
995
|
+
print(f" {BOLD}{YELLOW}Watch out:{RESET}")
|
|
996
|
+
for line in textwrap.wrap(profile["watch_out"], W - 4):
|
|
997
|
+
print(f" {line}")
|
|
998
|
+
print()
|
|
999
|
+
|
|
1000
|
+
# Secondary trait
|
|
1001
|
+
if secondary:
|
|
1002
|
+
print(f" {DIM}{'─' * W}{RESET}")
|
|
1003
|
+
print()
|
|
1004
|
+
trait_name, trait_desc = secondary
|
|
1005
|
+
print(f" {BOLD}✦ Secondary: {trait_name}{RESET}")
|
|
1006
|
+
for line in textwrap.wrap(trait_desc, W - 2):
|
|
1007
|
+
print(f" {line}")
|
|
1008
|
+
print()
|
|
1009
|
+
|
|
1010
|
+
# Footer
|
|
1011
|
+
print(f" {DIM}{'─' * W}{RESET}")
|
|
1012
|
+
print()
|
|
1013
|
+
print(f" {DIM}Try it: github.com/turleydesigns/claude-wrapped{RESET}")
|
|
1014
|
+
print(f" {DIM}python3 wrapped.py --archetype{RESET}")
|
|
1015
|
+
print(f" {DIM}What's yours?{RESET}")
|
|
1016
|
+
print()
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def print_slide(emoji: str, headline: str, body: str, color: str = WHITE):
|
|
1020
|
+
w = 46
|
|
1021
|
+
print()
|
|
1022
|
+
print(f" {BOLD}{color}{emoji} {headline}{RESET}")
|
|
1023
|
+
print()
|
|
1024
|
+
for line in textwrap.wrap(body, w):
|
|
1025
|
+
print(f" {line}")
|
|
1026
|
+
print()
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def print_separator():
|
|
1030
|
+
print(f" {DIM}{'─' * 44}{RESET}")
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def print_wrapped(stats: dict, days: int, no_color: bool = False):
|
|
1034
|
+
if no_color:
|
|
1035
|
+
color_disabled()
|
|
1036
|
+
|
|
1037
|
+
archetype_name, arch_color, arch_desc = get_archetype(stats)
|
|
1038
|
+
secondary = get_secondary_trait(stats)
|
|
1039
|
+
|
|
1040
|
+
total_s = stats["total_sessions"]
|
|
1041
|
+
out_tok = stats["total_output_tokens"]
|
|
1042
|
+
in_tok = stats["total_input_tokens"]
|
|
1043
|
+
proj = stats["top_project"][0]
|
|
1044
|
+
proj_sess = stats["top_project"][1]
|
|
1045
|
+
peak_h = stats["peak_hour"]
|
|
1046
|
+
night_pct = stats["night_pct"]
|
|
1047
|
+
longest = stats["longest"]
|
|
1048
|
+
top_tool = stats["top_tools"][0] if stats["top_tools"] else ("Bash", 0)
|
|
1049
|
+
top_word = stats["top_word"]
|
|
1050
|
+
proj_ct = stats["project_count"]
|
|
1051
|
+
|
|
1052
|
+
# output tokens / 5 ≈ words; avg book ~90k words
|
|
1053
|
+
books = round(out_tok / 5 / 90_000, 1)
|
|
1054
|
+
# rough estimate: avg session with >5 messages ≈ 12 min active
|
|
1055
|
+
hours_est = round(total_s * 12 / 60)
|
|
1056
|
+
|
|
1057
|
+
# Header
|
|
1058
|
+
print()
|
|
1059
|
+
print(f" {BOLD}{CYAN}{'━' * 44}{RESET}")
|
|
1060
|
+
print(f" {BOLD}{CYAN} YOUR CLAUDE CODE WRAPPED{RESET}")
|
|
1061
|
+
period = f"last {days} days"
|
|
1062
|
+
print(f" {DIM} {period}{RESET}")
|
|
1063
|
+
print(f" {BOLD}{CYAN}{'━' * 44}{RESET}")
|
|
1064
|
+
|
|
1065
|
+
# Slide 1 — Big number
|
|
1066
|
+
sessions_label = "sessions" if total_s != 1 else "session"
|
|
1067
|
+
print_slide("🔥", "YOUR BIGGEST NUMBER", f"{BOLD}{total_s:,}{RESET} {sessions_label} with Claude in the last {days} days.", YELLOW)
|
|
1068
|
+
|
|
1069
|
+
print_separator()
|
|
1070
|
+
|
|
1071
|
+
# Slide 2 — Top project
|
|
1072
|
+
print_slide("📍", "WHERE YOU LIVED", f"Your most active project was {BOLD}{proj}{RESET} — {proj_sess} sessions across {proj_ct} total projects.", GREEN)
|
|
1073
|
+
|
|
1074
|
+
print_separator()
|
|
1075
|
+
|
|
1076
|
+
# Slide 3 — Top tool
|
|
1077
|
+
tool_pct = stats["tool_pct"].get(top_tool[0], 0)
|
|
1078
|
+
print_slide("⚡", "YOUR WEAPON OF CHOICE", f"{BOLD}{top_tool[0]}{RESET} — you reached for it {tool_pct}% of the time. {top_tool[1]:,} calls total.", YELLOW)
|
|
1079
|
+
|
|
1080
|
+
print_separator()
|
|
1081
|
+
|
|
1082
|
+
# Slide 4 — Tokens
|
|
1083
|
+
print_slide("🧠", "TOKENS BURNED", f"Claude generated {BOLD}{fmt_tokens(out_tok)}{RESET} output tokens for you.", CYAN)
|
|
1084
|
+
if books > 0.5:
|
|
1085
|
+
print(f" {DIM}That's {books}x the word count of Lord of the Rings.{RESET}")
|
|
1086
|
+
|
|
1087
|
+
print_separator()
|
|
1088
|
+
|
|
1089
|
+
# Slide 5 — Timing
|
|
1090
|
+
if night_pct > 20:
|
|
1091
|
+
print_slide("🌙", f"YOU'RE A NIGHT OWL", f"{night_pct}% of your sessions started after 10pm. Your peak hour was {fmt_hour(peak_h)}.", BLUE)
|
|
1092
|
+
else:
|
|
1093
|
+
print_slide("⏰", f"PEAK HOUR: {fmt_hour(peak_h).upper()}", f"That's when you hit your stride. Night sessions: {night_pct}% of the total.", BLUE)
|
|
1094
|
+
|
|
1095
|
+
print_separator()
|
|
1096
|
+
|
|
1097
|
+
# Slide 6 — Longest session
|
|
1098
|
+
if longest:
|
|
1099
|
+
date_str = ""
|
|
1100
|
+
if longest["timestamps"]:
|
|
1101
|
+
date_str = f" on {min(longest['timestamps']).strftime('%b %d')}"
|
|
1102
|
+
proj_str = f" in {longest['project']}" if longest["project"] else ""
|
|
1103
|
+
print_slide("⏱", "LONGEST GRIND", f"{BOLD}{fmt_duration(stats['longest_mins'])}{RESET}{date_str}{proj_str}.", MAGENTA)
|
|
1104
|
+
|
|
1105
|
+
print_separator()
|
|
1106
|
+
|
|
1107
|
+
# Slide 7 — Archetype
|
|
1108
|
+
print_slide("🧬", "YOUR ARCHETYPE", f"{BOLD}{arch_color}{archetype_name}{RESET}", arch_color)
|
|
1109
|
+
for line in textwrap.wrap(arch_desc.replace(BOLD, "").replace(RESET, ""), 44):
|
|
1110
|
+
print(f" {line}")
|
|
1111
|
+
print()
|
|
1112
|
+
|
|
1113
|
+
# Secondary trait
|
|
1114
|
+
if secondary:
|
|
1115
|
+
print_separator()
|
|
1116
|
+
trait_name, trait_desc = secondary
|
|
1117
|
+
print_slide("✦", f"SECONDARY TRAIT: {trait_name.upper()}", trait_desc, DIM)
|
|
1118
|
+
|
|
1119
|
+
# Slide 8 — Top word
|
|
1120
|
+
if top_word[0]:
|
|
1121
|
+
print_separator()
|
|
1122
|
+
print_slide("💬", "YOUR MOST USED WORD", f"You said \"{BOLD}{top_word[0]}{RESET}\" {top_word[1]} times in your prompts.", DIM)
|
|
1123
|
+
|
|
1124
|
+
# Footer
|
|
1125
|
+
print(f" {BOLD}{CYAN}{'━' * 44}{RESET}")
|
|
1126
|
+
print()
|
|
1127
|
+
print(f" {DIM}Try it: github.com/turleydesigns/claude-wrapped{RESET}")
|
|
1128
|
+
print(f" {DIM}What does yours say?{RESET}")
|
|
1129
|
+
print()
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def main():
|
|
1133
|
+
parser = argparse.ArgumentParser(
|
|
1134
|
+
description="Claude Code Wrapped — Spotify Wrapped for your Claude sessions.",
|
|
1135
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1136
|
+
)
|
|
1137
|
+
parser.add_argument("--days", type=int, default=30, help="days to look back (default: 30)")
|
|
1138
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
1139
|
+
parser.add_argument("--archetype", action="store_true", help="your coder archetype card")
|
|
1140
|
+
parser.add_argument("--roast", action="store_true", help="personalized roast from your session data")
|
|
1141
|
+
parser.add_argument("--dna", action="store_true", help="your prompt style DNA")
|
|
1142
|
+
parser.add_argument("--tune-up", action="store_true", help="generate CLAUDE.md suggestions from your patterns")
|
|
1143
|
+
parser.add_argument("--write", action="store_true", help="write tune-up suggestions to CLAUDE.md (use with --tune-up)")
|
|
1144
|
+
parser.add_argument("--spirit", action="store_true", help="your spirit model — which Claude are you based on how you prompt")
|
|
1145
|
+
args = parser.parse_args()
|
|
1146
|
+
|
|
1147
|
+
sessions = load_sessions(args.days)
|
|
1148
|
+
|
|
1149
|
+
if not sessions:
|
|
1150
|
+
print(f"No Claude Code sessions found in the last {args.days} days.")
|
|
1151
|
+
print(f"Looking in: {CLAUDE_DIR}")
|
|
1152
|
+
sys.exit(0)
|
|
1153
|
+
|
|
1154
|
+
stats = aggregate(sessions)
|
|
1155
|
+
|
|
1156
|
+
if args.archetype:
|
|
1157
|
+
print_archetype(stats, args.days, no_color=args.no_color)
|
|
1158
|
+
elif args.roast:
|
|
1159
|
+
print_roast(stats, args.days, no_color=args.no_color)
|
|
1160
|
+
elif args.dna:
|
|
1161
|
+
print_dna(stats, args.days, no_color=args.no_color)
|
|
1162
|
+
elif getattr(args, "tune_up", False):
|
|
1163
|
+
print_tune_up(stats, args.days, no_color=args.no_color, write=args.write)
|
|
1164
|
+
elif args.spirit:
|
|
1165
|
+
print_spirit(stats, args.days, no_color=args.no_color)
|
|
1166
|
+
else:
|
|
1167
|
+
print_wrapped(stats, args.days, no_color=args.no_color)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
if __name__ == "__main__":
|
|
1171
|
+
main()
|