Ver Fonte

feat(skills): Add agentmail inter-session messaging from feature branch

SQLite-backed mail system for Claude Code sessions across projects.
Includes mail-db.sh (send/read/reply/broadcast/search), check-mail.sh
PreToolUse hook with 10s cooldown, per-project disable, priority levels,
and 537 lines of tests. Ported from feature/inter-session-mail (152 commits).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter há 3 meses atrás
pai
commit
015e101cfc

+ 4 - 2
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
   "version": "2.3.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 65 skills, 3 commands, 5 rules, 3 hooks, 5 output styles, modern CLI tools",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 66 skills, 3 commands, 5 rules, 4 hooks, 5 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -49,6 +49,7 @@
     ],
     "skills": [
       "skills/api-design-ops",
+      "skills/agentmail",
       "skills/astro-ops",
       "skills/atomise",
       "skills/auth-ops",
@@ -124,7 +125,8 @@
     "hooks": [
       "hooks/pre-commit-lint.sh",
       "hooks/post-edit-format.sh",
-      "hooks/dangerous-cmd-warn.sh"
+      "hooks/dangerous-cmd-warn.sh",
+      "hooks/check-mail.sh"
     ],
     "output-styles": [
       "output-styles/vesper.md",

+ 2 - 2
AGENTS.md

@@ -5,9 +5,9 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **23 expert agents** for specialized domains (React, Python, Go, Rust, AWS, git, etc.)
 - **3 commands** for session management (/sync, /save) and experimental features (/canvas)
-- **65 skills** for CLI tools, patterns, workflows, and development tasks
+- **66 skills** for CLI tools, patterns, workflows, and development tasks
 - **5 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair)
-- **3 hooks** for pre-commit linting, post-edit formatting, and dangerous command warnings
+- **4 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, and inter-session mail
 
 ## Installation
 

+ 5 - 3
README.md

@@ -12,13 +12,13 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 65 specialized skills, 5 output styles, 3 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 23 expert agents, 66 specialized skills, 5 output styles, 4 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on [Anthropic's Agent Skills standard](https://github.com/anthropics/skills), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**23 agents. 65 skills. 5 styles. 3 hooks. One install.**
+**23 agents. 66 skills. 5 styles. 4 hooks. One install.**
 
 ## Recent Updates
 
@@ -99,7 +99,7 @@ claude-mods/
 ├── .claude-plugin/     # Plugin metadata
 ├── agents/             # Expert subagents (22)
 ├── commands/           # Slash commands (3)
-├── skills/             # Custom skills (65)
+├── skills/             # Custom skills (66)
 ├── output-styles/      # Response personalities
 ├── hooks/              # Hook examples & docs
 ├── rules/              # Claude Code rules
@@ -250,6 +250,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [python-env](skills/python-env/) | Fast Python environment management with uv |
 | [task-runner](skills/task-runner/) | Run project commands with just |
 | [screenshot](skills/screenshot/) | Find and display recent screenshots from common screenshot directories |
+| [agentmail](skills/agentmail/) | Inter-session messaging - SQLite-backed mail between Claude Code sessions across projects |
 
 #### Development Skills
 | Skill | Description |
@@ -275,6 +276,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [pre-commit-lint.sh](hooks/pre-commit-lint.sh) | PreToolUse | Auto-lint staged files before commit (JS/TS, Python, Go, Rust, PHP) |
 | [post-edit-format.sh](hooks/post-edit-format.sh) | PostToolUse | Auto-format files after Write/Edit (Prettier, Ruff, gofmt, rustfmt) |
 | [dangerous-cmd-warn.sh](hooks/dangerous-cmd-warn.sh) | PreToolUse | Block destructive commands (force push, rm -rf, DROP TABLE) |
+| [check-mail.sh](hooks/check-mail.sh) | PreToolUse | Check for unread inter-session mail (10s cooldown, silent when empty) |
 
 ### Output Styles
 

+ 61 - 0
hooks/check-mail.sh

@@ -0,0 +1,61 @@
+#!/bin/bash
+# hooks/check-mail.sh
+# PreToolUse hook - checks for unread inter-session mail
+# Runs on every tool call. Silent when inbox is empty.
+# Matcher: * (all tools)
+#
+# Configuration in .claude/settings.json or .claude/settings.local.json:
+# {
+#   "hooks": {
+#     "PreToolUse": [{
+#       "matcher": "*",
+#       "hooks": ["bash hooks/check-mail.sh"]
+#     }]
+#   }
+# }
+
+MAIL_DB="$HOME/.claude/mail.db"
+COOLDOWN_SECONDS=10
+
+# Skip if disabled for this project
+[ -f ".claude/agentmail.disable" ] && exit 0
+
+# Skip if no database exists yet
+[ -f "$MAIL_DB" ] || exit 0
+
+PROJECT=$(basename "$PWD" | sed "s/'/''/g")
+COOLDOWN_FILE="/tmp/agentmail_${PROJECT}"
+
+# Cooldown: skip if checked recently (within COOLDOWN_SECONDS)
+if [ -f "$COOLDOWN_FILE" ]; then
+  last_check=$(stat -c %Y "$COOLDOWN_FILE" 2>/dev/null || stat -f %m "$COOLDOWN_FILE" 2>/dev/null || echo 0)
+  now=$(date +%s)
+  if [ $((now - last_check)) -lt $COOLDOWN_SECONDS ]; then
+    exit 0
+  fi
+fi
+touch "$COOLDOWN_FILE"
+
+# Single fast query - count unread
+UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0;" 2>/dev/null)
+
+# Silent exit if no mail
+[ "${UNREAD:-0}" -eq 0 ] && exit 0
+
+# Check for urgent messages
+URGENT=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0 AND priority='urgent';" 2>/dev/null)
+
+# Show notification with preview of first 3 messages
+echo ""
+if [ "${URGENT:-0}" -gt 0 ]; then
+  echo "=== URGENT MAIL: ${UNREAD} unread (${URGENT} urgent) ==="
+else
+  echo "=== MAIL: ${UNREAD} unread message(s) ==="
+fi
+sqlite3 -separator '  ' "$MAIL_DB" \
+  "SELECT '  ' || CASE WHEN priority='urgent' THEN '[!] ' ELSE '' END || 'From: ' || from_project || '  |  ' || subject FROM messages WHERE to_project='${PROJECT}' AND read=0 ORDER BY priority DESC, timestamp DESC LIMIT 3;" 2>/dev/null
+if [ "$UNREAD" -gt 3 ]; then
+  echo "  ... and $((UNREAD - 3)) more"
+fi
+echo "Use agentmail read to read messages."
+echo "==="

+ 161 - 0
skills/agentmail/SKILL.md

@@ -0,0 +1,161 @@
+---
+name: agentmail
+description: "Inter-session mail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/mail.db. Triggers on: mail, send message, check mail, inbox, inter-session, message another session, agentmail."
+allowed-tools: "Read Bash Grep"
+related-skills: [sqlite-ops]
+---
+
+# AgentMail
+
+Inter-session messaging for Claude Code. Send and receive messages between sessions running in different projects.
+
+## Quick Reference
+
+All commands go through `MAIL`, a shorthand for `bash "$HOME/.claude/agentmail/mail-db.sh"`.
+
+Set this at the top of execution:
+
+```bash
+MAIL="$HOME/.claude/agentmail/mail-db.sh"
+```
+
+Then use it for all commands below.
+
+## Command Router
+
+Parse the user's input after `agentmail` (or `/agentmail`) and run the matching command:
+
+| User says | Run |
+|-----------|-----|
+| `agentmail read` | `bash "$MAIL" read` |
+| `agentmail read 42` | `bash "$MAIL" read 42` |
+| `agentmail send <project> "<subject>" "<body>"` | `bash "$MAIL" send "<project>" "<subject>" "<body>"` |
+| `agentmail send --urgent <project> "<subject>" "<body>"` | `bash "$MAIL" send --urgent "<project>" "<subject>" "<body>"` |
+| `agentmail reply <id> "<body>"` | `bash "$MAIL" reply <id> "<body>"` |
+| `agentmail broadcast "<subject>" "<body>"` | `bash "$MAIL" broadcast "<subject>" "<body>"` |
+| `agentmail search <keyword>` | `bash "$MAIL" search "<keyword>"` |
+| `agentmail status` | `bash "$MAIL" status` |
+| `agentmail unread` | `bash "$MAIL" unread` |
+| `agentmail list` | `bash "$MAIL" list` |
+| `agentmail list 50` | `bash "$MAIL" list 50` |
+| `agentmail projects` | `bash "$MAIL" projects` |
+| `agentmail clear` | `bash "$MAIL" clear` |
+| `agentmail clear 7` | `bash "$MAIL" clear 7` |
+| `agentmail alias <old> <new>` | `bash "$MAIL" alias "<old>" "<new>"` |
+| `agentmail purge` | `bash "$MAIL" purge` |
+| `agentmail purge --all` | `bash "$MAIL" purge --all` |
+| `agentmail init` | `bash "$MAIL" init` |
+
+When the user just says "check mail", "read mail", "inbox", or "any mail?" - run `bash "$MAIL" read`.
+
+When the user says "send mail to X" or "message X" - parse out the project name, subject, and body, then run `bash "$MAIL" send`.
+
+## Project Identity
+
+Project name = `basename` of current working directory. No configuration needed.
+
+- `X:\Forge\claude-mods` -> `claude-mods`
+- `X:\Forge\some-api` -> `some-api`
+- `X:\Roam\Fathom` -> `Fathom`
+
+## Passive Notification (Hook)
+
+A global PreToolUse hook checks for mail on every tool call (10-second cooldown). Silent when inbox is empty.
+
+```
+=== MAIL: 3 unread message(s) ===
+  From: some-api  |  Auth endpoints ready
+  From: frontend  |  Need updated types
+  ... and 1 more
+Use agentmail read to read messages.
+```
+
+## When to Send
+
+- You've completed work another session depends on
+- An API contract or shared interface changed
+- A shared branch (main) is broken or fixed
+- You need input from a session working on a different project
+
+## Per-Project Disable
+
+```bash
+touch .claude/agentmail.disable    # Disable hook notifications
+rm .claude/agentmail.disable       # Re-enable
+```
+
+Only the hook is disabled - you can still send messages from the project.
+
+---
+
+## Installation
+
+Agentmail installs globally - one setup, every project gets mail automatically.
+
+### Files
+
+```
+~/.claude/
+  mail.db                  # Message store (auto-created on first use)
+  agentmail/
+    mail-db.sh             # Mail commands
+    check-mail.sh          # PreToolUse hook
+```
+
+### Setup
+
+1. Copy scripts to global location:
+
+```bash
+mkdir -p ~/.claude/agentmail
+cp skills/agentmail/scripts/mail-db.sh ~/.claude/agentmail/
+cp hooks/check-mail.sh ~/.claude/agentmail/
+```
+
+2. Add the hook to `~/.claude/settings.json`:
+
+```json
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "matcher": "*",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"$HOME/.claude/agentmail/check-mail.sh\"",
+            "timeout": 5
+          }
+        ]
+      }
+    ]
+  }
+}
+```
+
+## Database
+
+Single SQLite file at `~/.claude/mail.db`. Auto-created on first use.
+
+```sql
+CREATE TABLE messages (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    from_project TEXT NOT NULL,
+    to_project TEXT NOT NULL,
+    subject TEXT DEFAULT '',
+    body TEXT NOT NULL,
+    timestamp TEXT DEFAULT (datetime('now')),
+    read INTEGER DEFAULT 0,
+    priority TEXT DEFAULT 'normal'
+);
+```
+
+## Troubleshooting
+
+| Issue | Fix |
+|-------|-----|
+| `sqlite3: not found` | Ships with macOS, Linux, and Git Bash on Windows |
+| Hook not firing | Add PreToolUse hook to `~/.claude/settings.json` (see Installation) |
+| Wrong project name | Uses `basename $PWD` - ensure cwd is project root |
+| Messages not arriving | `to_project` must match target's directory basename exactly |
+| Renamed directory | Use `agentmail alias old-name new-name` |

+ 323 - 0
skills/agentmail/scripts/mail-db.sh

@@ -0,0 +1,323 @@
+#!/bin/bash
+# mail-db.sh - SQLite mail database operations
+# Global mail database at ~/.claude/mail.db
+# Project identity derived from basename of working directory
+
+set -euo pipefail
+
+MAIL_DB="$HOME/.claude/mail.db"
+
+# Ensure database and schema exist
+init_db() {
+  mkdir -p "$(dirname "$MAIL_DB")"
+  sqlite3 "$MAIL_DB" <<'SQL'
+CREATE TABLE IF NOT EXISTS messages (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    from_project TEXT NOT NULL,
+    to_project TEXT NOT NULL,
+    subject TEXT DEFAULT '',
+    body TEXT NOT NULL,
+    timestamp TEXT DEFAULT (datetime('now')),
+    read INTEGER DEFAULT 0,
+    priority TEXT DEFAULT 'normal'
+);
+CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
+CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
+SQL
+  # Migration: add priority column if missing
+  sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN priority TEXT DEFAULT 'normal';" 2>/dev/null
+}
+
+# Sanitize string for safe SQL interpolation (escape single quotes)
+sql_escape() {
+  printf '%s' "$1" | sed "s/'/''/g"
+}
+
+# Get project name from cwd
+get_project() {
+  basename "$PWD"
+}
+
+# Count unread messages for current project
+count_unread() {
+  init_db
+  local project
+  project=$(sql_escape "$(get_project)")
+  sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;"
+}
+
+# List unread messages (brief) for current project
+list_unread() {
+  init_db
+  local project
+  project=$(sql_escape "$(get_project)")
+  sqlite3 -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp DESC;"
+}
+
+# Read all unread messages (full) and mark as read
+read_mail() {
+  init_db
+  local project
+  project=$(sql_escape "$(get_project)")
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, body, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp ASC;"
+  sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET read=1 WHERE to_project='${project}' AND read=0;"
+}
+
+# Read a single message by ID and mark as read
+read_one() {
+  local msg_id="$1"
+  # Validate ID is numeric
+  if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
+    echo "Error: message ID must be numeric" >&2
+    return 1
+  fi
+  init_db
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, to_project, subject, body, timestamp FROM messages WHERE id=${msg_id};"
+  sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET read=1 WHERE id=${msg_id};"
+}
+
+# Send a message (optional --urgent flag before args)
+send() {
+  local priority="normal"
+  if [ "${1:-}" = "--urgent" ]; then
+    priority="urgent"
+    shift
+  fi
+  local to_project="${1:?to_project required}"
+  local subject="${2:-no subject}"
+  local body="${3:?body required}"
+  if [ -z "$body" ]; then
+    echo "Error: message body cannot be empty" >&2
+    return 1
+  fi
+  init_db
+  local from_project
+  from_project=$(sql_escape "$(get_project)")
+  local safe_to safe_subject safe_body
+  safe_to=$(sql_escape "$to_project")
+  safe_subject=$(sql_escape "$subject")
+  safe_body=$(sql_escape "$body")
+  sqlite3 "$MAIL_DB" \
+    "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_project}', '${safe_to}', '${safe_subject}', '${safe_body}', '${priority}');"
+  echo "Sent to ${to_project}: ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
+}
+
+# Search messages by keyword
+search() {
+  local keyword="$1"
+  if [ -z "$keyword" ]; then
+    echo "Error: search keyword required" >&2
+    return 1
+  fi
+  init_db
+  local project
+  project=$(sql_escape "$(get_project)")
+  local safe_keyword
+  safe_keyword=$(sql_escape "$keyword")
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;"
+}
+
+# List all messages (read and unread) for current project
+list_all() {
+  init_db
+  local project
+  project=$(sql_escape "$(get_project)")
+  local limit="${1:-20}"
+  # Validate limit is numeric
+  if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
+    limit=20
+  fi
+  sqlite3 -header -separator ' | ' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' ORDER BY timestamp DESC LIMIT ${limit};"
+}
+
+# Clear old read messages (default: older than 7 days)
+clear_old() {
+  init_db
+  local days="${1:-7}"
+  # Validate days is numeric
+  if ! [[ "$days" =~ ^[0-9]+$ ]]; then
+    days=7
+  fi
+  local deleted
+  deleted=$(sqlite3 "$MAIL_DB" \
+    "DELETE FROM messages WHERE read=1 AND timestamp < datetime('now', '-${days} days'); SELECT changes();")
+  echo "Cleared ${deleted} read messages older than ${days} days"
+}
+
+# Reply to a message by ID
+reply() {
+  local msg_id="$1"
+  local body="$2"
+  if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
+    echo "Error: message ID must be numeric" >&2
+    return 1
+  fi
+  if [ -z "$body" ]; then
+    echo "Error: reply body cannot be empty" >&2
+    return 1
+  fi
+  init_db
+  # Get original sender and subject
+  local orig
+  orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject FROM messages WHERE id=${msg_id};")
+  if [ -z "$orig" ]; then
+    echo "Error: message #${msg_id} not found" >&2
+    return 1
+  fi
+  local orig_from orig_subject
+  orig_from=$(echo "$orig" | cut -d'|' -f1)
+  orig_subject=$(echo "$orig" | cut -d'|' -f2)
+  local from_project
+  from_project=$(sql_escape "$(get_project)")
+  local safe_to safe_subject safe_body
+  safe_to=$(sql_escape "$orig_from")
+  safe_subject=$(sql_escape "Re: ${orig_subject}")
+  safe_body=$(sql_escape "$body")
+  sqlite3 "$MAIL_DB" \
+    "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_project}', '${safe_to}', '${safe_subject}', '${safe_body}');"
+  echo "Replied to ${orig_from}: Re: ${orig_subject}"
+}
+
+# Broadcast a message to all known projects (except self)
+broadcast() {
+  local subject="$1"
+  local body="$2"
+  if [ -z "$body" ]; then
+    echo "Error: message body cannot be empty" >&2
+    return 1
+  fi
+  init_db
+  local from_project
+  from_project=$(get_project)
+  local targets
+  targets=$(sqlite3 "$MAIL_DB" \
+    "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;")
+  local count=0
+  local safe_subject safe_body safe_from
+  safe_from=$(sql_escape "$from_project")
+  safe_subject=$(sql_escape "$subject")
+  safe_body=$(sql_escape "$body")
+  while IFS= read -r target; do
+    [ -z "$target" ] && continue
+    [ "$target" = "$from_project" ] && continue
+    local safe_to
+    safe_to=$(sql_escape "$target")
+    sqlite3 "$MAIL_DB" \
+      "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${safe_from}', '${safe_to}', '${safe_subject}', '${safe_body}');"
+    count=$((count + 1))
+  done <<< "$targets"
+  echo "Broadcast to ${count} project(s): ${subject}"
+}
+
+# Show inbox status summary
+status() {
+  init_db
+  local project
+  project=$(sql_escape "$(get_project)")
+  local unread total
+  unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;")
+  total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}';")
+  local projects
+  projects=$(sqlite3 "$MAIL_DB" \
+    "SELECT COUNT(DISTINCT from_project) FROM messages WHERE to_project='${project}' AND read=0;")
+  echo "Inbox: ${unread} unread / ${total} total"
+  if [ "${unread:-0}" -gt 0 ]; then
+    echo "From: ${projects} project(s)"
+    sqlite3 -separator ': ' "$MAIL_DB" \
+      "SELECT from_project, COUNT(*) || ' message(s)' FROM messages WHERE to_project='${project}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;"
+  fi
+}
+
+# Purge all messages for current project (or all projects with --all)
+purge() {
+  init_db
+  if [ "${1:-}" = "--all" ]; then
+    local count
+    count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
+    echo "Purged all ${count} message(s) from database"
+  else
+    local project
+    project=$(sql_escape "$(get_project)")
+    local count
+    count=$(sqlite3 "$MAIL_DB" \
+      "DELETE FROM messages WHERE to_project='${project}' OR from_project='${project}'; SELECT changes();")
+    echo "Purged ${count} message(s) for $(get_project)"
+  fi
+}
+
+# Rename a project in all messages (for directory renames/moves)
+alias_project() {
+  local old_name="$1"
+  local new_name="$2"
+  if [ -z "$old_name" ] || [ -z "$new_name" ]; then
+    echo "Error: both old and new project names required" >&2
+    return 1
+  fi
+  init_db
+  local safe_old safe_new
+  safe_old=$(sql_escape "$old_name")
+  safe_new=$(sql_escape "$new_name")
+  local updated=0
+  local count
+  count=$(sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET from_project='${safe_new}' WHERE from_project='${safe_old}'; SELECT changes();")
+  updated=$((updated + count))
+  count=$(sqlite3 "$MAIL_DB" \
+    "UPDATE messages SET to_project='${safe_new}' WHERE to_project='${safe_old}'; SELECT changes();")
+  updated=$((updated + count))
+  echo "Renamed '${old_name}' -> '${new_name}' in ${updated} message(s)"
+}
+
+# List all known projects (that have sent or received mail)
+list_projects() {
+  init_db
+  sqlite3 "$MAIL_DB" \
+    "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;"
+}
+
+# Dispatch
+case "${1:-help}" in
+  init)       init_db && echo "Mail database initialized at $MAIL_DB" ;;
+  count)      count_unread ;;
+  unread)     list_unread ;;
+  read)       if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
+  send)       shift; send "$@" ;;
+  reply)      reply "${2:?message_id required}" "${3:?body required}" ;;
+  list)       list_all "${2:-20}" ;;
+  clear)      clear_old "${2:-7}" ;;
+  broadcast)  broadcast "${2:-no subject}" "${3:?body required}" ;;
+  search)     search "${2:?keyword required}" ;;
+  status)     status ;;
+  purge)      purge "${2:-}" ;;
+  alias)      alias_project "${2:?old name required}" "${3:?new name required}" ;;
+  projects)   list_projects ;;
+  help)
+    echo "Usage: mail-db.sh <command> [args]"
+    echo ""
+    echo "Commands:"
+    echo "  init                    Initialize database"
+    echo "  count                   Count unread messages"
+    echo "  unread                  List unread messages (brief)"
+    echo "  read [id]               Read messages and mark as read"
+    echo "  send [--urgent] <to> <subj> <body>"
+    echo "                          Send a message"
+    echo "  reply <id> <body>       Reply to a message"
+    echo "  list [limit]            List recent messages (default 20)"
+    echo "  clear [days]            Clear read messages older than N days"
+    echo "  broadcast <subj> <body> Send to all known projects"
+    echo "  search <keyword>        Search messages by keyword"
+    echo "  status                  Inbox summary"
+    echo "  purge [--all]           Delete all messages for this project"
+    echo "  alias <old> <new>       Rename project in all messages"
+    echo "  projects                List known projects"
+    ;;
+  *)          echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
+esac

