diff --git a/README.md b/README.md index 7fb6948..9228790 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,9 @@ cn alerts reset # Back to default (idle_prompt only) | `TaskCreated` | Claude agent-team task was created | | `TaskCompleted` | Claude agent-team task completed | -Alert-type matching applies to Claude Code notification hooks and Gemini CLI notification hooks. `ask_user` is a Claude-only `PreToolUse` hook for `AskUserQuestion`; it is applied immediately when Claude notifications are already enabled. Claude Code agent/team events are separate hook events and are opt-in via `cn alerts add SubagentStop`, `cn alerts add TeammateIdle`, or `cn alerts add TaskCompleted`. +Alert-type matching applies to Claude Code notification hooks and Gemini CLI notification hooks. Changes are applied immediately when Claude notifications are already enabled; otherwise, run `cn on` after choosing the alert types. `ask_user` is a Claude-only `PreToolUse` hook for `AskUserQuestion`. Claude Code agent/team events are separate hook events and are opt-in via `cn alerts add SubagentStop`, `cn alerts add TeammateIdle`, or `cn alerts add TaskCompleted`. -Agent-team and subagent workflows can be noisy if `permission_prompt` is enabled. If you only want idle pings, run `cn alerts remove permission_prompt && cn on`. Codex currently uses completion events from `notify`, so `permission_prompt` and `idle_prompt` settings do not change Codex behavior. +Agent-team and subagent workflows can be noisy if `permission_prompt` is enabled. If you only want idle pings, run `cn alerts remove permission_prompt`. Codex currently uses completion events from `notify`, so `permission_prompt` and `idle_prompt` settings do not change Codex behavior. ### Slack And Discord diff --git a/lib/code-notify/commands/global.sh b/lib/code-notify/commands/global.sh index 0779e6c..8311bec 100755 --- a/lib/code-notify/commands/global.sh +++ b/lib/code-notify/commands/global.sh @@ -1129,6 +1129,17 @@ show_available_alert_types() { echo "Aliases like ${CYAN}subagent_stop${RESET}, ${CYAN}teammate-idle${RESET}, and ${CYAN}task_completed${RESET} are accepted." } +# Rewrite enabled Claude hooks after an alert-type change. The previous matcher +# no longer passes is_enabled_globally(), so detect the stable managed Stop hook +# instead. +apply_alert_types_to_enabled_hooks() { + if ! has_managed_global_claude_hooks; then + return 2 + fi + + enable_hooks_in_settings +} + # Add an alert type add_alert_type() { local type @@ -1141,20 +1152,31 @@ add_alert_type() { return 1 fi + local already_enabled=0 if is_notify_type_enabled "$type"; then - warning "$type is already enabled" - return 0 + already_enabled=1 + else + add_notify_type "$type" fi - add_notify_type "$type" + local applied=0 apply_result=0 + apply_alert_types_to_enabled_hooks || apply_result=$? + if [[ $apply_result -eq 0 ]]; then + applied=1 + elif [[ $apply_result -ne 2 ]]; then + error "Failed to apply alert types to enabled Claude hooks" + return 1 + fi - # For ask_user: register PreToolUse hook immediately - if [[ "$type" == "ask_user" ]] && is_tool_enabled "claude"; then - register_ask_user_hook "$GLOBAL_SETTINGS_FILE" "$(get_global_claude_pre_tool_use_command)" + if [[ $already_enabled -eq 1 ]]; then + warning "$type is already enabled" + else + success "Added: $type" fi - success "Added: $type" - if [[ "$type" != "ask_user" ]]; then + if [[ $applied -eq 1 ]]; then + info "Applied to enabled Claude hooks" + elif [[ "$type" != "ask_user" ]]; then echo "" info "Run ${CYAN}cn on${RESET} to apply changes" fi @@ -1172,20 +1194,30 @@ remove_alert_type() { return 1 fi - if ! is_notify_type_enabled "$type"; then - warning "$type is not currently enabled" - return 0 + local was_enabled=0 + if is_notify_type_enabled "$type"; then + was_enabled=1 + remove_notify_type "$type" fi - remove_notify_type "$type" + local applied=0 apply_result=0 + apply_alert_types_to_enabled_hooks || apply_result=$? + if [[ $apply_result -eq 0 ]]; then + applied=1 + elif [[ $apply_result -ne 2 ]]; then + error "Failed to apply alert types to enabled Claude hooks" + return 1 + fi - # For ask_user: unregister PreToolUse hook immediately - if [[ "$type" == "ask_user" ]] && is_tool_enabled "claude"; then - unregister_ask_user_hook "$GLOBAL_SETTINGS_FILE" "$(get_global_claude_pre_tool_use_command)" + if [[ $was_enabled -eq 0 ]]; then + warning "$type is not currently enabled" + else + success "Removed: $type" fi - success "Removed: $type" - if [[ "$type" != "ask_user" ]]; then + if [[ $applied -eq 1 ]]; then + info "Applied to enabled Claude hooks" + elif [[ "$type" != "ask_user" ]]; then echo "" info "Run ${CYAN}cn on${RESET} to apply changes" fi @@ -1195,8 +1227,17 @@ remove_alert_type() { reset_alert_types() { reset_notify_types success "Reset to default: idle_prompt" - echo "" - info "Run ${CYAN}cn on${RESET} to apply changes" + local apply_result=0 + apply_alert_types_to_enabled_hooks || apply_result=$? + if [[ $apply_result -eq 0 ]]; then + info "Applied to enabled Claude hooks" + elif [[ $apply_result -ne 2 ]]; then + error "Failed to apply alert types to enabled Claude hooks" + return 1 + else + echo "" + info "Run ${CYAN}cn on${RESET} to apply changes" + fi } # Show alerts help diff --git a/lib/code-notify/core/config.sh b/lib/code-notify/core/config.sh index f953c09..7b1cf1e 100755 --- a/lib/code-notify/core/config.sh +++ b/lib/code-notify/core/config.sh @@ -519,6 +519,46 @@ is_enabled_globally() { [[ -f "$GLOBAL_HOOKS_FILE" ]] } +# Check for an installed Code-Notify Claude hook without requiring its +# Notification matcher to match the current alert-type configuration. This is +# used when alert types change, because the matcher is stale until it is +# rewritten. +has_managed_global_claude_hooks() { + local file="${1:-$GLOBAL_SETTINGS_FILE}" + local stop_cmd + stop_cmd="$(get_global_claude_stop_command)" + + if [[ -f "$file" ]]; then + if has_jq; then + jq -e --arg cmd "$stop_cmd" ' + any(.hooks.Stop[]?.hooks[]?; + (.type // "") == "command" and (.command // "") == $cmd + ) + ' "$file" >/dev/null 2>&1 && return 0 + elif has_python3; then + python3 - "$file" "$stop_cmd" <<'PYTHON' >/dev/null 2>&1 && return 0 +import json +import sys + +file_path, stop_cmd = sys.argv[1:3] +with open(file_path, "r") as fh: + settings = json.load(fh) + +for entry in settings.get("hooks", {}).get("Stop", []): + for hook in entry.get("hooks", []): + if hook.get("type") == "command" and hook.get("command") == stop_cmd: + raise SystemExit(0) + +raise SystemExit(1) +PYTHON + else + grep -qF "\"command\": \"$stop_cmd\"" "$file" && return 0 + fi + fi + + [[ -f "$GLOBAL_HOOKS_FILE" ]] +} + # Check if notifications are enabled for current project is_enabled_project() { local project_root=$(get_project_root 2>/dev/null || echo "$PWD") diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index bf472b9..9669cd3 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -251,6 +251,13 @@ else test_fail "ask_user alert preservation failed" fi +test_start "alert changes auto-apply" +if bash tests/test-alert-auto-apply.sh >/dev/null 2>&1; then + test_pass +else + test_fail "alert changes were not applied to enabled Claude hooks" +fi + # Test 24: omp (Oh My Pi) extension install/detect/notify test_start "omp extension support" if bash tests/test-omp-extension.sh >/dev/null 2>&1; then diff --git a/tests/test-alert-auto-apply.sh b/tests/test-alert-auto-apply.sh new file mode 100755 index 0000000..babb8e0 --- /dev/null +++ b/tests/test-alert-auto-apply.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +export HOME="$test_dir/home" +export CLAUDE_HOME="$HOME/.claude" +export CLAUDE_SETTINGS_HOME="$CLAUDE_HOME" +mkdir -p "$CLAUDE_HOME/notifications" + +source "$ROOT_DIR/lib/code-notify/utils/colors.sh" +source "$ROOT_DIR/lib/code-notify/core/config.sh" +source "$ROOT_DIR/lib/code-notify/commands/global.sh" + +notify_script="$test_dir/notify.sh" +get_notify_script() { + printf '%s\n' "$notify_script" +} + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +get_claude_matcher() { + python3 - "$GLOBAL_SETTINGS_FILE" "$notify_script" <<'PYTHON' +import json +import sys + +settings_file, notify_script = sys.argv[1:3] +with open(settings_file, "r") as fh: + settings = json.load(fh) + +command = f"{notify_script} notification claude" +for entry in settings.get("hooks", {}).get("Notification", []): + if any(hook.get("command") == command for hook in entry.get("hooks", [])): + print(entry.get("matcher", ""), end="") + break +PYTHON +} + +enable_hooks_in_settings +[[ "$(get_claude_matcher)" == "idle_prompt" ]] || fail "initial matcher is incorrect" + +add_alert_type permission_prompt >/dev/null +[[ "$(get_claude_matcher)" == "idle_prompt|permission_prompt" ]] || + fail "adding permission_prompt did not immediately refresh Claude hooks" + +# Repeating the command must also repair a stale matcher left by an older +# version that changed notify-types without rewriting settings.json. +python3 - "$GLOBAL_SETTINGS_FILE" <<'PYTHON' +import json +import sys + +path = sys.argv[1] +with open(path, "r") as fh: + settings = json.load(fh) +settings["hooks"]["Notification"][0]["matcher"] = "idle_prompt" +with open(path, "w") as fh: + json.dump(settings, fh, indent=2) + fh.write("\n") +PYTHON + +add_alert_type permission_prompt >/dev/null +[[ "$(get_claude_matcher)" == "idle_prompt|permission_prompt" ]] || + fail "re-adding an enabled type did not repair the stale matcher" + +remove_alert_type permission_prompt >/dev/null +[[ "$(get_claude_matcher)" == "idle_prompt" ]] || + fail "removing permission_prompt did not immediately refresh Claude hooks" + +add_alert_type permission_prompt >/dev/null +reset_alert_types >/dev/null +[[ "$(get_claude_matcher)" == "idle_prompt" ]] || + fail "reset did not immediately refresh Claude hooks" + +echo "PASS: alert changes automatically refresh enabled Claude hooks"