pigeon
Inter-session pmail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/pmail.db. Triggers on: mail, pmail, send message, check mail, inbox, inter-session, message another session, pigeon.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/pigeon
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pigeon
Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects.
Quick Reference
All commands go through MAIL, a shorthand for bash "$HOME/.claude/pigeon/mail-db.sh".
Set this at the top of execution:
MAIL="$HOME/.claude/pigeon/mail-db.sh"
Then use it for all commands below.
Command Router
Parse the user's input after pigeon (or /pigeon) and run the matching command:
| User says | Run |
|---|---|
pigeon read |
bash "$MAIL" read |
pigeon read 42 |
bash "$MAIL" read 42 |
pigeon send <project> "<subject>" "<body>" |
bash "$MAIL" send "<project>" "<subject>" "<body>" |
pigeon send --urgent <project> "<subject>" "<body>" |
bash "$MAIL" send --urgent "<project>" "<subject>" "<body>" |
pigeon send --attach <path> <project> "<subject>" "<body>" |
bash "$MAIL" send --attach "<path>" "<project>" "<subject>" "<body>" |
pigeon reply <id> "<body>" |
bash "$MAIL" reply <id> "<body>" |
pigeon reply --attach <path> <id> "<body>" |
bash "$MAIL" reply --attach "<path>" <id> "<body>" |
pigeon broadcast "<subject>" "<body>" |
bash "$MAIL" broadcast "<subject>" "<body>" |
pigeon search <keyword> |
bash "$MAIL" search "<keyword>" |
pigeon status |
bash "$MAIL" status |
pigeon unread |
bash "$MAIL" unread |
pigeon list |
bash "$MAIL" list |
pigeon list 50 |
bash "$MAIL" list 50 |
pigeon projects |
bash "$MAIL" projects |
pigeon clear |
bash "$MAIL" clear |
pigeon clear 7 |
bash "$MAIL" clear 7 |
pigeon alias <old> <new> |
bash "$MAIL" alias "<old>" "<new>" |
pigeon purge |
bash "$MAIL" purge |
pigeon purge --all |
bash "$MAIL" purge --all |
pigeon id |
bash "$MAIL" id |
pigeon migrate |
bash "$MAIL" migrate |
pigeon init |
bash "$MAIL" init |
When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run bash "$MAIL" read.
When the user says "send mail to X", "send pmail to X", or "message X" - parse out the project name, subject, and body, then run bash "$MAIL" send.
Project Identity
Each project gets a stable 6-character hash ID derived from its git root commit (the very first commit in the repo). This means:
- IDs survive directory renames, moves, and clones
- Case-insensitive filesystems (macOS) don't cause collisions
- Every clone of the same repo shares the same identity
For non-git directories, falls back to a hash of the canonical path (pwd -P).
Use pigeon id to see your project's name and hash:
claude-mods 7663d6
When sending messages, you can address projects by name, hash, or path - they all resolve to the same hash ID.
Identicons
Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run identicon.sh to see yours, or view all projects with pigeon projects.
Passive Notification (Hook)
A global PreToolUse hook checks for pmail on every tool call (no cooldown). Silent when inbox is empty. When mail is waiting it prints one JSON envelope, and Claude Code passes its additionalContext to the model:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"=== INCOMING PMAIL (1 message(s)) ===\n..."}}
What the model reads:
=== INCOMING PMAIL (1 message(s)) ===
--- #42 from some-api (a1b2c3) @ 2026-09-30 10:12:04 ---
Subject: Auth endpoints ready
Login and refresh are live on staging.
=== ACTION REQUIRED: Inform the user about these messages and ask if they want to reply. ===
=== Then run: pigeon read (to mark as read) ===
=== To reply: pigeon reply <id> "message" ===
The JSON form is required. For PreToolUse, plain stdout goes to Claude Code's debug log and never reaches the model. Claude Code caps additionalContext at 10,000 chars, so the hook truncates message bodies at about 9,000 and points at pigeon read. The header and footer are always kept. Delivery does not mark mail read. The signal file is cleared, so each new send triggers one notice.
Attachments
Send file references with --attach <path> (repeatable). Paths are resolved to absolute and stored as references - files are not copied.
# Send with one attachment
pigeon send --attach src/config.ts my-api "Config update" "Updated the auth config"
# Send with multiple attachments
pigeon send --attach src/schema.sql --attach docs/API.md my-api "Schema + docs" "See attached"
# Reply with attachment
pigeon reply --attach output/report.json 42 "Here's the analysis"
Recipients see attachment paths with file sizes and can read them directly with the Read tool. If a file has been moved or deleted since sending, it shows as (missing).
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
touch .claude/pigeon.disable # Disable hook notifications
rm .claude/pigeon.disable # Re-enable
Only the hook is disabled - you can still send messages from the project.
Installation
Pigeon requires two things: scripts (the mail engine) and a hook (passive notifications). Both install globally - one setup, every project gets pmail.
Prerequisites
sqlite3- ships with macOS, most Linux distros, and Git Bash on Windows. No install needed.jq- the hook uses it to build the JSON envelope Claude Code requires (brew install jq,scoop install jq,apt install jq). Withoutjqthe hook stays silent, andpigeon readstill works.
Step 1: Copy Scripts
mkdir -p ~/.claude/pigeon
cp skills/pigeon/scripts/mail-db.sh ~/.claude/pigeon/
cp hooks/check-mail.sh ~/.claude/pigeon/
chmod +x ~/.claude/pigeon/mail-db.sh ~/.claude/pigeon/check-mail.sh
This gives you the pmail commands. You can now send and read messages manually:
bash ~/.claude/pigeon/mail-db.sh init # Create database
bash ~/.claude/pigeon/mail-db.sh status # Check it works
Step 2: Enable the Hook
Add a hooks block to ~/.claude/settings.json. This makes Claude check for pmail automatically on every tool call:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"",
"timeout": 5
}
]
}
]
}
}
Important: If you already have a hooks section in your settings, merge the PreToolUse entry into the existing array - don't replace the whole block.
Without this step, pigeon still works but you have to check manually (pigeon read). With the hook, unread pmail appears automatically.
What Gets Created
~/.claude/
settings.json # Hook config (you edit this)
pmail.db # Message store (auto-created on first use)
pigeon/
mail-db.sh # All pmail commands (send, read, reply, etc.)
check-mail.sh # PreToolUse hook (silent when inbox empty)
Verify
# Check your project identity
bash ~/.claude/pigeon/mail-db.sh id
# Send yourself a test message (use your project name from above)
bash ~/.claude/pigeon/mail-db.sh send "my-project" "Test" "Hello from pigeon"
# Check it arrived
bash ~/.claude/pigeon/mail-db.sh read
# Clean up
bash ~/.claude/pigeon/mail-db.sh purge --all
Uninstall
rm -rf ~/.claude/pigeon ~/.claude/pmail.db
# Then remove the hooks.PreToolUse entry from ~/.claude/settings.json
Database
Single SQLite file at ~/.claude/pmail.db. Auto-created on first init or send.
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_project TEXT NOT NULL, -- 6-char hash ID
to_project TEXT NOT NULL, -- 6-char hash ID
subject TEXT DEFAULT '',
body TEXT NOT NULL,
timestamp TEXT DEFAULT (datetime('now')),
read INTEGER DEFAULT 0,
priority TEXT DEFAULT 'normal'
);
CREATE TABLE projects (
hash TEXT PRIMARY KEY, -- 6-char ID (git root commit or path hash)
name TEXT NOT NULL, -- Display name (basename of project dir)
path TEXT NOT NULL, -- Canonical path
registered TEXT DEFAULT (datetime('now'))
);
Troubleshooting
| Issue | Fix |
|---|---|
sqlite3: not found |
Ships with macOS, Linux, and Git Bash on Windows. Run sqlite3 --version to check. |
| Hook not firing | Ensure hooks block is in ~/.claude/settings.json (Step 2 above) |
| Hook fires but no notification | Working as intended - hook is silent when inbox is empty |
| Mail is unread but Claude never mentions it | The hook must print JSON additionalContext, because plain PreToolUse stdout only reaches the debug log. Reinstall check-mail.sh from this repo and check jq --version |
| Messages not arriving | Target must be a known name, hash, or path. Use pigeon projects to see registered projects |
| Upgraded from basename IDs | Run pigeon migrate to convert old messages to hash-based IDs |
| Changed display name | Use pigeon alias old-name new-name to update the project's display name |
| Want to disable for one project | touch .claude/pigeon.disable in that project's root |
| Check your project ID | Run pigeon id to see name and 6-char hash |
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
.gitkeep 0 B · in bundle
-
-
scripts
-
identicon.sh 4.9 KB
#!/usr/bin/env bash # Generate a symmetric pixel art identicon from a hash # Usage: bash identicon.sh <path_or_string> [--compact] # # 11x11 pixel grid (mirrored from 6 columns), rendered with Unicode # half-block characters for double vertical resolution. Each project # gets a unique colored portrait derived from sha256 of its canonical path. set -e INPUT="${1:-$PWD}" COMPACT=false [[ "${2:-}" == "--compact" || "${1:-}" == "--compact" ]] && COMPACT=true [[ "${1:-}" == "--compact" ]] && INPUT="$PWD" # Identity: git root commit hash > canonical path hash # This must match mail-db.sh project_hash() logic if [ -d "$INPUT" ]; then CANONICAL=$(cd "$INPUT" && pwd -P) ROOT_COMMIT=$(git -C "$INPUT" rev-list --max-parents=0 HEAD 2>/dev/null | head -1) if [ -n "$ROOT_COMMIT" ]; then # Use full root commit for visual entropy, short ID from first 6 HASH=$(printf '%s' "$ROOT_COMMIT" | shasum -a 256 | cut -c1-40) SHORT="${ROOT_COMMIT:0:6}" else HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40) SHORT="${HASH:0:6}" fi else CANONICAL="$INPUT" HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40) SHORT="${HASH:0:6}" fi NAME=$(basename "$CANONICAL") # --- Color palette --- # Two colors per identicon: foreground + accent, from different hash regions FG_IDX=$(( $(printf '%d' "0x${HASH:6:2}") % 7 )) BG_IDX=$(( $(printf '%d' "0x${HASH:8:2}") % 4 )) # Foreground: vivid ANSI colors FG_CODES=(31 32 33 34 35 36 91) FG="\033[${FG_CODES[$FG_IDX]}m" # Shade characters: full, dark, medium, light CHARS=("█" "▓" "▒" "░") RESET="\033[0m" DIM="\033[2m" # --- Build 11x12 pixel grid --- # 6 columns generated, mirrored to 11 (c0 c1 c2 c3 c4 c5 c4 c3 c2 c1 c0) # 12 rows, rendered as 6 lines using half-block characters # Each cell has 2 bits (4 shade levels): 6 cols * 12 rows = 72 cells = 144 bits # We have 160 bits from 40 hex chars declare -a GRID # GRID[row*6+col] = shade level (0-3) bit_pos=0 for row in $(seq 0 11); do for col in $(seq 0 5); do hex_pos=$((bit_pos / 4)) bit_offset=$((bit_pos % 4)) hex_char="${HASH:$hex_pos:1}" nibble=$(printf '%d' "0x${hex_char}") # Extract 2 bits for shade level if [ $bit_offset -le 2 ]; then shade=$(( (nibble >> bit_offset) & 3 )) else # Straddle nibble boundary next_char="${HASH:$((hex_pos+1)):1}" next_nibble=$(printf '%d' "0x${next_char}") shade=$(( ((nibble >> bit_offset) | (next_nibble << (4 - bit_offset))) & 3 )) fi GRID[$((row * 6 + col))]=$shade bit_pos=$((bit_pos + 2)) done done # --- Render with half-blocks --- # Each output line combines two pixel rows using ▀▄█ and space # Top pixel = upper half, Bottom pixel = lower half # # Both filled = █ (full block) # Top only = ▀ (upper half) # Bottom only = ▄ (lower half) # Neither = " " (space) get_mirrored_col() { local col=$1 # Mirror pattern: 0 1 2 3 4 5 4 3 2 1 0 if [ $col -le 5 ]; then echo $col else echo $((10 - col)) fi } render_cell() { local top_shade=$1 local bot_shade=$2 # Threshold: shades 0-1 = filled, 2-3 = empty (gives ~50% fill) local top_on=$(( top_shade <= 1 ? 1 : 0 )) local bot_on=$(( bot_shade <= 1 ? 1 : 0 )) if [ $top_on -eq 1 ] && [ $bot_on -eq 1 ]; then # Both filled - use shade of top for character choice printf '%s' "${CHARS[$top_shade]}" elif [ $top_on -eq 1 ]; then printf '▀' elif [ $bot_on -eq 1 ]; then printf '▄' else printf ' ' fi } # Width: 11 columns, each 1 char wide = 11 chars inside frame BORDER_TOP="${DIM}┌───────────┐${RESET}" BORDER_BOT="${DIM}└───────────┘${RESET}" if [ "$COMPACT" = true ]; then # Compact: no frame, just the icon + hash for line in $(seq 0 5); do top_row=$((line * 2)) bot_row=$((line * 2 + 1)) printf '%b' "${FG}" for col in $(seq 0 10); do src_col=$(get_mirrored_col $col) top_shade=${GRID[$((top_row * 6 + src_col))]} bot_shade=${GRID[$((bot_row * 6 + src_col))]} render_cell $top_shade $bot_shade done printf '%b\n' "${RESET}" done echo -e "${FG}${SHORT}${RESET}" else # Framed display echo -e "$BORDER_TOP" for line in $(seq 0 5); do top_row=$((line * 2)) bot_row=$((line * 2 + 1)) printf '%b' "${DIM}│${RESET}${FG}" for col in $(seq 0 10); do src_col=$(get_mirrored_col $col) top_shade=${GRID[$((top_row * 6 + src_col))]} bot_shade=${GRID[$((bot_row * 6 + src_col))]} render_cell $top_shade $bot_shade done printf '%b\n' "${RESET}${DIM}│${RESET}" done echo -e "$BORDER_BOT" echo -e " ${FG}${NAME}${RESET} ${DIM}${SHORT}${RESET}" fi -
mail-db.sh 28.1 KB
#!/bin/bash # mail-db.sh - SQLite pmail database operations # Global mail database at ~/.claude/pmail.db # Project identity: 6-char ID derived from git root commit (stable across # renames, moves, clones) with fallback to canonical path hash for non-git dirs. set -euo pipefail MAIL_DB="$HOME/.claude/pmail.db" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ============================================================================ # SQLite access - the one choke point every query goes through # ============================================================================ # Guard: this function deliberately SHADOWS the sqlite3 binary. Windows' native # sqlite3.exe (the one Git Bash finds at C:\Windows\sqlite3) writes its output in # text mode, so every "\n" - row separators AND newlines stored inside a value - # arrives as "\r\n". Command substitution strips only the final newline, so every # line-by-line parser here kept a stray "\r" on all but its last line: a message # with four attachments showed the first three as "(missing)" although the stored # paths were clean. Stripping CR here fixes every read site at once; `pipefail` # (set above) keeps sqlite3's own exit status, which the `|| ALTER TABLE` # migrations depend on. Don't bypass it with `command sqlite3` at a call site. sqlite3() { command sqlite3 "$@" | tr -d '\r' } # ============================================================================ # Identity - git-rooted project IDs # ============================================================================ # Get canonical path (resolves symlinks + case on macOS) canonical_path() { if [ -d "${1:-$PWD}" ]; then (cd "${1:-$PWD}" && pwd -P) else printf '%s' "${1:-$PWD}" fi } # Resolve to the canonical main-repository root. If the given dir is inside # a git worktree, returns the MAIN repo's top-level directory rather than the # worktree's. This prevents pigeon from registering a worktree as if it were # the project (a worktree session would otherwise INSERT OR REPLACE the main # repo's projects row with the worktree's name + path). # # Mechanism: # - `git rev-parse --git-common-dir` returns the canonical .git directory: # - For a main repo: same as --git-dir (e.g. /repo/.git) # - For a worktree: the main repo's .git (e.g. /repo/.git, NOT # /repo/.git/worktrees/<wt-name>) # - Strip trailing /.git to get the main repo's top-level directory. # - Bare repos / non-git dirs fall back to canonical_path. resolve_main_repo() { local dir="${1:-$PWD}" if [ ! -d "$dir" ]; then canonical_path "$dir" return fi local commondir commondir=$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null) if [ -z "$commondir" ]; then # Not a git repo — fall through canonical_path "$dir" return fi # commondir may be relative to $dir; make it absolute and canonical. case "$commondir" in /*) ;; # absolute *) commondir=$(cd "$dir" && cd "$commondir" 2>/dev/null && pwd -P) ;; esac # Strip trailing /.git to get the main repo's top-level (non-bare repos). # Bare repos: commondir IS the repo top-level, no /.git suffix. case "$commondir" in */.git) dirname "$commondir" ;; *) printf '%s' "$commondir" ;; esac } # Generate 6-char project ID # Priority: git root commit hash > canonical path hash project_hash() { local dir="${1:-$PWD}" # Try git root commit (first commit in repo history) if [ -d "$dir" ]; then local root_commit root_commit=$(git -C "$dir" rev-list --max-parents=0 HEAD 2>/dev/null | head -1) if [ -n "$root_commit" ]; then echo "${root_commit:0:6}" return 0 fi fi # Fallback: hash of canonical path local path path=$(canonical_path "$dir") printf '%s' "$path" | shasum -a 256 | cut -c1-6 } # Get display name (basename of the MAIN-REPO top-level — never a worktree's). project_name() { basename "$(resolve_main_repo "${1:-$PWD}")" } # ============================================================================ # Database # ============================================================================ 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); CREATE TABLE IF NOT EXISTS projects ( hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')) ); 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 # Migration: create projects table if missing (for existing installs) sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \ sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null # Migration: add thread_id column if missing sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages LIMIT 0;" 2>/dev/null || \ sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);" 2>/dev/null # Migration: add attachments column if missing sqlite3 "$MAIL_DB" "SELECT attachments FROM messages LIMIT 0;" 2>/dev/null || \ sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN attachments TEXT DEFAULT '';" 2>/dev/null } sql_escape() { printf '%s' "$1" | sed "s/'/''/g" } # Resolve attachment path to absolute, validate existence resolve_attach() { local p="$1" if [ ! -e "$p" ]; then echo "Error: attachment not found: $p" >&2 return 1 fi (cd "$(dirname "$p")" && echo "$(pwd -P)/$(basename "$p")") } # Read body from argument or stdin (use - or omit for stdin) read_body() { local arg="$1" if [ "$arg" = "-" ] || [ -z "$arg" ]; then cat else printf '%s' "$arg" fi } # Register current project in the projects table (idempotent). # Always registers the main repo's top-level — a worktree session must NOT # overwrite the main repo's row with the worktree's path/name. register_project() { local hash name path hash=$(project_hash "${1:-$PWD}") name=$(sql_escape "$(project_name "${1:-$PWD}")") path=$(sql_escape "$(resolve_main_repo "${1:-$PWD}")") sqlite3 "$MAIL_DB" \ "INSERT OR REPLACE INTO projects (hash, name, path) VALUES ('${hash}', '${name}', '${path}');" } # Get project ID for current directory get_project_id() { project_hash "${1:-$PWD}" } # Resolve a user-supplied name/hash to a project hash # Accepts: hash (6 chars), project name, or path resolve_target() { local target="$1" local safe_target safe_target=$(sql_escape "$target") # 1. Exact hash match if [[ ${#target} -eq 6 ]] && [[ "$target" =~ ^[0-9a-f]+$ ]]; then local found found=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE hash='${safe_target}';") if [ -n "$found" ]; then echo "$found" return 0 fi fi # 2. Name match (case-insensitive) local by_name by_name=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE LOWER(name)=LOWER('${safe_target}') ORDER BY registered DESC LIMIT 1;") if [ -n "$by_name" ]; then echo "$by_name" return 0 fi # 3. Path match - target might be a directory if [ -d "$target" ]; then local hash hash=$(project_hash "$target") echo "$hash" return 0 fi # 4. Generate hash from target as a string (for unknown projects) # Register it so replies work local hash hash=$(printf '%s' "$target" | shasum -a 256 | cut -c1-6) sqlite3 "$MAIL_DB" \ "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${hash}', '${safe_target}', '${safe_target}');" echo "$hash" } # Look up display name for a hash display_name() { local hash="$1" local name name=$(sqlite3 "$MAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';") if [ -n "$name" ]; then echo "$name" else echo "$hash" fi } # ============================================================================ # Identicon display (inline, compact) # ============================================================================ show_identicon() { local target="${1:-$PWD}" if [ -f "$SCRIPT_DIR/identicon.sh" ]; then bash "$SCRIPT_DIR/identicon.sh" "$target" fi } # ============================================================================ # Mail operations # ============================================================================ count_unread() { init_db register_project local pid pid=$(get_project_id) sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;" } list_unread() { init_db register_project local pid pid=$(get_project_id) local rows rows=$(sqlite3 -separator '|' "$MAIL_DB" \ "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp DESC;") [ -z "$rows" ] && return 0 while IFS='|' read -r id from_hash subj ts; do local from_name from_name=$(display_name "$from_hash") echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${ts}" done <<< "$rows" } read_mail() { init_db register_project local pid pid=$(get_project_id) # Use ASCII record separator (0x1E) to avoid splitting on pipes/newlines in body local RS=$'\x1e' local count count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;") [ "${count:-0}" -eq 0 ] && return 0 # Query each message individually to preserve multi-line bodies local ids ids=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;") echo "id | from_project | subject | body | timestamp" while read -r msg_id; do [ -z "$msg_id" ] && continue local from_hash subj body ts from_name attachments from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};") subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};") body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};") ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};") attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};") from_name=$(display_name "$from_hash") echo "${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}" if [ -n "$attachments" ]; then while IFS= read -r apath; do [ -z "$apath" ] && continue local astat="missing" [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes" echo " [Attached: ${apath} (${astat})]" done <<< "$attachments" fi done <<< "$ids" sqlite3 "$MAIL_DB" \ "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;" # Clear signal file rm -f "/tmp/pigeon_signal_${pid}" } read_one() { local msg_id="$1" if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then echo "Error: message ID must be numeric" >&2 return 1 fi init_db local exists exists=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${msg_id};") [ "${exists:-0}" -eq 0 ] && return 0 local from_hash to_hash subj body ts from_name to_name attachments from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};") to_hash=$(sqlite3 "$MAIL_DB" "SELECT to_project FROM messages WHERE id=${msg_id};") subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};") body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};") ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};") attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};") from_name=$(display_name "$from_hash") to_name=$(display_name "$to_hash") echo "id | from_project | to_project | subject | body | timestamp" echo "${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}" if [ -n "$attachments" ]; then while IFS= read -r apath; do [ -z "$apath" ] && continue local astat="missing" [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes" echo " [Attached: ${apath} (${astat})]" done <<< "$attachments" fi sqlite3 "$MAIL_DB" \ "UPDATE messages SET read=1 WHERE id=${msg_id};" } send() { local priority="normal" local -a attach_paths=() # Parse flags before positional args while [ $# -gt 0 ]; do case "$1" in --urgent) priority="urgent"; shift ;; --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;; *) break ;; esac done local to_input="${1:?to_project required}" local subject="${2:-no subject}" local body body=$(read_body "${3:-}") if [ -z "$body" ]; then echo "Error: message body cannot be empty" >&2 return 1 fi init_db register_project local from_id to_id from_id=$(get_project_id) to_id=$(resolve_target "$to_input") local safe_subject safe_body safe_attachments safe_subject=$(sql_escape "$subject") safe_body=$(sql_escape "$body") # Join attachment paths with newlines local attachments="" if [ ${#attach_paths[@]} -gt 0 ]; then attachments=$(IFS=$'\n'; echo "${attach_paths[*]}") fi safe_attachments=$(sql_escape "$attachments") sqlite3 "$MAIL_DB" \ "INSERT INTO messages (from_project, to_project, subject, body, priority, attachments) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}', '${safe_attachments}');" # Signal the recipient touch "/tmp/pigeon_signal_${to_id}" local to_name to_name=$(display_name "$to_id") local attach_note="" [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]" echo "Sent to ${to_name} (${to_id}): ${subject}${attach_note}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)" } sent() { local limit="${1:-20}" init_db register_project local pid pid=$(get_project_id) local rows rows=$(sqlite3 -separator '|' "$MAIL_DB" \ "SELECT id, to_project, subject, timestamp FROM messages WHERE from_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};") [ -z "$rows" ] && echo "No sent messages" && return 0 echo "id | to | subject | timestamp" while IFS='|' read -r id to_hash subj ts; do local to_name to_name=$(display_name "$to_hash") echo "${id} | ${to_name} (${to_hash}) | ${subj} | ${ts}" done <<< "$rows" } search() { local keyword="$1" if [ -z "$keyword" ]; then echo "Error: search keyword required" >&2 return 1 fi init_db register_project local pid pid=$(get_project_id) local safe_keyword safe_keyword=$(sql_escape "$keyword") local rows rows=$(sqlite3 -separator '|' "$MAIL_DB" \ "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;") [ -z "$rows" ] && return 0 echo "id | from | subject | status | timestamp" while IFS='|' read -r id from_hash subj status ts; do local from_name from_name=$(display_name "$from_hash") echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}" done <<< "$rows" } list_all() { init_db register_project local pid pid=$(get_project_id) local limit="${1:-20}" if ! [[ "$limit" =~ ^[0-9]+$ ]]; then limit=20 fi local rows rows=$(sqlite3 -separator '|' "$MAIL_DB" \ "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};") [ -z "$rows" ] && return 0 echo "id | from | subject | status | timestamp" while IFS='|' read -r id from_hash subj status ts; do local from_name from_name=$(display_name "$from_hash") echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}" done <<< "$rows" } clear_old() { init_db local days="${1:-7}" 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() { local -a attach_paths=() # Parse flags before positional args while [ $# -gt 0 ]; do case "$1" in --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;; *) break ;; esac done local msg_id="$1" local body body=$(read_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 register_project local orig orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject, thread_id FROM messages WHERE id=${msg_id};") if [ -z "$orig" ]; then echo "Error: message #${msg_id} not found" >&2 return 1 fi local orig_from_hash orig_subject orig_thread orig_from_hash=$(echo "$orig" | cut -d'|' -f1) orig_subject=$(echo "$orig" | cut -d'|' -f2) orig_thread=$(echo "$orig" | cut -d'|' -f3) # Thread ID: inherit from parent, or use parent's ID as thread root local thread_id="${orig_thread:-$msg_id}" local from_id from_id=$(get_project_id) local safe_subject safe_body safe_attachments safe_subject=$(sql_escape "Re: ${orig_subject}") safe_body=$(sql_escape "$body") local attachments="" if [ ${#attach_paths[@]} -gt 0 ]; then attachments=$(IFS=$'\n'; echo "${attach_paths[*]}") fi safe_attachments=$(sql_escape "$attachments") sqlite3 "$MAIL_DB" \ "INSERT INTO messages (from_project, to_project, subject, body, thread_id, attachments) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}', ${thread_id}, '${safe_attachments}');" # Signal the recipient touch "/tmp/pigeon_signal_${orig_from_hash}" local orig_name orig_name=$(display_name "$orig_from_hash") local attach_note="" [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]" echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}${attach_note}" } thread() { local msg_id="$1" if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then echo "Error: message ID must be numeric" >&2 return 1 fi init_db # Find the thread root: either the message itself or its thread_id local thread_root thread_root=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(thread_id, id) FROM messages WHERE id=${msg_id};" 2>/dev/null) [ -z "$thread_root" ] && echo "Message not found" && return 1 # Get all message IDs in this thread (root + replies) local ids ids=$(sqlite3 "$MAIL_DB" \ "SELECT id FROM messages WHERE id=${thread_root} OR thread_id=${thread_root} ORDER BY timestamp ASC;") [ -z "$ids" ] && echo "No thread found" && return 0 local msg_count=0 echo "=== Thread #${thread_root} ===" while read -r tid; do [ -z "$tid" ] && continue local from_hash body ts from_name attachments from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${tid};") body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${tid};") ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${tid};") attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${tid};") from_name=$(display_name "$from_hash") echo "" echo "--- #${tid} ${from_name} @ ${ts} ---" echo "${body}" if [ -n "$attachments" ]; then while IFS= read -r apath; do [ -z "$apath" ] && continue local astat="missing" [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes" echo " [Attached: ${apath} (${astat})]" done <<< "$attachments" fi msg_count=$((msg_count + 1)) done <<< "$ids" echo "" echo "=== End of thread (${msg_count} messages) ===" } broadcast() { local subject="$1" local body="$2" if [ -z "$body" ]; then echo "Error: message body cannot be empty" >&2 return 1 fi init_db register_project local from_id from_id=$(get_project_id) local targets targets=$(sqlite3 "$MAIL_DB" \ "SELECT hash FROM projects WHERE hash != '${from_id}' ORDER BY name;") local count=0 local safe_subject safe_body safe_subject=$(sql_escape "$subject") safe_body=$(sql_escape "$body") while IFS= read -r target_hash; do [ -z "$target_hash" ] && continue sqlite3 "$MAIL_DB" \ "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');" touch "/tmp/pigeon_signal_${target_hash}" count=$((count + 1)) done <<< "$targets" echo "Broadcast to ${count} project(s): ${subject}" } status() { init_db register_project local pid pid=$(get_project_id) local unread total unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;") total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}';") echo "Inbox: ${unread} unread / ${total} total" if [ "${unread:-0}" -gt 0 ]; then local senders senders=$(sqlite3 -separator '|' "$MAIL_DB" \ "SELECT from_project, COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;") while IFS='|' read -r from_hash cnt; do local from_name from_name=$(display_name "$from_hash") echo " ${from_name} (${from_hash}): ${cnt} message(s)" done <<< "$senders" fi } 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 register_project local pid pid=$(get_project_id) local count count=$(sqlite3 "$MAIL_DB" \ "DELETE FROM messages WHERE to_project='${pid}' OR from_project='${pid}'; SELECT changes();") local name name=$(project_name) echo "Purged ${count} message(s) for ${name} (${pid})" fi } 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 # Resolve old name to hash, then update the display name local old_hash old_hash=$(resolve_target "$old_name") local safe_new safe_new=$(sql_escape "$new_name") local safe_old safe_old=$(sql_escape "$old_name") sqlite3 "$MAIL_DB" \ "UPDATE projects SET name='${safe_new}' WHERE hash='${old_hash}';" # Also update path if it matches the old name (phantom projects) sqlite3 "$MAIL_DB" \ "UPDATE projects SET path='${safe_new}' WHERE hash='${old_hash}' AND path='${safe_old}';" echo "Renamed '${old_name}' -> '${new_name}' (hash: ${old_hash})" } list_projects() { init_db register_project local rows rows=$(sqlite3 -separator '|' "$MAIL_DB" \ "SELECT hash, name, path FROM projects ORDER BY name;") [ -z "$rows" ] && echo "No known projects" && return 0 local my_id my_id=$(get_project_id) while IFS='|' read -r hash name path; do local marker="" [ "$hash" = "$my_id" ] && marker=" (you)" echo "" # Show identicon if available if [ -f "$SCRIPT_DIR/identicon.sh" ]; then bash "$SCRIPT_DIR/identicon.sh" "$path" --compact 2>/dev/null || true fi echo "${name} ${hash}${marker}" echo "${path}" done <<< "$rows" } # Migrate old basename-style messages to hash IDs migrate() { init_db register_project echo "Migrating old messages to hash-based IDs..." # Find all unique project names in messages that aren't 6-char hex hashes local old_names old_names=$(sqlite3 "$MAIL_DB" \ "SELECT DISTINCT from_project FROM messages WHERE LENGTH(from_project) != 6 OR from_project GLOB '*[^0-9a-f]*' UNION SELECT DISTINCT to_project FROM messages WHERE LENGTH(to_project) != 6 OR to_project GLOB '*[^0-9a-f]*';") if [ -z "$old_names" ]; then echo "No messages need migration." return 0 fi local count=0 while IFS= read -r old_name; do [ -z "$old_name" ] && continue # Try to find the project path - check common locations local found_path="" for base_dir in "$HOME/projects" "$HOME/Projects" "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/repos"; do if [ -d "${base_dir}/${old_name}" ]; then found_path=$(cd "${base_dir}/${old_name}" && pwd -P) break fi done local new_hash if [ -n "$found_path" ]; then new_hash=$(printf '%s' "$found_path" | shasum -a 256 | cut -c1-6) local safe_name safe_path safe_name=$(sql_escape "$old_name") safe_path=$(sql_escape "$found_path") sqlite3 "$MAIL_DB" \ "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_path}');" else # Can't find directory - hash the name itself new_hash=$(printf '%s' "$old_name" | shasum -a 256 | cut -c1-6) local safe_name safe_name=$(sql_escape "$old_name") sqlite3 "$MAIL_DB" \ "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_name}');" fi local safe_old safe_old=$(sql_escape "$old_name") sqlite3 "$MAIL_DB" "UPDATE messages SET from_project='${new_hash}' WHERE from_project='${safe_old}';" sqlite3 "$MAIL_DB" "UPDATE messages SET to_project='${new_hash}' WHERE to_project='${safe_old}';" echo " ${old_name} -> ${new_hash}$([ -n "$found_path" ] && echo " (${found_path})" || echo " (name only)")" count=$((count + 1)) done <<< "$old_names" echo "Migrated ${count} project name(s)." } # ============================================================================ # 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) shift; reply "$@" ;; sent) sent "${2:-20}" ;; thread) thread "${2:?message_id 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 ;; migrate) migrate ;; id) init_db; register_project; echo "$(project_name) $(get_project_id)" ;; help) echo "Usage: mail-db.sh <command> [args]" echo "" echo "Commands:" echo " init Initialize database" echo " id Show this project's name and hash" echo " count Count unread messages" echo " unread List unread messages (brief)" echo " read [id] Read messages and mark as read" echo " send [--urgent] [--attach <path>]... <to> <subj> <body|-> Send with optional attachments" echo " reply [--attach <path>]... <id> <body|-> Reply with optional attachments" echo " sent [limit] Show sent messages (outbox)" echo " thread <id> View full conversation thread" 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 display name" echo " projects List known projects with identicons" echo " migrate Convert old basename messages to hash IDs" ;; *) echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;; esac -
test-mail.sh 25.5 KB
#!/bin/bash # test-mail.sh - Integration test harness for pigeon mail-ops (mail-db.sh + the # check-mail.sh delivery hook). # # Outputs: the passing-case count on the final line; exits 0 on a fully green # run, 1 if any assertion FAILed (see the tail). Machine-readable last line. # # RUNTIME / TIMEOUT — this is a HEAVYWEIGHT suite, not a unit test. Every one of # its ~90 assertions shells out to a fresh `bash mail-db.sh <cmd>`, and each of # those spawns git + sqlite3 + sed a handful of times. On Windows/Git-Bash that # is ~0.8s per call, so the whole suite legitimately takes 1-2 MINUTES. It is # NOT hung when it is slow. Invoke it with a generous timeout (>=180s); a short # `timeout 15` will kill it mid-run and look exactly like an indefinite hang # (rc=124) even though it would have completed. That false "hang" is why callers # must budget real wall-clock, not evidence the script blocks. # # HERMETIC — the suite is fully self-contained so it is safe headless in CI and # cannot be perturbed by (or perturb) a live pigeon session: # * stdin is closed (exec </dev/null) so no read/cat can ever block on a TTY; # * it runs from a throwaway cwd named "claude-mods" so project identity, the # /tmp/pigeon_signal_* file, and the .claude/pigeon.disable toggle all live # in an isolated namespace — no cross-session signal-file races (a live # check-mail hook clearing the shared signal used to flake the hook tests), # and it no longer writes .claude/ into the real repo. set -uo pipefail # Never block on stdin: this suite runs headless in CI with stdin closed, and a # stray interactive read (e.g. mail-db's `read_body` cat path) would hang it. exec </dev/null # Resolve script paths to ABSOLUTE before we change directory below — otherwise # the cd would break the relative "$(dirname "$0")" lookups. SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MAIL_SCRIPT="$SELF_DIR/mail-db.sh" HOOK_SCRIPT="$SELF_DIR/../../hooks/check-mail.sh" if [ ! -f "$HOOK_SCRIPT" ]; then HOOK_SCRIPT="$(cd "$SELF_DIR/../../.." && pwd)/hooks/check-mail.sh" fi # Sandbox everything under one throwaway dir, removed on exit: # * HOME -> so the global DB ("$HOME/.claude/pmail.db") is a private copy. # Without this, the `rm -f "$MAIL_DB"` clean-slate below would delete the # caller's REAL pmail.db when the suite is run without a HOME override. # * cwd -> a dir literally named "claude-mods" so project identity resolves # to that name (the assertions expect it) while its /tmp/pigeon_signal_* # hash is unique to this run. mail-db.sh and check-mail.sh both derive # identity from $PWD the same way, so they agree inside this sandbox and it # stays hermetic against any concurrently-running pigeon session's hook. TEST_WORKDIR="$(mktemp -d)" trap 'rm -rf "$TEST_WORKDIR"' EXIT export HOME="$TEST_WORKDIR/home" mkdir -p "$HOME" "$TEST_WORKDIR/claude-mods" cd "$TEST_WORKDIR/claude-mods" MAIL_DB="$HOME/.claude/pmail.db" 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 } # No-op: cooldown was removed, but tests still call this clear_cooldown() { :; } # --- 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) # Pure-bash body generation via `printf -v` + brace expansion — spawns no # external process. The previous `python3 -c` call is a Windows headless # LANDMINE: `python3` there resolves to the Microsoft Store App-Execution-Alias # stub (a reparse-point shim), which can block instead of failing fast, so a # CI runner without real Python could stall here. Never reintroduce an external # interpreter for something bash can do itself. printf -v long_body 'x%.0s' {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 delivers message inline (does NOT auto-read) 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 INCOMING PMAIL" "INCOMING PMAIL" "$result" assert_contains "hook shows subject" "Hook test" "$result" assert_contains "hook shows body" "Should trigger hook" "$result" # Signal cleared after first delivery, so second call is silent result2=$(bash "$HOOK_SCRIPT" 2>&1) assert_empty "hook silent after signal cleared" "$result2" # But messages are still unread (hook does NOT auto-read) unread_count=$(bash "$MAIL_SCRIPT" count 2>&1) assert_contains "messages persist unread after hook" "1" "$unread_count" # Manually mark read for cleanup bash "$MAIL_SCRIPT" read >/dev/null 2>&1 # 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 delivers urgent message with marker clear_cooldown result=$(bash "$HOOK_SCRIPT" 2>&1) assert_contains "hook shows URGENT" "URGENT" "$result" assert_contains "hook shows urgent body" "Production is on fire" "$result" # 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 "=== Hook ===" # T52: Hook delivers without auto-read bash "$MAIL_SCRIPT" send "claude-mods" "hook test" "testing hook" >/dev/null 2>&1 result1=$(bash "$HOOK_SCRIPT" 2>&1) assert_contains "hook delivers message" "INCOMING PMAIL" "$result1" # T53: Signal cleared after delivery, second call silent result2=$(bash "$HOOK_SCRIPT" 2>&1) assert_empty "hook silent after signal cleared (2)" "$result2" # Messages still unread - verify then clean up bash "$MAIL_SCRIPT" read >/dev/null 2>&1 echo "" echo "=== Attachments ===" # Create temp files for attachment tests ATTACH_DIR=$(mktemp -d) echo "file one content" > "$ATTACH_DIR/file1.txt" echo "file two content" > "$ATTACH_DIR/file2.txt" mkdir -p "$ATTACH_DIR/sub dir" echo "spaced path" > "$ATTACH_DIR/sub dir/spaced.txt" # T: Send with single attachment result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "attach test" "one file" 2>&1) assert_contains "send with attachment succeeds" "1 attachment" "$result" # T: Attachment path stored as absolute last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};") assert_contains "attachment path is absolute" "$ATTACH_DIR/file1.txt" "$stored" # T: Read shows attachment with size result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1) assert_contains "read shows Attached" "[Attached:" "$result" assert_contains "read shows file size" "bytes" "$result" # T: Send with multiple attachments result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" --attach "$ATTACH_DIR/file2.txt" "claude-mods" "multi attach" "two files" 2>&1) assert_contains "send with 2 attachments" "2 attachment" "$result" # T: Multiple attachment paths stored correctly last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") attach_count=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | grep -c '.') assert "two attachment paths stored" "2" "$attach_count" # T: No trailing empty line in stored attachments trailing=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | tail -1) assert_not_empty "no trailing empty line" "$trailing" # T: Nonexistent file rejected result=$(bash "$MAIL_SCRIPT" send --attach "/tmp/nonexistent_$$.txt" "claude-mods" "fail" "body" 2>&1) exit_code=$? assert_contains "nonexistent attach rejected" "not found" "$result" assert_exit_code "nonexistent attach exits 1" "1" "$exit_code" # T: Send without attachment still works (no regression) result=$(bash "$MAIL_SCRIPT" send "claude-mods" "no attach" "plain message" 2>&1) assert_contains "send without attach works" "Sent to" "$result" last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") stored=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${last_id};") assert "no-attach message has empty attachments" "" "$stored" # T: Reply with attachment via dispatch base_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") result=$(bash "$MAIL_SCRIPT" reply --attach "$ATTACH_DIR/file1.txt" "$base_id" "reply with file" 2>&1) assert_contains "reply with attachment succeeds" "1 attachment" "$result" # T: Reply attachment stored correctly last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};") assert_contains "reply attachment path stored" "$ATTACH_DIR/file1.txt" "$stored" # T: Attachment with spaces in path result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/sub dir/spaced.txt" "claude-mods" "spaced path" "path has spaces" 2>&1) assert_contains "spaced path attachment succeeds" "1 attachment" "$result" last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};") assert_contains "spaced path preserved" "sub dir/spaced.txt" "$stored" # T: Hook shows attachments bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "hook attach" "check hook" >/dev/null 2>&1 clear_cooldown result=$(bash "$HOOK_SCRIPT" 2>&1) assert_contains "hook shows attachment" "[Attached:" "$result" assert_contains "hook shows Read hint" "Use Read tool" "$result" bash "$MAIL_SCRIPT" read >/dev/null 2>&1 # T: Mixed flags - --urgent with --attach result=$(bash "$MAIL_SCRIPT" send --urgent --attach "$ATTACH_DIR/file1.txt" "claude-mods" "urgent+attach" "both flags" 2>&1) assert_contains "urgent+attach shows attachment" "1 attachment" "$result" assert_contains "urgent+attach shows URGENT" "URGENT" "$result" # T: Deleted file shows as missing VANISH="$ATTACH_DIR/vanish.txt" echo "temporary" > "$VANISH" bash "$MAIL_SCRIPT" send --attach "$VANISH" "claude-mods" "vanish test" "file will disappear" >/dev/null 2>&1 rm -f "$VANISH" last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;") result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1) assert_contains "deleted file shows missing" "missing" "$result" # Clean up temp dir rm -rf "$ATTACH_DIR" 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/pigeon.disable bash "$MAIL_SCRIPT" send "claude-mods" "disable test" "should not appear" >/dev/null 2>&1 clear_cooldown mkdir -p .claude touch .claude/pigeon.disable result=$(bash "$HOOK_SCRIPT" 2>&1) assert_empty "hook silent when disabled" "$result" # T53: Hook delivers after re-enable rm -f .claude/pigeon.disable clear_cooldown result=$(bash "$HOOK_SCRIPT" 2>&1) assert_contains "hook works after re-enable" "INCOMING PMAIL" "$result" echo "" echo "=== Results ===" echo "Passed: $PASS / $TOTAL" echo "Failed: $FAIL / $TOTAL" echo "" # Machine-readable last line: the passing-case count. echo "$PASS" # Propagate real failures as a non-zero exit — the previous final `echo` masked # every FAIL behind exit 0, so a broken suite still looked green to any caller. [ "$FAIL" -eq 0 ] || exit 1
-
-
tests
-
run.sh 6.3 KB
#!/usr/bin/env bash # Behavioural tests for pigeon. HOME is isolated before any production script # runs so ~/.claude/pmail.db always resolves inside the disposable sandbox. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" MAIL="$SKILL/scripts/mail-db.sh" SB="$(mktemp -d)" trap 'rm -rf "$SB"' EXIT export HOME="$SB/home" mkdir -p "$HOME" DB="$HOME/.claude/pmail.db" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; } no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } expect_eq() { [[ "$2" == "$3" ]] && ok "$1" || no "$1 (want '$2', got '$3')"; } echo "=== pigeon behavioural self-test ===" command -v sqlite3 >/dev/null 2>&1 || { echo "sqlite3 is required" >&2; exit 1; } bash -n "$MAIL" && ok "bash -n mail-db.sh" || no "bash -n mail-db.sh" # NOTE: scripts/test-mail.sh (the legacy harness) is deliberately NOT invoked # here — it blocks indefinitely (rc=124) and would hang CI. The focused # corruption guards below are the authoritative signal; see the spawned # follow-up task for the test-mail.sh hang itself. echo "-- migration idempotency and schema --" rm -f "$DB" bash "$MAIL" migrate >/dev/null schema_first="$(sqlite3 "$DB" ".schema")" bash "$MAIL" migrate >/dev/null schema_second="$(sqlite3 "$DB" ".schema")" expect_eq "second migration leaves schema identical" "$schema_first" "$schema_second" tables="$(sqlite3 "$DB" "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('messages','projects') ORDER BY name;" | tr -d '\r')" expect_eq "expected tables exist" $'messages\nprojects' "$tables" columns="$(sqlite3 "$DB" "SELECT name FROM pragma_table_info('messages') WHERE name IN ('priority','thread_id','attachments') ORDER BY name;" | tr -d '\r')" expect_eq "migration columns exist once" $'attachments\npriority\nthread_id' "$columns" echo "-- round trip --" bash "$MAIL" send "$(pwd)" "round trip" "isolated body" >/dev/null expect_eq "unread count after send" "1" "$(bash "$MAIL" count)" read_out="$(bash "$MAIL" read)" case "$read_out" in *"round trip"*"isolated body"*) ok "read returns sent message";; *) no "read omitted sent message";; esac expect_eq "read marks message read" "0" "$(bash "$MAIL" count)" echo "-- attachments --" # Regression: Windows' sqlite3.exe emits "\r\n", which left a stray "\r" on every # attachment path but the last, so existing files read back as "(missing)". # Two or more attachments are needed: the LAST line always survived. printf 'alpha' > "$SB/att-one.txt" printf 'beta!' > "$SB/att-two.txt" bash "$MAIL" send --attach "$SB/att-one.txt" --attach "$SB/att-two.txt" "$(pwd)" "attach trip" "two files" >/dev/null att_out="$(bash "$MAIL" read)" case "$att_out" in *"(missing)"*) no "every existing attachment resolves (got: (missing))";; *) ok "every existing attachment resolves";; esac expect_eq "each attachment reports its size" "2" "$(printf '%s\n' "$att_out" | grep -c '(5 bytes)')" echo "-- hook delivery (PreToolUse additionalContext envelope) --" # Regression: check-mail.sh used to echo plain text, which Claude Code sends to # the debug log for PreToolUse - the model never saw a single notification. The # hook must print exactly one JSON envelope (contract at the top of the hook). # It runs from a throwaway non-git dir so its /tmp/pigeon_signal_* name is # unique to this run and a live pigeon session's hook can't clear it mid-test. HOOK="$(cd "$SKILL/../.." && pwd)/hooks/check-mail.sh" if ! command -v jq >/dev/null 2>&1; then echo " SKIP hook envelope tests (jq not installed)" else mkdir -p "$SB/hookproj" hook() { (cd "$SB/hookproj" && bash "$HOOK" </dev/null); } mail_hookproj() { (cd "$SB/hookproj" && bash "$MAIL" send "$(pwd)" "$1" "$2" </dev/null >/dev/null); } envelope_event() { printf '%s' "$1" | jq -rs 'if length == 1 then .[0].hookSpecificOutput.hookEventName else "not-one-json-value" end' 2>/dev/null; } # tr: Windows jq.exe writes -r output in text mode (every "\n" becomes "\r\n"). # Test for CRs inside the string with envelope_has_cr, never on this output. envelope_ctx() { printf '%s' "$1" | jq -r '.hookSpecificOutput.additionalContext' 2>/dev/null | tr -d '\r'; } envelope_has_cr() { printf '%s' "$1" | jq '.hookSpecificOutput.additionalContext | test("\r")' 2>/dev/null | tr -d '\r'; } expect_eq "hook silent with no mail" "" "$(hook)" mail_hookproj "envelope trip" $'line one\nsays "quoted" \\ back' hook_out="$(hook)" expect_eq "hook prints exactly one PreToolUse envelope" "PreToolUse" "$(envelope_event "$hook_out")" ctx="$(envelope_ctx "$hook_out")" case "$ctx" in *"INCOMING PMAIL"*"envelope trip"*'says "quoted" \ back'*"ACTION REQUIRED"*) ok "additionalContext carries header, message and footer";; *) no "additionalContext missing delivery text (got: ${ctx:0:200})";; esac expect_eq "hook silent once its signal is cleared" "" "$(hook)" # Over the 10,000-char additionalContext cap Claude Code shows the model only a # 2,000-char preview, so the hook truncates bodies but keeps the footer. mail_hookproj "big one" "$(head -c 12000 /dev/zero | tr '\0' x)" ctx="$(envelope_ctx "$(hook)")" [[ -n "$ctx" && "${#ctx}" -lt 10000 ]] && ok "oversized mail stays under the 10k cap (${#ctx} chars)" || no "oversized mail is ${#ctx} chars" case "$ctx" in *"truncated"*"pigeon read"*) ok "truncation points at pigeon read";; *) no "truncation notice missing";; esac expect_eq "footer survives truncation" '=== To reply: pigeon reply <id> "message" ===' "$(printf '%s\n' "$ctx" | tail -n 1)" # Regression (Windows): the hook has its own attachment loop, so it carried the # same sqlite3.exe "\r\n" bug mail-db.sh had - every path but the last showed # "(missing)", and multi-line bodies reached the model with stray CRs. (cd "$SB/hookproj" && bash "$MAIL" read </dev/null >/dev/null) (cd "$SB/hookproj" && bash "$MAIL" send --attach "$SB/att-one.txt" --attach "$SB/att-two.txt" "$(pwd)" "hook attach" $'two\nlines' </dev/null >/dev/null) hook_out="$(hook)" ctx="$(envelope_ctx "$hook_out")" case "$ctx" in *"(missing)"*) no "hook resolves every existing attachment (got: (missing))";; *) ok "hook resolves every existing attachment";; esac expect_eq "hook reports each attachment's size" "2" "$(printf '%s\n' "$ctx" | grep -c '(5 bytes)')" expect_eq "hook context carries no CR" "false" "$(envelope_has_cr "$hook_out")" fi echo "" echo "=== $PASS passed, $FAIL failed ===" [[ "$FAIL" -eq 0 ]]
-
-
SKILL.md 9.7 KB
--- name: pigeon description: "Inter-session pmail - send and receive messages between Claude Code sessions running in different project directories. Uses global SQLite database at ~/.claude/pmail.db. Triggers on: mail, pmail, send message, check mail, inbox, inter-session, message another session, pigeon." license: MIT allowed-tools: "Read Bash Grep" metadata: author: claude-mods related-skills: sqlite-ops --- # Pigeon Inter-session messaging for Claude Code. Send and receive pmail between sessions running in different projects. ## Quick Reference All commands go through `MAIL`, a shorthand for `bash "$HOME/.claude/pigeon/mail-db.sh"`. Set this at the top of execution: ```bash MAIL="$HOME/.claude/pigeon/mail-db.sh" ``` Then use it for all commands below. ## Command Router Parse the user's input after `pigeon` (or `/pigeon`) and run the matching command: | User says | Run | |-----------|-----| | `pigeon read` | `bash "$MAIL" read` | | `pigeon read 42` | `bash "$MAIL" read 42` | | `pigeon send <project> "<subject>" "<body>"` | `bash "$MAIL" send "<project>" "<subject>" "<body>"` | | `pigeon send --urgent <project> "<subject>" "<body>"` | `bash "$MAIL" send --urgent "<project>" "<subject>" "<body>"` | | `pigeon send --attach <path> <project> "<subject>" "<body>"` | `bash "$MAIL" send --attach "<path>" "<project>" "<subject>" "<body>"` | | `pigeon reply <id> "<body>"` | `bash "$MAIL" reply <id> "<body>"` | | `pigeon reply --attach <path> <id> "<body>"` | `bash "$MAIL" reply --attach "<path>" <id> "<body>"` | | `pigeon broadcast "<subject>" "<body>"` | `bash "$MAIL" broadcast "<subject>" "<body>"` | | `pigeon search <keyword>` | `bash "$MAIL" search "<keyword>"` | | `pigeon status` | `bash "$MAIL" status` | | `pigeon unread` | `bash "$MAIL" unread` | | `pigeon list` | `bash "$MAIL" list` | | `pigeon list 50` | `bash "$MAIL" list 50` | | `pigeon projects` | `bash "$MAIL" projects` | | `pigeon clear` | `bash "$MAIL" clear` | | `pigeon clear 7` | `bash "$MAIL" clear 7` | | `pigeon alias <old> <new>` | `bash "$MAIL" alias "<old>" "<new>"` | | `pigeon purge` | `bash "$MAIL" purge` | | `pigeon purge --all` | `bash "$MAIL" purge --all` | | `pigeon id` | `bash "$MAIL" id` | | `pigeon migrate` | `bash "$MAIL" migrate` | | `pigeon init` | `bash "$MAIL" init` | When the user just says "check mail", "read mail", "inbox", "any mail?", or "any pmail?" - run `bash "$MAIL" read`. When the user says "send mail to X", "send pmail to X", or "message X" - parse out the project name, subject, and body, then run `bash "$MAIL" send`. ## Project Identity Each project gets a stable 6-character hash ID derived from its **git root commit** (the very first commit in the repo). This means: - IDs survive directory renames, moves, and clones - Case-insensitive filesystems (macOS) don't cause collisions - Every clone of the same repo shares the same identity For non-git directories, falls back to a hash of the canonical path (`pwd -P`). Use `pigeon id` to see your project's name and hash: ``` claude-mods 7663d6 ``` When sending messages, you can address projects by **name**, **hash**, or **path** - they all resolve to the same hash ID. ### Identicons Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run `identicon.sh` to see yours, or view all projects with `pigeon projects`. ## Passive Notification (Hook) A global PreToolUse hook checks for pmail on every tool call (no cooldown). Silent when inbox is empty. When mail is waiting it prints one JSON envelope, and Claude Code passes its `additionalContext` to the model: ```json {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"=== INCOMING PMAIL (1 message(s)) ===\n..."}} ``` What the model reads: ``` === INCOMING PMAIL (1 message(s)) === --- #42 from some-api (a1b2c3) @ 2026-09-30 10:12:04 --- Subject: Auth endpoints ready Login and refresh are live on staging. === ACTION REQUIRED: Inform the user about these messages and ask if they want to reply. === === Then run: pigeon read (to mark as read) === === To reply: pigeon reply <id> "message" === ``` The JSON form is required. For PreToolUse, plain stdout goes to Claude Code's debug log and never reaches the model. Claude Code caps `additionalContext` at 10,000 chars, so the hook truncates message bodies at about 9,000 and points at `pigeon read`. The header and footer are always kept. Delivery does not mark mail read. The signal file is cleared, so each new send triggers one notice. ## Attachments Send file references with `--attach <path>` (repeatable). Paths are resolved to absolute and stored as references - files are not copied. ```bash # Send with one attachment pigeon send --attach src/config.ts my-api "Config update" "Updated the auth config" # Send with multiple attachments pigeon send --attach src/schema.sql --attach docs/API.md my-api "Schema + docs" "See attached" # Reply with attachment pigeon reply --attach output/report.json 42 "Here's the analysis" ``` Recipients see attachment paths with file sizes and can read them directly with the Read tool. If a file has been moved or deleted since sending, it shows as `(missing)`. ## 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/pigeon.disable # Disable hook notifications rm .claude/pigeon.disable # Re-enable ``` Only the hook is disabled - you can still send messages from the project. --- ## Installation Pigeon requires two things: **scripts** (the mail engine) and a **hook** (passive notifications). Both install globally - one setup, every project gets pmail. ### Prerequisites - `sqlite3` - ships with macOS, most Linux distros, and Git Bash on Windows. No install needed. - `jq` - the hook uses it to build the JSON envelope Claude Code requires (`brew install jq`, `scoop install jq`, `apt install jq`). Without `jq` the hook stays silent, and `pigeon read` still works. ### Step 1: Copy Scripts ```bash mkdir -p ~/.claude/pigeon cp skills/pigeon/scripts/mail-db.sh ~/.claude/pigeon/ cp hooks/check-mail.sh ~/.claude/pigeon/ chmod +x ~/.claude/pigeon/mail-db.sh ~/.claude/pigeon/check-mail.sh ``` This gives you the pmail commands. You can now send and read messages manually: ```bash bash ~/.claude/pigeon/mail-db.sh init # Create database bash ~/.claude/pigeon/mail-db.sh status # Check it works ``` ### Step 2: Enable the Hook Add a `hooks` block to `~/.claude/settings.json`. This makes Claude check for pmail automatically on every tool call: ```json { "hooks": { "PreToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "bash \"$HOME/.claude/pigeon/check-mail.sh\"", "timeout": 5 } ] } ] } } ``` **Important:** If you already have a `hooks` section in your settings, merge the PreToolUse entry into the existing array - don't replace the whole block. Without this step, pigeon still works but you have to check manually (`pigeon read`). With the hook, unread pmail appears automatically. ### What Gets Created ``` ~/.claude/ settings.json # Hook config (you edit this) pmail.db # Message store (auto-created on first use) pigeon/ mail-db.sh # All pmail commands (send, read, reply, etc.) check-mail.sh # PreToolUse hook (silent when inbox empty) ``` ### Verify ```bash # Check your project identity bash ~/.claude/pigeon/mail-db.sh id # Send yourself a test message (use your project name from above) bash ~/.claude/pigeon/mail-db.sh send "my-project" "Test" "Hello from pigeon" # Check it arrived bash ~/.claude/pigeon/mail-db.sh read # Clean up bash ~/.claude/pigeon/mail-db.sh purge --all ``` ### Uninstall ```bash rm -rf ~/.claude/pigeon ~/.claude/pmail.db # Then remove the hooks.PreToolUse entry from ~/.claude/settings.json ``` ## Database Single SQLite file at `~/.claude/pmail.db`. Auto-created on first `init` or `send`. ```sql CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_project TEXT NOT NULL, -- 6-char hash ID to_project TEXT NOT NULL, -- 6-char hash ID subject TEXT DEFAULT '', body TEXT NOT NULL, timestamp TEXT DEFAULT (datetime('now')), read INTEGER DEFAULT 0, priority TEXT DEFAULT 'normal' ); CREATE TABLE projects ( hash TEXT PRIMARY KEY, -- 6-char ID (git root commit or path hash) name TEXT NOT NULL, -- Display name (basename of project dir) path TEXT NOT NULL, -- Canonical path registered TEXT DEFAULT (datetime('now')) ); ``` ## Troubleshooting | Issue | Fix | |-------|-----| | `sqlite3: not found` | Ships with macOS, Linux, and Git Bash on Windows. Run `sqlite3 --version` to check. | | Hook not firing | Ensure `hooks` block is in `~/.claude/settings.json` (Step 2 above) | | Hook fires but no notification | Working as intended - hook is silent when inbox is empty | | Mail is unread but Claude never mentions it | The hook must print JSON `additionalContext`, because plain PreToolUse stdout only reaches the debug log. Reinstall `check-mail.sh` from this repo and check `jq --version` | | Messages not arriving | Target must be a known name, hash, or path. Use `pigeon projects` to see registered projects | | Upgraded from basename IDs | Run `pigeon migrate` to convert old messages to hash-based IDs | | Changed display name | Use `pigeon alias old-name new-name` to update the project's display name | | Want to disable for one project | `touch .claude/pigeon.disable` in that project's root | | Check your project ID | Run `pigeon id` to see name and 6-char hash |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.