captain-hook

Stop repeating yourself to Claude.


Claude Code hooks are shell commands wired through JSON. I wanted them to be what every other guardrail in a Python repo already is: typed functions with tests. capt-hook gives you [block_command](reference/primitives.html#captain_hook.block_command), [gate](reference/primitives.html#captain_hook.gate), and friends -- one `init`, zero settings surgery.

``` bash
uvx capt-hook init
```

- [x] **Declarative Python hooks** -- conditions and primitives, not regex-in-JSON
- [x] **Tested inline** -- every hook carries `tests={...}`; `capt-hook test` runs them
- [x] **Zero wiring** -- `init` scaffolds `.claude/hooks/` and wires settings itself

<a href="./docs/getting-started/quickstart.html" class="btn btn-primary"><img src="data:image/svg+xml;base64,PHN2ZyBzdHlsZT0iaGVpZ2h0OjFlbTt3aWR0aDoxZW07dmVydGljYWwtYWxpZ246LTAuMTI1ZW07Zm9udC1zaXplOmluaGVyaXQ7b3ZlcmZsb3c6dmlzaWJsZTtwb3NpdGlvbjpyZWxhdGl2ZTsiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld2JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLSBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJnZC1pY29uIiBhcmlhLWhpZGRlbj0idHJ1ZSI+PHBhdGggZD0iTTUgMTJoMTQiIC8+PHBhdGggZD0ibTEyIDUgNyA3LTcgNyIgLz48L3N2Zz4=" class="gd-icon" /> Quickstart</a> <a href="./docs/tutorial/index.html" class="btn btn-outline-primary"><img src="data:image/svg+xml;base64,PHN2ZyBzdHlsZT0iaGVpZ2h0OjFlbTt3aWR0aDoxZW07dmVydGljYWwtYWxpZ246LTAuMTI1ZW07Zm9udC1zaXplOmluaGVyaXQ7b3ZlcmZsb3c6dmlzaWJsZTtwb3NpdGlvbjpyZWxhdGl2ZTsiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld2JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLSBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJnZC1pY29uIiBhcmlhLWhpZGRlbj0idHJ1ZSI+PHBhdGggZD0iTTUgMTJoMTQiIC8+PHBhdGggZD0ibTEyIDUgNyA3LTcgNyIgLz48L3N2Zz4=" class="gd-icon" /> Tutorial</a>


## Poke the gate above

That Stop gate is live on this page -- flip the session state and watch the verdict change. Every preset is a row in the parity suite, so the browser's answer is `capt-hook test`'s answer.


<img src="./termshow/demo/frame-000.svg" class="gd-termshow-poster" loading="lazy" alt="Terminal recording: demo" />

Terminal recording: demo (requires JavaScript for playback)


## Block force-push before it runs

One bad Bash call rewrites shared history, and by the time you spot it in the transcript it already ran. Declare the block once, tests inline:

``` python
# .claude/hooks/safety.py
from captain_hook import Allow, Block, Input, block_command

block_command(
    ["git", "push", "--force"],
    reason="Force-pushing rewrites shared history",
    hint="Use `git push --force-with-lease` instead",
    tests={
        Input(command="git push --force"): Block(),
        Input(command="git push origin main"): Allow(),
    },
)
```

The next `git push --force` never executes: the agent sees the reason plus the hint, and reaches for `--force-with-lease` instead. And when a regex isn't enough, walk the parse -- the shipped `general` pack guards `rm` exactly like this:

``` python
for call in evt.command.calls("rm"):
    if call.targets.expand().exhausted:
        return evt.block("rm targets too broad to verify")
    return call.sub("rm", "trash", args=call.targets)
```

`evt.command` is the parsed command line -- every `rm` across `&&` and pipes, each target resolved against the working directory, the rewrite quote-safe. [Learn the walk in the tutorial.](./docs/tutorial/index.html)


## Turn repeated corrections into rules Claude can't forget

You've typed "use uv, not pip" in a dozen sessions, and session thirteen makes the same mistake. The session reviewer reads each transcript as the session ends, keeps the corrections that are standing rules, and -- once a pattern proves itself -- opens a PR that codifies it as a hook:

``` bash
uvx capt-hook status
```

The dashboard stages every correction from first sighting to open PR. You review the PR like any other; merged hooks enforce the rule from then on. [See the corrections lifecycle.](./docs/guide/session-reviewer.html)


## Gate "done" until the tests actually pass

The agent declares victory while the suite is red. A Stop gate holds the line:

``` python
# .claude/hooks/quality.py
from captain_hook import RanCommand, TouchedFile, gate

gate(
    "You edited Python files but never ran the tests. Run `uv run pytest` before finishing.",
    only_if=[TouchedFile("**/*.py")],
    skip_if=[RanCommand(r"\bpytest\b")],
)
```

The agent can't end the turn until a pytest run shows up in the transcript, and the gate stands down on its own once one does. [More Stop-gate patterns.](./docs/guide/state.html#enforce-a-multi-step-workflow)

------------------------------------------------------------------------


### [Tutorial](./docs/tutorial/index.html)

Block your first command in the browser -- live widgets, verified against the real engine.


### [Guide](./docs/guide/index.html)

How-to pages for writing, testing, and shipping hooks.


### [Examples](./docs/examples/index.html)

Runnable hooks with inline tests, one per pattern.


### [Reference](./docs/reference/index.html)

Every class, condition, and CLI command, generated from source.

> **Note: Note**
>
> Every command on this site is the full `uvx capt-hook ...` form -- copy-paste from a clean machine and it runs.
