Last active
July 24, 2025 12:23
-
-
Save ranaroussi/42b7c319e1e36be39b554b66e8fba3cb to your computer and use it in GitHub Desktop.
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
{ | |
"enableAllProjectMcpServers": true, | |
"permissions": { | |
"allow": [ | |
"Read(**)", | |
"Edit(**)", | |
"MultiEdit(**)", | |
"Write(**)", | |
"Glob(**)", | |
"Grep(**)", | |
"LS(**)", | |
"WebSearch(**)", | |
"WebFetch(**)", | |
"TodoRead()", | |
"TodoWrite(**)", | |
"Task(**)", | |
"Bash(git status*)", | |
"Bash(git log*)", | |
"Bash(git diff*)", | |
"Bash(git show*)", | |
"Bash(git blame*)", | |
"Bash(git branch*)", | |
"Bash(git remote -v*)", | |
"Bash(git config --get*)", | |
"Bash(cp*)", | |
"Bash(mv*)", | |
"Bash(rm*)", | |
"Bash(mkdir*)", | |
"Bash(touch*)", | |
"Bash(chmod*)", | |
"Bash(eza*)", | |
"Bash(ls*)", | |
"Bash(cat *)", | |
"Bash(less *)", | |
"Bash(head*)", | |
"Bash(tail*)", | |
"Bash(grep*)", | |
"Bash(find*)", | |
"Bash(tree*)", | |
"Bash(pwd*)", | |
"Bash(wc*)", | |
"Bash(diff *)", | |
"Bash(sed -n*)", | |
"Bash(awk*)", | |
"Bash(cut*)", | |
"Bash(sort*)", | |
"Bash(uniq*)", | |
"Bash(basename *)", | |
"Bash(dirname *)", | |
"Bash(realpath *)", | |
"Bash(readlink *)", | |
"Bash(curl*)", | |
"Bash(jq*)", | |
"Bash(yq eval*)", | |
"Bash(python*)", | |
"Bash(python3*)", | |
"Bash(uv*)", | |
"Bash(uvx*)", | |
"Bash(pip install*)", | |
"Bash(node*)", | |
"Bash(npm list*)", | |
"Bash(npm run*)", | |
"Bash(npx*)", | |
"Bash(black --check*)", | |
"Bash(black --diff*)", | |
"Bash(pylint*)", | |
"Bash(flake8*)", | |
"Bash(pyright*)", | |
"Bash(mypy*)", | |
"Bash(eslint*)", | |
"Bash(pytest*)", | |
"Bash(make test*)", | |
"Bash(npm test*)", | |
"Bash(make -n*)", | |
"Bash(man *)", | |
"Bash(pydoc*)", | |
"Bash(which *)", | |
"Bash(type *)", | |
"Bash(echo *)", | |
"Bash(printf *)", | |
"Bash(test *)", | |
"Bash(true*)", | |
"Bash(false*)", | |
"Bash(* | grep*)", | |
"Bash(* | jq*)", | |
"Bash(* | head*)", | |
"Bash(* | tail*)", | |
"Bash(* | wc*)", | |
"Bash(* | sort*)", | |
"Bash(* | uniq*)", | |
"Bash(gh:*)", | |
"Bash(claude*)", | |
"Bash(task-master*)", | |
"Bash(psql:*)", | |
"Bash(do*)", | |
"Bash(done)", | |
"Bash(for*)", | |
"Bash(pkill:*)", | |
"Bash(say:*)", | |
"Bash(time:*)", | |
"Bash(timeout:*)" | |
], | |
"deny": ["Bash(rm -rf:*)"] | |
}, | |
"hooks": { | |
"PreToolUse": [ | |
{ | |
"matcher": "", | |
"hooks": [ | |
{ | |
"type": "command", | |
"command": "uv run ~/.claude/hooks/pre_tool_use.py" | |
} | |
] | |
} | |
], | |
"Notification": [ | |
{ | |
"matcher": "", | |
"hooks": [ | |
{ | |
"type": "command", | |
"command": "uv run ~/.claude/hooks/notify.py --notification" | |
} | |
] | |
} | |
], | |
"Stop": [ | |
{ | |
"matcher": "", | |
"hooks": [ | |
{ | |
"type": "command", | |
"command": "uv run ~/.claude/hooks/notify.py --stop" | |
} | |
] | |
} | |
] | |
} | |
} |
~/.claude/hooks/notify.py
#!/usr/bin/env -S uv run --script
import os
import sys
import subprocess
import json
from pathlib import Path
import re
# used for device-specific notifications
DEVICES = {"desktop": "DEVICE-NAME", "mobile": "DEVICE-NA<E"}
def get_most_recent_session_file():
"""Find the most recently modified session file across all projects."""
projects_dir = Path.home() / ".claude" / "projects"
if not projects_dir.exists():
return None
# Get all session files from all projects
all_session_files = []
for project_dir in projects_dir.iterdir():
if project_dir.is_dir():
session_files = list(project_dir.glob("*.jsonl"))
all_session_files.extend(session_files)
if not all_session_files:
return None
# Get the most recently modified file across all projects
most_recent = max(all_session_files, key=lambda f: f.stat().st_mtime)
return most_recent
def summarize_with_gemini(text):
"""Use external AI to summarize the message into title and body."""
try:
# Load environment variables from .env file
env_files = [Path(".claude/.env"), Path.home() / ".claude/.env"]
env_vars = os.environ.copy()
for env_file in env_files:
if env_file.exists():
with open(env_file) as f:
for line in f:
if "=" in line and not line.strip().startswith("#"):
key, value = line.strip().split("=", 1)
env_vars[key] = value.strip('"').strip("'")
break
# Ensure GEMINI_API_KEY is available
if "GEMINI_API_KEY" not in env_vars and "GEMINI_API_KEY" in os.environ:
env_vars["GEMINI_API_KEY"] = os.environ["GEMINI_API_KEY"]
prompt = f"""Summarize this AI assistant response into a notification:
{text}
Return ONLY a JSON object with:
- "title": 4-5 words maximum, describing what was done
- "message": short summary, max 72 characters
Example: {{"title": "Updated notification hook", "message": "Modified the hook to read session transcripts and show context-aware messages."}}"""
cmd = ["gemini", "-p", prompt]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30, env=env_vars
)
if result.returncode == 0 and result.stdout:
try:
# Try to parse as direct JSON first
summary = json.loads(result.stdout.strip())
return summary.get("title"), summary.get("message")
except json.JSONDecodeError:
# Try to extract JSON from the response
json_match = re.search(r"\{[^}]+\}", result.stdout, re.DOTALL)
if json_match:
summary = json.loads(json_match.group())
return summary.get("title"), summary.get("message")
return None, None
except Exception:
return None, None
def extract_latest_assistant_message(session_file, use_claude_summary=True):
"""Extract the latest assistant message from the session file."""
if not session_file or not session_file.exists():
return None
assistant_messages = []
try:
with open(session_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if entry.get("type") == "assistant" and "message" in entry:
assistant_messages.append(entry)
except json.JSONDecodeError:
continue
if not assistant_messages:
return None
# Get the last valid assistant message - skip API key errors and tool uses
last_message = None
for entry in reversed(assistant_messages):
if "message" in entry and isinstance(entry["message"], dict):
# Handle nested message structure from Claude Code
content = entry["message"].get("content", [])
if isinstance(content, list) and content:
# Extract text from text content blocks (skip tool_use blocks)
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
temp_msg = block.get("text", "")
# Skip messages about API keys and empty messages
if (
temp_msg
and "Invalid API key" not in temp_msg
and len(temp_msg.strip()) > 10
):
last_message = temp_msg
break
if last_message:
break
elif "message" in entry:
# Fallback for simple string messages
temp_msg = entry.get("message", "")
# Skip the specific problematic message and API key errors
if (
temp_msg
and temp_msg != "Invalid API key Β· Fix external API key"
and "Invalid API key" not in temp_msg
and len(temp_msg.strip()) > 10
):
last_message = temp_msg
break
if not last_message:
return None
# Clean up the message - remove code blocks but keep the content meaningful
cleaned_message = re.sub(r"```[\s\S]*?```", "", last_message)
cleaned_message = re.sub(
r"`([^`]+)`", r"\1", cleaned_message
) # Keep content of inline code
cleaned_message = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned_message)
cleaned_message = re.sub(r"\*(.*?)\*", r"\1", cleaned_message)
# Split into sentences and get the first meaningful one
sentences = re.split(r"[.!?]\s+", cleaned_message)
# Find the first sentence with substance
for sentence in sentences:
sentence = sentence.strip()
# Skip short sentences, list items, or fragments
if len(sentence) > 15 and not sentence.startswith(
("-", "*", "β’", "1.", "2.", "3.")
):
cleaned_message = sentence
break
else:
# If no good sentence found, just clean up the whole message
cleaned_message = re.sub(r"\n+", " ", cleaned_message)
cleaned_message = re.sub(r"\s+", " ", cleaned_message).strip()
# Remove any trailing punctuation for cleaner notifications
cleaned_message = cleaned_message.rstrip(".,;:")
# Try to get a Claude summary if enabled
if use_claude_summary and last_message:
title, summary = summarize_with_gemini(last_message)
if title and summary:
return {"title": title, "message": summary}
# Fallback to cleaned message
# Truncate to max_chars
if len(cleaned_message) > 150:
cleaned_message = cleaned_message[:147] + "..."
return {"title": None, "message": cleaned_message}
except Exception as e:
print(f"Error reading session file: {e}")
return None
def is_screen_on():
"""Check if the screen is on (user is active) on macOS."""
try:
result = subprocess.run(
[
"python3",
"-c",
'import Quartz; print("1" if Quartz.CGSessionCopyCurrentDictionary() else "0")',
],
capture_output=True,
text=True,
)
return result.stdout.strip() == "1"
except Exception:
# Default to screen on if we can't detect
return True
def main():
# Load environment variables from .env file
env_files = [
Path(".claude/.env"),
Path.home() / ".claude/.env",
] # Check .claude/.env first
for env_file in env_files:
if env_file.exists():
with open(env_file) as f:
for line in f:
if "=" in line and not line.strip().startswith("#"):
key, value = line.strip().split("=", 1)
os.environ[key] = value.strip('"').strip("'")
break # Use the first .env file found
# Send Pushover notification
pushover_token = os.getenv("PUSHOVER_TOKEN")
pushover_user = os.getenv("PUSHOVER_USER")
if pushover_token and pushover_user:
# Get project name from current directory
cwd = Path.cwd()
project_name = cwd.name
# Try to get the latest session message
context_data = None
try:
session_file = get_most_recent_session_file()
context_data = extract_latest_assistant_message(session_file)
except Exception as e:
print(f"Error extracting session context: {e}")
# Determine notification type and content based on arguments
if "--subagent-stop" in sys.argv:
icon = "π€"
if context_data and isinstance(context_data, dict):
title = context_data.get("title") or "Subagent Complete"
message = f"{icon} [{project_name}] {context_data.get('message', 'Background task finished')}"
else:
title = "Subagent Complete"
message = f"{icon} [{project_name}] Background task finished"
elif "--stop" in sys.argv:
icon = "π"
if context_data and isinstance(context_data, dict):
title = context_data.get("title") or "Task Complete"
message = f"{icon} [{project_name}] {context_data.get('message', 'Ready for your input')}"
else:
title = "Task Complete"
message = f"{icon} [{project_name}] Ready for your input"
elif "--notification" in sys.argv:
icon = "π"
if context_data and isinstance(context_data, dict):
title = context_data.get("title") or "Permission Required"
msg = context_data.get("message", "")[:50]
message = f"{icon} [{project_name}] Tool approval needed: {msg}..."
else:
title = "Permission Required"
message = (
f"{icon} [{project_name}] Your approval is required to use a tool"
)
else:
icon = "ποΈ"
title = "Claude Idle"
message = (
f"{icon} [{project_name}] Waiting for your input (idle 60+ seconds)"
)
# Determine target device based on screen state
screen_on = is_screen_on()
device = DEVICES["desktop"] if screen_on else DEVICES["mobile"]
# Send notification via curl
cmd = [
"curl",
"-s",
"--form-string",
f"token={pushover_token}",
"--form-string",
f"user={pushover_user}",
"--form-string",
f"title={title}",
"--form-string",
f"message={message}",
"--form-string",
f"device={device}",
"https://api.pushover.net/1/messages.json",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Pushover notification failed: {result.stderr}")
if __name__ == "__main__":
main()
~/.claude/.env
MY_NAME=Engineer
PUSHOVER_TOKEN=""
PUSHOVER_USER=""
GEMINI_API_KEY=""
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
~/.claude/hooks/pre_tool_use.py