Browse Source

fix(scripts): migrate agentmail hook path to pigeon on install

Both installers checked for 'check-mail.sh' anywhere in settings.json,
which matched the stale 'agentmail/check-mail.sh' path and reported
'already configured' without migrating it - causing PreToolUse hook
errors on every tool call after upgrading to v2.3.0.

- Add active migration step: rewrite agentmail/check-mail.sh ->
  pigeon/check-mail.sh in settings.json before the configured check
- Fix configured check to match pigeon path specifically (not any
  check-mail.sh reference)
- Add skills/agentmail to deprecated cleanup list in both installers
- Remove skills/agentmail/ from source repo (pigeon is canonical)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
0xDarkMatter 3 days ago
parent
commit
346066578d

+ 13 - 2
scripts/install.ps1

@@ -41,7 +41,8 @@ $deprecated = @(
     "$claudeDir\commands\conclave.md",
     "$claudeDir\commands\pulse.md",
     "$claudeDir\skills\conclave",
-    "$claudeDir\skills\claude-code-templates"   # Replaced by skill-creator
+    "$claudeDir\skills\claude-code-templates",  # Replaced by skill-creator
+    "$claudeDir\skills\agentmail"               # Renamed to pigeon (v2.3.0)
 )
 
 # Renamed skills: -patterns -> -ops (March 2026)
@@ -178,7 +179,17 @@ if (Test-Path $checkMailSrc) {
 }
 
 $settingsPath = Join-Path $claudeDir "settings.json"
-if ((Test-Path $settingsPath) -and (Select-String -Path $settingsPath -Pattern "check-mail.sh" -Quiet)) {
+
+# Migrate stale agentmail hook path -> pigeon
+if ((Test-Path $settingsPath) -and (Select-String -Path $settingsPath -Pattern "agentmail/check-mail\.sh" -Quiet)) {
+    $content = Get-Content $settingsPath -Raw
+    $content = $content -replace 'agentmail/check-mail\.sh', 'pigeon/check-mail.sh'
+    Set-Content $settingsPath -Value $content -NoNewline
+    Write-Host "  Migrated agentmail hook -> pigeon in settings.json" -ForegroundColor Green
+}
+
+# Check if hook is already configured (pigeon path)
+if ((Test-Path $settingsPath) -and (Select-String -Path $settingsPath -Pattern "pigeon/check-mail\.sh" -Quiet)) {
     Write-Host "  Hook already configured in settings.json" -ForegroundColor Green
 } else {
     Write-Host ""

+ 9 - 2
scripts/install.sh

@@ -43,6 +43,7 @@ deprecated_items=(
     # Removed skills
     "$CLAUDE_DIR/skills/conclave"                # Deprecated
     "$CLAUDE_DIR/skills/claude-code-templates"   # Replaced by skill-creator
+    "$CLAUDE_DIR/skills/agentmail"               # Renamed to pigeon (v2.3.0)
 )
 
 # Renamed skills: -patterns -> -ops (March 2026)
@@ -187,8 +188,14 @@ if [ -f "$PROJECT_ROOT/hooks/check-mail.sh" ]; then
     echo -e "  ${GREEN}check-mail.sh${NC}"
 fi
 
-# Check if hook is already configured
-if grep -q "check-mail.sh" "$CLAUDE_DIR/settings.json" 2>/dev/null; then
+# Migrate stale agentmail hook path → pigeon
+if grep -q "agentmail/check-mail.sh" "$CLAUDE_DIR/settings.json" 2>/dev/null; then
+    sed -i 's|agentmail/check-mail\.sh|pigeon/check-mail.sh|g' "$CLAUDE_DIR/settings.json"
+    echo -e "  ${GREEN}Migrated agentmail hook → pigeon in settings.json${NC}"
+fi
+
+# Check if hook is already configured (pigeon path)
+if grep -q "pigeon/check-mail.sh" "$CLAUDE_DIR/settings.json" 2>/dev/null; then
     echo -e "  ${GREEN}Hook already configured in settings.json${NC}"
 else
     echo ""

+ 0 - 161
skills/agentmail/SKILL.md

@@ -1,161 +0,0 @@
----
-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` |

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

@@ -1,323 +0,0 @@
-#!/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

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

@@ -1,537 +0,0 @@
-#!/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"