Skip to content

Commit 8d8d39b

Browse files
committed
refactor(hooks): introduce typed HookDecision classes
Add hook_decision.py with HookEvent, PreToolUseOutcome, PreToolUseDecision, and PostToolUseContext. Each class owns its own to_payload() serialization so callers have no branches over event type. hooks_cmd.py is untouched; existing tests remain green.
1 parent 2905652 commit 8d8d39b

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

jupyter_jcli/hook_decision.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Typed hook decisions — each class owns its Claude Code output schema.
2+
3+
PreToolUse and PostToolUse have fundamentally different JSON shapes
4+
and semantics. PreToolUse is a permission gate (allow/deny/ask).
5+
PostToolUse can only inject context — the tool already ran, so there
6+
is no meaningful way to "deny" it. Each valid (event, outcome) pair
7+
is modelled as its own dataclass so the emitter has no branches.
8+
9+
See https://code.claude.com/docs/en/hooks for the wire schema.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from dataclasses import dataclass
15+
from enum import Enum
16+
from typing import Protocol
17+
18+
19+
class HookEvent(str, Enum):
20+
"""Claude Code hook event names — single source of truth for the
21+
wire strings that appear in payload's hookEventName field."""
22+
PRE_TOOL_USE = "PreToolUse"
23+
POST_TOOL_USE = "PostToolUse"
24+
25+
26+
class PreToolUseOutcome(str, Enum):
27+
"""Valid values for PreToolUse permissionDecision."""
28+
ALLOW = "allow"
29+
DENY = "deny"
30+
ASK = "ask"
31+
32+
33+
class HookDecision(Protocol):
34+
"""Anything that serializes to a Claude Code hook JSON payload."""
35+
def to_payload(self) -> dict: ...
36+
37+
38+
@dataclass(frozen=True)
39+
class PreToolUseDecision:
40+
"""Permission gate emitted from a PreToolUse hook.
41+
42+
- ALLOW/ASK: reason shown to the user
43+
- DENY: reason shown to Claude (fed into its context)
44+
"""
45+
outcome: PreToolUseOutcome
46+
reason: str
47+
48+
def to_payload(self) -> dict:
49+
return {
50+
"hookSpecificOutput": {
51+
"hookEventName": HookEvent.PRE_TOOL_USE.value,
52+
"permissionDecision": self.outcome.value,
53+
"permissionDecisionReason": self.reason,
54+
}
55+
}
56+
57+
58+
@dataclass(frozen=True)
59+
class PostToolUseContext:
60+
"""Non-blocking context injection from a PostToolUse hook.
61+
62+
The tool already ran — we can only tell Claude what happened.
63+
Used for both "paired file auto-synced" and "paired file drifted,
64+
someone else may have edited it" notifications.
65+
"""
66+
context: str
67+
68+
def to_payload(self) -> dict:
69+
return {
70+
"hookSpecificOutput": {
71+
"hookEventName": HookEvent.POST_TOOL_USE.value,
72+
"additionalContext": self.context,
73+
}
74+
}

tests/test_hook_decision.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Unit tests for jupyter_jcli.hook_decision typed decision classes."""
2+
3+
import pytest
4+
5+
from jupyter_jcli.hook_decision import (
6+
HookEvent,
7+
PostToolUseContext,
8+
PreToolUseDecision,
9+
PreToolUseOutcome,
10+
)
11+
12+
13+
class TestHookEvent:
14+
def test_pre_tool_use_value(self):
15+
assert HookEvent.PRE_TOOL_USE == "PreToolUse"
16+
assert isinstance(HookEvent.PRE_TOOL_USE, str)
17+
18+
def test_post_tool_use_value(self):
19+
assert HookEvent.POST_TOOL_USE == "PostToolUse"
20+
assert isinstance(HookEvent.POST_TOOL_USE, str)
21+
22+
def test_invalid_event_raises(self):
23+
with pytest.raises(ValueError):
24+
HookEvent("bogus")
25+
26+
27+
class TestPreToolUseOutcome:
28+
def test_allow(self):
29+
assert PreToolUseOutcome.ALLOW == "allow"
30+
assert isinstance(PreToolUseOutcome.ALLOW, str)
31+
32+
def test_deny(self):
33+
assert PreToolUseOutcome.DENY == "deny"
34+
35+
def test_ask(self):
36+
assert PreToolUseOutcome.ASK == "ask"
37+
38+
def test_invalid_outcome_raises(self):
39+
with pytest.raises(ValueError):
40+
PreToolUseOutcome("bogus")
41+
42+
43+
class TestPreToolUseDecision:
44+
def test_deny_payload(self):
45+
d = PreToolUseDecision(PreToolUseOutcome.DENY, "you shall not pass")
46+
p = d.to_payload()
47+
assert p == {
48+
"hookSpecificOutput": {
49+
"hookEventName": "PreToolUse",
50+
"permissionDecision": "deny",
51+
"permissionDecisionReason": "you shall not pass",
52+
}
53+
}
54+
55+
def test_allow_payload(self):
56+
d = PreToolUseDecision(PreToolUseOutcome.ALLOW, "go ahead")
57+
p = d.to_payload()
58+
assert p["hookSpecificOutput"]["permissionDecision"] == "allow"
59+
assert p["hookSpecificOutput"]["permissionDecisionReason"] == "go ahead"
60+
assert "additionalContext" not in p["hookSpecificOutput"]
61+
62+
def test_ask_payload(self):
63+
d = PreToolUseDecision(PreToolUseOutcome.ASK, "are you sure?")
64+
p = d.to_payload()
65+
assert p["hookSpecificOutput"]["permissionDecision"] == "ask"
66+
assert p["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
67+
68+
def test_no_additional_context_in_pre(self):
69+
d = PreToolUseDecision(PreToolUseOutcome.DENY, "reason")
70+
p = d.to_payload()
71+
assert "additionalContext" not in p["hookSpecificOutput"]
72+
assert "decision" not in p
73+
74+
75+
class TestPostToolUseContext:
76+
def test_payload_shape(self):
77+
c = PostToolUseContext("paired notebook drifted, run j-cli convert")
78+
p = c.to_payload()
79+
assert p == {
80+
"hookSpecificOutput": {
81+
"hookEventName": "PostToolUse",
82+
"additionalContext": "paired notebook drifted, run j-cli convert",
83+
}
84+
}
85+
86+
def test_no_permission_decision_in_post(self):
87+
c = PostToolUseContext("some context")
88+
p = c.to_payload()
89+
assert "permissionDecision" not in p["hookSpecificOutput"]
90+
assert "permissionDecisionReason" not in p["hookSpecificOutput"]
91+
assert "decision" not in p
92+
93+
def test_auto_synced_context(self):
94+
c = PostToolUseContext("Auto-synced foo.py to bar.ipynb. Pair is now in sync.")
95+
p = c.to_payload()
96+
assert "Auto-synced" in p["hookSpecificOutput"]["additionalContext"]

0 commit comments

Comments
 (0)