Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 60 additions & 19 deletions lib/code-notify/commands/global.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions lib/code-notify/core/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions scripts/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/test-alert-auto-apply.sh
Original file line number Diff line number Diff line change
@@ -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"