giginet/claudehookkit
A Swift framework for building Claude Code Hooks with type-safe APIs.
Overview
ClaudeHookKit provides a type-safe way to implement Claude Code hooks in Swift. It handles JSON serialization/deserialization, input validation, and output formatting, allowing you to focus on your hook logic.
Requirements
- Swift 6.2+
- macOS 14+
Usage
ClaudeHookKit uses the @main attribute to define the entry point. Simply add @main to your hook struct and implement the static func invoke method:
@main
struct MyHook: NotificationHook {
static func invoke(input: NotificationInput, context: Context) async -> HookResult<NotificationOutput> {
// Your hook logic here
return .exitCode(.success)
}
}The Hook protocol extension provides a static func main() that handles:
- Reading JSON input from stdin
- Decoding input to the appropriate type
- Calling your
invokemethod - Outputting results (exit code or JSON)
- Error handling
Basic Example
Here's a simple example of a Notification hook that plays a sound when Claude Code sends a notification:
import ClaudeHookKit
import Foundation
@main
struct NotificationSoundPlayer: NotificationHook {
static func invoke(input: NotificationInput, context: Context) async -> HookResult<NotificationOutput> {
// Play the default system sound
let task = Process()
task.executableURL = URL(filePath: "/usr/bin/afplay")
task.arguments = ["/System/Library/Sounds/Glass.aiff"]
try? task.run()
return .exitCode(.success)
}
}Use Decodable ToolInput
You can define custom Decodable structs to represent the input for specific tools. Here's an example of a hook that blocks dangerous bash commands:
import ClaudeHookKit
struct BashToolInput: Decodable {
let command: String
let description: String
}
@main
struct DangerousCommandBlocker: PreToolUseHook {
typealias ToolInput = BashToolInput
typealias UpdatedInput = Empty
static func invoke(input: PreToolUseInput<BashToolInput>, context: Context) async -> HookResult<PreToolUseOutput<Empty>> {
guard let toolInput = input.toolInput else {
return .exitCode(.success)
}
let dangerousCommands = ["rm -rf", "sudo rm", "mkfs", "> /dev/"]
for dangerous in dangerousCommands {
if toolInput.command.contains(dangerous) {
return .jsonOutput(
PreToolUseOutput(
hookSpecificOutput: .init(
permissionDecision: .deny,
permissionDecisionReason: "Blocked dangerous command: \(dangerous)",
updatedInput: nil
)
)
)
}
}
return .exitCode(.success)
}
}Auto-approve Documentation Files
Here's an example of a PreToolUse hook that auto-approves Read tool calls for documentation files:
import ClaudeHookKit
struct ReadToolInput: Decodable {
let filePath: String
enum CodingKeys: String, CodingKey {
case filePath = "file_path"
}
}
@main
struct DocumentationAutoApprover: PreToolUseHook {
typealias ToolInput = ReadToolInput
typealias UpdatedInput = Empty
static func invoke(input: PreToolUseInput<ReadToolInput>, context: Context) async -> HookResult<PreToolUseOutput<Empty>> {
guard let toolInput = input.toolInput else {
return .exitCode(.success)
}
let documentationExtensions = [".md", ".mdx", ".txt", ".json"]
// Check if file is a documentation file
for ext in documentationExtensions {
if toolInput.filePath.hasSuffix(ext) {
return .jsonOutput(
PreToolUseOutput(
suppressOutput: true,
hookSpecificOutput: .init(
permissionDecision: .allow,
permissionDecisionReason: "Documentation file auto-approved",
updatedInput: nil
)
)
)
}
}
// Let the normal permission flow proceed
return .exitCode(.success)
}
}Supported Hook Types
ClaudeHookKit supports all Claude Code hook events:
| Hook Protocol | Event | Description | |---------------|-------|-------------| | PreToolUseHook | PreToolUse | Called before a tool is executed | | PostToolUseHook | PostToolUse | Called after a tool is executed | | NotificationHook | Notification | Called when Claude Code sends a notification | | UserPromptSubmitHook | UserPromptSubmit | Called when the user submits a prompt | | StopHook | Stop | Called when Claude Code stops | | SubagentStopHook | SubagentStop | Called when a subagent stops | | SessionStartHook | SessionStart | Called when a session starts | | SessionEndHook | SessionEnd | Called when a session ends | | PermissionRequestHook | PermissionRequest | Called when a permission is requested |
Hook Results
Hooks can return two types of results. See Hook Output section.
Exit Code Results
return .exitCode(.success) // Exit with success (exit code 0)
return .exitCode(.blockingError) // Block the action (exit code 2)
return .exitCode(.nonBlockingError(exitCode: 1)) // Non-blocking error with custom exit codeJSON Output Results
For hooks that need to return structured output:
return .jsonOutput(
PreToolUseOutput(
hookSpecificOutput: .init(
permissionDecision: .deny,
permissionDecisionReason: "Reason for denying",
updatedInput: nil
)
)
)Debug and Logging
You can use the logger in the Context to log messages for debugging:
static func invoke(input: Input, context: Context) async -> HookResult<Output> {
// Log a message
context.logger.debug("Hook invoked with input: \(input)")
return .exitCode(.success)
}[!WARNING] Do not use
Configuring Log Output
By default, logging is disabled. To enable logging to a file, override the logMode property in your hook:
@main
struct MyHook: NotificationHook {
static var logMode: LogMode {
.enabled(URL(filePath: "/tmp/my-hook.log"))
}
static func invoke(input: NotificationInput, context: Context) async -> HookResult<NotificationOutput> {
context.logger.debug("Hook invoked")
return .exitCode(.success)
}
}The LogMode enum has two cases:
.disabled- Logging is disabled (default).enabled(URL)- Logging is enabled, writing to the specified file URL
You can also use the standard debug logger of Claude Code. See Debugging section of the official documentation.
Context
The Context object provides access to environment information:
static func invoke(input: Input, context: Context) async -> HookResult<Output> {
// Access the project directory
if let projectDir = context.projectDirectoryPath {
// ...
}
// Use the logger
context.logger.debug("Processing hook...")
return .exitCode(.success)
}Configuring Hooks in Claude Code
After building your hook executable, configure it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "/path/to/your/hook"
}
]
}
]
}
}License
MIT License
Package Metadata
Repository: giginet/claudehookkit
Default branch: main
README: README.md