Contents

giginet/claudehookkit

A Swift framework for building [Claude Code Hooks](https://code.claude.com/docs/en/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:

```swift
@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 `invoke` method
- 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:

```swift
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:

```swift
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:

```swift
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](https://code.claude.com/docs/en/hooks#hook-output) section.

#### Exit Code Results

```swift
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 code
```

#### JSON Output Results

For hooks that need to return structured output:

```swift
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:

```swift
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 `print` to output logs, as it may interfere with the hook's JSON output. Use the provided logger instead.

#### Configuring Log Output

By default, logging is disabled. To enable logging to a file, override the `logMode` property in your hook:

```swift
@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](https://code.claude.com/docs/en/hooks#debugging) section of the official documentation.

### Context

The `Context` object provides access to environment information:

```swift
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`:

```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