Skip to content

Commit a72776c

Browse files
committed
Clean up hook types and implement example
1 parent ff4fe89 commit a72776c

File tree

2 files changed

+227
-9
lines changed

2 files changed

+227
-9
lines changed

examples/hooks.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python
2+
"""Example of using hooks with Claude Code SDK via ClaudeCodeOptions.
3+
4+
This file demonstrates various hook patterns using the hooks parameter
5+
in ClaudeCodeOptions instead of decorator-based hooks.
6+
7+
Usage:
8+
./examples/hooks.py - List the examples
9+
./examples/hooks.py all - Run all examples
10+
./examples/hooks.py PreToolUse - Run a specific example
11+
"""
12+
13+
import asyncio
14+
import logging
15+
import sys
16+
from typing import Any
17+
18+
from claude_code_sdk import ClaudeCodeOptions, ClaudeSDKClient
19+
from claude_code_sdk.types import (
20+
AssistantMessage,
21+
HookContext,
22+
HookJSONOutput,
23+
HookMatcher,
24+
Message,
25+
ResultMessage,
26+
TextBlock,
27+
)
28+
29+
# Set up logging to see what's happening
30+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
31+
logger = logging.getLogger(__name__)
32+
33+
34+
def display_message(msg: Message) -> None:
35+
"""Standardized message display function."""
36+
if isinstance(msg, AssistantMessage):
37+
for block in msg.content:
38+
if isinstance(block, TextBlock):
39+
print(f"Claude: {block.text}")
40+
elif isinstance(msg, ResultMessage):
41+
print("Result ended")
42+
43+
44+
##### Hook callback functions
45+
async def check_bash_command(
46+
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
47+
) -> HookJSONOutput:
48+
"""Prevent certain bash commands from being executed."""
49+
tool_name = input_data["tool_name"]
50+
tool_input = input_data["tool_input"]
51+
52+
if tool_name != "Bash":
53+
return {}
54+
55+
command = tool_input.get("command", "")
56+
block_patterns = ["foo.sh"]
57+
58+
for pattern in block_patterns:
59+
if pattern in command:
60+
logger.warning(f"Blocked command: {command}")
61+
return {
62+
"hookSpecificOutput": {
63+
"hookEventName": "PreToolUse",
64+
"permissionDecision": "deny",
65+
"permissionDecisionReason": f"Command contains invalid pattern: {pattern}",
66+
}
67+
}
68+
69+
return {}
70+
71+
72+
async def add_custom_instructions(
73+
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
74+
) -> HookJSONOutput:
75+
"""Add custom instructions when a session starts."""
76+
return {
77+
"hookSpecificOutput": {
78+
"hookEventName": "SessionStart",
79+
"additionalContext": "My favorite color is hot pink",
80+
}
81+
}
82+
83+
84+
async def example_pretooluse() -> None:
85+
"""Basic example demonstrating hook protection."""
86+
print("=== PreToolUse Example ===")
87+
print("This example demonstrates how PreToolUse can block some bash commands but not others.\n")
88+
89+
# Configure hooks using ClaudeCodeOptions
90+
options = ClaudeCodeOptions(
91+
allowed_tools=["Bash"],
92+
hooks={
93+
"PreToolUse": [
94+
HookMatcher(matcher="Bash", hooks=[check_bash_command]),
95+
],
96+
}
97+
)
98+
99+
async with ClaudeSDKClient(options=options) as client:
100+
# Test 1: Command with forbidden pattern (will be blocked)
101+
print("Test 1: Trying a command that our PreToolUse hook should block...")
102+
print("User: Run the bash command: ./foo.sh --help")
103+
await client.query("Run the bash command: ./foo.sh --help")
104+
105+
async for msg in client.receive_response():
106+
display_message(msg)
107+
108+
print("\n" + "=" * 50 + "\n")
109+
110+
# Test 2: Safe command that should work
111+
print("Test 2: Trying a command that our PreToolUse hook should allow...")
112+
print("User: Run the bash command: echo 'Hello from hooks example!'")
113+
await client.query("Run the bash command: echo 'Hello from hooks example!'")
114+
115+
async for msg in client.receive_response():
116+
display_message(msg)
117+
118+
print("\n" + "=" * 50 + "\n")
119+
120+
print("\n")
121+
122+
123+
async def example_userpromptsubmit() -> None:
124+
"""Demonstrate context retention across conversation."""
125+
print("=== UserPromptSubmit Example ===")
126+
print("This example shows how a UserPromptSubmit hook can add context.\n")
127+
128+
options = ClaudeCodeOptions(
129+
hooks={
130+
"UserPromptSubmit": [
131+
HookMatcher(matcher=None, hooks=[add_custom_instructions]),
132+
],
133+
}
134+
)
135+
136+
async with ClaudeSDKClient(options=options) as client:
137+
print("User: What's my favorite color?")
138+
await client.query("What's my favorite color?")
139+
140+
async for msg in client.receive_response():
141+
display_message(msg)
142+
143+
print("\n")
144+
145+
146+
async def main() -> None:
147+
"""Run all examples or a specific example based on command line argument."""
148+
examples = {
149+
"PreToolUse": example_pretooluse,
150+
"UserPromptSubmit": example_userpromptsubmit,
151+
}
152+
153+
if len(sys.argv) < 2:
154+
# List available examples
155+
print("Usage: python hooks.py <example_name>")
156+
print("\nAvailable examples:")
157+
print(" all - Run all examples")
158+
for name in examples:
159+
print(f" {name}")
160+
sys.exit(0)
161+
162+
example_name = sys.argv[1]
163+
164+
if example_name == "all":
165+
# Run all examples
166+
for example in examples.values():
167+
await example()
168+
print("-" * 50 + "\n")
169+
elif example_name in examples:
170+
# Run specific example
171+
await examples[example_name]()
172+
else:
173+
print(f"Error: Unknown example '{example_name}'")
174+
print("\nAvailable examples:")
175+
print(" all - Run all examples")
176+
for name in examples:
177+
print(f" {name}")
178+
sys.exit(1)
179+
180+
181+
if __name__ == "__main__":
182+
print("Starting Claude SDK Hooks Examples...")
183+
print("=" * 50 + "\n")
184+
asyncio.run(main())

