Browse Source

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 1 week ago
parent
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
 

File diff suppressed because it is too large
+ 5 - 3
README.md


+ 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"