diff --git a/README.md b/README.md index 9024c38..e1d3908 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,34 @@ Also enables seamless 📱 mobile development via push notifications. # Install dependencies brew install --cask hammerspoon brew install terminal-notifier +``` + +**Launch Hammerspoon for the first time and install its CLI** (required — the `hs` command is a shim that talks to a running Hammerspoon, and the symlink doesn't exist until you ask for it): + +1. Open Hammerspoon.app (grant Accessibility permission when prompted). +2. Open the Hammerspoon console (click the menu bar icon → Console). +3. Run: `hs.ipc.cliInstall()` -# Configure Hammerspoon (~/.hammerspoon/init.lua) +Then configure Hammerspoon (`~/.hammerspoon/init.lua`): + +```lua require("hs.ipc") require("hs.window") require("hs.window.filter") require("hs.timer") +``` -# Reload: hs -c "hs.timer.doAfter(0, hs.reload)" +Reload Hammerspoon: -# Install cc-notifier +```bash +hs -c "hs.timer.doAfter(0, hs.reload)" +``` + +> If this command hangs, Hammerspoon isn't running or the CLI wasn't installed — repeat the launch + `hs.ipc.cliInstall()` step above. + +Install cc-notifier: + +```bash git clone https://github.com/trentmcnitt/cc-notifier.git cd cc-notifier ./install.sh diff --git a/cc_notifier.context.md b/cc_notifier.context.md index e9b5749..1edd5b5 100644 --- a/cc_notifier.context.md +++ b/cc_notifier.context.md @@ -40,6 +40,7 @@ Flows are in the order they are executed, and are performed synchronously, unles **Flow**: 1. Parse hook data from stdin JSON 2. Load original window ID and tmux session ID from session file + - If session file is missing (init never ran for this session — e.g. installed mid-session, or Claude Code bug #7911 session ID mismatch): fall back to `UNAVAILABLE` window/app, capture current tmux session ID, skip deduplication, and proceed. Local notification path then treats it the same as "Hammerspoon missing"; push still works. 3. Check deduplication threshold (prevent spam within 2 seconds, preserves tmux session ID) 4. **Desktop Mode Only**: - If window ID is UNAVAILABLE (no Hammerspoon): diff --git a/cc_notifier.py b/cc_notifier.py index 92a1351..ea76e29 100644 --- a/cc_notifier.py +++ b/cc_notifier.py @@ -126,14 +126,26 @@ def cmd_notify() -> None: hook_data = HookData.from_stdin() session_file = SESSION_DIR / hook_data.session_id - if check_deduplication(session_file): - return - - lines = session_file.read_text().strip().split("\n") - original_window_id = lines[0] - app_path = lines[1] - tmux_session_id = lines[3] if len(lines) > 3 else "" - iterm2_session_id = lines[4] if len(lines) > 4 else "" + if session_file.exists(): + if check_deduplication(session_file): + return + lines = session_file.read_text().strip().split("\n") + original_window_id = lines[0] + app_path = lines[1] + tmux_session_id = lines[3] if len(lines) > 3 else "" + iterm2_session_id = lines[4] if len(lines) > 4 else "" + else: + # init never ran for this session (installed mid-session, or Claude Code + # session ID mismatch per bug #7911). Fall through with no original + # window context — local notification path treats UNAVAILABLE as + # "send unless tmux is attached", and push still works. + debug_log( + f"Session file missing for {hook_data.session_id} — falling back to UNAVAILABLE" + ) + original_window_id = "UNAVAILABLE" + app_path = "UNAVAILABLE" + tmux_session_id = get_tmux_session_id() or "" + iterm2_session_id = "" # Set global app path for error handling _CURRENT_APP_PATH = app_path diff --git a/install.sh b/install.sh index 3d5e555..6563409 100755 --- a/install.sh +++ b/install.sh @@ -34,7 +34,7 @@ if [ ${#missing_deps[@]} -ne 0 ]; then case "$dep" in "hs") echo " • Hammerspoon CLI - Install with: brew install --cask hammerspoon" - echo " After installing, ensure Hammerspoon is running and CLI is enabled" + echo " Then launch Hammerspoon.app and run hs.ipc.cliInstall() in its console" ;; "terminal-notifier") echo " • terminal-notifier - Install with: brew install terminal-notifier" @@ -49,9 +49,25 @@ if [ ${#missing_deps[@]} -ne 0 ]; then exit 1 fi -# Hammerspoon setup reminder -echo "⚠️ Remember to setup Hammerspoon" -echo " See README section: 🔧 Hammerspoon Setup" +# Verify hs CLI is actually responsive (not just present on PATH). +# A missing/idle Hammerspoon makes `hs -c` hang forever — bound it with perl's +# alarm() since macOS has no portable `timeout` command. +echo "✅ Verifying Hammerspoon CLI is responsive..." +hs_check=$(perl -e 'alarm 5; exec @ARGV' hs -c 'print("ok")' 2>&1 || echo "__HS_FAILED__") +if [ "$hs_check" != "ok" ]; then + echo "❌ Hammerspoon CLI is not responsive." + echo + echo " The 'hs' command exists, but it couldn't reach a running Hammerspoon." + echo " Usually one of:" + echo " 1. Hammerspoon.app isn't running — launch it from /Applications" + echo " 2. The CLI shim was never installed — open the Hammerspoon Console" + echo " (menu bar icon → Console) and run: hs.ipc.cliInstall()" + echo + echo " Then re-run ./install.sh." + echo + echo "📖 See README → Desktop Mode for the full setup sequence." + exit 1 +fi # Check source files exist echo "✅ Checking source files..." diff --git a/tests/test_core.py b/tests/test_core.py index a66ed58..82a1d0f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -366,6 +366,52 @@ def test_notify_sent_without_hammerspoon_or_tmux(self, tmp_path): ] assert len(terminal_notifier_calls) >= 1 + def test_notify_handles_missing_session_file(self, tmp_path): + """Test notify gracefully handles missing session file (init never ran). + + Regression for GitHub issue #11: installing mid-session left no session + file, causing FileNotFoundError to crash every Stop hook. Now we fall + through to UNAVAILABLE so push still works and the user isn't spammed + with error notifications. + """ + test_input = {"session_id": "missing-session", "cwd": "/test/project"} + session_dir = tmp_path / "cc_notifier" + session_dir.mkdir() + # Intentionally do NOT create the session file + log_file = tmp_path / ".cc-notifier" / "cc-notifier.log" + + with ( + patch.object(cc_notifier, "LOG_FILE", log_file), + patch.object(cc_notifier, "SESSION_DIR", session_dir), + patch("cc_notifier.get_tmux_session_id", return_value=None), + patch("cc_notifier.run_background_command") as mock_bg, + patch("cc_notifier.check_idle_and_notify_push") as mock_push, + patch( + "cc_notifier.PushConfig.from_env", + return_value=cc_notifier.PushConfig(token="t", user="u"), + ), + patch("sys.stdin", StringIO(json.dumps(test_input))), + patch.object(sys, "argv", ["cc-notifier", "notify"]), + patch.dict(os.environ, {"CC_NOTIFIER_WRAPPER": "1"}), + ): + cc_notifier.main() # must not raise + + # Push notification path was still reached + mock_push.assert_called_once() + # Local notification was sent (no tmux, falls through to unconditional send) + bg_calls = [call[0][0] for call in mock_bg.call_args_list] + terminal_notifier_calls = [ + cmd + for cmd in bg_calls + if any("terminal-notifier" in str(arg) for arg in cmd) + ] + assert len(terminal_notifier_calls) >= 1 + # No -execute (no original window to focus back to) + assert "-execute" not in terminal_notifier_calls[0] + # No error was logged + if log_file.exists(): + assert "[ERROR]" not in log_file.read_text() + def test_notify_workflow_user_switched_sends_notification(self, tmp_path): """Test notify workflow when user switched: JSON input → file read → real notification.""" test_input = {"session_id": "notify123", "cwd": "/test/project"} diff --git a/tests/tests.context.md b/tests/tests.context.md index 7f14012..604070b 100644 --- a/tests/tests.context.md +++ b/tests/tests.context.md @@ -8,13 +8,13 @@ Associated with: all tests in the codebase **Format**: `test_name` - [concise description of what's being tested] - [rationale for why test is needed] -**Status**: **71 total tests** (62 core + 9 integration) across 2 files - All tests properly accounted for and documented +**Status**: **72 total tests** (63 core + 9 integration) across 2 files - All tests properly accounted for and documented **Structure**: Tests are organized by functionality and concerns, emphasizing behavior-focused testing over implementation details. The 2-file structure matches the natural architectural boundary between core logic and external system integration. --- -## test_core.py (62 tests) - Core Functionality & Essential Business Logic +## test_core.py (63 tests) - Core Functionality & Essential Business Logic ### TestCLIInterface (9 tests) - Essential CLI Contract Testing - `test_main_with_no_args_exits_with_error` - CLI error handling when no command provided - CLI must provide helpful usage info and exit gracefully @@ -27,13 +27,14 @@ Associated with: all tests in the codebase - `test_main_blocks_direct_execution_without_wrapper_env` - Prevents direct execution without wrapper environment variable - Critical for preventing Claude Code hooks from blocking - `test_main_allows_execution_with_wrapper_env` - Allows execution when wrapper environment variable is set - Ensures proper wrapper integration works correctly -### TestCoreWorkflows (15 tests) - End-to-End Workflow Validation +### TestCoreWorkflows (16 tests) - End-to-End Workflow Validation - `test_init_workflow_captures_and_saves_window` - Complete init workflow from JSON input to file creation including tmux session ID - End-to-end validation of session initialization - `test_init_workflow_without_hammerspoon` - Init falls back to UNAVAILABLE but still captures tmux session ID - Validates graceful degradation - `test_init_workflow_captures_iterm2_session_id` - Init captures iTerm2 focused session ID alongside window metadata - Enables same-window tab restoration for iTerm2 - `test_notify_suppressed_when_tmux_attached_without_hammerspoon` - Notify suppresses local notification when tmux session is attached - Prevents false positives in tmux - `test_notify_sent_when_tmux_detached_without_hammerspoon` - Notify sends local notification when tmux session is detached - Ensures notifications when user truly away - `test_notify_sent_without_hammerspoon_or_tmux` - Notify sends unconditionally when neither Hammerspoon nor tmux available - Fallback behavior +- `test_notify_handles_missing_session_file` - Notify gracefully falls back to UNAVAILABLE when init never ran for the session - Regression for GitHub issue #11 (FileNotFoundError crashed every Stop hook for sessions started before install) - `test_notify_workflow_user_switched_sends_notification` - Complete notify workflow when user switched windows - End-to-end validation of notification sending - `test_notify_workflow_user_stayed_no_notification` - Complete notify workflow when user stayed on same window - End-to-end validation of intelligent notification suppression - `test_cleanup_workflow_removes_session` - Complete cleanup workflow with age-based file removal - End-to-end validation of session cleanup functionality