Skip to content
Open
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
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cc_notifier.context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
28 changes: 20 additions & 8 deletions cc_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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..."
Expand Down
46 changes: 46 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
7 changes: 4 additions & 3 deletions tests/tests.context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading