Skip to content

Instantly share code, notes, and snippets.

@jordangarcia
Created January 6, 2026 18:40
Show Gist options
  • Select an option

  • Save jordangarcia/2a9e981474b0c3b0b53468c0ba2043df to your computer and use it in GitHub Desktop.

Select an option

Save jordangarcia/2a9e981474b0c3b0b53468c0ba2043df to your computer and use it in GitHub Desktop.
Claude Code tool usage tracking hooks
#!/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()
#!/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()
#!/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