fasthooks 0.1.4
Delightful Claude Code hooks - FastAPI-like DX for building hooks
Requires Python: >=3.11
Keywords: ai, anthropic, claude, cli, hooks

# fasthooks

<p align="center">
  <em>Claude Code Hook SDK for Python</em>
</p>

<p align="center">
<a href="https://pypi.org/project/fasthooks"><img src="https://img.shields.io/pypi/v/fasthooks?color=%2334D058&label=pypi" alt="PyPI version"></a>
<a href="https://pypi.org/project/fasthooks"><img src="https://img.shields.io/pypi/dm/fasthooks?color=%2334D058&label=downloads" alt="Downloads"></a>
<a href="https://github.com/oneryalcin/fasthooks"><img src="https://img.shields.io/github/stars/oneryalcin/fasthooks?style=flat&color=yellow" alt="GitHub stars"></a>
<a href="https://github.com/oneryalcin/fasthooks/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
</p>

<p align="center">
<strong><a href="https://oneryalcin.github.io/fasthooks/">Documentation</a></strong> · <strong><a href="https://github.com/oneryalcin/fasthooks">GitHub</a></strong> · <strong><a href="https://pypi.org/project/fasthooks/">PyPI</a></strong>
</p>

---

Delightful Claude Code hooks with a FastAPI-like developer experience.

```python
from fasthooks import HookApp, deny

app = HookApp()

@app.pre_tool("Bash")
def no_rm_rf(event):
    if "rm -rf" in event.command:
        return deny("Dangerous command")

if __name__ == "__main__":
    app.run()
```

## Features

- **Typed events** - Autocomplete for `event.command`, `event.file_path`, etc.
- **Decorators** - `@app.pre_tool("Bash")`, `@app.on_stop()`, `@app.on_session_start()`
- **Dependency injection** - `def handler(event, transcript: Transcript, state: State)`
- **Background tasks** - Spawn async work that feeds back in subsequent hooks
- **Claude sub-agents** - Use Claude Agent SDK for AI-powered background tasks
- **Blueprints** - Compose handlers from multiple modules
- **Middleware** - Cross-cutting concerns like timing and logging
- **Guards** - `@app.pre_tool("Bash", when=lambda e: "sudo" in e.command)`
- **Testing utilities** - `MockEvent` and `TestClient` for easy testing

## Installation

```bash
pip install fasthooks
```

Or with uv:

```bash
uv add fasthooks
```

## Quick Start

### 1. Create a hooks project

```bash
fasthooks init my-hooks
cd my-hooks
```

### 2. Edit hooks.py

```python
from fasthooks import HookApp, allow, deny

app = HookApp()

@app.pre_tool("Bash")
def check_bash(event):
    # event.command has autocomplete!
    if "rm -rf" in event.command:
        return deny("Dangerous command blocked")
    return allow()

@app.pre_tool("Write")
def check_write(event):
    # event.file_path, event.content available
    if event.file_path.endswith(".env"):
        return deny("Cannot modify .env files")
    return allow()

@app.on_stop()
def on_stop(event):
    return allow()

if __name__ == "__main__":
    app.run()
```

### 3. Configure Claude Code

Add to your `settings.json`:

```json
{
  "hooks": {
    "PreToolUse": [{"command": "python /path/to/hooks.py"}],
    "Stop": [{"command": "python /path/to/hooks.py"}]
  }
}
```

## API Reference

### Responses

```python
from fasthooks import allow, deny, block, approve_permission, deny_permission

return allow()                              # Proceed
return allow(message="Approved by hook")    # With message
return deny("Reason shown to Claude")       # Block tool
return block("Continue working on X")       # For Stop hooks

# For PermissionRequest hooks
return approve_permission()                 # Auto-approve permission
return approve_permission(modify={"command": "safe"})  # Approve with modified input
return deny_permission("Not allowed")       # Deny permission
```

### Tool Decorators

```python
@app.pre_tool("Bash")                    # Single tool
@app.pre_tool("Write", "Edit")           # Multiple tools
@app.post_tool("Bash")                   # After execution
```

### Lifecycle Decorators

