Skip to content

Instantly share code, notes, and snippets.

@stefanprodan
Last active February 6, 2026 05:44
Show Gist options
  • Select an option

  • Save stefanprodan/2ec2e9cd77fd98aad31b1e64a522ae45 to your computer and use it in GitHub Desktop.

Select an option

Save stefanprodan/2ec2e9cd77fd98aad31b1e64a522ae45 to your computer and use it in GitHub Desktop.
Claude Code Statistics - Lines of Code Report Generator
#!/usr/bin/env python3
"""
Claude Code Statistics - Lines of Code Report Generator
Analyzes Claude Code session files to extract lines of code written/edited
by language and project. Generates an HTML report.
Usage:
python3 claude-code-stats.py [--output path/to/report.html]
"""
import json
import os
import argparse
from collections import defaultdict
from pathlib import Path
from datetime import datetime
# Map file extensions to language names
EXT_TO_LANG = {
'.swift': 'Swift',
'.go': 'Go',
'.js': 'JavaScript',
'.ts': 'TypeScript',
'.tsx': 'TypeScript (TSX)',
'.jsx': 'JavaScript (JSX)',
'.py': 'Python',
'.md': 'Markdown',
'.yaml': 'YAML',
'.yml': 'YAML',
'.json': 'JSON',
'.html': 'HTML',
'.css': 'CSS',
'.scss': 'SCSS',
'.less': 'LESS',
'.sh': 'Shell',
'.bash': 'Shell',
'.zsh': 'Shell',
'.fish': 'Shell',
'.sql': 'SQL',
'.proto': 'Protocol Buffers',
'.toml': 'TOML',
'.rs': 'Rust',
'.rb': 'Ruby',
'.java': 'Java',
'.kt': 'Kotlin',
'.kts': 'Kotlin',
'.c': 'C',
'.cpp': 'C++',
'.cc': 'C++',
'.cxx': 'C++',
'.h': 'C/C++ Header',
'.hpp': 'C++ Header',
'.hxx': 'C++ Header',
'.m': 'Objective-C',
'.mm': 'Objective-C++',
'.plist': 'Property List',
'.svg': 'SVG',
'.xml': 'XML',
'.vue': 'Vue',
'.svelte': 'Svelte',
'.astro': 'Astro',
'.php': 'PHP',
'.cs': 'C#',
'.fs': 'F#',
'.ex': 'Elixir',
'.exs': 'Elixir',
'.erl': 'Erlang',
'.hrl': 'Erlang',
'.hs': 'Haskell',
'.lhs': 'Haskell',
'.ml': 'OCaml',
'.mli': 'OCaml',
'.clj': 'Clojure',
'.cljs': 'ClojureScript',
'.scala': 'Scala',
'.lua': 'Lua',
'.r': 'R',
'.jl': 'Julia',
'.dart': 'Dart',
'.tf': 'Terraform',
'.tfvars': 'Terraform',
'.hcl': 'HCL',
'.dockerfile': 'Dockerfile',
'.graphql': 'GraphQL',
'.gql': 'GraphQL',
'.prisma': 'Prisma',
'.env': 'Environment',
'.ini': 'INI',
'.cfg': 'Config',
'.conf': 'Config',
'no_ext': 'No Extension',
}
def get_lang(ext: str) -> str:
"""Convert file extension to language name."""
return EXT_TO_LANG.get(ext.lower(), ext)
def analyze_sessions(projects_dir: Path) -> dict:
"""Analyze all Claude Code session files."""
all_projects = defaultdict(lambda: {
'edits': defaultdict(int),
'writes': defaultdict(int),
'added': defaultdict(int),
'removed': defaultdict(int),
'sessions': 0
})
global_edits = defaultdict(int)
global_writes = defaultdict(int)
global_added = defaultdict(int)
global_removed = defaultdict(int)
total_sessions = 0
for project_dir in projects_dir.iterdir():
if not project_dir.is_dir():
continue
project_name = project_dir.name
# Use recursive glob to include subagent sessions
session_files = list(project_dir.glob("**/*.jsonl"))
all_projects[project_name]['sessions'] = len(session_files)
total_sessions += len(session_files)
for session_file in session_files:
try:
with open(session_file, 'r', encoding='utf-8') as fp:
for line in fp:
try:
data = json.loads(line)
msg = data.get("message", {})
content = msg.get("content", [])
if isinstance(content, list):
for item in content:
if item.get("type") != "tool_use":
continue
name = item.get("name")
inp = item.get("input", {})
file_path = inp.get("file_path", "")
if not file_path:
continue
ext = Path(file_path).suffix.lower() or "no_ext"
if name == "Edit":
all_projects[project_name]['edits'][ext] += 1
global_edits[ext] += 1
old = inp.get("old_string", "")
new = inp.get("new_string", "")
old_lines = old.count('\n') + (1 if old else 0)
new_lines = new.count('\n') + (1 if new else 0)
all_projects[project_name]['removed'][ext] += old_lines
all_projects[project_name]['added'][ext] += new_lines
global_removed[ext] += old_lines
global_added[ext] += new_lines
elif name == "Write":
all_projects[project_name]['writes'][ext] += 1
global_writes[ext] += 1
content_str = inp.get("content", "")
lines = content_str.count('\n') + (1 if content_str else 0)
all_projects[project_name]['added'][ext] += lines
global_added[ext] += lines
except json.JSONDecodeError:
pass
except (IOError, OSError):
pass
return {
'all_projects': dict(all_projects),
'global_edits': dict(global_edits),
'global_writes': dict(global_writes),
'global_added': dict(global_added),
'global_removed': dict(global_removed),
'total_sessions': total_sessions,
}
def generate_html_report(stats: dict, output_path: Path) -> None:
"""Generate an HTML report from the statistics."""
global_edits = stats['global_edits']
global_writes = stats['global_writes']
global_added = stats['global_added']
global_removed = stats['global_removed']
all_projects = stats['all_projects']
total_sessions = stats['total_sessions']
# Aggregate by language
lang_stats = defaultdict(lambda: {'edits': 0, 'writes': 0, 'added': 0, 'removed': 0})
for ext in set(list(global_edits.keys()) + list(global_writes.keys())):
lang = get_lang(ext)
lang_stats[lang]['edits'] += global_edits.get(ext, 0)
lang_stats[lang]['writes'] += global_writes.get(ext, 0)
lang_stats[lang]['added'] += global_added.get(ext, 0)
lang_stats[lang]['removed'] += global_removed.get(ext, 0)
sorted_langs = sorted(lang_stats.items(), key=lambda x: -x[1]['added'])
# Project stats
project_stats = []
for proj, pstats in all_projects.items():
total_added = sum(pstats['added'].values())
total_removed = sum(pstats['removed'].values())
if total_added > 0 or total_removed > 0:
lang_added = defaultdict(int)
for ext, lines in pstats['added'].items():
lang_added[get_lang(ext)] += lines
top_lang = max(lang_added.items(), key=lambda x: x[1])[0] if lang_added else 'N/A'
# Clean up project name for display
display_name = proj
for prefix in ['-Users-stefanprodan-go-src-github-com-', '-Users-', '~-']:
display_name = display_name.replace(prefix, '')
display_name = display_name.replace('-', '/')
project_stats.append({
'name': display_name,
'sessions': pstats['sessions'],
'edits': sum(pstats['edits'].values()),
'writes': sum(pstats['writes'].values()),
'added': total_added,
'removed': total_removed,
'net': total_added - total_removed,
'top_lang': top_lang
})
project_stats.sort(key=lambda x: -x['added'])
total_added = sum(global_added.values())
total_removed = sum(global_removed.values())
total_edits = sum(global_edits.values())
total_writes = sum(global_writes.values())
# Generate HTML
html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code - Lines of Code Report</title>
<style>
:root {{
--bg: #0d1117;
--bg-secondary: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 2rem;
}}
.container {{ max-width: 1200px; margin: 0 auto; }}
h1 {{ font-size: 2rem; margin-bottom: 0.5rem; }}
.subtitle {{ color: var(--text-muted); margin-bottom: 2rem; }}
.summary {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}}
.stat-card {{
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}}
.stat-card .label {{ color: var(--text-muted); font-size: 0.875rem; }}
.stat-card .value {{ font-size: 1.75rem; font-weight: 600; }}
.stat-card .value.green {{ color: var(--green); }}
.stat-card .value.red {{ color: var(--red); }}
h2 {{ margin: 2rem 0 1rem; font-size: 1.5rem; }}
table {{
width: 100%;
border-collapse: collapse;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin-bottom: 2rem;
}}
th, td {{
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}}
th {{ background: rgba(255,255,255,0.05); font-weight: 600; color: var(--text-muted); }}
tr:last-child td {{ border-bottom: none; }}
tr:hover {{ background: rgba(255,255,255,0.02); }}
.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
.green {{ color: var(--green); }}
.red {{ color: var(--red); }}
.bar-container {{ width: 100px; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }}
.bar {{ height: 100%; background: var(--accent); border-radius: 4px; }}
.lang-badge {{
display: inline-block;
padding: 0.125rem 0.5rem;
background: rgba(88,166,255,0.15);
color: var(--accent);
border-radius: 4px;
font-size: 0.75rem;
}}
footer {{ margin-top: 3rem; color: var(--text-muted); font-size: 0.875rem; text-align: center; }}
</style>
</head>
<body>
<div class="container">
<h1>Claude Code - Lines of Code Report</h1>
<p class="subtitle">Generated {datetime.now().strftime('%Y-%m-%d %H:%M')} &middot; {total_sessions} sessions analyzed</p>
<div class="summary">
<div class="stat-card">
<div class="label">Lines Added</div>
<div class="value green">+{total_added:,}</div>
</div>
<div class="stat-card">
<div class="label">Lines Removed</div>
<div class="value red">-{total_removed:,}</div>
</div>
<div class="stat-card">
<div class="label">Net Change</div>
<div class="value">{total_added - total_removed:+,}</div>
</div>
<div class="stat-card">
<div class="label">Total Edits</div>
<div class="value">{total_edits:,}</div>
</div>
<div class="stat-card">
<div class="label">Total Writes</div>
<div class="value">{total_writes:,}</div>
</div>
</div>
<h2>By Language</h2>
<table>
<thead>
<tr>
<th>Language</th>
<th class="num">Edits</th>
<th class="num">Writes</th>
<th class="num">+Lines</th>
<th class="num">-Lines</th>
<th class="num">Net</th>
<th>Share</th>
</tr>
</thead>
<tbody>
'''
max_added = sorted_langs[0][1]['added'] if sorted_langs else 1
for lang, lstats in sorted_langs[:25]:
net = lstats['added'] - lstats['removed']
pct = (lstats['added'] / total_added * 100) if total_added else 0
bar_width = int(lstats['added'] / max_added * 100)
html += f''' <tr>
<td>{lang}</td>
<td class="num">{lstats['edits']:,}</td>
<td class="num">{lstats['writes']:,}</td>
<td class="num green">+{lstats['added']:,}</td>
<td class="num red">-{lstats['removed']:,}</td>
<td class="num">{net:+,}</td>
<td><div class="bar-container"><div class="bar" style="width:{bar_width}%"></div></div> {pct:.1f}%</td>
</tr>
'''
html += ''' </tbody>
</table>
<h2>By Project</h2>
<table>
<thead>
<tr>
<th>Project</th>
<th class="num">Sessions</th>
<th>Top Language</th>
<th class="num">+Lines</th>
<th class="num">-Lines</th>
<th class="num">Net</th>
</tr>
</thead>
<tbody>
'''
for proj in project_stats[:20]:
html += f''' <tr>
<td>{proj['name']}</td>
<td class="num">{proj['sessions']}</td>
<td><span class="lang-badge">{proj['top_lang']}</span></td>
<td class="num green">+{proj['added']:,}</td>
<td class="num red">-{proj['removed']:,}</td>
<td class="num">{proj['net']:+,}</td>
</tr>
'''
html += f''' </tbody>
</table>
<footer>
Report generated by Claude Code Stats &middot; Analyzing {total_sessions} sessions across {len(project_stats)} projects
</footer>
</div>
</body>
</html>
'''
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html)
def print_summary(stats: dict) -> None:
"""Print a summary to the console."""
global_added = stats['global_added']
global_removed = stats['global_removed']
global_edits = stats['global_edits']
global_writes = stats['global_writes']
# Aggregate by language
lang_stats = defaultdict(lambda: {'edits': 0, 'writes': 0, 'added': 0, 'removed': 0})
for ext in set(list(global_edits.keys()) + list(global_writes.keys())):
lang = get_lang(ext)
lang_stats[lang]['edits'] += global_edits.get(ext, 0)
lang_stats[lang]['writes'] += global_writes.get(ext, 0)
lang_stats[lang]['added'] += global_added.get(ext, 0)
lang_stats[lang]['removed'] += global_removed.get(ext, 0)
sorted_langs = sorted(lang_stats.items(), key=lambda x: -x[1]['added'])
total_added = sum(global_added.values())
total_removed = sum(global_removed.values())
print(f"\n{'='*60}")
print(f"Claude Code - Lines of Code Summary")
print(f"{'='*60}")
print(f"\nTotal: +{total_added:,} / -{total_removed:,} (net {total_added - total_removed:+,})")
print(f"Sessions analyzed: {stats['total_sessions']}")
print(f"\nTop Languages:")
print(f"{'-'*60}")
print(f"{'Language':<20} {'Edits':>8} {'Writes':>8} {'+Lines':>12} {'-Lines':>12}")
print(f"{'-'*60}")
for lang, lstats in sorted_langs[:15]:
print(f"{lang:<20} {lstats['edits']:>8,} {lstats['writes']:>8,} {lstats['added']:>+12,} {lstats['removed']:>12,}")
print(f"{'='*60}\n")
def main():
parser = argparse.ArgumentParser(
description='Analyze Claude Code sessions and generate a lines-of-code report.'
)
parser.add_argument(
'--output', '-o',
type=str,
default=os.path.expanduser('~/.claude/usage-data/lines-of-code-report.html'),
help='Output path for the HTML report'
)
parser.add_argument(
'--projects-dir',
type=str,
default=os.path.expanduser('~/.claude/projects'),
help='Path to Claude Code projects directory'
)
parser.add_argument(
'--no-html',
action='store_true',
help='Skip HTML report generation, print summary only'
)
args = parser.parse_args()
projects_dir = Path(args.projects_dir)
if not projects_dir.exists():
print(f"Error: Projects directory not found: {projects_dir}")
return 1
print(f"Analyzing Claude Code sessions in {projects_dir}...")
stats = analyze_sessions(projects_dir)
print_summary(stats)
if not args.no_html:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
generate_html_report(stats, output_path)
print(f"HTML report saved to: {output_path}")
return 0
if __name__ == '__main__':
exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment