Hookable is small programs for using arbitrary commands as Claude Code hook decisions.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Go 94.5%
Nix 5.4%
Shell 0.1%
5 1 0

Clone this repository

https://tangled.org/adriano.tngl.sh/hookable https://tangled.org/did:plc:3lekddzmkd3zyk2nhmv4k4n5/hookable
git@tangled.org:adriano.tngl.sh/hookable git@tangled.org:did:plc:3lekddzmkd3zyk2nhmv4k4n5/hookable

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz
README.md

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.