
Dual Channel Watchexec
- 85 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Helps with ai & agent building tasks.
About
dual-channel-watchexec is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- dual-channel-watchexec
- AI & Agent Building
- AI-coding skill
Dual Channel Watchexec by the numbers
- 85 all-time installs (skills.sh)
- Ranked #5,069 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill dual-channel-watchexecAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 85 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Dual-Channel Watchexec Notifications
Send reliable notifications to both Telegram and Pushover when watchexec detects file changes or process crashes.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
When to Use This Skill
Use this skill when:
- Setting up file change monitoring with notifications
- Implementing process crash alerting via Telegram and Pushover
- Creating watchexec wrappers with dual-channel notification support
- Formatting messages for both HTML (Telegram) and plain text (Pushover)
- Troubleshooting notification delivery or formatting issues
Core Pattern
watchexec wrapper script → detect event → notify-script → Telegram + Pushover
# wrapper.sh - Monitors process and detects restart reasons
watchexec --restart -- python bot.py
# On event, call:
notify-script.sh <reason> <exit_code> <watchexec_info_file> <crash_context>---
Critical Rule: Format Differences
Telegram: HTML mode ONLY
MESSAGE="<b>Alert</b>: <code>file.py</code>"
# Escape 3 chars: & → &, < → <, > → >Pushover: Plain text ONLY
/usr/bin/env bash << 'SKILL_SCRIPT_EOF'
# Strip HTML tags before sending
MESSAGE_PLAIN=$(echo "$MESSAGE_HTML" | sed 's/<[^>]*>//g')
SKILL_SCRIPT_EOFWhy HTML for Telegram:
- Markdown requires escaping 40+ chars (
.,-,_, etc.) - HTML only requires escaping 3 chars (
&,<,>) - Industry best practice
---
Quick Reference
Send to Both Channels
/usr/bin/env bash << 'SKILL_SCRIPT_EOF_2'
# 1. Build HTML message for Telegram
MESSAGE_HTML="<b>File</b>: <code>handler_classes.py</code>"
# 2. Strip HTML for Pushover
MESSAGE_PLAIN=$(echo "$MESSAGE_HTML" | sed 's/<[^>]*>//g')
# 3. Send to Telegram with HTML
curl -s -d "chat_id=$CHAT_ID" \
-d "text=$MESSAGE_HTML" \
-d "parse_mode=HTML" \
https://api.telegram.org/bot$BOT_TOKEN/sendMessage
# 4. Send to Pushover with plain text
curl -s --form-string "message=$MESSAGE_PLAIN" \
https://api.pushover.net/1/messages.json
SKILL_SCRIPT_EOF_2Execution Pattern
# Fire-and-forget background notifications (don't block restarts)
"$NOTIFY_SCRIPT" "crash" "$EXIT_CODE" "$INFO_FILE" "$CONTEXT_FILE" &---
Validation Checklist
Before deploying:
- [ ] Using HTML parse mode for Telegram (not Markdown)
- [ ] HTML tags stripped for Pushover (plain text only)
- [ ] HTML escaping applied to all dynamic content (
&,<,>) - [ ] Credentials loaded from env vars/Doppler (not hardcoded)
- [ ] Message archiving enabled for debugging
- [ ] File detection uses
stat(notfind -newermt) - [ ] Heredocs use unquoted delimiters for variable expansion
- [ ] Notifications run in background (fire-and-forget)
- [ ] Tested with files containing special chars (
_,.,-) - [ ] Both Telegram and Pushover successfully receiving
---
Summary
Key Lessons:
1. Always use HTML mode for Telegram (simpler escaping) 2. Always strip HTML tags for Pushover (plain text only) 3. Escape only 3 chars in HTML: & → &, < → <, > → > 4. Archive messages before sending for debugging 5. Use stat for file detection on macOS (not find -newermt) 6. Load credentials from env vars/Doppler (never hardcode) 7. Fire-and-forget background notifications (don't block restarts)
---
Reference Documentation
For detailed information, see:
- Telegram HTML - HTML mode formatting and message templates
- Pushover Integration - API calls and priority levels
- Credential Management - Doppler, env vars, and keychain patterns
- Watchexec Patterns - File detection and restart reason detection
- Common Pitfalls - HTML tags in Pushover, escaping issues, macOS compatibility
Bundled Examples:
examples/notify-restart.sh- Complete dual-channel notification scriptexamples/bot-wrapper.sh- watchexec wrapper with restart detectionexamples/setup-example.sh- Setup guide and installation steps
---
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Telegram escaping errors | Using Markdown instead of HTML | Switch to HTML mode with parse_mode=HTML |
| Pushover shows HTML tags | HTML not stripped | Strip tags with sed before sending to Pushover |
| Notifications not arriving | Credentials missing | Verify BOT_TOKEN, CHAT_ID, Pushover keys |
| Special chars broken | Missing HTML escaping | Escape &, <, > in dynamic content |
| find -newermt fails macOS | macOS incompatibility | Use stat for file detection instead |
| Notifications blocking | Not running in background | Add & to run notify script fire-and-forget |
| Duplicate notifications | Restart loop | Add debounce logic or cooldown period |
| Missing crash context | Context file not written | Verify watchexec info file path exists |
Post-Execution Reflection
After this skill completes, check before closing:
1. Did the command succeed? — If not, fix the instruction or error table that caused the failure. 2. Did parameters or output change? — If the underlying tool's interface drifted, update Usage examples and Parameters table to match. 3. Was a workaround needed? — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.
Only update if the issue is real and reproducible — not speculative.
#!/usr/bin/env bash
# watchexec Wrapper Script with Restart Detection
# Wraps a process to detect restart reasons and send notifications
#
# Usage: watchexec --restart -- ./bot-wrapper.sh
#
# This is a self-contained example. Adapt for your project.
set -euo pipefail
# ============================================================================
# CONFIGURATION - Adapt these for your project
# ============================================================================
# Path to your main script/process
MAIN_SCRIPT="${MAIN_SCRIPT:-./bot.py}"
# Path to notification script
NOTIFY_SCRIPT="${NOTIFY_SCRIPT:-./notify-restart.sh}"
# Log files
BOT_LOG="${BOT_LOG:-./logs/bot.log}"
CRASH_LOG="${CRASH_LOG:-./logs/crash.log}"
# Runtime state
FIRST_RUN_MARKER="/tmp/watchexec_first_run_$$"
WATCHEXEC_INFO_FILE="/tmp/watchexec_info_$$.json"
# Directories to watch for file changes
WATCH_DIRS=(
./src
./lib
)
mkdir -p "$(dirname "$BOT_LOG")"
mkdir -p "$(dirname "$CRASH_LOG")"
# ============================================================================
# FILE CHANGE DETECTION (macOS Compatible)
# ============================================================================
# Get current time in seconds since epoch
NOW=$(date +%s)
# Find most recently modified file in watched directories
MOST_RECENT_FILE=""
MOST_RECENT_TIME=0
for dir in "${WATCH_DIRS[@]}"; do
if [[ -d "$dir" ]]; then
while IFS= read -r file; do
if [[ -f "$file" ]]; then
# Get file modification time (macOS: -f %m, Linux: -c %Y)
FILE_MTIME=$(stat -f %m "$file" 2>/dev/null || stat -c %Y "$file" 2>/dev/null || echo "0")
AGE=$((NOW - FILE_MTIME))
# If file was modified in last 60 seconds and is newer than current best
if [[ $AGE -lt 60 ]] && [[ $FILE_MTIME -gt $MOST_RECENT_TIME ]]; then
MOST_RECENT_FILE="$file"
MOST_RECENT_TIME=$FILE_MTIME
echo "📝 Found recently modified file: $(basename "$file") (${AGE}s ago)"
fi
fi
done < <(find "$dir" -name "*.py" -o -name "*.sh" -type f 2>/dev/null)
fi
done
if [[ -n "$MOST_RECENT_FILE" ]]; then
CHANGED_FILE=$(basename "$MOST_RECENT_FILE")
RECENT_CHANGE_FULL="$MOST_RECENT_FILE"
AGE=$((NOW - MOST_RECENT_TIME))
echo "✅ Detected file change: $CHANGED_FILE (${AGE}s ago, path: $RECENT_CHANGE_FULL)"
# Create watchexec info JSON - atomic write using mktemp + mv
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
tmp=$(mktemp)
cat > "$tmp" <<WATCHEXEC_EOF
{
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"watchexec": {
"common_path": "$RECENT_CHANGE_FULL",
"created_path": "",
"removed_path": "",
"renamed_path": "",
"written_path": "$RECENT_CHANGE_FULL",
"meta_changed_path": "",
"otherwise_changed_path": ""
},
"environment": {
"user": "$(whoami)",
"shell": "$SHELL",
"pwd": "$(pwd)"
}
}
WATCHEXEC_EOF
mv "$tmp" "$WATCHEXEC_INFO_FILE"
else
echo "⚠️ No recently modified files detected (checked last 60s)"
# Create empty watchexec info - atomic write
tmp=$(mktemp)
echo "{}" > "$tmp"
mv "$tmp" "$WATCHEXEC_INFO_FILE"
fi
# ============================================================================
# DETERMINE RESTART REASON
# ============================================================================
# Atomic first-run check using mkdir (mkdir is atomic, fails if exists)
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
if mkdir "$FIRST_RUN_MARKER" 2>/dev/null; then
REASON="startup"
echo "🚀 First run - sending startup notification"
"$NOTIFY_SCRIPT" "$REASON" 0 "$WATCHEXEC_INFO_FILE" "" &
else
# After first run, assume code_change (watchexec restart)
# Will be updated to "crash" if exit code != 0
REASON="code_change"
echo "🔄 Code change detected - restarting process"
"$NOTIFY_SCRIPT" "$REASON" 0 "$WATCHEXEC_INFO_FILE" "" &
fi
# ============================================================================
# RUN THE MAIN PROCESS
# ============================================================================
echo "▶️ Starting process: $MAIN_SCRIPT"
# Clear previous crash log - safe truncation with existence check
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
: > "$CRASH_LOG" # ':' is a no-op, ensures file exists and is truncated
# Run the main script and capture exit code and stderr
EXIT_CODE=0
if [[ "$MAIN_SCRIPT" == *.py ]]; then
# Python script
python3 "$MAIN_SCRIPT" 2> >(tee -a "$CRASH_LOG" >&2) || EXIT_CODE=$?
elif [[ "$MAIN_SCRIPT" == *.sh ]]; then
# Shell script
bash "$MAIN_SCRIPT" 2> >(tee -a "$CRASH_LOG" >&2) || EXIT_CODE=$?
else
# Generic executable
"$MAIN_SCRIPT" 2> >(tee -a "$CRASH_LOG" >&2) || EXIT_CODE=$?
fi
# ============================================================================
# HANDLE CRASH (if exit code != 0)
# ============================================================================
if [[ $EXIT_CODE -ne 0 ]]; then
echo "💥 Process crashed with exit code: $EXIT_CODE"
# Capture crash context - atomic write using mktemp + mv
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
CRASH_CONTEXT="/tmp/crash_context_$$.txt"
tmp=$(mktemp)
{
# Last 20 lines of bot log
if [[ -f "$BOT_LOG" ]]; then
echo "--- BOT LOG (last 20 lines) ---"
tail -20 "$BOT_LOG" 2>/dev/null || true
fi
# Stderr from crash
if [[ -f "$CRASH_LOG" && -s "$CRASH_LOG" ]]; then
echo "--- STDERR ---"
tail -10 "$CRASH_LOG" 2>/dev/null || true
fi
} > "$tmp"
mv "$tmp" "$CRASH_CONTEXT"
# Send crash notification (background, non-blocking)
"$NOTIFY_SCRIPT" "crash" "$EXIT_CODE" "$WATCHEXEC_INFO_FILE" "$CRASH_CONTEXT" &
# Wait for background notification to complete before exit
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
wait
# Exit with same code (watchexec will restart)
exit $EXIT_CODE
fi
echo "✅ Process exited cleanly (exit code: 0)"
# Wait for any background notifications to complete
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
wait
#!/usr/bin/env bash
# Dual-Channel Notification Script (Telegram + Pushover)
# Usage: notify-restart.sh <reason> [exit_code] [watchexec_info_file] [crash_context_file]
#
# This is a self-contained example demonstrating the pattern.
# Adapt paths and credentials loading for your project.
set -euo pipefail
# ============================================================================
# IDEMPOTENCY HELPERS
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
# ============================================================================
# Log rotation - keep last N logs to prevent unbounded growth
LOG_ROTATION_KEEP_COUNT=5
rotate_log() {
local log_file="$1"
local keep_count="${2:-$LOG_ROTATION_KEEP_COUNT}"
if [ -f "$log_file" ]; then
mv "$log_file" "${log_file}.$(date +%s)"
# Keep only last N logs.
# shellcheck disable=SC2012
# SC2012 false-positive: ls -t (mtime-sort) is the right tool here —
# find does NOT sort by mtime in a portable, single-pass way on all
# platforms (would require a second `sort -k` pass + stat parsing).
# The glob `${log_file}.*` is the safety boundary; ls is just sorting.
ls -t "${log_file}."* 2>/dev/null | tail -n +$((keep_count + 1)) | xargs rm -f 2>/dev/null || true
fi
}
# Trap for cleanup - will be set after MESSAGE_FILE is created
MESSAGE_FILE=""
cleanup() {
[[ -n "$MESSAGE_FILE" ]] && rm -f "$MESSAGE_FILE"
}
trap cleanup EXIT
# ============================================================================
# CONFIGURATION - Adapt these for your project
# ============================================================================
# Log output location
NOTIFICATION_LOG="${NOTIFICATION_LOG:-./logs/bot-notifications.log}"
mkdir -p "$(dirname "$NOTIFICATION_LOG")"
rotate_log "$NOTIFICATION_LOG" "$LOG_ROTATION_KEEP_COUNT"
exec >> "$NOTIFICATION_LOG" 2>&1
# Message archive directory
MESSAGE_ARCHIVE_DIR="${MESSAGE_ARCHIVE_DIR:-./logs/notification-archive}"
mkdir -p "$MESSAGE_ARCHIVE_DIR"
# ============================================================================
# ARGUMENTS
# ============================================================================
REASON="${1:-unknown}"
EXIT_CODE="${2:-0}"
WATCHEXEC_INFO_FILE="${3:-}"
CRASH_CONTEXT_FILE="${4:-}"
# ============================================================================
# METADATA
# ============================================================================
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S %Z')
HOSTNAME_SHORT="$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo 'unknown')"
PID="$$"
echo "========================================================================"
echo "🔔 Notification - $TIMESTAMP"
echo "========================================================================"
# ============================================================================
# PARSE WATCHEXEC INFO (if available)
# ============================================================================
WATCHEXEC_DETAILS=""
CHANGED_FILES=""
if [[ -f "$WATCHEXEC_INFO_FILE" ]]; then
echo "📊 Watchexec diagnostic info available"
# Extract file change information using jq
if command -v jq >/dev/null 2>&1; then
COMMON_PATH=$(jq -r '.watchexec.common_path // ""' "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "")
WRITTEN_PATH=$(jq -r '.watchexec.written_path // ""' "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "")
CREATED_PATH=$(jq -r '.watchexec.created_path // ""' "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "")
REMOVED_PATH=$(jq -r '.watchexec.removed_path // ""' "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "")
# Build file change summary (HTML format - escape <, >, &)
if [[ -n "$WRITTEN_PATH" ]]; then
FILENAME=$(basename "$WRITTEN_PATH" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
CHANGED_FILES="Modified: <code>$FILENAME</code>"
TRIGGER_PATH="$WRITTEN_PATH"
elif [[ -n "$CREATED_PATH" ]]; then
FILENAME=$(basename "$CREATED_PATH" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
CHANGED_FILES="Created: <code>$FILENAME</code>"
TRIGGER_PATH="$CREATED_PATH"
elif [[ -n "$REMOVED_PATH" ]]; then
FILENAME=$(basename "$REMOVED_PATH" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
CHANGED_FILES="Deleted: <code>$FILENAME</code>"
TRIGGER_PATH="$REMOVED_PATH"
elif [[ -n "$COMMON_PATH" ]]; then
FILENAME=$(basename "$COMMON_PATH" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
CHANGED_FILES="Changed: <code>$FILENAME</code>"
TRIGGER_PATH="$COMMON_PATH"
else
CHANGED_FILES="Watchexec detected change (file not identified)"
TRIGGER_PATH="(file detection failed)"
fi
# HTML escape the full path
TRIGGER_PATH_ESCAPED=$(echo "$TRIGGER_PATH" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
WATCHEXEC_DETAILS="
<b>Trigger</b>: <code>$TRIGGER_PATH_ESCAPED</code>
<b>Action</b>: $CHANGED_FILES"
else
WATCHEXEC_DETAILS="
<i>Watchexec info available (jq not installed)</i>"
fi
fi
# ============================================================================
# EXTRACT CRASH CONTEXT (if available)
# ============================================================================
CRASH_INFO=""
if [[ -f "$CRASH_CONTEXT_FILE" ]]; then
echo "💥 Crash context available"
# Read last error lines and HTML escape them
CRASH_PREVIEW=$(tail -5 "$CRASH_CONTEXT_FILE" 2>/dev/null | sed 's/&/\&/g; s/</\</g; s/>/\>/g' || echo "(no context)")
CRASH_INFO="
<b>Last Log Lines</b>:
<pre>$CRASH_PREVIEW</pre>"
fi
# ============================================================================
# DETERMINE RESTART TYPE AND EMOJI
# ============================================================================
if [[ "$REASON" == "startup" ]]; then
EMOJI="🚀"
STATUS="Started"
PUSHOVER_SOUND="cosmic"
PUSHOVER_PRIORITY=0
elif [[ "$REASON" == "code_change" ]]; then
EMOJI="🔄"
STATUS="Restarted (code change)"
PUSHOVER_SOUND="bike"
PUSHOVER_PRIORITY=0
elif [[ "$REASON" == "crash" ]]; then
EMOJI="💥"
STATUS="Restarted (crash)"
PUSHOVER_SOUND="siren"
PUSHOVER_PRIORITY=1 # High priority, bypasses quiet hours
else
EMOJI="⚠️"
STATUS="Restarted ($REASON)"
PUSHOVER_SOUND="cosmic"
PUSHOVER_PRIORITY=0
fi
# ============================================================================
# BUILD TELEGRAM MESSAGE (HTML FORMAT)
# ============================================================================
# Use HTML parse mode - only need to escape 3 chars: & < >
MESSAGE="$EMOJI <b>Service $STATUS</b>
<b>Host</b>: <code>$HOSTNAME_SHORT</code>
<b>Time</b>: $TIMESTAMP
<b>PID</b>: $PID
<b>Exit Code</b>: $EXIT_CODE$WATCHEXEC_DETAILS$CRASH_INFO
<i>Monitoring: watchexec</i>"
# ============================================================================
# ARCHIVE MESSAGE (for debugging)
# ============================================================================
# Nanosecond precision to prevent filename collisions
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
MESSAGE_ARCHIVE_FILE="$MESSAGE_ARCHIVE_DIR/$(date '+%Y%m%d-%H%M%S-%N')-$REASON-$PID.txt"
cat > "$MESSAGE_ARCHIVE_FILE" <<ARCHIVE_EOF
========================================================================
Notification Archive
========================================================================
Timestamp: $TIMESTAMP
Reason: $REASON
Exit Code: $EXIT_CODE
Host: $HOSTNAME_SHORT
PID: $PID
--- TELEGRAM MESSAGE ---
$MESSAGE
--- VARIABLES ---
WATCHEXEC_DETAILS: ${WATCHEXEC_DETAILS:-<empty>}
CRASH_INFO: ${CRASH_INFO:-<empty>}
CHANGED_FILES: ${CHANGED_FILES:-<empty>}
--- WATCHEXEC INFO FILE ---
$(cat "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "Not available")
--- CRASH CONTEXT FILE ---
$(cat "$CRASH_CONTEXT_FILE" 2>/dev/null || echo "Not available")
========================================================================
ARCHIVE_EOF
echo "📝 Message archived: $MESSAGE_ARCHIVE_FILE"
# ============================================================================
# SEND TO TELEGRAM (HTML MODE)
# ============================================================================
if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_CHAT_ID:-}" ]]; then
echo "📱 Sending Telegram notification..."
# Create temp file using mktemp to avoid race conditions
# ADR: /docs/adr/2025-12-07-idempotency-backup-traceability.md
MESSAGE_FILE=$(mktemp /tmp/telegram_message.XXXXXX)
cat > "$MESSAGE_FILE" <<MSGEOF
$MESSAGE
MSGEOF
# Use Python for reliable Telegram API call with HTML parse mode
python3 - "$MESSAGE_FILE" <<'EOF'
import os
import urllib.request
import urllib.parse
import json
import sys
bot_token = os.environ.get('TELEGRAM_BOT_TOKEN')
chat_id = os.environ.get('TELEGRAM_CHAT_ID')
# Read message from file to avoid shell escaping issues
with open(sys.argv[1], 'r') as f:
message = f.read()
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data = {
'chat_id': chat_id,
'text': message,
'parse_mode': 'HTML' # Use HTML mode (NOT Markdown)
}
try:
req = urllib.request.Request(
url,
data=json.dumps(data).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
if result.get('ok'):
print(' ✅ Telegram notification sent')
else:
print(f' ❌ Telegram API error: {result}')
print(f' Message preview: {message[:200]}...')
except Exception as e:
print(f' ❌ Failed to send Telegram notification: {e}')
print(f' Message preview: {message[:200]}...')
EOF
rm -f "$MESSAGE_FILE"
else
echo " ⏭️ Skipping Telegram (TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID not set)"
fi
# ============================================================================
# SEND TO PUSHOVER
# ============================================================================
if [[ -n "${PUSHOVER_APP_TOKEN:-}" ]] && [[ -n "${PUSHOVER_USER_KEY:-}" ]]; then
echo "📲 Sending Pushover notification..."
PUSHOVER_TITLE="Service $STATUS"
# Build Pushover message (plain text, no HTML)
PUSHOVER_MESSAGE="Host: $HOSTNAME_SHORT
Time: $TIMESTAMP
PID: $PID
Exit: $EXIT_CODE"
# Add file change info if available (strip HTML tags for Pushover).
# shellcheck disable=SC2001
# SC2001 false-positive: bash's ${VAR//pattern/replacement} uses GLOB
# patterns, not regex. The shellcheck-suggested replacement `${VAR//<*>/}`
# would be GREEDY (matches `<a>foo<b>` as one match from first `<` to
# last `>`), which is a behavioral regression vs the original sed regex
# `<[^>]*>` (non-greedy via negated character class). The non-greedy
# semantics require either `shopt -s extglob` + `<*([^>])>` (changes
# script-wide glob behavior, risky) or keeping the sed pipeline. The
# sed form is the correct tool here.
if [[ -n "$CHANGED_FILES" ]]; then
CHANGED_FILES_PLAIN=$(echo "$CHANGED_FILES" | sed 's/<[^>]*>//g')
PUSHOVER_MESSAGE="$PUSHOVER_MESSAGE
File: $CHANGED_FILES_PLAIN"
fi
# Add crash preview if available (first 2 lines only)
if [[ -f "$CRASH_CONTEXT_FILE" ]]; then
CRASH_SHORT=$(tail -2 "$CRASH_CONTEXT_FILE" 2>/dev/null | tr '\n' ' ' || echo "")
if [[ -n "$CRASH_SHORT" ]]; then
PUSHOVER_MESSAGE="$PUSHOVER_MESSAGE
Error: ${CRASH_SHORT:0:200}"
fi
fi
# Send with curl
DEVICE_NAME="${PUSHOVER_DEVICE:-default}"
curl -s \
--form-string "token=$PUSHOVER_APP_TOKEN" \
--form-string "user=$PUSHOVER_USER_KEY" \
--form-string "device=$DEVICE_NAME" \
--form-string "title=$PUSHOVER_TITLE" \
--form-string "message=$PUSHOVER_MESSAGE" \
--form-string "sound=$PUSHOVER_SOUND" \
--form-string "priority=$PUSHOVER_PRIORITY" \
https://api.pushover.net/1/messages.json >/dev/null 2>&1
if [[ $? -eq 0 ]]; then
echo " ✅ Pushover notification sent (priority: $PUSHOVER_PRIORITY, sound: $PUSHOVER_SOUND)"
else
echo " ❌ Pushover notification failed"
fi
else
echo " ⏭️ Skipping Pushover (PUSHOVER_APP_TOKEN or PUSHOVER_USER_KEY not set)"
fi
echo "✅ Notification completed"
#!/usr/bin/env bash
# Example Setup Script
# Shows how to set up dual-channel watchexec notifications for your project
set -euo pipefail
# ============================================================================
# STEP 1: Install Dependencies
# ============================================================================
echo "📦 Installing dependencies..."
# Ensure watchexec is installed
if ! command -v watchexec >/dev/null 2>&1; then
echo "Installing watchexec..."
# macOS
if [[ "$(uname)" == "Darwin" ]]; then
brew install watchexec
# Linux
else
cargo install watchexec-cli
fi
fi
# Ensure jq is installed (for parsing watchexec JSON)
if ! command -v jq >/dev/null 2>&1; then
echo "Installing jq..."
if [[ "$(uname)" == "Darwin" ]]; then
brew install jq
else
sudo apt-get install -y jq || sudo yum install -y jq
fi
fi
# ============================================================================
# STEP 2: Set Up Credentials
# ============================================================================
echo ""
echo "🔑 Setting up credentials..."
echo ""
echo "Choose credential management method:"
echo " 1) Environment variables (simple)"
echo " 2) Doppler (recommended for production)"
echo " 3) macOS Keychain"
read -p "Enter choice [1-3]: " CRED_METHOD
case "$CRED_METHOD" in
1)
echo ""
echo "Add these to your shell profile (~/.bashrc, ~/.zshrc):"
echo ""
echo "export TELEGRAM_BOT_TOKEN='your_bot_token_here'"
echo "export TELEGRAM_CHAT_ID='your_chat_id_here'"
echo "export PUSHOVER_APP_TOKEN='your_app_token_here'"
echo "export PUSHOVER_USER_KEY='your_user_key_here'"
echo "export PUSHOVER_DEVICE='device_name'"
;;
2)
echo ""
echo "Install Doppler CLI:"
echo " brew install dopplerhq/cli/doppler"
echo ""
echo "Then configure secrets:"
echo " doppler secrets set TELEGRAM_BOT_TOKEN"
echo " doppler secrets set TELEGRAM_CHAT_ID"
echo " doppler secrets set PUSHOVER_APP_TOKEN"
echo " doppler secrets set PUSHOVER_USER_KEY"
echo ""
echo "Run your script with:"
echo " doppler run -- watchexec --restart -- ./bot-wrapper.sh"
;;
3)
echo ""
echo "Store in macOS Keychain:"
echo " security add-generic-password -s 'telegram-bot-token' -a '$USER' -w 'your_token'"
echo " security add-generic-password -s 'telegram-chat-id' -a '$USER' -w 'your_chat_id'"
echo " security add-generic-password -s 'pushover-app-token' -a '$USER' -w 'your_token'"
echo " security add-generic-password -s 'pushover-user-key' -a '$USER' -w 'your_key'"
echo ""
echo "Then load in scripts:"
echo " export TELEGRAM_BOT_TOKEN=\$(security find-generic-password -s 'telegram-bot-token' -a '$USER' -w)"
;;
esac
# ============================================================================
# STEP 3: Copy Example Scripts
# ============================================================================
echo ""
echo "📋 Copy example scripts to your project:"
echo ""
echo " cp notify-restart.sh /path/to/your/project/"
echo " cp bot-wrapper.sh /path/to/your/project/"
echo " chmod +x /path/to/your/project/*.sh"
echo ""
# ============================================================================
# STEP 4: Configure Paths
# ============================================================================
echo "📝 Edit bot-wrapper.sh to configure:"
echo ""
echo " MAIN_SCRIPT='./your-script.py' # Your main process"
echo " WATCH_DIRS=('./src' './lib') # Directories to watch"
echo " BOT_LOG='./logs/app.log' # Your log file"
echo ""
# ============================================================================
# STEP 5: Run with watchexec
# ============================================================================
echo "▶️ Run your process with watchexec:"
echo ""
echo " watchexec --restart --watch ./src --exts py -- ./bot-wrapper.sh"
echo ""
echo "Or run the wrapper directly (for testing):"
echo ""
echo " ./bot-wrapper.sh"
echo ""
# ============================================================================
# STEP 6: Test Notifications
# ============================================================================
echo "🧪 Test notifications manually:"
echo ""
echo " ./notify-restart.sh startup 0"
echo " ./notify-restart.sh code_change 0"
echo " ./notify-restart.sh crash 1"
echo ""
# ============================================================================
# STEP 7: systemd Service (Optional, Linux only)
# ============================================================================
if [[ "$(uname)" == "Linux" ]]; then
echo "🔧 Create systemd service (optional):"
echo ""
echo "File: /etc/systemd/system/myapp-watchexec.service"
echo ""
cat <<'SYSTEMD_EOF'
[Unit]
Description=My App with watchexec monitoring
After=network.target
[Service]
Type=simple
User=myuser
WorkingDirectory=/path/to/project
Environment="TELEGRAM_BOT_TOKEN=your_token"
Environment="TELEGRAM_CHAT_ID=your_chat_id"
Environment="PUSHOVER_APP_TOKEN=your_token"
Environment="PUSHOVER_USER_KEY=your_key"
ExecStart=/usr/local/bin/watchexec --restart --watch ./src --exts py -- ./bot-wrapper.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SYSTEMD_EOF
echo ""
echo "Enable and start:"
echo " sudo systemctl enable myapp-watchexec"
echo " sudo systemctl start myapp-watchexec"
echo ""
fi
# ============================================================================
# DONE
# ============================================================================
echo "✅ Setup complete!"
echo ""
echo "Next steps:"
echo " 1. Configure credentials"
echo " 2. Copy and customize scripts"
echo " 3. Test notifications manually"
echo " 4. Run with watchexec"
echo ""
Implementation Reference
Detailed implementation notes for dual-channel watchexec notifications.
Directory Structure
your-project/
├── notify-restart.sh # Dual-channel notification script
├── bot-wrapper.sh # watchexec wrapper with restart detection
├── your-app.py # Your main application
└── logs/
├── bot-notifications.log # Notification execution log
└── notification-archive/ # Pre-send message archives
└── YYYYMMDD-HHMMSS-reason-PID.txtComplete Example Scripts
All examples are available in this skill's examples/ directory:
examples/notify-restart.sh- Dual-channel notification scriptexamples/bot-wrapper.sh- watchexec wrapperexamples/setup-example.sh- Complete setup guide
notify-restart.sh Deep Dive
Script Architecture
Arguments → Parse watchexec info → Build HTML message → Archive → Send (Telegram + Pushover)Key Implementation Details
1. HTML Escaping Function
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF'
# Escape only 3 characters for HTML: & < >
ESCAPED=$(echo "$text" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
REFERENCE_SCRIPT_EOFWhy this works:
- HTML only treats
&,<,>as special - Markdown requires escaping 40+ chars (
.,-,_,*, etc.) - Simpler = more reliable
2. Heredoc Variable Expansion
WRONG (literal $MESSAGE sent):
cat > "$FILE" <<'MSGEOF'
$MESSAGE
MSGEOFCORRECT (variable expanded):
cat > "$FILE" <<MSGEOF
$MESSAGE
MSGEOFRule: Unquoted heredoc delimiter (<<MSGEOF) allows variable expansion, quoted (<<'MSGEOF') treats as literal.
3. Python Telegram API Call
# Read message from temp file (avoids shell escaping hell)
with open(sys.argv[1], 'r') as f:
message = f.read()
data = {
'chat_id': chat_id,
'text': message,
'parse_mode': 'HTML' # Key: HTML mode
}
req = urllib.request.Request(
url,
data=json.dumps(data).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)Why Python + temp file:
- Avoids bash quote escaping complexity
- Handles Unicode properly
- Reliable JSON encoding
4. Priority and Sound Mapping
case "$REASON" in
startup)
EMOJI="🚀"
PUSHOVER_SOUND="cosmic"
PUSHOVER_PRIORITY=0 # Normal
;;
code_change)
EMOJI="🔄"
PUSHOVER_SOUND="bike"
PUSHOVER_PRIORITY=0 # Normal
;;
crash)
EMOJI="💥"
PUSHOVER_SOUND="siren"
PUSHOVER_PRIORITY=1 # High (bypasses quiet hours)
;;
esacPushover Priority Levels:
0: Normal (respects quiet hours, default sound)1: High (bypasses quiet hours, requires acknowledgment)2: Emergency (repeats until acknowledged)
5. Message Archiving
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_2'
MESSAGE_ARCHIVE_FILE="$MESSAGE_ARCHIVE_DIR/$(date '+%Y%m%d-%H%M%S')-$REASON-$PID.txt"
cat > "$MESSAGE_ARCHIVE_FILE" <<ARCHIVE_EOF
========================================================================
Notification Archive
========================================================================
Timestamp: $TIMESTAMP
Reason: $REASON
--- TELEGRAM MESSAGE ---
$MESSAGE
--- WATCHEXEC INFO FILE ---
$(cat "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "Not available")
--- CRASH CONTEXT FILE ---
$(cat "$CRASH_CONTEXT_FILE" 2>/dev/null || echo "Not available")
========================================================================
ARCHIVE_EOF
REFERENCE_SCRIPT_EOF_2Why archive:
- Post-mortem debugging (what was actually sent?)
- Audit trail
- Reproducing Telegram 400 errors
- ~5ms overhead per notification
bot-wrapper.sh Deep Dive
Restart Detection Logic
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_3'
FIRST_RUN_MARKER="/tmp/watchexec_first_run_$$"
if [[ ! -f "$FIRST_RUN_MARKER" ]]; then
REASON="startup"
touch "$FIRST_RUN_MARKER"
else
REASON="code_change"
fi
# Run process
EXIT_CODE=0
python3 "$MAIN_SCRIPT" || EXIT_CODE=$?
# Update reason if crashed
if [[ $EXIT_CODE -ne 0 ]]; then
REASON="crash"
# Send crash notification
"$NOTIFY_SCRIPT" "crash" "$EXIT_CODE" "$INFO_FILE" "$CRASH_CONTEXT" &
fi
REFERENCE_SCRIPT_EOF_3State transitions:
1. First run: startup → notify, create marker 1. watchexec restart (exit=0): code_change → notify 1. Process crash (exit≠0): crash → notify with context
File Change Detection (macOS Compatible)
Problem: find -newermt syntax differs on BSD (macOS) vs GNU (Linux)
Solution: Use stat to check modification time directly
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_4'
NOW=$(date +%s)
FILE_MTIME=$(stat -f %m "$file" 2>/dev/null || stat -c %Y "$file" 2>/dev/null)
AGE=$((NOW - FILE_MTIME))
if [[ $AGE -lt 60 ]]; then
echo "File modified ${AGE}s ago"
fi
REFERENCE_SCRIPT_EOF_4Platform compatibility:
- macOS:
stat -f %m(BSD stat) - Linux:
stat -c %Y(GNU stat) - Fallback with
||ensures portability
Crash Context Capture
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_5'
CRASH_CONTEXT="/tmp/crash_context_$$.txt"
# Last 20 lines of main log
tail -20 "$BOT_LOG" > "$CRASH_CONTEXT"
# Last 10 lines of stderr
if [[ -f "$CRASH_LOG" ]]; then
echo "--- STDERR ---" >> "$CRASH_CONTEXT"
tail -10 "$CRASH_LOG" >> "$CRASH_CONTEXT"
fi
# Send in background (non-blocking)
"$NOTIFY_SCRIPT" "crash" "$EXIT_CODE" "$INFO_FILE" "$CRASH_CONTEXT" &
REFERENCE_SCRIPT_EOF_5Why last N lines:
- Crash often has error at end of log
- Keeps notification message short
- Full logs available on server for deep debugging
Credential Management Patterns
Pattern 1: Environment Variables (Simple)
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_6'
# ~/.bashrc or ~/.zshrc
export TELEGRAM_BOT_TOKEN="1234567890:ABC..."
export TELEGRAM_CHAT_ID="-1001234567890"
export PUSHOVER_APP_TOKEN="azGDORePK8gMa..."
export PUSHOVER_USER_KEY="uQiRzpo4DXghD..."
# In script
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
echo "Error: TELEGRAM_BOT_TOKEN not set"
exit 1
fi
REFERENCE_SCRIPT_EOF_6Pros: Simple, works everywhere Cons: Visible in ps, stored in shell history
Pattern 2: Doppler (Recommended for Production)
For Pushover (notifications/dev):
/usr/bin/env bash << 'SETUP_EOF'
# Install Doppler CLI
brew install dopplerhq/cli/doppler
# Load Pushover credentials from dedicated project
export PUSHOVER_APP_TOKEN=$(doppler secrets get PUSHOVER_APP_TOKEN \
--project notifications \
--config dev \
--plain)
export PUSHOVER_USER_KEY=$(doppler secrets get PUSHOVER_USER_KEY \
--project notifications \
--config dev \
--plain)
# Run with Doppler
doppler run --project notifications --config dev -- \
watchexec --restart -- ./bot-wrapper.sh
SETUP_EOFFor Telegram (generic):
/usr/bin/env bash << 'DOPPLER_EOF'
# Set secrets
doppler secrets set TELEGRAM_BOT_TOKEN --value "..."
doppler secrets set TELEGRAM_CHAT_ID --value "..."
# Load in script
export TELEGRAM_BOT_TOKEN=$(doppler secrets get TELEGRAM_BOT_TOKEN --plain)
export TELEGRAM_CHAT_ID=$(doppler secrets get TELEGRAM_CHAT_ID --plain)
DOPPLER_EOFPros: Encrypted, team sync, audit trail, rotation Cons: Requires Doppler account
Pattern 3: macOS Keychain
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_7'
# Store secret
security add-generic-password \
-s 'telegram-bot-token' \
-a "$USER" \
-w 'your_token_here'
# Load in script
TELEGRAM_BOT_TOKEN=$(security find-generic-password \
-s 'telegram-bot-token' \
-a "$USER" \
-w)
REFERENCE_SCRIPT_EOF_7Pros: OS-level encryption, native macOS Cons: macOS only, no team sync
Pattern 4: systemd Environment File (Linux)
# /etc/systemd/system/myapp.service.d/env.conf
[Service]
EnvironmentFile=/etc/myapp/secrets.env
# /etc/myapp/secrets.env (chmod 600)
TELEGRAM_BOT_TOKEN=1234567890:ABC...
TELEGRAM_CHAT_ID=-1001234567890Pros: systemd integration, file permissions Cons: Linux only, manual rotation
watchexec Configuration
Basic Usage
# Watch ./src directory, restart on .py file changes
watchexec --restart --watch ./src --exts py -- ./bot-wrapper.shAdvanced Options
# Watch multiple directories
watchexec \
--restart \
--watch ./src \
--watch ./lib \
--watch ./config \
--exts py,yaml \
--ignore '*.pyc' \
--ignore '__pycache__' \
-- ./bot-wrapper.shDiagnostic Output
# Export watchexec events to JSON (for debugging)
watchexec \
--restart \
--watch ./src \
--emit-events-to json \
-- ./bot-wrapper.shWith Delay (Debouncing)
# Wait 2s after file change before restarting (debounce rapid edits)
watchexec \
--restart \
--watch ./src \
--debounce 2000 \
-- ./bot-wrapper.shHTML Message Construction
HTML Tags Supported by Telegram
| Tag | Purpose | Example |
|---|---|---|
<b> | Bold | <b>Alert</b> |
<strong> | Bold (alt) | <strong>Alert</strong> |
<i> | Italic | <i>monitoring</i> |
<em> | Italic (alt) | <em>monitoring</em> |
<code> | Inline code | <code>file.py</code> |
<pre> | Code block | <pre>error log</pre> |
<a href=""> | Link | <a href="https://...">Link</a> |
Not supported: <h1>, <div>, <span>, CSS, JavaScript
HTML Entity Escaping
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_8'
# Required escaping
& → & # Must be first to avoid double-escaping
< → <
> → >
# Escaping function
escape_html() {
echo "$1" | sed 's/&/\&/g; s/</\</g; s/>/\>/g'
}
# Usage
FILENAME=$(basename "$file" | escape_html)
MESSAGE="Modified: <code>$FILENAME</code>"
REFERENCE_SCRIPT_EOF_8Order matters: Always escape & first, otherwise you'll double-escape the & in < and >.
Message Template
MESSAGE="$EMOJI <b>Service $STATUS</b>
<b>Host</b>: <code>$HOSTNAME</code>
<b>Time</b>: $TIMESTAMP
<b>Exit Code</b>: $EXIT_CODE
<b>Trigger</b>: <code>$TRIGGER_PATH</code>
<b>Action</b>: $CHANGED_FILES
<i>Monitoring: watchexec</i>"Pushover Message Format
Pushover uses plain text (no HTML or Markdown):
PUSHOVER_MESSAGE="Host: $HOSTNAME
Time: $TIMESTAMP
Exit: $EXIT_CODE
File: $CHANGED_FILE"To strip HTML tags from Telegram message:
/usr/bin/env bash << 'REFERENCE_SCRIPT_EOF_9'
# Remove all <tag> and </tag>
PLAIN_TEXT=$(echo "$HTML_MESSAGE" | sed 's/<[^>]*>//g')
REFERENCE_SCRIPT_EOF_9Testing Procedures
1. Test Notification Script Directly
# Startup notification
./notify-restart.sh startup 0
# Code change notification
./notify-restart.sh code_change 0
# Crash notification (with fake context)
echo "Error: Something went wrong" > /tmp/crash_context.txt
./notify-restart.sh crash 1 "" /tmp/crash_context.txt2. Test HTML Rendering
# Message with special characters
MESSAGE="<b>Test</b>: <code>handler_classes.py</code> & <i>special_chars</i>"
# Should render correctly with underscores visible3. Test watchexec Integration
# Start watchexec
watchexec --restart --watch ./src --exts py -- ./bot-wrapper.sh
# In another terminal, trigger change
touch ./src/test.py
# Check logs
tail -f ./logs/bot-notifications.log4. Test Crash Handling
# Create script that crashes
cat > ./crash-test.py <<EOF
import sys
print("About to crash...")
sys.exit(1)
EOF
# Run wrapper
MAIN_SCRIPT=./crash-test.py ./bot-wrapper.sh
# Should send crash notification with exit code 1Troubleshooting
Telegram 400 Bad Request
Symptoms: HTTP 400 error, no message received
Common causes:
1. Unescaped HTML entities (&, <, >) 1. Unclosed HTML tags (<b>text without </b>) 1. Unsupported HTML tags (<div>, <h1>) 1. Message too long (>4096 chars)
Debug:
/usr/bin/env bash << 'PREFLIGHT_EOF'
# Check archived message
cat logs/notification-archive/$(ls -t logs/notification-archive/ | head -1)
# Validate HTML structure
echo "$MESSAGE" | grep -E '<[^>]*$' # Check for unclosed tags
PREFLIGHT_EOFFile Detection Not Working
Symptoms: Empty Trigger/Action fields
Check:
# Test stat command
stat -f %m ./src/test.py # macOS
stat -c %Y ./src/test.py # Linux
# Check watchexec info file
cat /tmp/watchexec_info_*.json
# Verify time window (default 60s)
# File must be modified within last 60sCredentials Not Loading
Check environment:
# Are variables set?
echo "$TELEGRAM_BOT_TOKEN"
env | grep TELEGRAM
# Test Doppler
doppler secrets get TELEGRAM_BOT_TOKEN --plain
# Test keychain
security find-generic-password -s 'telegram-bot-token' -a "$USER" -wNo Notifications Received
Check:
# Telegram bot token valid?
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe"
# Chat ID correct?
# Should be negative for groups: -1001234567890
# Pushover credentials valid?
curl -s \
--form-string "token=$PUSHOVER_APP_TOKEN" \
--form-string "user=$PUSHOVER_USER_KEY" \
--form-string "message=Test" \
https://api.pushover.net/1/messages.jsonPerformance Metrics
From production deployment (2025-10-29):
| Operation | Latency | Notes |
|---|---|---|
| Message archiving | ~5ms | File write to logs/ |
| HTML escaping | \<1ms | sed operations |
| Telegram API call | 200-500ms | Network dependent |
| Pushover API call | 100-300ms | Network dependent |
| Total overhead | 300-800ms | Per notification |
Fire-and-forget: Notifications run in background (&), so process restart not delayed.
Security Best Practices
1. Never log credentials: Secrets should only exist in memory 1. Restrict archive permissions: chmod 700 logs/notification-archive/ 1. No secrets in filenames: File paths appear in messages 1. Use read-only API scopes: Limit bot permissions 1. Rotate credentials: Use Doppler or similar for automated rotation 1. Validate inputs: Sanitize any user-provided data before archiving
Real-World Output Example
Telegram Message (HTML Rendered)
🔄 Service Restarted (code change)
Host: myserver
Time: 2025-10-29 22:58:21 PDT
PID: 31307
Exit Code: 0
Trigger: /app/lib/format_utils.py
Action: Modified: format_utils.py
Monitoring: watchexecArchived Message File
========================================================================
Notification Archive
========================================================================
Timestamp: 2025-10-29 22:58:21 PDT
Reason: code_change
Exit Code: 0
Host: myserver
PID: 31307
--- TELEGRAM MESSAGE ---
🔄 <b>Service Restarted (code change)</b>
<b>Host</b>: <code>myserver</code>
<b>Time</b>: 2025-10-29 22:58:21 PDT
<b>PID</b>: 31307
<b>Exit Code</b>: 0
<b>Trigger</b>: <code>/app/lib/format_utils.py</code>
<b>Action</b>: Modified: <code>format_utils.py</code>
<i>Monitoring: watchexec</i>
--- WATCHEXEC INFO FILE ---
{
"timestamp": "2025-10-30T05:58:21Z",
"watchexec": {
"written_path": "/app/lib/format_utils.py"
}
}
========================================================================Success Metrics
Production deployment results:
- ✅ 100+ notifications sent successfully
- ✅ 0 formatting errors (after HTML migration)
- ✅ 100% dual-channel delivery
- ✅ File detection: 95% accuracy (5% missing due to rapid restart \<60s window)
- ✅ Average latency: 400ms per notification
- ✅ Zero blocking (fire-and-forget background execution)
Further Reading
- Telegram Bot API: https://core.telegram.org/bots/api#html-style
- Pushover API: https://pushover.net/api
- watchexec: https://github.com/watchexec/watchexec
- Doppler: https://docs.doppler.com/docs/cli
Skill: Dual-Channel Watchexec Notifications
Common Pitfalls
Pitfall 1: Pushover Shows HTML Tags (CRITICAL)
Problem: Pushover displays literal <code>, <b>, </code> in notifications
Cause: Pushover uses plain text only - does NOT interpret HTML
Solution: Strip HTML tags before sending to Pushover
/usr/bin/env bash << 'COMMON_PITFALLS_SCRIPT_EOF'
# ❌ WRONG - Sends HTML to Pushover
PUSHOVER_MESSAGE="Modified: <code>handler_classes.py</code>"
# User sees: Modified: <code>handler_classes.py</code>
# ✅ CORRECT - Strip HTML tags
CHANGED_FILES_PLAIN=$(echo "$CHANGED_FILES" | sed 's/<[^>]*>//g')
PUSHOVER_MESSAGE="Modified: $CHANGED_FILES_PLAIN"
# User sees: Modified: handler_classes.py
COMMON_PITFALLS_SCRIPT_EOFRemember: Telegram = HTML, Pushover = Plain Text
Pitfall 2: Markdown Escaping Hell
Problem: Files with underscores (handler_classes.py) display as handlerclasses.py
Cause: Markdown treats _ as italic marker
Solution: Use HTML mode, wrap in <code> tags
/usr/bin/env bash << 'COMMON_PITFALLS_SCRIPT_EOF_2'
# ❌ WRONG (Markdown)
MESSAGE="Modified: handler_classes.py" # Renders: handlerclasses.py
# ✅ CORRECT (HTML)
FILENAME=$(basename "$file" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
MESSAGE="Modified: <code>$FILENAME</code>" # Renders: handler_classes.py
COMMON_PITFALLS_SCRIPT_EOF_2Pitfall 3: Literal Variable Names Sent
Problem: Telegram receives literal text "$MESSAGE" instead of content
Cause: Heredoc with quotes prevents variable expansion
Solution: Use heredoc WITHOUT quotes
# ❌ WRONG
cat > "$FILE" <<'MSGEOF'
$MESSAGE
MSGEOF
# ✅ CORRECT
cat > "$FILE" <<MSGEOF
$MESSAGE
MSGEOFPitfall 4: macOS File Detection Failures
Problem: Empty Trigger/Action fields, no file detected
Cause: find -newermt syntax differs on BSD (macOS) vs GNU (Linux)
Solution: Use stat instead of find -newermt
/usr/bin/env bash << 'COMMON_PITFALLS_SCRIPT_EOF_3'
# ✅ CORRECT (portable)
FILE_MTIME=$(stat -f %m "$file" 2>/dev/null || echo "0") # macOS
# For Linux: stat -c %Y "$file"
COMMON_PITFALLS_SCRIPT_EOF_3Pitfall 5: Telegram 400 Bad Request
Problem: HTTP 400 errors with "Bad Request"
Causes:
1. Missing HTML escaping (&, <, >) 2. Unclosed HTML tags 3. Invalid HTML structure
Solution: Always escape special chars, validate HTML structure
# Test message before sending
echo "$MESSAGE" | grep -E '<[^>]*$' # Check for unclosed tagsPitfall 6: Hardcoded Credentials
Problem: Secrets leaked in git, exposed in logs
Solution: Use Doppler (canonical), env vars, or keychain
/usr/bin/env bash << 'VALIDATE_EOF'
# ❌ WRONG - Hardcoded secrets
PUSHOVER_APP_TOKEN="aej7osoja3x8nvxgi96up2poxdjmfj"
TELEGRAM_BOT_TOKEN="1234567890:ABC..."
# ✅ CORRECT - Load from Doppler (canonical source)
# For Pushover (notifications/dev):
PUSHOVER_APP_TOKEN=$(doppler secrets get PUSHOVER_APP_TOKEN \
--project notifications --config dev --plain)
PUSHOVER_USER_KEY=$(doppler secrets get PUSHOVER_USER_KEY \
--project notifications --config dev --plain)
# For Telegram (claude-config/dev):
TELEGRAM_BOT_TOKEN=$(doppler secrets get TELEGRAM_BOT_TOKEN \
--project claude-config --config dev --plain)
# ✅ ALSO CORRECT - Validate env vars are set
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
if [[ -z "$TELEGRAM_BOT_TOKEN" ]]; then
echo "Error: TELEGRAM_BOT_TOKEN not set"
exit 1
fi
VALIDATE_EOFSee: credential-management.md for complete patterns
Skill: Dual-Channel Watchexec Notifications
Credential Management
Pattern 1: Doppler (Recommended)
For Pushover (notifications/dev):
/usr/bin/env bash << 'CONFIG_EOF'
# Load Pushover credentials from Doppler
export PUSHOVER_APP_TOKEN=$(doppler secrets get PUSHOVER_APP_TOKEN \
--project notifications \
--config dev \
--plain)
export PUSHOVER_USER_KEY=$(doppler secrets get PUSHOVER_USER_KEY \
--project notifications \
--config dev \
--plain)
CONFIG_EOFFor Telegram (generic example):
/usr/bin/env bash << 'DOPPLER_EOF'
# Load from Doppler project (use bash wrapper for zsh compatibility)
/usr/bin/env bash -c 'export TELEGRAM_BOT_TOKEN=$(doppler secrets get TELEGRAM_BOT_TOKEN --plain) && export TELEGRAM_CHAT_ID=$(doppler secrets get TELEGRAM_CHAT_ID --plain)'
DOPPLER_EOFPattern 2: Environment Variables
/usr/bin/env bash << 'CREDENTIAL_MANAGEMENT_SCRIPT_EOF'
# From shell environment
if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_CHAT_ID:-}" ]]; then
# Send notification
fi
CREDENTIAL_MANAGEMENT_SCRIPT_EOFPattern 3: Keychain (macOS)
/usr/bin/env bash << 'CREDENTIAL_MANAGEMENT_SCRIPT_EOF_2'
/usr/bin/env bash -c 'PUSHOVER_TOKEN=$(security find-generic-password -s "pushover-app-token" -a "username" -w 2>/dev/null)'
CREDENTIAL_MANAGEMENT_SCRIPT_EOF_2Security: Never hardcode credentials in scripts or skill files!
Evolution Log
Convention: Reverse chronological order (newest on top, oldest at bottom). Prepend new entries.
---
2026-02-26: Initial Evolution Log
Status: Skill is in use and maintained. Track improvements here.
Purpose
This evolution log tracks updates to the skill. Each entry should note:
- What changed (content, structure, tooling)
- Why it changed (bug fix, feature request, best practice)
- Files affected
How to Use
1. When updating SKILL.md or references, add an entry here with the date 2. Keep entries reverse-chronological (newest first) 3. Link to ADRs or GitHub issues when relevant 4. Reference specific line changes when helpful
---
Skill: Dual-Channel Watchexec Notifications
Credential Loading (Canonical Source)
Load from Doppler (notifications/dev):
/usr/bin/env bash << 'CONFIG_EOF'
# Canonical source for Pushover credentials
export PUSHOVER_APP_TOKEN=$(doppler secrets get PUSHOVER_APP_TOKEN \
--project notifications \
--config dev \
--plain)
export PUSHOVER_USER_KEY=$(doppler secrets get PUSHOVER_USER_KEY \
--project notifications \
--config dev \
--plain)
CONFIG_EOFSee: credential-management.md for fallback patterns (JSON config, local override)
---
API Call Pattern
curl -s \
--form-string "token=$PUSHOVER_APP_TOKEN" \
--form-string "user=$PUSHOVER_USER_KEY" \
--form-string "device=device_name" \
--form-string "title=$TITLE" \
--form-string "message=$MESSAGE" \
--form-string "sound=$SOUND" \
--form-string "priority=$PRIORITY" \
https://api.pushover.net/1/messages.jsonPriority Levels:
0: Normal (default sound, respects quiet hours)1: High (bypasses quiet hours, alert sound)
Sounds: cosmic, bike, siren, etc.
CRITICAL: Pushover Does NOT Support HTML
Pushover uses plain text only - MUST strip HTML tags before sending:
/usr/bin/env bash << 'PUSHOVER_INTEGRATION_SCRIPT_EOF'
# ❌ WRONG - Pushover will display literal HTML tags
PUSHOVER_MESSAGE="<b>Alert</b>: <code>file.py</code>"
# User sees: <b>Alert</b>: <code>file.py</code>
# ✅ CORRECT - Strip HTML tags for plain text
CHANGED_FILES_PLAIN=$(echo "$CHANGED_FILES" | sed 's/<[^>]*>//g')
PUSHOVER_MESSAGE="Alert: $CHANGED_FILES_PLAIN"
# User sees: Alert: file.py
PUSHOVER_INTEGRATION_SCRIPT_EOFWhy This Matters:
- Telegram uses HTML mode for formatting
- Pushover does NOT interpret HTML
- Sending HTML to Pushover shows ugly
<code>,<b>tags in notification - Always strip tags:
sed 's/<[^>]*>//g'
Pattern: Build message in HTML for Telegram, then strip tags for Pushover:
/usr/bin/env bash << 'PUSHOVER_INTEGRATION_SCRIPT_EOF_2'
# 1. Build HTML message for Telegram
MESSAGE_HTML="<b>File</b>: <code>handler_classes.py</code>"
# 2. Strip HTML for Pushover
MESSAGE_PLAIN=$(echo "$MESSAGE_HTML" | sed 's/<[^>]*>//g')
# Result: "File: handler_classes.py"
PUSHOVER_INTEGRATION_SCRIPT_EOF_2Skill: Dual-Channel Watchexec Notifications
Telegram: Use HTML Mode (NOT Markdown)
Why HTML Mode
Industry Best Practice:
- Markdown/MarkdownV2 requires escaping 40+ special characters (
.,-,_, etc.) - HTML only requires escaping 3 characters:
&,<,> - More reliable, simpler, less error-prone
HTML Formatting
# Python API call
data = {
'chat_id': chat_id,
'text': message,
'parse_mode': 'HTML' # NOT 'Markdown' or 'MarkdownV2'
}HTML Tags:
- Bold:
<b>text</b> - Code:
<code>text</code> - Italic:
<i>text</i> - Code blocks:
<pre>text</pre>
HTML Escaping (Bash):
/usr/bin/env bash << 'TELEGRAM_HTML_SCRIPT_EOF'
# Escape special chars before sending
ESCAPED=$(echo "$text" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
MESSAGE="<b>Alert</b>: <code>$ESCAPED</code>"
TELEGRAM_HTML_SCRIPT_EOFMessage Template
Simplified format:
/usr/bin/env bash << 'TELEGRAM_HTML_SCRIPT_EOF_2'
# Build session debug line
SESSION_DEBUG_LINE="session=$CLAUDE_SESSION_ID | debug=~/.claude/debug/\${session}.txt"
# Normal restart (code change or startup)
MESSAGE="$EMOJI <b>Bot $STATUS</b>
<b>Directory</b>: <code>$WORKING_DIR</code>
<b>Branch</b>: <code>$GIT_BRANCH</code>
<code>$SESSION_DEBUG_LINE</code>
$WATCHEXEC_DETAILS"
# Crash (includes exit code and error details)
MESSAGE="$EMOJI <b>Bot Crashed</b>
<b>Directory</b>: <code>$WORKING_DIR</code>
<b>Branch</b>: <code>$GIT_BRANCH</code>
<code>$SESSION_DEBUG_LINE</code>
<b>Exit Code</b>: $EXIT_CODE
$CRASH_INFO"
TELEGRAM_HTML_SCRIPT_EOF_2Why this format:
- Consistent with other Telegram messages (workflow completions, notifications)
- Removes unnecessary info (host, monitoring system, timestamp)
- Adds context (session ID, branch, directory)
- Exit code only shown for crashes (not for normal restarts with exit code 0)
Skill: Dual-Channel Watchexec Notifications
watchexec Integration
File Change Detection (macOS Compatible)
DO (works on macOS):
/usr/bin/env bash << 'PREFLIGHT_EOF'
# Use stat to check modification time
NOW=$(date +%s)
FILE_MTIME=$(stat -f %m "$file" 2>/dev/null || echo "0")
AGE=$((NOW - FILE_MTIME))
if [[ $AGE -lt 60 ]]; then
echo "File modified ${AGE}s ago"
fi
PREFLIGHT_EOFDON'T (broken on macOS):
# find -newermt has different syntax on BSD/macOS
find . -newermt "60 seconds ago" # ❌ Fails on macOSRestart Reason Detection
/usr/bin/env bash << 'WATCHEXEC_PATTERNS_SCRIPT_EOF'
# Determine why process restarted
if [[ ! -f "$FIRST_RUN_MARKER" ]]; then
REASON="startup"
touch "$FIRST_RUN_MARKER"
elif [[ $EXIT_CODE -ne 0 ]]; then
REASON="crash"
else
REASON="code_change"
fi
WATCHEXEC_PATTERNS_SCRIPT_EOFMessage Archiving (Debugging)
Always save messages before sending for post-mortem debugging:
/usr/bin/env bash << 'WATCHEXEC_PATTERNS_SCRIPT_EOF_2'
MESSAGE_ARCHIVE_DIR="/path/to/logs/notification-archive"
mkdir -p "$MESSAGE_ARCHIVE_DIR"
MESSAGE_FILE="$MESSAGE_ARCHIVE_DIR/$(date '+%Y%m%d-%H%M%S')-$REASON-$PID.txt"
cat > "$MESSAGE_FILE" <<ARCHIVE_EOF
========================================================================
Timestamp: $TIMESTAMP
Reason: $REASON
Exit Code: $EXIT_CODE
--- TELEGRAM MESSAGE ---
$MESSAGE
--- CONTEXT ---
$(cat "$WATCHEXEC_INFO_FILE" 2>/dev/null || echo "Not available")
========================================================================
ARCHIVE_EOF
WATCHEXEC_PATTERNS_SCRIPT_EOF_2