Skip to content

Commit 7bdd719

Browse files
author
Zvi
committed
refactor: replace inline hook commands with hook_handler module
Inline python -c one-liners with nested escaping (exec, triple-escaped URLs) broke on Windows and were fragile to quote. Now hooks invoke `python -m aictl.hook_handler --event X --port Y` — a proper module that handles stdin/stdout passthrough and server POST. _is_aictl_hook detects all three hook generations (module, inline, curl) so install/uninstall can clean up any legacy format.
1 parent 92c6893 commit 7bdd719

3 files changed

Lines changed: 158 additions & 45 deletions

File tree

aictl/commands/integrations.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,28 @@ def _settings_path(scope: str) -> Path:
138138
return claude_global_dir() / "settings.json"
139139

140140

141+
_AICTL_HOOK_MARKERS = ("/api/hooks", "aictl.hook_handler")
142+
143+
141144
def _is_aictl_hook(hook: dict) -> bool:
142145
"""Return True if the hook entry was installed by aictl.
143146
144-
Handles both the current nested format {"matcher": ..., "hooks": [...]} and
145-
the old flat format {"type": "command", "command": "..."} (for migration).
147+
Detects three generations of hook format:
148+
- Current: ``python -m aictl.hook_handler --event ...``
149+
- Previous: inline ``python -c "... /api/hooks ..."``
150+
- Legacy flat: ``curl ... /api/hooks ...``
146151
"""
147152
if not isinstance(hook, dict):
148153
return False
149-
# Current format: {"matcher": ..., "hooks": [{"type": "command", "command": "..."}]}
154+
# Current nested format: {"matcher": ..., "hooks": [{"type": "command", "command": "..."}]}
150155
if "hooks" in hook and isinstance(hook["hooks"], list):
151-
return any("/api/hooks" in str(h.get("command", "")) for h in hook["hooks"])
156+
return any(
157+
any(m in str(h.get("command", "")) for m in _AICTL_HOOK_MARKERS)
158+
for h in hook["hooks"]
159+
)
152160
# Old flat format (pre-fix): {"type": "command", "command": "..."}
153-
return "/api/hooks" in str(hook.get("command", ""))
161+
cmd = str(hook.get("command", ""))
162+
return any(m in cmd for m in _AICTL_HOOK_MARKERS)
154163

155164

156165
def _python_cmd() -> str:
@@ -168,9 +177,12 @@ def _python_cmd() -> str:
168177
def _build_hook_config(port: int, events: list[str] | None, event_map: dict[str, str] | None = None, matcher: str = "") -> dict:
169178
"""Build the hooks configuration dict for AI tools.
170179
171-
Each hook reads the rich JSON payload from stdin, merges in
172-
environment variables, and POSTs everything to aictl.
173-
Uses sys.executable instead of 'python3' for cross-platform compatibility.
180+
Each hook invokes ``python -m aictl.hook_handler`` which reads the
181+
rich JSON payload from stdin, merges environment variables, POSTs
182+
everything to aictl, and passes the payload through to stdout.
183+
184+
Using a module invocation instead of an inline ``-c`` one-liner
185+
avoids shell-escaping issues on Windows and complex quoting.
174186
175187
Optional event_map can translate internal HOOK_EVENTS names to tool-specific names.
176188
matcher: "" for Claude (default), "*" for Gemini.
@@ -179,24 +191,9 @@ def _build_hook_config(port: int, events: list[str] | None, event_map: dict[str,
179191
hooks: dict[str, list[dict]] = {}
180192
python = _python_cmd()
181193

182-
# The hook command: read stdin (Node-based tool's rich JSON), merge env vars, POST to aictl.
183-
# We must print the final JSON to stdout for tool continuity.
184194
for event in target_events:
185195
tool_event_name = event_map.get(event, event) if event_map else event
186-
# Single-line Python command using semicolons for maximum compatibility.
187-
# No literal newlines or complex escaping.
188-
cmd = (
189-
f"{python} -c \""
190-
f"import sys,json,os,urllib.request as u; "
191-
f"d=json.load(sys.stdin) if not sys.stdin.isatty() else {{}}; "
192-
f"d['event']='{event}'; "
193-
f"d.setdefault('session_id',os.environ.get('SESSION_ID','')); "
194-
f"d.setdefault('cwd',os.environ.get('CWD','')); "
195-
f"exec('try: u.urlopen(u.Request(\\\"http://localhost:{port}/api/hooks\\\", "
196-
f"json.dumps(d).encode(), {{\\\"Content-Type\\\":\\\"application/json\\\"}}), timeout=2)\\n"
197-
f"except: pass'); " # noqa: S110 — generated hook script must not crash if aictl server is unreachable
198-
f"print(json.dumps(d))\""
199-
)
196+
cmd = f"{python} -m aictl.hook_handler --event {event} --port {port}"
200197
hooks[tool_event_name] = [{"matcher": matcher, "hooks": [{"type": "command", "command": cmd}]}]
201198
return hooks
202199

aictl/hook_handler.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# aictl - Cross-platform AI Tool Context Control + Dashboard
2+
# Copyright (c) 2026 Zvi Schneider. MIT License.
3+
"""Hook handler for AI coding tool integration.
4+
5+
Invoked by AI tools (Claude Code, Gemini CLI) as a hook command.
6+
Reads JSON payload from stdin, enriches it with event metadata and
7+
environment variables, POSTs it to the aictl server, and passes the
8+
payload through to stdout.
9+
10+
Usage:
11+
python -m aictl.hook_handler --event SessionStart --port 8484
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import os
18+
import sys
19+
import urllib.request
20+
21+
22+
def main() -> None:
23+
# Minimal arg parsing — no argparse import to keep startup fast.
24+
event = ""
25+
port = 8484
26+
args = sys.argv[1:]
27+
i = 0
28+
while i < len(args):
29+
if args[i] == "--event" and i + 1 < len(args):
30+
event = args[i + 1]
31+
i += 2
32+
elif args[i] == "--port" and i + 1 < len(args):
33+
port = int(args[i + 1])
34+
i += 2
35+
else:
36+
i += 1
37+
38+
# Read payload from stdin (AI tools pipe rich JSON here).
39+
if not sys.stdin.isatty():
40+
data = json.load(sys.stdin)
41+
else:
42+
data = {}
43+
44+
# Enrich with event name and environment context.
45+
data["event"] = event
46+
data.setdefault("session_id", os.environ.get("SESSION_ID", ""))
47+
data.setdefault("cwd", os.environ.get("CWD", ""))
48+
49+
# POST to aictl server (best-effort — never crash if server is down).
50+
try:
51+
req = urllib.request.Request(
52+
f"http://localhost:{port}/api/hooks",
53+
json.dumps(data).encode(),
54+
{"Content-Type": "application/json"},
55+
)
56+
urllib.request.urlopen(req, timeout=2) # noqa: S310 — localhost only
57+
except Exception: # noqa: BLE001, S110 — must not crash if aictl unreachable
58+
pass
59+
60+
# Pass through to stdout for tool continuity.
61+
print(json.dumps(data))
62+
63+
64+
if __name__ == "__main__":
65+
main()

test/test_hooks.py

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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

7874
class 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+
335387
class 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

Comments
 (0)