#!/bin/bash # # ShadowNtfy hook installer # Configures Claude Code hooks so permission requests and questions reach # ShadowNtfy on your iPhone or iPad through YOUR OWN self-hosted ntfy server. # # Usage: # curl -fsSL https://shadowntfy.shadowagent.ai/install.sh -o install.sh # less install.sh # read it first # bash install.sh # # Or with options: # bash install.sh --url https://ntfy.example.com --topic my-topic # # Derived from ShadowNtfy's install.sh (hooks v2026.09.08). The hook payload # and the ntfy wire format are unchanged; that is what the app parses. # Names that changed: # shadowntfy-hook.sh -> shadowntfy-hook.sh # shadowntfy-notify.sh -> shadowntfy-notify.sh # shadowntfy.conf -> shadowntfy.conf (chmod 600, holds the token) # SHADOWNTFY_CDN_BASE -> SHADOWNTFY_CDN_BASE (https://shadowntfy.shadowagent.ai) # Names kept for compatibility: the SHADOWTERM_* keys inside the conf, and # the -responses reply topic. # # The public ntfy.sh service is refused. Use a server you run. # # Requirements: # - Claude Code CLI installed # - jq (will be installed if missing) # - curl # set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # No Color # Disable colors if not in a terminal if [ ! -t 1 ]; then RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' NC='' fi print_banner() { echo "" echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}" echo -e "${CYAN}║${NC} ${BOLD}ShadowNtfy Hook Installer${NC} ${CYAN}║${NC}" echo -e "${CYAN}║${NC} Remote notifications & permission approval for Claude Code ${CYAN}║${NC}" echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}" echo "" } print_step() { echo -e "${BLUE}==>${NC} ${BOLD}$1${NC}" } print_success() { echo -e "${GREEN}✓${NC} $1" } print_warning() { echo -e "${YELLOW}!${NC} $1" } print_error() { echo -e "${RED}✗${NC} $1" } # Detect OS detect_os() { case "$(uname -s)" in Darwin*) OS="macos" ;; Linux*) OS="linux" ;; CYGWIN*|MINGW*|MSYS*) OS="windows" ;; *) OS="unknown" ;; esac echo "$OS" } # Check if command exists command_exists() { command -v "$1" >/dev/null 2>&1 } # Lower-cased host of a URL, without surrounding space, scheme, user info, # port, path, or query. url_host() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's#^[[:space:]]+##; s#[[:space:]]+$##; s#^[a-z][a-z0-9+.-]*://##; s#^[^@/]*@##; s#[/:?\#].*$##; s#\.$##' } # ShadowNtfy never uses the public ntfy.sh service (the app refuses to poll # it too). Exit before anything is written. The URL must be plain # http(s)://host[:port][/path] so the check cannot be dodged and the value # is safe to write into the sourced conf. refuse_public_ntfy() { local url="$1" host # [[ =~ ]] matches the whole string, so an embedded newline fails too. local url_re='^https?://([A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$' if [[ ! "$url" =~ $url_re ]]; then print_error "Server URL must look like https://ntfy.example.com (letters, digits, dots, dashes; optional :port and /path)." exit 1 fi host="$(url_host "$url")" case "$host" in ntfy.sh|www.ntfy.sh) print_error "The public ntfy.sh server is not supported." echo " ShadowNtfy works with a ntfy server you run yourself." echo " See https://docs.ntfy.sh/install/ and use that server's URL." exit 1 ;; esac case "$url" in http://*) case "$host" in localhost|127.*|"[::1]") ;; *) print_warning "http:// sends your token unencrypted. Use https:// unless this network is private (LAN, VPN, tailnet)." ;; esac ;; esac } # Topic and token end up in a conf the hooks source: allow only the # characters ntfy itself allows, so nothing in them can run as shell code. validate_topic_and_token() { local topic_re='^[A-Za-z0-9_-]{1,64}$' token_re='^[A-Za-z0-9._~+/=-]+$' if [[ ! "$NTFY_TOPIC" =~ $topic_re ]]; then print_error "Topic may use letters, digits, - and _ only (max 64)." exit 1 fi if [ -n "$AUTH_TOKEN" ] && [[ ! "$AUTH_TOKEN" =~ $token_re ]]; then print_error "Auth token has characters a ntfy token never has. Check it and try again." exit 1 fi } # Install jq if missing install_jq() { if command_exists jq; then return 0 fi print_step "Installing jq (required for JSON processing)..." case "$(detect_os)" in macos) if command_exists brew; then brew install jq else print_error "Homebrew not found. Please install jq manually:" echo " brew install jq" echo " Or download from: https://stedolan.github.io/jq/download/" exit 1 fi ;; linux) if command_exists apt-get; then sudo apt-get update && sudo apt-get install -y jq elif command_exists yum; then sudo yum install -y jq elif command_exists dnf; then sudo dnf install -y jq elif command_exists pacman; then sudo pacman -S --noconfirm jq elif command_exists apk; then sudo apk add jq else print_error "Could not detect package manager. Please install jq manually." exit 1 fi ;; *) print_error "Unsupported OS. Please install jq manually." exit 1 ;; esac print_success "jq installed" } # Check prerequisites check_prerequisites() { print_step "Checking prerequisites..." # Check curl if ! command_exists curl; then print_error "curl is required but not installed." exit 1 fi print_success "curl found" # Check/install jq if command_exists jq; then print_success "jq found" else install_jq fi # Check if Claude Code directory exists (indicates CLI is installed) if [ ! -d "$HOME/.claude" ]; then print_warning "~/.claude directory not found" print_warning "Creating directory (Claude Code CLI may not be installed)" mkdir -p "$HOME/.claude" fi print_success "~/.claude directory exists" } # Parse command line arguments parse_args() { while [[ $# -gt 0 ]]; do case $1 in --url) NTFY_URL="$2" shift 2 ;; --topic) NTFY_TOPIC="$2" shift 2 ;; --token) AUTH_TOKEN="$2" shift 2 ;; --timeout) TIMEOUT="$2" shift 2 ;; --permission-if) PERMISSION_IF="$2" shift 2 ;; --uninstall) UNINSTALL=true shift ;; --update|--upgrade) UPDATE=true shift ;; --set-mode) SET_MODE="$2" shift 2 ;; --show-mode) SHOW_MODE=true shift ;; --notify-mode) NOTIFY_MODE="$2" NOTIFY_MODE_FROM_CLI=1 shift 2 ;; --set-notify) SET_NOTIFY="$2" shift 2 ;; --help|-h) show_help exit 0 ;; *) print_error "Unknown option: $1" show_help exit 1 ;; esac done } show_help() { echo "ShadowNtfy Hook Installer" echo "" echo "Usage: install.sh [options]" echo "" echo "Options:" echo " --url URL self-hosted ntfy server URL (not ntfy.sh)" echo " --topic TOPIC ntfy topic name (e.g., my-claude-notifications)" echo " --token TOKEN Optional auth token for ntfy" echo " --timeout SECS Permission request timeout (default: 300)" echo " --permission-if RULE" echo " Only send permission requests matching RULE to" echo " the phone, e.g. 'Bash(*)|Edit(*)|Write(*)'." echo " Empty (default) = every request." echo " --update Refresh hook scripts + settings.json using" echo " the existing ~/.claude/hooks/shadowntfy.conf" echo " (no prompts). Use this after upgrading." echo " --set-mode MODE Change AskUserQuestion mode in the conf and exit." echo " MODE is one of: console, mirror, phone." echo " --show-mode Print the current AskUserQuestion mode and exit." echo " --notify-mode MODE Install with a notification preset. Skips the" echo " per-event questions. MODE is action or all." echo " action = questions and permission approvals only." echo " all = also session start, session end, and when" echo " the AI stops." echo " --set-notify MODE Flip an existing conf to action or all and exit." echo " Next hook call picks it up. No Claude restart." echo " --uninstall Remove ShadowNtfy hooks" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " # Interactive install" echo " bash install.sh" echo "" echo " # Non-interactive install" echo " bash install.sh --url https://ntfy.example.com --topic my-topic" echo "" echo " # Update hook scripts to the latest version (keeps your config)" echo " bash install.sh --update" echo "" echo " # Toggle AskUserQuestion mode at runtime (no restart needed)" echo " bash install.sh --set-mode phone" echo " bash install.sh --show-mode" echo "" echo " # Only questions and permission approvals (no session start/end," echo " # no AI stopped). Also the interactive default." echo " bash install.sh --set-notify action" echo " bash install.sh --notify-mode all" } # action: phone hears only what needs a person. # all: also session start, session end, and when the AI stops. # Do not write QUESTIONS=0 because AskUserQuestion mode is console. # The hook already skips the post in console mode. A 0 in the conf # would keep skipping after a later --set-mode phone. apply_notify_mode() { local mode="$1" case "$mode" in action|all) ;; *) print_error "Invalid notify mode: $mode (expected: action or all)" exit 1 ;; esac NOTIFY_MODE="$mode" NOTIFY_PERMISSIONS="1" NOTIFY_PERMISSION_DENIED="1" NOTIFY_AGENT_NEEDS_INPUT="1" if [ "$mode" = "action" ]; then NOTIFY_QUESTIONS="1" NOTIFY_TASK_COMPLETE="0" NOTIFY_SESSION_START="0" NOTIFY_SESSION_END="0" NOTIFY_TOOL_FAIL="0" NOTIFY_IDLE="0" NOTIFY_STOP_FAILURE="0" NOTIFY_SUBAGENT_STOP="0" NOTIFY_QUOTA="0" else NOTIFY_QUESTIONS="1" NOTIFY_TASK_COMPLETE="1" NOTIFY_SESSION_START="1" NOTIFY_SESSION_END="1" NOTIFY_TOOL_FAIL="1" NOTIFY_IDLE="1" NOTIFY_STOP_FAILURE="1" NOTIFY_SUBAGENT_STOP="1" NOTIFY_QUOTA="1" fi } set_conf_assign() { local key="$1" val="$2" conf="$3" if grep -q "^${key}=" "$conf"; then if sed --version >/dev/null 2>&1; then sed -i "s|^${key}=.*|${key}=\"${val}\"|" "$conf" else sed -i '' "s|^${key}=.*|${key}=\"${val}\"|" "$conf" fi else printf '%s="%s"\n' "$key" "$val" >> "$conf" fi } # Flip notification flags in an existing conf. Does not touch the ntfy # URL, topic, or AskUserQuestion mode. Hooks re-read the file each call. set_notify_in_conf() { local mode="$1" local conf="$HOME/.claude/hooks/shadowntfy.conf" case "$mode" in action|all) ;; *) print_error "Invalid notify mode: $mode (expected: action or all)" exit 1 ;; esac if [ ! -f "$conf" ]; then print_error "No ShadowNtfy conf at $conf. Run a regular install first." exit 1 fi apply_notify_mode "$mode" set_conf_assign SHADOWTERM_NOTIFY_MODE "$NOTIFY_MODE" "$conf" set_conf_assign SHADOWTERM_NOTIFY_PERMISSIONS "$NOTIFY_PERMISSIONS" "$conf" set_conf_assign SHADOWTERM_NOTIFY_QUESTIONS "$NOTIFY_QUESTIONS" "$conf" set_conf_assign SHADOWTERM_NOTIFY_TASK_COMPLETE "$NOTIFY_TASK_COMPLETE" "$conf" set_conf_assign SHADOWTERM_NOTIFY_SESSION_START "$NOTIFY_SESSION_START" "$conf" set_conf_assign SHADOWTERM_NOTIFY_SESSION_END "$NOTIFY_SESSION_END" "$conf" set_conf_assign SHADOWTERM_NOTIFY_TOOL_FAIL "$NOTIFY_TOOL_FAIL" "$conf" set_conf_assign SHADOWTERM_NOTIFY_IDLE "$NOTIFY_IDLE" "$conf" set_conf_assign SHADOWTERM_NOTIFY_STOP_FAILURE "$NOTIFY_STOP_FAILURE" "$conf" set_conf_assign SHADOWTERM_NOTIFY_SUBAGENT_STOP "$NOTIFY_SUBAGENT_STOP" "$conf" set_conf_assign SHADOWTERM_NOTIFY_AGENT_NEEDS_INPUT "$NOTIFY_AGENT_NEEDS_INPUT" "$conf" set_conf_assign SHADOWTERM_NOTIFY_QUOTA "$NOTIFY_QUOTA" "$conf" set_conf_assign SHADOWTERM_NOTIFY_PERMISSION_DENIED "$NOTIFY_PERMISSION_DENIED" "$conf" print_success "Notifications set to: $mode" if [ "$mode" = "action" ]; then echo " Phone hears questions and permission approvals only." echo " Session start, session end, and AI stopped stay quiet." else echo " Phone also hears session start, session end, and when the AI stops." fi echo " Takes effect on the next hook call. No Claude Code restart." } # Prompt for configuration prompt_config() { echo "" print_step "Configuration" echo "" # Check if we can read from terminal if [ ! -t 0 ]; then # stdin is not a terminal (piped, or run by an AI agent) # Try to open /dev/tty for user input. /dev/tty can exist and # still fail to open when there is no controlling terminal. if (: < /dev/tty) 2>/dev/null; then exec < /dev/tty else NO_TTY=1 # No terminal available, require command line args if [ -z "$NTFY_URL" ] || [ -z "$NTFY_TOPIC" ]; then print_error "No terminal available for interactive input." echo "" echo "Please use command line arguments:" echo " bash install.sh --url https://ntfy.example.com --topic my-topic" echo "" exit 1 fi fi fi # ntfy URL if [ -z "$NTFY_URL" ]; then echo -e "${BOLD}Enter your ntfy server URL${NC}" echo -e " Examples: ${CYAN}https://ntfy.example.com${NC}, ${CYAN}http://localhost:8080${NC}" echo -n " URL: " read -r NTFY_URL if [ -z "$NTFY_URL" ]; then print_error "ntfy URL is required" exit 1 fi fi # Remove trailing slash NTFY_URL="${NTFY_URL%/}" refuse_public_ntfy "$NTFY_URL" # ntfy topic if [ -z "$NTFY_TOPIC" ]; then echo "" echo -e "${BOLD}Enter your ntfy topic name${NC}" echo -e " You enter the same topic in ShadowNtfy as the Channel" echo -e " Example: ${CYAN}claude-code${NC}, ${CYAN}my-notifications${NC}" echo -n " Topic: " read -r NTFY_TOPIC if [ -z "$NTFY_TOPIC" ]; then print_error "ntfy topic is required" exit 1 fi fi # Auth token (optional). Without a terminal, pass --token instead. if [ -z "$AUTH_TOKEN" ] && [ -z "$NO_TTY" ]; then echo "" echo -e "${BOLD}Enter auth token (optional, press Enter to skip)${NC}" echo -e " Required if your ntfy server uses authentication" echo -n " Token: " read -rs AUTH_TOKEN || true echo "" fi # Timeout (optional) if [ -z "$TIMEOUT" ]; then TIMEOUT="300" fi # AskUserQuestion mode. console never posts questions. The hook # checks that itself, so the notify preset does not write QUESTIONS=0. echo "" echo -e "${BOLD}AskUserQuestion mode${NC}" echo -e " Note: you always answer in the console (Claude Code shows its native" echo -e " prompt). The phone gets a read-only copy unless you pick ${CYAN}console${NC}." echo "" echo -e " ${CYAN}console${NC} No ntfy POST. Phone gets nothing. Pure console." echo -e " ${CYAN}mirror${NC} ntfy POST + console banner with the question text." echo -e " ${CYAN}phone${NC} ntfy POST only. No console banner from us (Claude Code's" echo -e " native prompt still shows the question)." if [ -z "$ASKUQ_MODE" ] && [ -n "$NO_TTY" ]; then ASKUQ_MODE="mirror" fi if [ -z "$ASKUQ_MODE" ]; then printf " Mode [console/mirror/phone] (default: mirror): " >&2 read -r ASKUQ_MODE case "$ASKUQ_MODE" in console|Console|CONSOLE) ASKUQ_MODE="console" ;; phone|Phone|PHONE) ASKUQ_MODE="phone" ;; *) ASKUQ_MODE="mirror" ;; esac fi # One choice. action (default) is questions and permission approvals # only. all also pings when a session starts, ends, or the AI stops. # Per-event flags in the conf can still be edited by hand later. if [ -z "$NOTIFY_MODE" ]; then if [ -z "$NO_TTY" ]; then echo "" echo -e "${BOLD}Which notifications should the phone get?${NC}" echo -e " ${CYAN}action${NC} Only questions and permission approvals." echo -e " No session start, session end, or AI stopped." echo -e " ${CYAN}all${NC} Also session start, session end, task finished," echo -e " tool failures, and idle." printf " Mode [action/all] (default: action): " >&2 read -r NOTIFY_MODE fi case "$NOTIFY_MODE" in all|All|ALL) NOTIFY_MODE="all" ;; *) NOTIFY_MODE="action" ;; esac fi apply_notify_mode "$NOTIFY_MODE" validate_topic_and_token echo "" } # --------------------------------------------------------------------------- # Hook script install + settings merge (Claude Code hooks reference 2026) # Source of truth: shadowntfy-hook.sh / shadowntfy-notify.sh next to this # installer, or https://shadowntfy.shadowagent.ai/ otherwise. # --------------------------------------------------------------------------- SHADOWTERM_HOOKS_VERSION="2026.09.08" SHADOWNTFY_CDN_BASE="${SHADOWNTFY_CDN_BASE:-https://shadowntfy.shadowagent.ai}" # Resolve directory of this installer when run from a real file path # (repo checkout / app bundle). Empty when stdin-piped via curl | bash. installer_dir() { local src="${BASH_SOURCE[0]:-}" # Piped installs often show as empty, /dev/fd/*, or process substitution. case "$src" in ""|/dev/*|/proc/*) return 1 ;; esac local dir dir="$(cd "$(dirname "$src")" 2>/dev/null && pwd)" || return 1 [ -n "$dir" ] || return 1 echo "$dir" } # Install one script: prefer sibling file, else download from CDN. install_hook_file() { local name="$1" local dest="$2" local dir if dir="$(installer_dir)" && [ -f "$dir/$name" ]; then cp "$dir/$name" "$dest" print_success "Installed $name (from local Scripts/)" else print_step "Downloading $name from $SHADOWNTFY_CDN_BASE ..." if ! curl -fsSL "$SHADOWNTFY_CDN_BASE/$name" -o "$dest"; then print_error "Failed to download $SHADOWNTFY_CDN_BASE/$name" print_error "Publish the hook scripts to the CDN, or run install.sh from the repo Scripts/ directory." exit 1 fi print_success "Installed $name (from CDN)" fi chmod +x "$dest" } # Create the hook scripts create_hook_scripts() { print_step "Creating hook scripts (v$SHADOWTERM_HOOKS_VERSION)..." HOOKS_DIR="$HOME/.claude/hooks" mkdir -p "$HOOKS_DIR" install_hook_file "shadowntfy-hook.sh" "$HOOKS_DIR/shadowntfy-hook.sh" install_hook_file "shadowntfy-notify.sh" "$HOOKS_DIR/shadowntfy-notify.sh" } # Create configuration file create_config() { print_step "Creating configuration file..." HOOKS_DIR="$HOME/.claude/hooks" # The conf holds the auth token: owner read/write only. Written to a # 0600 temp file, then moved over the old one. local previous="" if [ -n "$KEEP_EXTRA_CONF" ] && [ -f "$HOOKS_DIR/shadowntfy.conf" ]; then previous="$(cat "$HOOKS_DIR/shadowntfy.conf")" fi ( umask 077; : > "$HOOKS_DIR/shadowntfy.conf.new" ) chmod 600 "$HOOKS_DIR/shadowntfy.conf.new" cat > "$HOOKS_DIR/shadowntfy.conf.new" << CONF_EOF # ShadowNtfy Configuration # Auto-generated by the ShadowNtfy installer v${SHADOWTERM_HOOKS_VERSION} # Edit these values to change your ntfy / notification preferences. SHADOWTERM_HOOKS_VERSION="${SHADOWTERM_HOOKS_VERSION}" SHADOWTERM_NTFY_URL="$NTFY_URL" SHADOWTERM_TOPIC="$NTFY_TOPIC" SHADOWTERM_RESPONSE_TOPIC="${NTFY_TOPIC}-responses" SHADOWTERM_TIMEOUT="${TIMEOUT:-300}" SHADOWTERM_AUTH_TOKEN="$AUTH_TOKEN" # How long an auto-mode denial waits for a "Retry" tap before the # denial stands (the turn is paused meanwhile). SHADOWTERM_DENIED_TIMEOUT="${DENIED_TIMEOUT:-45}" # Per-event notification toggles. 1 = enabled, 0 = disabled. The hooks # re-read this file on every call, so a change applies to the next # event without restarting Claude Code (same for --set-mode and # --set-notify). # Preset: action = questions and permission approvals only. # all = also session start, session end, and when the AI stops. SHADOWTERM_NOTIFY_MODE="${NOTIFY_MODE:-action}" SHADOWTERM_NOTIFY_PERMISSIONS="${NOTIFY_PERMISSIONS:-1}" SHADOWTERM_NOTIFY_QUESTIONS="${NOTIFY_QUESTIONS:-1}" SHADOWTERM_NOTIFY_TASK_COMPLETE="${NOTIFY_TASK_COMPLETE:-0}" SHADOWTERM_NOTIFY_SESSION_START="${NOTIFY_SESSION_START:-0}" SHADOWTERM_NOTIFY_SESSION_END="${NOTIFY_SESSION_END:-0}" SHADOWTERM_NOTIFY_TOOL_FAIL="${NOTIFY_TOOL_FAIL:-0}" SHADOWTERM_NOTIFY_IDLE="${NOTIFY_IDLE:-0}" SHADOWTERM_NOTIFY_STOP_FAILURE="${NOTIFY_STOP_FAILURE:-0}" SHADOWTERM_NOTIFY_SUBAGENT_STOP="${NOTIFY_SUBAGENT_STOP:-0}" SHADOWTERM_NOTIFY_AGENT_NEEDS_INPUT="${NOTIFY_AGENT_NEEDS_INPUT:-1}" SHADOWTERM_NOTIFY_QUOTA="${NOTIFY_QUOTA:-0}" SHADOWTERM_NOTIFY_PERMISSION_DENIED="${NOTIFY_PERMISSION_DENIED:-1}" # Only forward permission requests whose tool + arguments match this # Claude Code permission rule (same syntax as settings.json allow rules, # e.g. "Bash(*)|Edit(*)|Write(*)"). Empty = every request. Applied to # settings.json by the installer, so re-run --update after editing. SHADOWTERM_PERMISSION_IF="${PERMISSION_IF:-}" # AskUserQuestion mode: # console = question stays in Claude Code, no ntfy POST # mirror = phone gets a read-only copy; you answer in the console # phone = console is silent; hook waits for the answer from the phone # (may fall back to console if Claude Code shows native UI) SHADOWTERM_ASKUQ_MODE="${ASKUQ_MODE:-mirror}" # Where the hook prints its status lines ("Waiting for response...", # the mirror banner). Auto-detected: the terminal when one is attached, # otherwise stderr. Set to /dev/null to silence them. # SHADOWTERM_USER_OUT="/dev/null" CONF_EOF # --update: carry over assignments the template does not write (for # example an uncommented SHADOWTERM_USER_OUT), unchanged. if [ -n "$previous" ]; then local extra key extra="" while IFS= read -r line; do key="${line%%=*}" case "$line" in [A-Za-z_]*=*) if ! grep -q "^${key}=" "$HOOKS_DIR/shadowntfy.conf.new"; then extra="${extra}${line}"$'\n' fi ;; esac done <<< "$previous" if [ -n "$extra" ]; then printf '\n# Kept from your previous conf\n%s' "$extra" >> "$HOOKS_DIR/shadowntfy.conf.new" fi fi mv "$HOOKS_DIR/shadowntfy.conf.new" "$HOOKS_DIR/shadowntfy.conf" print_success "Created shadowntfy.conf (v$SHADOWTERM_HOOKS_VERSION)" } # Surgically merge ShadowNtfy hooks into settings.json without wiping # foreign hooks (other matchers / other command handlers). update_settings() { print_step "Updating Claude Code settings (surgical merge)..." SETTINGS_FILE="$HOME/.claude/settings.json" if [ ! -f "$SETTINGS_FILE" ]; then echo '{}' > "$SETTINGS_FILE" fi # Desired ShadowNtfy matcher groups (appended / replacing only ours). # StopFailure + SubagentStop are new in 2026.08.12. statusMessage, # async and the optional "if" filter are 2026.09.07.2. PermissionDenied # (blocking, phone Retry) and Elicitation are 2026.09.08. local desired desired=$(cat << 'HOOKS_JSON' { "PermissionRequest": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-hook.sh", "timeout": 300, "statusMessage": "Waiting for approval on your phone (ShadowNtfy)…" } ] } ], "PreToolUse": [ { "matcher": "AskUserQuestion", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-hook.sh", "timeout": 300, "statusMessage": "Question sent to your phone (ShadowNtfy)…" } ] } ], "PermissionDenied": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-hook.sh", "timeout": 60, "statusMessage": "Auto mode denied a tool; your phone can allow a retry (ShadowNtfy)…" } ] } ], "Elicitation": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-hook.sh", "timeout": 300, "statusMessage": "MCP question sent to your phone (ShadowNtfy)…" } ] } ], "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ], "StopFailure": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ], "SessionStart": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ], "SessionEnd": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ], "PostToolUseFailure": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ], "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ], "SubagentStop": [ { "matcher": "", "hooks": [ { "type": "command", "command": "~/.claude/hooks/shadowntfy-notify.sh", "async": true } ] } ] } HOOKS_JSON ) if jq -e '[.hooks // {} | .[][]?.hooks[]?.command // ""] | any(test("shadowterm-(hook|notify)\\.sh"))' "$SETTINGS_FILE" >/dev/null 2>&1; then print_warning "ShadowTerm's hooks are also in settings.json. Both will post each request." print_warning "Keep one: remove ShadowTerm's hooks, or point both at the same topic." fi local updated if ! updated=$(jq --argjson desired "$desired" --arg pif "${PERMISSION_IF:-}" ' ($desired | if $pif != "" then .PermissionRequest[0].hooks[0].if = $pif else . end) as $desired | .hooks //= {} | . as $root | reduce ($desired | keys[]) as $event ($root; .hooks[$event] = ( ((.hooks[$event] // []) | map(.hooks = ((.hooks? // []) | map(select((.command // "") | test("shadowntfy-(hook|notify)\\.sh") | not)))) | map(select((.hooks | length) > 0))) + $desired[$event] ) ) | .hooks |= with_entries(select((.value | length) > 0)) ' "$SETTINGS_FILE"); then print_error "Failed to merge hooks into settings.json (is jq installed?)" exit 1 fi echo "$updated" > "$SETTINGS_FILE" print_success "Merged ShadowNtfy hooks into settings.json (foreign hooks preserved)" } # Remove only ShadowNtfy matchers/handlers from settings.json. remove_shadowntfy_from_settings() { local settings_file="$1" [ -f "$settings_file" ] || return 0 local updated updated=$(jq ' .hooks //= {} | .hooks |= with_entries( .value |= (map(.hooks = ((.hooks? // []) | map(select((.command // "") | test("shadowntfy-(hook|notify)\\.sh") | not)))) | map(select((.hooks | length) > 0))) | select((.value | length) > 0) ) | if (.hooks | length) == 0 then del(.hooks) else . end ' "$settings_file") || return 1 echo "$updated" > "$settings_file" } # Uninstall uninstall() { print_banner print_step "Uninstalling ShadowNtfy hooks..." HOOKS_DIR="$HOME/.claude/hooks" SETTINGS_FILE="$HOME/.claude/settings.json" if [ -f "$HOOKS_DIR/shadowntfy-hook.sh" ]; then rm "$HOOKS_DIR/shadowntfy-hook.sh" print_success "Removed shadowntfy-hook.sh" fi if [ -f "$HOOKS_DIR/shadowntfy-notify.sh" ]; then rm "$HOOKS_DIR/shadowntfy-notify.sh" print_success "Removed shadowntfy-notify.sh" fi if [ -f "$HOOKS_DIR/shadowntfy.conf" ]; then rm "$HOOKS_DIR/shadowntfy.conf" print_success "Removed shadowntfy.conf" fi # Remove only ShadowNtfy handlers; keep the user's other hooks. if [ -f "$SETTINGS_FILE" ]; then if remove_shadowntfy_from_settings "$SETTINGS_FILE"; then print_success "Removed ShadowNtfy entries from settings.json (other hooks kept)" else print_warning "Could not edit settings.json automatically" fi fi echo "" print_success "ShadowNtfy hooks uninstalled successfully!" echo "" } # Test the configuration test_config() { print_step "Testing configuration..." # Send a test notification PAYLOAD=$(jq -n \ --arg event_type "info" \ --arg details "ShadowNtfy hooks installed successfully!" \ --arg session_id "installer" \ --arg cwd "$PWD" \ '{ event_type: $event_type, details: $details, session_id: $session_id, cwd: $cwd }') # Include the same project/host label the runtime hooks use, so # the test ping looks identical to what the user will see later. TEST_PROJECT="$(basename "${PWD:-$HOME}" 2>/dev/null)" [ -z "$TEST_PROJECT" ] || [ "$TEST_PROJECT" = "/" ] && TEST_PROJECT="Claude Code" TEST_HOST="$(hostname -s 2>/dev/null || echo unknown)" CURL_HEADERS=(-H "Title: ${TEST_PROJECT} on ${TEST_HOST} · ShadowNtfy connected" -H "Tags: success" -H "Priority: default") if [ -n "$AUTH_TOKEN" ]; then CURL_HEADERS+=(-H "Authorization: Bearer $AUTH_TOKEN") fi local status status=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${NTFY_URL}/${NTFY_TOPIC}" \ "${CURL_HEADERS[@]}" \ -d "$PAYLOAD" 2>/dev/null) || status="000" case "$status" in 2??) print_success "Test notification sent successfully!" ;; 401|403) print_warning "Test notification refused (HTTP $status): the token is missing or lacks write access to $NTFY_TOPIC." ;; *) print_warning "Could not send test notification (HTTP $status)" print_warning "Check your ntfy URL and topic settings" ;; esac } # Print completion message print_completion() { echo "" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN} Installation complete!${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo "" echo "Configuration:" echo -e " ntfy URL: ${CYAN}$NTFY_URL${NC}" echo -e " Topic: ${CYAN}$NTFY_TOPIC${NC}" echo -e " Responses: ${CYAN}${NTFY_TOPIC}-responses${NC}" echo -e " Notify: ${CYAN}${NOTIFY_MODE:-action}${NC}" echo "" echo "Files created:" echo -e " ${CYAN}~/.claude/hooks/shadowntfy-hook.sh${NC} (permission requests)" echo -e " ${CYAN}~/.claude/hooks/shadowntfy-notify.sh${NC} (other notifications)" echo -e " ${CYAN}~/.claude/hooks/shadowntfy.conf${NC} (configuration)" echo -e " ${CYAN}~/.claude/settings.json${NC} (updated)" echo "" echo "Next steps:" echo " 1. Open ShadowNtfy on your iPhone or iPad" echo " 2. Go to Channels, Add Channel" echo -e " Server URL: ${CYAN}$NTFY_URL${NC}" echo -e " Channel: ${CYAN}$NTFY_TOPIC${NC}" echo " Auth Token: the token you entered here" echo " 3. Use Claude Code. Questions and permission requests show up in ShadowNtfy." echo "" echo "To reconfigure:" echo -e " Edit ${CYAN}~/.claude/hooks/shadowntfy.conf${NC}" echo -e " Or: ${CYAN}install.sh --set-notify action${NC} (questions and approvals only)" echo -e " ${CYAN}install.sh --set-notify all${NC} (also session start, end, AI stopped)" echo "" echo "To uninstall:" echo -e " bash install.sh --uninstall" echo "" } # Load an existing config file's values into our variables so `--update` # can re-use them without prompting the user again. Returns 0 if a conf # was found, 1 otherwise. load_existing_config() { local conf="$HOME/.claude/hooks/shadowntfy.conf" if [ ! -f "$conf" ]; then return 1 fi # The conf carries the version it was written with; sourcing it must # not downgrade the installer's own stamp for the conf we rewrite. local installer_version="$SHADOWTERM_HOOKS_VERSION" # shellcheck disable=SC1090 source "$conf" SHADOWTERM_HOOKS_VERSION="$installer_version" NTFY_URL="${NTFY_URL:-${SHADOWTERM_NTFY_URL:-}}" NTFY_TOPIC="${NTFY_TOPIC:-${SHADOWTERM_TOPIC:-}}" AUTH_TOKEN="${AUTH_TOKEN:-${SHADOWTERM_AUTH_TOKEN:-}}" TIMEOUT="${TIMEOUT:-${SHADOWTERM_TIMEOUT:-300}}" DENIED_TIMEOUT="${DENIED_TIMEOUT:-${SHADOWTERM_DENIED_TIMEOUT:-45}}" NOTIFY_PERMISSIONS="${SHADOWTERM_NOTIFY_PERMISSIONS:-1}" NOTIFY_QUESTIONS="${SHADOWTERM_NOTIFY_QUESTIONS:-1}" NOTIFY_TASK_COMPLETE="${SHADOWTERM_NOTIFY_TASK_COMPLETE:-0}" NOTIFY_SESSION_START="${SHADOWTERM_NOTIFY_SESSION_START:-0}" NOTIFY_SESSION_END="${SHADOWTERM_NOTIFY_SESSION_END:-0}" NOTIFY_TOOL_FAIL="${SHADOWTERM_NOTIFY_TOOL_FAIL:-0}" NOTIFY_IDLE="${SHADOWTERM_NOTIFY_IDLE:-0}" NOTIFY_STOP_FAILURE="${SHADOWTERM_NOTIFY_STOP_FAILURE:-1}" NOTIFY_SUBAGENT_STOP="${SHADOWTERM_NOTIFY_SUBAGENT_STOP:-0}" NOTIFY_AGENT_NEEDS_INPUT="${SHADOWTERM_NOTIFY_AGENT_NEEDS_INPUT:-1}" NOTIFY_QUOTA="${SHADOWTERM_NOTIFY_QUOTA:-1}" NOTIFY_PERMISSION_DENIED="${SHADOWTERM_NOTIFY_PERMISSION_DENIED:-1}" PERMISSION_IF="${PERMISSION_IF:-${SHADOWTERM_PERMISSION_IF:-}}" ASKUQ_MODE="${SHADOWTERM_ASKUQ_MODE:-mirror}" # CLI --notify-mode wins. Otherwise keep the preset already in the # conf. Missing preset means a hand-edited flag mix: do not relabel # it as action. if [ -z "$NOTIFY_MODE_FROM_CLI" ]; then NOTIFY_MODE="${SHADOWTERM_NOTIFY_MODE:-custom}" fi return 0 } print_update_completion() { echo "" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN} Update complete!${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo "" echo "Hook scripts and settings.json refreshed." echo -e " ntfy URL: ${CYAN}$NTFY_URL${NC}" echo -e " Topic: ${CYAN}$NTFY_TOPIC${NC}" echo "" echo "Hook version: v${SHADOWTERM_HOOKS_VERSION:-2026.09.08}" echo " Your server URL, topic, and token were kept." echo " AskUserQuestion still supports console / mirror / phone modes." echo "" echo "Your existing config is preserved. Restart any active Claude" echo "Code sessions for the new hooks to take effect." echo "" } # Fast in-place edit of SHADOWTERM_ASKUQ_MODE in the conf file. Used by # --set-mode for runtime mode flips (no reinstall, no Claude Code # restart needed; the hook reads the conf at each invocation). set_mode_in_conf() { local new_mode="$1" local conf="$HOME/.claude/hooks/shadowntfy.conf" case "$new_mode" in console|mirror|phone) ;; *) print_error "Invalid mode: $new_mode (expected: console, mirror, or phone)" exit 1 ;; esac if [ ! -f "$conf" ]; then print_error "No ShadowNtfy conf at $conf ... run a regular install first." exit 1 fi if grep -q '^SHADOWTERM_ASKUQ_MODE=' "$conf"; then # macOS sed needs an empty -i argument; this works on both BSD # and GNU sed via the leading '' for BSD and the trailing # marker being interpreted as the in-place suffix. if sed --version >/dev/null 2>&1; then sed -i "s/^SHADOWTERM_ASKUQ_MODE=.*/SHADOWTERM_ASKUQ_MODE=\"$new_mode\"/" "$conf" else sed -i '' "s/^SHADOWTERM_ASKUQ_MODE=.*/SHADOWTERM_ASKUQ_MODE=\"$new_mode\"/" "$conf" fi else printf '\nSHADOWTERM_ASKUQ_MODE="%s"\n' "$new_mode" >> "$conf" fi print_success "AskUserQuestion mode set to: $new_mode" echo " (takes effect on the next AskUserQuestion call; no restart needed)" } show_mode_from_conf() { local conf="$HOME/.claude/hooks/shadowntfy.conf" if [ ! -f "$conf" ]; then print_error "No ShadowNtfy conf at $conf ... run a regular install first." exit 1 fi local mode mode=$(grep '^SHADOWTERM_ASKUQ_MODE=' "$conf" | head -1 | sed 's/^SHADOWTERM_ASKUQ_MODE=//;s/^"//;s/"$//') echo "${mode:-mirror}" } # Main main() { parse_args "$@" if [ -n "$NTFY_URL" ]; then refuse_public_ntfy "$NTFY_URL" fi if [ -n "$SET_MODE" ]; then set_mode_in_conf "$SET_MODE" exit 0 fi if [ -n "$SET_NOTIFY" ]; then set_notify_in_conf "$SET_NOTIFY" exit 0 fi if [ "$SHOW_MODE" = true ]; then show_mode_from_conf exit 0 fi if [ "$UNINSTALL" = true ]; then uninstall exit 0 fi if [ "$UPDATE" = true ]; then print_banner check_prerequisites if ! load_existing_config; then print_error "No existing ShadowNtfy config found at ~/.claude/hooks/shadowntfy.conf" echo "" echo "Run a regular install first:" echo " bash install.sh" exit 1 fi if [ -z "$NTFY_URL" ] || [ -z "$NTFY_TOPIC" ]; then print_error "Existing config is missing url/topic. Re-run the full installer." exit 1 fi refuse_public_ntfy "$NTFY_URL" validate_topic_and_token print_step "Updating from existing config (ntfy URL: $NTFY_URL, topic: $NTFY_TOPIC)" if [ -n "$NOTIFY_MODE_FROM_CLI" ]; then apply_notify_mode "$NOTIFY_MODE" fi create_hook_scripts KEEP_EXTRA_CONF=1 create_config update_settings print_update_completion exit 0 fi print_banner check_prerequisites prompt_config create_hook_scripts create_config update_settings test_config print_completion # Explicit exit. Without this, bash tries to read the next # command from stdin after main returns ... but prompt_config did # `exec < /dev/tty` to capture user input, so stdin is now the # terminal and bash blocks forever waiting for keystrokes that # never come. The UNINSTALL and UPDATE branches already exit; # this matches them. exit 0 } main "$@"