hookable#
A small program for using arbitrary commands as Claude Code hook decisions.
It reads the hook JSON Claude sends on stdin, runs a command, and writes a permissionDecision JSON response to stdout. Two modes are supported:
Exit-code mode (default) — the command's exit code determines the decision. Exit 0 allows; any other exit denies. Output from the command goes to /dev/tty so the user can see it.
Interactive mode (--interactive) — the command runs under a PTY. The user sees its output and presses a key to accept or reject. All other keystrokes are forwarded to the subprocess, so TUI programs work correctly.
Usage#
hookable --cmd <command> [options]
| Flag | Default | Description |
|---|---|---|
--cmd |
(required) | Command to run, passed to sh -c |
--interactive |
false | Run under a PTY and intercept accept/reject keys |
--accept-key |
y |
Key that allows the tool call (interactive mode) |
--reject-key |
n |
Key that rejects the tool call (interactive mode) |
--no-exit-code |
false | Interactive mode: ignore process exit code; always wait for accept/reject key |
Key strings use the same format as charmbracelet/x/input: "y", "ctrl+y", "enter", "alt+x", etc.
Examples#
Allow a Bash tool call only if a diff shows no changes:
hookable --cmd "diff /tmp/before.sh /tmp/after.sh"
Show a diff interactively and press y/n to decide:
hookable --interactive --cmd "adiff before.rb after.rb"
Use a custom accept keybinding:
hookable --interactive --cmd "git diff HEAD" --accept-key "ctrl+y" --reject-key "ctrl+n"
Show a diff from a command that exits non-zero (e.g. adiff exits 1 when files differ) and still wait for the user to accept or reject:
hookable --interactive --no-exit-code --cmd "adiff before.py after.py"
Environment variables#
hookable exports the Claude hook payload as environment variables so --cmd scripts can reference them:
| Variable | Source |
|---|---|
HOOKABLE_HOOK_EVENT_NAME |
hook_event_name (e.g. PreToolUse) |
HOOKABLE_TOOL_NAME |
tool_name (e.g. Bash, Edit, Write) |
HOOKABLE_TOOL_INPUT_<KEY> |
one variable per field in tool_input, key uppercased |
For example, when Claude runs the Edit tool:
# Deny edits to files outside a specific directory
hookable --cmd 'echo "$HOOKABLE_TOOL_INPUT_FILE_PATH" | grep -q "^/safe/dir/" || exit 1'
String values are unquoted; booleans, numbers, and nested objects are left as raw JSON.
Using hookable with adiff#
adiff is a side-by-side diff TUI that reads HOOKABLE_* environment variables directly. When invoked with no file arguments it constructs the diff from the hook payload automatically:
- Edit — diffs the current file against the result of applying
old_string → new_string - Write — diffs the current file against the new content
Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{"type": "command", "command": "hookable --interactive --no-exit-code --cmd 'adiff -i'"}]
},
{
"matcher": "Write",
"hooks": [{"type": "command", "command": "hookable --interactive --no-exit-code --cmd 'adiff -i'"}]
}
]
}
}
Press y to allow the file change or n to deny it.
Claude Code hook configuration#
Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "hookable --cmd 'diff /tmp/before.sh /tmp/after.sh'"
}]
}]
}
}
Building#
go build .
Using with Nix#
Build the package:
nix build
./result/bin/hookable --help
Run without installing:
nix run . -- --cmd "diff a b"
Enter a development shell with Go, gopls, gotools, and delve:
nix develop
Overlay#
The flake exposes overlays.default, which adds pkgs.hookable to nixpkgs. Consume it from another flake:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
hookable.url = "https://flakes.adriano.fyi/hookable";
};
outputs = { self, nixpkgs, hookable, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
{ nixpkgs.overlays = [ hookable.overlays.default ]; }
({ pkgs, ... }: {
environment.systemPackages = [ pkgs.hookable ];
})
];
};
};
}
The same pattern works for home-manager (home.packages = [ pkgs.hookable ];) or any other consumer that accepts an overlay list.
How it works#
Claude pipes a JSON object to the hook process on stdin:
{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {...}}
hookable runs the given command and writes a decision to stdout:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}
hookable always exits 0 so that Claude reads the JSON decision. Any non-zero exit is treated by Claude as a non-blocking error and the JSON is ignored. The decision (allow or deny) is derived from the command's exit code or from the key the user presses.