@@ -45,44 +45,45 @@ def test_nested_format(self):
4545 def test_custom_port (self ):
4646 config = _build_hook_config (9999 , None )
4747 cmd = config ["SessionStart" ][0 ]["hooks" ][0 ]["command" ]
48- assert "localhost: 9999" in cmd
48+ assert "--port 9999" in cmd
4949
5050 def test_custom_event_subset (self ):
5151 config = _build_hook_config (8484 , ["SessionStart" , "SessionEnd" ])
5252 assert len (config ) == 2
5353 assert "SessionStart" in config
5454 assert "SessionEnd" in config
5555
56- def test_reads_stdin (self ):
56+ def test_invokes_hook_handler_module (self ):
5757 config = _build_hook_config (8484 , ["PostToolUse" ])
5858 cmd = config ["PostToolUse" ][0 ]["hooks" ][0 ]["command" ]
59- assert "sys.stdin" in cmd
60-
61- def test_posts_to_api_hooks (self ):
62- config = _build_hook_config (8484 , ["PostToolUse" ])
63- cmd = config ["PostToolUse" ][0 ]["hooks" ][0 ]["command" ]
64- assert "/api/hooks" in cmd
59+ assert "aictl.hook_handler" in cmd
6560
6661 def test_sets_event_name (self ):
6762 config = _build_hook_config (8484 , ["PreToolUse" ])
6863 cmd = config ["PreToolUse" ][0 ]["hooks" ][0 ]["command" ]
69- assert "' PreToolUse' " in cmd
64+ assert "--event PreToolUse" in cmd
7065
71- def test_merges_env_vars (self ):
66+ def test_no_inline_python (self ):
67+ """Command must invoke the module, not an inline -c one-liner."""
7268 config = _build_hook_config (8484 , ["SessionStart" ])
7369 cmd = config ["SessionStart" ][0 ]["hooks" ][0 ]["command" ]
74- assert "SESSION_ID" in cmd
75- assert "CWD " in cmd
70+ assert " -c " not in cmd
71+ assert "-m aictl.hook_handler " in cmd
7672
7773
7874class TestIsAictlHook :
79- def test_detects_nested_format (self ):
80- """Current nested format {"matcher", "hooks": [...]} is detected."""
75+ def test_detects_module_format (self ):
76+ """Current module-based format is detected."""
77+ hook = {"matcher" : "" , "hooks" : [{"type" : "command" , "command" : "python -m aictl.hook_handler --event SessionStart --port 8484" }]}
78+ assert _is_aictl_hook (hook )
79+
80+ def test_detects_old_inline_format (self ):
81+ """Previous inline -c format with /api/hooks is detected for migration."""
8182 hook = {"matcher" : "" , "hooks" : [{"type" : "command" , "command" : "python -c '...http://localhost:8484/api/hooks...'" }]}
8283 assert _is_aictl_hook (hook )
8384
8485 def test_detects_old_flat_format_for_migration (self ):
85- """Old flat format (pre-fix) is detected so reinstall can clean it up."""
86+ """Legacy flat format (pre-fix) is detected so reinstall can clean it up."""
8687 hook = {"type" : "command" , "command" : "curl -s http://localhost:8484/api/hooks ..." }
8788 assert _is_aictl_hook (hook )
8889
@@ -237,7 +238,7 @@ def test_force_installs_alongside_user_hooks(self, tmp_settings):
237238 else :
238239 all_cmds .append (h .get ("command" , "" ))
239240 assert any ("my-linter.sh" in c for c in all_cmds )
240- assert any ("/api/hooks " in c for c in all_cmds )
241+ assert any ("aictl.hook_handler " in c for c in all_cmds )
241242
242243 def test_no_conflict_when_only_aictl_hooks_exist (self , tmp_settings ):
243244 """Re-installing when only old aictl hooks exist should succeed without --force."""
@@ -332,17 +333,67 @@ def test_hook_events_match_validator(self):
332333 )
333334
334335
336+ class TestHookHandler :
337+ """Tests for the aictl.hook_handler module."""
338+
339+ def test_enriches_event_and_env (self , monkeypatch ):
340+ import io
341+ from aictl .hook_handler import main
342+ monkeypatch .setattr ("sys.argv" , ["hook_handler" , "--event" , "PreToolUse" , "--port" , "9999" ])
343+ monkeypatch .setattr ("sys.stdin" , io .StringIO ('{"tool": "Bash"}' ))
344+ monkeypatch .setenv ("SESSION_ID" , "test-session" )
345+ monkeypatch .setenv ("CWD" , "/tmp" )
346+ captured = io .StringIO ()
347+ monkeypatch .setattr ("sys.stdout" , captured )
348+ # urlopen will fail (no server) but should not raise
349+ main ()
350+ result = json .loads (captured .getvalue ())
351+ assert result ["event" ] == "PreToolUse"
352+ assert result ["tool" ] == "Bash"
353+ assert result ["session_id" ] == "test-session"
354+ assert result ["cwd" ] == "/tmp"
355+
356+ def test_empty_stdin (self , monkeypatch ):
357+ """When stdin is a tty (no pipe), handler uses empty dict."""
358+ import io
359+ from aictl .hook_handler import main
360+ monkeypatch .setattr ("sys.argv" , ["hook_handler" , "--event" , "SessionStart" , "--port" , "8484" ])
361+
362+ class FakeTTY (io .StringIO ):
363+ def isatty (self ):
364+ return True
365+
366+ monkeypatch .setattr ("sys.stdin" , FakeTTY ())
367+ captured = io .StringIO ()
368+ monkeypatch .setattr ("sys.stdout" , captured )
369+ main ()
370+ result = json .loads (captured .getvalue ())
371+ assert result ["event" ] == "SessionStart"
372+
373+ def test_does_not_overwrite_existing_session_id (self , monkeypatch ):
374+ """If payload already has session_id, don't overwrite with env var."""
375+ import io
376+ from aictl .hook_handler import main
377+ monkeypatch .setattr ("sys.argv" , ["hook_handler" , "--event" , "Stop" , "--port" , "8484" ])
378+ monkeypatch .setattr ("sys.stdin" , io .StringIO ('{"session_id": "from-payload"}' ))
379+ monkeypatch .setenv ("SESSION_ID" , "from-env" )
380+ captured = io .StringIO ()
381+ monkeypatch .setattr ("sys.stdout" , captured )
382+ main ()
383+ result = json .loads (captured .getvalue ())
384+ assert result ["session_id" ] == "from-payload"
385+
386+
335387class TestPythonCmd :
336388 """Hook command uses sys.executable, not the hardcoded 'python3' string."""
337389
338390 def test_does_not_use_bare_python3 (self ):
339- """Hook command must not start with the bare 'python3' invocation.
391+ """Hook command must not start with a bare 'python3' invocation.
340392
341393 The interpreter is sys.executable (a full path, possibly containing
342- 'python3' as part of the path, e.g. /usr/bin/python3.11). The old
343- implementation used the bare literal 'python3 -c', which breaks on
344- Windows where 'python3' is not available. We check that the command
345- starts with a quoted full path, not the literal word 'python3'.
394+ 'python3' as part of the path, e.g. /usr/bin/python3.11). We check
395+ that the command starts with a quoted full path, not the literal
396+ word 'python3', since 'python3' is not available on Windows.
346397 """
347398 import sys
348399 config = _build_hook_config (8484 , ["SessionStart" ])
0 commit comments