Created
January 6, 2026 18:40
-
-
Save jordangarcia/2a9e981474b0c3b0b53468c0ba2043df to your computer and use it in GitHub Desktop.
Claude Code tool usage tracking hooks
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 | |
| """ | |
| CLI to query Claude Code tool usage stats. | |
| Usage: | |
| claude-tools # list repos | |
| claude-tools <repo> # show tool counts | |
| claude-tools <repo> --perms # show permission strings (for allowlisting) | |
| claude-tools <repo> --raw # show recent raw entries | |
| claude-tools <repo> --since 7 # last 7 days | |
| """ | |
| import argparse | |
| import sqlite3 | |
| from pathlib import Path | |
| DB_DIR = Path.home() / ".claude" / "tool-usage" | |
| def list_repos(): | |
| if not DB_DIR.exists(): | |
| print("No usage data yet.") | |
| return | |
| repos = sorted(f.stem for f in DB_DIR.glob("*.db")) | |
| if not repos: | |
| print("No usage data yet.") | |
| else: | |
| print("Repos with data:") | |
| for r in repos: | |
| db = DB_DIR / f"{r}.db" | |
| size = db.stat().st_size // 1024 | |
| conn = sqlite3.connect(db) | |
| count = conn.execute("SELECT COUNT(*) FROM tool_usage").fetchone()[0] | |
| conn.close() | |
| print(f" {r:<30} {count:>6} entries ({size}KB)") | |
| def query_repo(repo, since_days, show_perms, show_raw, tool_filter): | |
| db_path = DB_DIR / f"{repo}.db" | |
| if not db_path.exists(): | |
| print(f"No data for repo: {repo}") | |
| return | |
| conn = sqlite3.connect(db_path) | |
| # Filter by specific tool | |
| if tool_filter: | |
| print(f"\n=== {tool_filter} entries (last {since_days} days) - {repo} ===\n") | |
| rows = conn.execute(f""" | |
| SELECT permission_string, timestamp FROM tool_usage | |
| WHERE tool_name = ? AND timestamp > datetime('now', '-{since_days} days') | |
| ORDER BY timestamp DESC LIMIT 100 | |
| """, (tool_filter,)).fetchall() | |
| for perm, ts in rows: | |
| print(f"{ts[:16]} {perm}") | |
| conn.close() | |
| return | |
| if show_raw: | |
| print(f"\n=== Recent Entries (last 50) - {repo} ===\n") | |
| rows = conn.execute(""" | |
| SELECT permission_string, timestamp FROM tool_usage | |
| ORDER BY timestamp DESC LIMIT 50 | |
| """).fetchall() | |
| for perm, ts in rows: | |
| print(f"{ts[:16]} {perm}") | |
| conn.close() | |
| return | |
| print(f"\n=== Tool Usage (last {since_days} days) - {repo} ===\n") | |
| # Tool counts | |
| rows = conn.execute(f""" | |
| SELECT tool_name, COUNT(*) as cnt FROM tool_usage | |
| WHERE timestamp > datetime('now', '-{since_days} days') | |
| GROUP BY tool_name ORDER BY cnt DESC | |
| """).fetchall() | |
| print("Tool Count") | |
| print("-" * 40) | |
| for tool, cnt in rows: | |
| print(f"{tool:<30} {cnt:>5}") | |
| if show_perms: | |
| print(f"\n=== Permission Strings (for allowlist) ===\n") | |
| rows = conn.execute(f""" | |
| SELECT permission_string, COUNT(*) as cnt FROM tool_usage | |
| WHERE timestamp > datetime('now', '-{since_days} days') | |
| AND permission_string IS NOT NULL | |
| GROUP BY permission_string ORDER BY cnt DESC | |
| LIMIT 100 | |
| """).fetchall() | |
| print("Count Permission String") | |
| print("-" * 70) | |
| for perm, cnt in rows: | |
| print(f"{cnt:>5} {perm}") | |
| conn.close() | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Query Claude Code tool usage stats", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| claude-tools List repos with data | |
| claude-tools gamma Show tool counts for gamma | |
| claude-tools gamma --perms Show permission strings for allowlisting | |
| claude-tools gamma --raw Show recent raw entries | |
| claude-tools gamma --since 7 Show last 7 days only | |
| """ | |
| ) | |
| parser.add_argument("repo", nargs="?", help="Repo name") | |
| parser.add_argument("--perms", action="store_true", help="Show permission strings") | |
| parser.add_argument("--raw", action="store_true", help="Show raw recent entries") | |
| parser.add_argument("--tool", type=str, help="Filter by tool name (Read, Bash, Edit, etc.)") | |
| parser.add_argument("--since", type=int, default=30, help="Days to look back (default: 30)") | |
| args = parser.parse_args() | |
| if not args.repo: | |
| list_repos() | |
| else: | |
| query_repo(args.repo, args.since, args.perms, args.raw, args.tool) | |
| if __name__ == "__main__": | |
| main() |
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 | |
| """ | |
| Query tool usage stats from SQLite db. | |
| Usage: ./query-tool-usage.py [repo] [--bash] [--since DAYS] | |
| """ | |
| import argparse | |
| import sqlite3 | |
| from pathlib import Path | |
| def get_db_dir(): | |
| return Path.home() / ".claude" / "tool-usage" | |
| def list_repos(): | |
| db_dir = get_db_dir() | |
| if not db_dir.exists(): | |
| return [] | |
| return [f.stem for f in db_dir.glob("*.db")] | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("repo", nargs="?", help="Repo name (omit to list available)") | |
| parser.add_argument("--bash", action="store_true", help="Show bash command breakdown") | |
| parser.add_argument("--since", type=int, default=30, help="Days to look back (default: 30)") | |
| parser.add_argument("--raw", action="store_true", help="Show raw recent entries") | |
| args = parser.parse_args() | |
| if not args.repo: | |
| repos = list_repos() | |
| if not repos: | |
| print("No usage data yet.") | |
| else: | |
| print("Available repos:") | |
| for r in repos: | |
| print(f" {r}") | |
| return | |
| db_path = get_db_dir() / f"{args.repo}.db" | |
| if not db_path.exists(): | |
| print(f"No data for repo: {args.repo}") | |
| return | |
| conn = sqlite3.connect(db_path) | |
| if args.raw: | |
| print(f"\n=== Recent Tool Usage (last 50) - {args.repo} ===") | |
| rows = conn.execute(""" | |
| SELECT tool_name, bash_command_prefix, timestamp | |
| FROM tool_usage ORDER BY timestamp DESC LIMIT 50 | |
| """).fetchall() | |
| for tool, bash_prefix, ts in rows: | |
| if bash_prefix: | |
| print(f"{ts[:16]} {tool}({bash_prefix}:*)") | |
| else: | |
| print(f"{ts[:16]} {tool}") | |
| return | |
| print(f"\n=== Tool Usage (last {args.since} days) - {args.repo} ===\n") | |
| rows = conn.execute(f""" | |
| SELECT tool_name, COUNT(*) as cnt FROM tool_usage | |
| WHERE timestamp > datetime('now', '-{args.since} days') | |
| GROUP BY tool_name ORDER BY cnt DESC | |
| """).fetchall() | |
| print("Tool Count") | |
| print("-" * 40) | |
| for tool, cnt in rows: | |
| print(f"{tool:<30} {cnt:>5}") | |
| if args.bash: | |
| print(f"\n=== Bash Command Prefixes ===\n") | |
| rows = conn.execute(f""" | |
| SELECT bash_command_prefix, COUNT(*) as cnt FROM tool_usage | |
| WHERE tool_name = 'Bash' AND bash_command_prefix IS NOT NULL | |
| AND timestamp > datetime('now', '-{args.since} days') | |
| GROUP BY bash_command_prefix ORDER BY cnt DESC LIMIT 50 | |
| """).fetchall() | |
| print("Prefix Count Suggested Permission") | |
| print("-" * 70) | |
| for prefix, cnt in rows: | |
| print(f"{prefix:<30} {cnt:>5} Bash({prefix}:*)") | |
| conn.close() | |
| if __name__ == "__main__": | |
| main() |
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 PostToolUse hook - tracks tool usage in SQLite. | |
| Stores per-repo DBs in ~/.claude/tool-usage/<repo-name>.db | |
| """ | |
| import json | |
| import os | |
| import re | |
| import sqlite3 | |
| import sys | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from urllib.parse import urlparse | |
| def get_db_path(): | |
| project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) | |
| repo_name = os.path.basename(project_dir) | |
| db_dir = Path.home() / ".claude" / "tool-usage" | |
| db_dir.mkdir(parents=True, exist_ok=True) | |
| return db_dir / f"{repo_name}.db" | |
| def init_db(conn): | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS tool_usage ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| tool_name TEXT NOT NULL, | |
| permission_string TEXT, | |
| timestamp TEXT NOT NULL, | |
| session_id TEXT | |
| ) | |
| """) | |
| # Migration: add permission_string column if missing (for old DBs) | |
| cursor = conn.execute("PRAGMA table_info(tool_usage)") | |
| columns = {row[1] for row in cursor.fetchall()} | |
| if "permission_string" not in columns: | |
| conn.execute("ALTER TABLE tool_usage ADD COLUMN permission_string TEXT") | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_tool_name ON tool_usage(tool_name)") | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_permission ON tool_usage(permission_string)") | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON tool_usage(timestamp)") | |
| conn.commit() | |
| def to_relative(path): | |
| """Convert absolute path to repo-relative path.""" | |
| project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") | |
| if project_dir and path.startswith(project_dir): | |
| return path[len(project_dir):].lstrip("/") | |
| return path | |
| def build_permission_string(tool_name, tool_input): | |
| """Build the permission string format used in Claude Code settings.""" | |
| if tool_name == "Bash": | |
| cmd = tool_input.get("command", "") | |
| # Extract first 2 words for grouping | |
| parts = cmd.split()[:2] | |
| prefix = " ".join(parts) if parts else cmd | |
| return f"Bash({prefix}:*)" | |
| elif tool_name in ("Read", "Edit", "Write", "MultiEdit"): | |
| path = to_relative(tool_input.get("file_path", "")) | |
| return f"{tool_name}({path})" | |
| elif tool_name == "WebFetch": | |
| url = tool_input.get("url", "") | |
| try: | |
| domain = urlparse(url).netloc | |
| return f"WebFetch(domain:{domain})" | |
| except: | |
| return f"WebFetch({url})" | |
| elif tool_name == "WebSearch": | |
| return "WebSearch" | |
| elif tool_name == "Glob": | |
| pattern = tool_input.get("pattern", "") | |
| path = to_relative(tool_input.get("path", "")) | |
| return f"Glob({path}/**/{pattern})" if path else f"Glob({pattern})" | |
| elif tool_name == "Grep": | |
| pattern = tool_input.get("pattern", "") | |
| path = to_relative(tool_input.get("path", "")) | |
| return f"Grep({path}:{pattern})" if path else f"Grep({pattern})" | |
| elif tool_name == "Task": | |
| subagent = tool_input.get("subagent_type", "") | |
| return f"Task({subagent})" | |
| elif tool_name.startswith("mcp__"): | |
| return tool_name | |
| else: | |
| # Generic fallback | |
| return tool_name | |
| def main(): | |
| try: | |
| data = json.load(sys.stdin) | |
| except json.JSONDecodeError: | |
| return | |
| tool_name = data.get("tool_name", "unknown") | |
| tool_input = data.get("tool_input", {}) | |
| session_id = data.get("session_id") | |
| permission_string = build_permission_string(tool_name, tool_input) | |
| db_path = get_db_path() | |
| conn = sqlite3.connect(db_path) | |
| init_db(conn) | |
| conn.execute( | |
| "INSERT INTO tool_usage (tool_name, permission_string, timestamp, session_id) VALUES (?, ?, ?, ?)", | |
| (tool_name, permission_string, datetime.now(timezone.utc).isoformat(), session_id), | |
| ) | |
| conn.commit() | |
| conn.close() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment