Last active
February 6, 2026 05:44
-
-
Save stefanprodan/2ec2e9cd77fd98aad31b1e64a522ae45 to your computer and use it in GitHub Desktop.
Claude Code Statistics - Lines of Code Report Generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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')} · {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 · 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