diff --git a/hooks/notify.py b/hooks/notify.py index af0d2d7..7207012 100755 --- a/hooks/notify.py +++ b/hooks/notify.py @@ -1,9 +1,15 @@ #!/usr/bin/env python3 """Cursor hook script that notifies the cursor-flasher daemon via Unix socket. -Installed as a Cursor hook (preToolUse, stop) to trigger a window flash -when the agent needs user attention. Reads hook JSON from stdin, extracts -workspace and event info, and sends it to the daemon's socket. +Installed as a Cursor hook to trigger a window flash when the agent needs +user attention. Reads hook JSON from stdin, extracts workspace and event +info, and sends it to the daemon's socket. + +Shell-specific hook mapping (beforeShellExecution -> shellApproved, +afterShellExecution -> shellCompleted) is retained but currently dead +code — those hooks are disabled in HOOKS_CONFIG / hooks.json because +beforeShellExecution fires pre-approval and Cursor expects a JSON +response we don't provide. """ import json import os @@ -12,6 +18,11 @@ import sys SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock") +_SHELL_EVENT_MAP = { + "beforeShellExecution": "shellApproved", + "afterShellExecution": "shellCompleted", +} + def main() -> None: try: @@ -22,9 +33,13 @@ def main() -> None: workspace_roots = data.get("workspace_roots") or [] workspace = workspace_roots[0] if workspace_roots else "" event = data.get("hook_event_name", "") - tool = data.get("tool_name", "") - msg = json.dumps({"workspace": workspace, "event": event, "tool": tool}) + mapped_event = _SHELL_EVENT_MAP.get(event) + if mapped_event: + msg = json.dumps({"workspace": workspace, "event": mapped_event, "tool": "Shell"}) + else: + tool = data.get("tool_name", "") + msg = json.dumps({"workspace": workspace, "event": event, "tool": tool}) try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/cursor_flasher/cli.py b/src/cursor_flasher/cli.py index 2ec7625..fcf290f 100644 --- a/src/cursor_flasher/cli.py +++ b/src/cursor_flasher/cli.py @@ -23,11 +23,27 @@ HOOKS_CONFIG = { "postToolUseFailure": [ {"command": f"./hooks/{HOOK_SCRIPT_NAME}"} ], + # DEAD CODE: shell execution hooks disabled — Cursor expects a JSON + # response from beforeShellExecution that we don't provide, and the + # daemon handlers (shellApproved/shellCompleted) are no-ops or redundant + # with postToolUse. Re-enable by uncommenting if a use is found. + # "beforeShellExecution": [ + # {"command": f"./hooks/{HOOK_SCRIPT_NAME}"} + # ], + # "afterShellExecution": [ + # {"command": f"./hooks/{HOOK_SCRIPT_NAME}"} + # ], "stop": [ {"command": f"./hooks/{HOOK_SCRIPT_NAME}"} ], } +# Events to clean from hooks.json on uninstall, including disabled ones. +_ALL_HOOK_EVENTS = list(HOOKS_CONFIG.keys()) + [ + "beforeShellExecution", + "afterShellExecution", +] + def _write_pid() -> None: PID_FILE.parent.mkdir(parents=True, exist_ok=True) @@ -110,7 +126,7 @@ def cmd_uninstall(args: argparse.Namespace) -> None: hooks = config.get("hooks", {}) changed = False - for event in HOOKS_CONFIG: + for event in _ALL_HOOK_EVENTS: if event in hooks: before = len(hooks[event]) hooks[event] = [ diff --git a/src/cursor_flasher/daemon.py b/src/cursor_flasher/daemon.py index 4a15a5f..9349df0 100644 --- a/src/cursor_flasher/daemon.py +++ b/src/cursor_flasher/daemon.py @@ -303,6 +303,12 @@ class FlasherDaemon: self._handle_approval(workspace, tool) elif event in ("postToolUse", "postToolUseFailure"): self._handle_dismiss(workspace, event, tool) + # DEAD CODE: shell execution hooks are disabled in hooks.json. + # shellApproved (beforeShellExecution) fires pre-approval so it + # can't distinguish "waiting for user" from "auto-approved". + # shellCompleted (afterShellExecution) is redundant with postToolUse. + elif event in ("shellApproved", "shellCompleted"): + logger.debug("Shell execution hook (disabled): event=%s workspace=%s", event, workspace) elif event == "stop": self._handle_stop(workspace) else: diff --git a/tests/test_daemon.py b/tests/test_daemon.py index c5c9d50..6522ece 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -607,3 +607,45 @@ class TestFlasherDaemon: ) assert "/a" not in daemon._pending_approvals assert "/b" in daemon._pending_approvals + + # --- shellApproved / shellCompleted (disabled, debug-logged only) --- + + def test_shellApproved_does_not_cancel_pending(self): + """shellApproved is a no-op — pending approval must survive.""" + daemon = self._make_daemon(approval_delay=10.0) + + daemon._handle_message( + json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode() + ) + assert "/path" in daemon._pending_approvals + + daemon._handle_message( + json.dumps({"workspace": "/path", "event": "shellApproved", "tool": "Shell"}).encode() + ) + assert "/path" in daemon._pending_approvals + + def test_shellCompleted_does_not_dismiss(self): + """shellCompleted is a no-op — active pulse must survive.""" + daemon = self._make_daemon() + daemon._active_pulses["/path"] = MagicMock() + + daemon._handle_message( + json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode() + ) + + daemon.overlay.dismiss_tag.assert_not_called() + assert "/path" in daemon._active_pulses + + def test_shellCompleted_does_not_cancel_pending(self): + """shellCompleted is a no-op — pending approval must survive.""" + daemon = self._make_daemon(approval_delay=10.0) + + daemon._handle_message( + json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode() + ) + assert "/path" in daemon._pending_approvals + + daemon._handle_message( + json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode() + ) + assert "/path" in daemon._pending_approvals diff --git a/tests/test_hook.py b/tests/test_hook.py index 3dbb069..b83515a 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -103,3 +103,74 @@ class TestHookNotify: os.unlink(sock_path) assert received[0]["workspace"] == "" + + def test_beforeShellExecution_mapped_to_shellApproved(self): + sock_path = _short_sock_path() + received = [] + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(sock_path) + server.listen(1) + + def accept(): + conn, _ = server.accept() + data = conn.recv(4096) + received.append(json.loads(data)) + conn.close() + + t = threading.Thread(target=accept) + t.start() + + try: + hook_input = json.dumps({ + "workspace_roots": ["/Users/me/project"], + "hook_event_name": "beforeShellExecution", + "command": "npm install", + }) + _run_hook_main(hook_input, sock_path) + t.join(timeout=2) + finally: + server.close() + if os.path.exists(sock_path): + os.unlink(sock_path) + + assert len(received) == 1 + assert received[0]["event"] == "shellApproved" + assert received[0]["tool"] == "Shell" + assert received[0]["workspace"] == "/Users/me/project" + + def test_afterShellExecution_mapped_to_shellCompleted(self): + sock_path = _short_sock_path() + received = [] + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(sock_path) + server.listen(1) + + def accept(): + conn, _ = server.accept() + data = conn.recv(4096) + received.append(json.loads(data)) + conn.close() + + t = threading.Thread(target=accept) + t.start() + + try: + hook_input = json.dumps({ + "workspace_roots": ["/Users/me/project"], + "hook_event_name": "afterShellExecution", + "command": "npm install", + "output": "added 100 packages", + "duration": 5432, + }) + _run_hook_main(hook_input, sock_path) + t.join(timeout=2) + finally: + server.close() + if os.path.exists(sock_path): + os.unlink(sock_path) + + assert len(received) == 1 + assert received[0]["event"] == "shellCompleted" + assert received[0]["tool"] == "Shell"