```python
@app.on_stop()                           # Main agent stops
@app.on_subagent_stop()                  # Subagent stops
@app.on_session_start()                  # Session begins
@app.on_session_end()                    # Session ends
@app.on_pre_compact()                    # Before compaction
@app.on_prompt()                         # User submits prompt
@app.on_notification()                   # Notification sent
@app.on_permission("Bash")               # Permission dialog shown (tool-specific)
@app.on_permission()                     # Permission dialog (catch-all)
```

### Typed Events

```python
@app.pre_tool("Bash")
def handle_bash(event):
    event.command      # str
    event.description  # str | None
    event.timeout      # int | None

@app.pre_tool("Write")
def handle_write(event):
    event.file_path    # str
    event.content      # str

@app.pre_tool("Edit")
def handle_edit(event):
    event.file_path    # str
    event.old_string   # str
    event.new_string   # str
```

### Dependency Injection

```python
from fasthooks.depends import Transcript, State

@app.on_stop()
def with_deps(event, transcript: Transcript, state: State):
    # transcript - lazy-parsed transcript with stats
    print(transcript.stats.tool_calls)  # {"Bash": 5, "Read": 3}
    print(transcript.stats.duration_seconds)

    # state - persistent dict (session-scoped)
    state["count"] = state.get("count", 0) + 1
    state.save()
```

### Guards

```python
@app.pre_tool("Write", when=lambda e: e.file_path.endswith(".py"))
def python_only(event):
    # Only called for .py files
    pass

@app.on_session_start(when=lambda e: e.source == "startup")
def startup_only(event):
    # Only on fresh startup, not resume
    pass
```

### Blueprints

```python
from fasthooks import Blueprint

security = Blueprint("security")

@security.pre_tool("Bash")
def no_sudo(event):
    if "sudo" in event.command:
        return deny("sudo not allowed")

# In main app
app.include(security)
```

### Middleware

```python
import time

@app.middleware
def timing(event, call_next):
    start = time.time()
    response = call_next(event)
    print(f"Took {time.time() - start:.3f}s")
    return response
```

### Background Tasks

Spawn async work that completes independently and feeds back results in subsequent hooks:

```python
from fasthooks import HookApp, allow
from fasthooks.tasks import task, Tasks

@task
def analyze_code(code: str) -> str:
    # Long-running analysis...
    return "Analysis result"

app = HookApp()

@app.pre_tool("Write")
def on_write(event, tasks: Tasks):
    # Spawn task (key defaults to function name)
    tasks.add(analyze_code, event.content)
    return allow()

@app.on_prompt()
def check_results(event, tasks: Tasks):
    # Pop by function reference (no string typos)
    if result := tasks.pop(analyze_code):
        return allow(message=f"Previous analysis: {result}")
    return allow()
```

### Claude Sub-Agents

Use Claude Agent SDK for AI-powered background tasks (requires `pip install fasthooks[claude]`):

```python
from fasthooks.contrib.claude import ClaudeAgent, agent_task
from fasthooks.tasks import Tasks

@agent_task(model="haiku", system_prompt="You review code for bugs.")
async def review_code(agent: ClaudeAgent, code: str) -> str:
    return await agent.query(f"Review this code:\n{code}")

@app.pre_tool("Write")
def on_write(event, tasks: Tasks):
    tasks.add(review_code, event.content)
    return allow()
```

## Testing

```python
from fasthooks.testing import MockEvent, TestClient

def test_no_rm_rf():
    app = HookApp()

    @app.pre_tool("Bash")
    def handler(event):
        if "rm" in event.command:
            return deny("No rm")
        return allow()

    client = TestClient(app)

    # Safe command - allowed
    response = client.send(MockEvent.bash(command="ls"))
    assert response is None

    # Dangerous command - denied
    response = client.send(MockEvent.bash(command="rm -rf /"))
    assert response.decision == "deny"
```

## CLI

```bash
# Initialize a new project (creates hooks.py, pyproject.toml, .claude/settings.json)
fasthooks init my-hooks

# Run hooks (called by Claude Code)
fasthooks run hooks.py

# Generate sample event JSON for testing
fasthooks example bash
fasthooks example bash_dangerous > event.json

# Test hooks locally
fasthooks run hooks.py --input event.json

# Show help
fasthooks --help
```

## License

MIT