A side-effect manager for Python scripts, specifically designed for building maintainable (easy to build, test, debug) Claude Code hooks, inspired by Redux Saga.
Unstable - API subject to change.
from claude_saga import (
BaseSagaState, SagaRuntime,
Call, Put, Select, Log, Stop, Complete,
run_command_effect
)
def add(x, y):
return x + y
class State(BaseSagaState):
math_result: int = 2
command_result: str = ""
def my_saga():
yield Log("info", "Starting saga")
initial_state = yield Select()
math_result = yield Call(add, initial_state.math_result, 3)
command_result = yield Call(run_command_effect, "echo 'Hello World'")
if command_result is None:
yield Log("error", "unable to run command")
yield Stop("hook failed, exited early")
yield Put({"command_result": command_result.stdout, "math_result": math_result})
yield Complete("Saga completed successfully")
runtime = SagaRuntime(State())
final_state = runtime.run(my_saga())
print(final_state.to_json())Claude Saga handles input/output conventions of claude code hooks:
#!/usr/bin/env python
import json
import sys
from claude_saga import (
BaseSagaState, SagaRuntime,
validate_input_saga, parse_json_saga,
Complete
)
class HookState(BaseSagaState):
# Add your custom state fields
pass
def main_saga():
# Validate and parse input
# https://docs.anthropic.com/en/docs/claude-code/hooks#hook-input
yield from validate_input_saga()
# Adds input data to state
yield from parse_json_saga()
# Your hook logic here
# Complete
yield Complete("Hook executed successfully")
def main():
runtime = SagaRuntime(HookState())
# Final state is an object that conforms to common json fields:
# https://docs.anthropic.com/en/docs/claude-code/hooks#common-json-fields
final_state = runtime.run(main_saga())
# Claude Code exit code behavior:
# https://docs.anthropic.com/en/docs/claude-code/hooks#simple%3A-exit-code
print(json.dumps(final_state.to_json()))
sys.exit(0 if final_state.continue_ else 1)
if __name__ == "__main__":
main()Execute functions, including(and especially) those with side-effects:
result = yield Call(function, arg1, arg2, kwarg=value)Update the state:
yield Put({"field": "value"})
# or with a function
yield Put(lambda state: MyState(counter=state.counter + 1))Read from the state:
state = yield Select()
# or with a selector
counter = yield Select(lambda state: state.counter)Log messages at different levels:
yield Log("info", "Information message")
yield Log("error", "Error message")
yield Log("debug", "Debug message") # Only shown with DEBUG=1Stop execution with an error, hook output contains continue:false:
yield Stop("Error message")Complete saga successfully, hook output contains continue:true:
yield Complete("Success message")The library includes common effects
log_info,log_error,log_debugrun_command_effect(cmd, cwd=None, capture_output=True)- Run shell commandswrite_file_effect(path, content)- Write fileschange_directory_effect(path)- Change working directorycreate_directory_effect(path)- Create directoriesconnect_pycharm_debugger_effect()- Connect to PyCharm debugger
Notes:
- When you write your own effects - you don't need to implement error handling - the saga runtime handles Call errors (logs them to stdout) and returns
Noneon failure.- If you want to terminate the saga on effect failure, check if the Call result is
Noneand yield aStop.
- If you want to terminate the saga on effect failure, check if the Call result is
Pre-built sagas for common tasks:
validate_input_saga()- Validate stdin input is providedparse_json_saga()- Parse JSON from stdin into hook state (parses specifically for Claude Code hook input)
uv pip install -e .The examples/ directory contains a practical demonstration:
simple_command_validator.py- Claude Code hook for validating bash commands (saga version of the official example)
# This will fail since the expected input to stdin is not provided
uv run examples/simple_command_validator.py
# Handle claude code stdin conventions, this command passes validation
echo '{"tool_name": "Bash", "tool_input": {"command": "ls -la"}}' | uv run examples/simple_command_validator.py
# This command fails validation (uses grep instead of rg)
echo '{"tool_name": "Bash", "tool_input": {"command": "grep pattern file.txt"}}' | uv run examples/simple_command_validator.py
Test the core saga framework components:
uv run pytest tests/test_claude_saga.py -vTest complete example hook behavior:
uv run pytest tests/test_e2e_simple_command_validator.py -vRun the complete test suite:
uv run pytest tests/ -vuv buildMIT License - see LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request. I'd like to hear what common effects can be added to the core lib. e.g.
- http_request_effect
- mcp_request_effect
Future work must incorporate
- parsing & validation for each hook's unique input/output behaviors, fields etc...
- retry-able effects
- cancel-able effects
- parallel effects (e.g.
All), see hypothetical async effects likemcp_requestetc... - concurrent effects