Skip to content

Instantly share code, notes, and snippets.

@ranaroussi
Last active July 24, 2025 12:23
Show Gist options
  • Save ranaroussi/42b7c319e1e36be39b554b66e8fba3cb to your computer and use it in GitHub Desktop.
Save ranaroussi/42b7c319e1e36be39b554b66e8fba3cb to your computer and use it in GitHub Desktop.
{
"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"
}
]
}
]
}
}
@ranaroussi
Copy link
Author

~/.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()

@ranaroussi
Copy link
Author

~/.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