src/claude_code_sdk/types.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,31 @@ class PermissionResultDeny:
8787
]
8888

8989

90-
# Hook callback types
90+
##### Hook types
91+
# Supported hook event types. Due to setup limitations, the Python SDK does not
92+
# support SessionStart, SessionEnd, and Notification hooks.
93+
HookEvent = (
94+
Literal["PreToolUse"]
95+
| Literal["PostToolUse"]
96+
| Literal["UserPromptSubmit"]
97+
| Literal["Stop"]
98+
| Literal["SubagentStop"]
99+
| Literal["PreCompact"]
100+
)
101+
102+
# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output
103+
# for documentation of the output types. Currently, "continue", "stopReason",
104+
# and "suppressOutput" are not supported in the Python SDK.
105+
class HookJSONOutput(TypedDict):
106+
# Whether to block the action related to the hook.
107+
decision: NotRequired[Literal['block']]
108+
# Optionally add a system message that is not visible to Claude but saved in
109+
# the chat transcript.
110+
systemMessage: NotRequired[str]
111+
# See each hook's individual "Decision Control" section in the documentation
112+
# for guidance.
113+
hookSpecificOutput: NotRequired[Any]
114+
91115
@dataclass
92116
class HookContext:
93117
"""Context information for hook callbacks."""
@@ -96,8 +120,14 @@ class HookContext:
96120

97121

98122
HookCallback = Callable[
99-
[dict[str, Any], str | None, HookContext], # input, tool_use_id, context
100-
Awaitable[dict[str, Any]], # response data
123+
# HookCallback input parameters:
124+
# - input
125+
# See https://docs.anthropic.com/en/docs/claude-code/hooks#hook-input for
126+
# the type of 'input', the first value.
127+
# - tool_use_id
128+
# - context
129+
[dict[str, Any], str | None, HookContext],
130+
Awaitable[HookJSONOutput],
101131
]
102132

103133

@@ -106,8 +136,14 @@ class HookContext:
106136
class HookMatcher:
107137
"""Hook matcher configuration."""
108138

109-
matcher: dict[str, Any] | None = None # Matcher criteria
110-
hooks: list[HookCallback] = field(default_factory=list) # Callbacks to invoke
139+
# See https://docs.anthropic.com/en/docs/claude-code/hooks#structure for the
140+
# expected string value. For example, for PreToolUse, the matcher can be
141+
# a tool name like "Bash" or a combination of tool names like
142+
# "Write|MultiEdit|Edit".
143+
matcher: str | None = None
144+
145+
# A list of Python functions with function signature HookCallback
146+
hooks: list[HookCallback] = field(default_factory=list)
111147

112148

113149
# MCP Server config
@@ -227,7 +263,6 @@ class ResultMessage:
227263

228264
Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage
229265

230-
231266
@dataclass
232267
class ClaudeCodeOptions:
233268
"""Query options for Claude SDK."""
@@ -259,7 +294,7 @@ class ClaudeCodeOptions:
259294
can_use_tool: CanUseTool | None = None
260295

261296
# Hook configurations
262-
hooks: dict[str, list[HookMatcher]] | None = None
297+
hooks: dict[HookEvent, list[HookMatcher]] | None = None
263298

264299

265300
# SDK Control Protocol
@@ -278,8 +313,7 @@ class SDKControlPermissionRequest(TypedDict):
278313

279314
class SDKControlInitializeRequest(TypedDict):
280315
subtype: Literal["initialize"]
281-
# TODO: Use HookEvent names as the key.
282-
hooks: dict[str, Any] | None
316+
hooks: dict[HookEvent, Any] | None
283317

284318

285319
class SDKControlSetPermissionModeRequest(TypedDict):

0 commit comments

Comments
 (0)