+ 537 - 0
skills/agentmail/scripts/test-mail.sh

@@ -0,0 +1,537 @@
+#!/bin/bash
+# test-mail.sh - Test harness for mail-ops
+# Outputs: number of passing test cases
+# Each test prints PASS/FAIL and we count PASSes at the end
+
+set -uo pipefail
+
+MAIL_DB="$HOME/.claude/mail.db"
+MAIL_SCRIPT="$(dirname "$0")/mail-db.sh"
+HOOK_SCRIPT="$(dirname "$0")/../../hooks/check-mail.sh"
+# Resolve relative to repo root if needed
+if [ ! -f "$HOOK_SCRIPT" ]; then
+  HOOK_SCRIPT="$(cd "$(dirname "$0")/../../.." && pwd)/hooks/check-mail.sh"
+fi
+
+PASS=0
+FAIL=0
+TOTAL=0
+
+assert() {
+  local name="$1"
+  local expected="$2"
+  local actual="$3"
+  TOTAL=$((TOTAL + 1))
+  if [ "$expected" = "$actual" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (expected='$expected', actual='$actual')"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_contains() {
+  local name="$1"
+  local needle="$2"
+  local haystack="$3"
+  TOTAL=$((TOTAL + 1))
+  if echo "$haystack" | grep -qF "$needle"; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (expected to contain '$needle')"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_not_empty() {
+  local name="$1"
+  local value="$2"
+  TOTAL=$((TOTAL + 1))
+  if [ -n "$value" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (was empty)"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_empty() {
+  local name="$1"
+  local value="$2"
+  TOTAL=$((TOTAL + 1))
+  if [ -z "$value" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (expected empty, got '$value')"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+assert_exit_code() {
+  local name="$1"
+  local expected="$2"
+  local actual="$3"
+  TOTAL=$((TOTAL + 1))
+  if [ "$expected" = "$actual" ]; then
+    echo "PASS: $name"
+    PASS=$((PASS + 1))
+  else
+    echo "FAIL: $name (exit code expected=$expected, actual=$actual)"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+# Helper: clear hook cooldown so next hook call fires
+clear_cooldown() {
+  rm -f /tmp/agentmail_claude-mods 2>/dev/null
+}
+
+# --- Setup: clean slate ---
+rm -f "$MAIL_DB"
+
+echo "=== Basic Operations ==="
+
+# T1: Init creates database
+bash "$MAIL_SCRIPT" init >/dev/null 2>&1
+assert "init creates database" "true" "$([ -f "$MAIL_DB" ] && echo true || echo false)"
+
+# T2: Count on empty inbox
+result=$(bash "$MAIL_SCRIPT" count)
+assert "empty inbox count is 0" "0" "$result"
+
+# T3: Send a message
+result=$(bash "$MAIL_SCRIPT" send "test-project" "Hello" "World" 2>&1)
+assert_contains "send succeeds" "Sent to test-project" "$result"
+
+# T4: Count after send (we're in claude-mods, sent to test-project)
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count still 0 for sender project" "0" "$result"
+
+# T5: Send to self
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Self mail" "Testing self-send" 2>&1)
+assert_contains "self-send succeeds" "Sent to claude-mods" "$result"
+
+# T6: Count after self-send
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count is 1 after self-send" "1" "$result"
+
+# T7: Unread shows message
+result=$(bash "$MAIL_SCRIPT" unread)
+assert_contains "unread shows subject" "Self mail" "$result"
+
+# T8: Read marks as read
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count is 0 after read" "0" "$result"
+
+# T9: List shows read messages
+result=$(bash "$MAIL_SCRIPT" list)
+assert_contains "list shows read status" "read" "$result"
+
+# T10: Projects lists known projects
+result=$(bash "$MAIL_SCRIPT" projects)
+assert_contains "projects lists claude-mods" "claude-mods" "$result"
+assert_contains "projects lists test-project" "test-project" "$result"
+
+echo ""
+echo "=== Edge Cases ==="
+
+# T11: Empty body - should fail gracefully
+result=$(bash "$MAIL_SCRIPT" send "target" "subject" "" 2>&1)
+exit_code=$?
+# Empty body should either fail or send empty - document the behavior
+TOTAL=$((TOTAL + 1))
+if [ $exit_code -ne 0 ] || echo "$result" | grep -qiE "error|required|empty"; then
+  echo "PASS: empty body rejected or warned"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: empty body accepted silently"
+  FAIL=$((FAIL + 1))
+fi
+
+# T12: Missing arguments to send
+result=$(bash "$MAIL_SCRIPT" send 2>&1)
+exit_code=$?
+assert_exit_code "send with no args fails" "1" "$exit_code"
+
+# T13: SQL injection in subject
+bash "$MAIL_SCRIPT" send "claude-mods" "'; DROP TABLE messages; --" "injection test" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+# If table still exists and count works, injection failed (good)
+TOTAL=$((TOTAL + 1))
+if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
+  echo "PASS: SQL injection in subject blocked"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: SQL injection may have succeeded"
+  FAIL=$((FAIL + 1))
+fi
+
+# T14: SQL injection in body
+bash "$MAIL_SCRIPT" send "claude-mods" "test" "'); DELETE FROM messages; --" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+TOTAL=$((TOTAL + 1))
+if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
+  echo "PASS: SQL injection in body blocked"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: SQL injection in body may have succeeded"
+  FAIL=$((FAIL + 1))
+fi
+
+# T15: SQL injection in project name
+bash "$MAIL_SCRIPT" send "'; DROP TABLE messages; --" "test" "injection via project" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+TOTAL=$((TOTAL + 1))
+if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
+  echo "PASS: SQL injection in project name blocked"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: SQL injection in project name may have succeeded"
+  FAIL=$((FAIL + 1))
+fi
+
+# T16: Special characters in body (newlines, quotes, backslashes)
+bash "$MAIL_SCRIPT" send "claude-mods" "special chars" 'Line1\nLine2 "quoted" and back\\slash' >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "special chars preserved" "special chars" "$result"
+
+# T17: Very long message body (1000+ chars)
+long_body=$(python3 -c "print('x' * 2000)" 2>/dev/null || printf '%0.s.' $(seq 1 2000))
+bash "$MAIL_SCRIPT" send "claude-mods" "long msg" "$long_body" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+assert "long message accepted" "1" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T18: Unicode in subject and body
+bash "$MAIL_SCRIPT" send "claude-mods" "Unicode test" "Hello from Tokyo" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "unicode in body" "Tokyo" "$result"
+
+# T19: Read by specific ID
+bash "$MAIL_SCRIPT" send "claude-mods" "ID test" "Read me by ID" >/dev/null 2>&1
+msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='ID test' AND read=0 LIMIT 1;")
+result=$(bash "$MAIL_SCRIPT" read "$msg_id" 2>&1)
+assert_contains "read by ID works" "Read me by ID" "$result"
+
+# T20: Read by invalid ID
+result=$(bash "$MAIL_SCRIPT" read 99999 2>&1)
+assert_empty "read invalid ID returns nothing" "$result"
+
+echo ""
+echo "=== Hook Tests ==="
+
+# T21: Hook silent on empty inbox
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # clear any unread
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent when no mail" "$result"
+
+# T22: Hook shows notification
+bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows MAIL notification" "MAIL" "$result"
+assert_contains "hook shows message count" "1 unread" "$result"
+
+# T23: Hook with missing database
+clear_cooldown
+backup_db="${MAIL_DB}.testbak"
+mv "$MAIL_DB" "$backup_db"
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+exit_code=$?
+assert_exit_code "hook exits 0 with missing db" "0" "$exit_code"
+assert_empty "hook silent with missing db" "$result"
+mv "$backup_db" "$MAIL_DB"
+
+echo ""
+echo "=== Cleanup ==="
+
+# T24: Clear old messages
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # mark all as read
+result=$(bash "$MAIL_SCRIPT" clear 0 2>&1)
+assert_contains "clear reports deleted count" "Cleared" "$result"
+
+# T25: Count after clear
+result=$(bash "$MAIL_SCRIPT" count)
+assert "count 0 after clear" "0" "$result"
+
+# T26: Help command
+result=$(bash "$MAIL_SCRIPT" help 2>&1)
+assert_contains "help shows usage" "Usage" "$result"
+
+# T27: Unknown command
+result=$(bash "$MAIL_SCRIPT" nonexistent 2>&1)
+exit_code=$?
+assert_exit_code "unknown command fails" "1" "$exit_code"
+
+echo ""
+echo "=== Input Validation ==="
+
+# T28: Non-numeric message ID rejected
+result=$(bash "$MAIL_SCRIPT" read "abc" 2>&1)
+exit_code=$?
+assert_exit_code "non-numeric ID rejected" "1" "$exit_code"
+
+# T29: SQL injection via message ID
+bash "$MAIL_SCRIPT" send "claude-mods" "id-inject-test" "before injection" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read "1 OR 1=1" 2>&1)
+exit_code=$?
+assert_exit_code "SQL injection via ID rejected" "1" "$exit_code"
+
+# T30: Non-numeric limit in list
+result=$(bash "$MAIL_SCRIPT" list "abc" 2>&1)
+exit_code=$?
+assert_exit_code "non-numeric limit handled" "0" "$exit_code"
+
+# T31: Non-numeric days in clear
+result=$(bash "$MAIL_SCRIPT" clear "abc" 2>&1)
+assert_contains "non-numeric days handled" "Cleared" "$result"
+
+# T32: Single quotes in subject preserved
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1  # clear unread
+bash "$MAIL_SCRIPT" send "claude-mods" "it's working" "body with 'quotes'" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "single quotes in subject" "it's working" "$result"
+
+# T33: Double quotes in body preserved
+bash "$MAIL_SCRIPT" send "claude-mods" "quotes" 'She said "hello"' >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" read 2>&1)
+assert_contains "double quotes in body" "hello" "$result"
+
+# T34: Project name with spaces (edge case)
+bash "$MAIL_SCRIPT" send "my project" "spaces" "project name has spaces" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" projects)
+assert_contains "project with spaces stored" "my project" "$result"
+
+# T35: Multiple rapid sends
+for i in 1 2 3 4 5; do
+  bash "$MAIL_SCRIPT" send "claude-mods" "rapid-$i" "rapid fire test $i" >/dev/null 2>&1
+done
+result=$(bash "$MAIL_SCRIPT" count)
+assert "5 rapid sends all counted" "5" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T36: Init is idempotent
+bash "$MAIL_SCRIPT" init >/dev/null 2>&1
+bash "$MAIL_SCRIPT" init >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" count)
+assert "init idempotent" "0" "$result"
+
+# T37: Empty subject defaults
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "" "empty subject body" 2>&1)
+assert_contains "empty subject accepted" "Sent to claude-mods" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Reply ==="
+
+# T38: Reply to a message
+bash "$MAIL_SCRIPT" send "claude-mods" "Original msg" "Please reply" >/dev/null 2>&1
+msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='Original msg' AND read=0 LIMIT 1;")
+bash "$MAIL_SCRIPT" read "$msg_id" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "Here is my reply" 2>&1)
+assert_contains "reply succeeds" "Replied to claude-mods" "$result"
+assert_contains "reply has Re: prefix" "Re: Original msg" "$result"
+
+# T39: Reply to nonexistent message
+result=$(bash "$MAIL_SCRIPT" reply 99999 "reply to nothing" 2>&1)
+exit_code=$?
+assert_exit_code "reply to nonexistent fails" "1" "$exit_code"
+
+# T40: Reply with empty body
+result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "" 2>&1)
+exit_code=$?
+assert_exit_code "reply with empty body fails" "1" "$exit_code"
+
+# T41: Reply with non-numeric ID
+result=$(bash "$MAIL_SCRIPT" reply "abc" "body" 2>&1)
+exit_code=$?
+assert_exit_code "reply with non-numeric ID fails" "1" "$exit_code"
+
+# Clean up
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Priority & Search ==="
+
+# T38: Send urgent message
+result=$(bash "$MAIL_SCRIPT" send --urgent "claude-mods" "Server down" "Production is on fire" 2>&1)
+assert_contains "urgent send succeeds" "URGENT" "$result"
+
+# T39: Hook highlights urgent
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows URGENT" "URGENT" "$result"
+assert_contains "hook shows [!] prefix" "[!]" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T40: Normal send still works after priority feature
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Normal msg" "not urgent" 2>&1)
+TOTAL=$((TOTAL + 1))
+if echo "$result" | grep -qvF "URGENT"; then
+  echo "PASS: normal send has no URGENT tag"
+  PASS=$((PASS + 1))
+else
+  echo "FAIL: normal send incorrectly tagged URGENT"
+  FAIL=$((FAIL + 1))
+fi
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T41: Search by keyword in subject
+bash "$MAIL_SCRIPT" send "claude-mods" "API endpoint changed" "details here" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "unrelated" "nothing relevant" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" search "API" 2>&1)
+assert_contains "search finds by subject" "API endpoint" "$result"
+
+# T42: Search by keyword in body
+result=$(bash "$MAIL_SCRIPT" search "relevant" 2>&1)
+assert_contains "search finds by body" "unrelated" "$result"
+
+# T43: Search with no results
+result=$(bash "$MAIL_SCRIPT" search "xyznonexistent" 2>&1)
+assert_empty "search no results is empty" "$result"
+
+# T44: Search with no keyword fails
+result=$(bash "$MAIL_SCRIPT" search 2>&1)
+exit_code=$?
+assert_exit_code "search no keyword fails" "1" "$exit_code"
+
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Broadcast & Status ==="
+
+# Setup: ensure multiple projects exist
+bash "$MAIL_SCRIPT" send "project-a" "setup" "creating project-a" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "project-b" "setup" "creating project-b" >/dev/null 2>&1
+
+# T42: Broadcast sends to all known projects except self
+result=$(bash "$MAIL_SCRIPT" broadcast "Announcement" "Main is frozen" 2>&1)
+assert_contains "broadcast reports count" "Broadcast to" "$result"
+
+# T43: Broadcast doesn't send to self
+self_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='claude-mods' AND subject='Announcement';")
+assert "broadcast skips self" "0" "$self_count"
+
+# T44: Broadcast with empty body fails
+result=$(bash "$MAIL_SCRIPT" broadcast "test" "" 2>&1)
+exit_code=$?
+assert_exit_code "broadcast empty body fails" "1" "$exit_code"
+
+# T45: Status shows inbox summary
+bash "$MAIL_SCRIPT" send "claude-mods" "Status test 1" "msg1" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "Status test 2" "msg2" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" status 2>&1)
+assert_contains "status shows unread count" "unread" "$result"
+assert_contains "status shows Inbox" "Inbox" "$result"
+
+# T46: Status on empty inbox
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" status 2>&1)
+assert_contains "status shows 0 unread" "0 unread" "$result"
+
+echo ""
+echo "=== Alias (Rename) ==="
+
+# Setup: send messages with old project name
+bash "$MAIL_SCRIPT" send "old-project" "before rename" "testing alias" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "from old" "message from old name" >/dev/null 2>&1
+
+# T47: Alias renames in all messages
+result=$(bash "$MAIL_SCRIPT" alias "old-project" "new-project" 2>&1)
+assert_contains "alias reports rename" "Renamed" "$result"
+assert_contains "alias shows old name" "old-project" "$result"
+assert_contains "alias shows new name" "new-project" "$result"
+
+# T48: Old project name no longer appears
+result=$(bash "$MAIL_SCRIPT" projects)
+TOTAL=$((TOTAL + 1))
+if echo "$result" | grep -qF "old-project"; then
+  echo "FAIL: old project name still present after alias"
+  FAIL=$((FAIL + 1))
+else
+  echo "PASS: old project name removed after alias"
+  PASS=$((PASS + 1))
+fi
+
+# T49: New project name appears
+assert_contains "new project name present" "new-project" "$result"
+
+# T50: Alias with missing args fails
+result=$(bash "$MAIL_SCRIPT" alias "only-one" 2>&1)
+exit_code=$?
+assert_exit_code "alias with missing arg fails" "1" "$exit_code"
+
+# Clean up
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Performance ==="
+
+# T52: Hook cooldown - second call within cooldown is silent
+bash "$MAIL_SCRIPT" send "claude-mods" "cooldown test" "testing cooldown" >/dev/null 2>&1
+# Clear cooldown file for this project
+rm -f /tmp/agentmail_claude-mods 2>/dev/null
+# First call should show mail
+result1=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook fires on first call" "MAIL" "$result1"
+
+# T53: Second call within cooldown is silent (cooldown file exists from first call)
+result2=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent during cooldown" "$result2"
+
+# Cleanup
+rm -f /tmp/agentmail_claude-mods 2>/dev/null
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Purge ==="
+
+# T54: Purge removes messages for current project
+bash "$MAIL_SCRIPT" send "claude-mods" "purge test 1" "msg1" >/dev/null 2>&1
+bash "$MAIL_SCRIPT" send "claude-mods" "purge test 2" "msg2" >/dev/null 2>&1
+# Insert a message not involving claude-mods at all
+sqlite3 "$MAIL_DB" "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('alpha', 'beta', 'unrelated', 'should survive');"
+result=$(bash "$MAIL_SCRIPT" purge 2>&1)
+assert_contains "purge reports count" "Purged" "$result"
+
+# T55: Unrelated project messages survive purge
+other_count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE from_project='alpha';")
+assert "unrelated messages survive purge" "1" "$other_count"
+
+# T56: Purge --all removes everything
+bash "$MAIL_SCRIPT" send "claude-mods" "test" "body" >/dev/null 2>&1
+result=$(bash "$MAIL_SCRIPT" purge --all 2>&1)
+assert_contains "purge --all reports count" "Purged all" "$result"
+total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages;")
+assert "purge --all empties db" "0" "$total"
+
+echo ""
+echo "=== Per-Project Disable ==="
+
+# T52: Hook respects .claude/agentmail.disable
+bash "$MAIL_SCRIPT" send "claude-mods" "disable test" "should not appear" >/dev/null 2>&1
+clear_cooldown
+mkdir -p .claude
+touch .claude/agentmail.disable
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_empty "hook silent when disabled" "$result"
+
+# T53: Hook works again after removing disable file
+rm -f .claude/agentmail.disable
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook works after re-enable" "MAIL" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
+echo "=== Results ==="
+echo "Passed: $PASS / $TOTAL"
+echo "Failed: $FAIL / $TOTAL"
+echo ""
+echo "$PASS"