Cheatsheet

Every hook you reach for, on one page. Copy a line, swap the strings, drop it in .claude/hooks/. Pick the action first, then narrow with conditions. Full signatures live in the primitives reference and the conditions reference.

TipRun any snippet

Save it to .claude/hooks/x.py and run uvx capt-hook --hooks .claude/hooks test to exercise its inline tests.

πŸ›‘ Block before it happens

block_command β€” stop a dangerous Bash command before it runs

block_command(["git", "stash"], reason="Use the team VCS workflow")

gate β€” refuse to let the agent stop until a condition holds

gate("Run the tests before finishing", skip_if=[RanCommand("pytest"), RanCommand("uv", "run", "pytest")])

hook(…, block=True) β€” a hand-rolled always-on guard when no primitive fits

hook(Event.PreToolUse, only_if=[Tool("Bash"), Command(r"curl.*\|.*sh")],
     message="Don't pipe curl into a shell", block=True)

✏️ Rewrite in place

rewrite_command β€” swap a command for a safer form before it runs

rewrite_command(r"^pip install ", replace="uv pip install ")

rewrite_command_occurrences β€” rewrite each occurrence of one program inside a compound Bash line β€” worked example

rewrite_code β€” structurally rewrite edited code before it is written, ast-grep pattern to fix template

rewrite_code("os.system($CMD)", "subprocess.run([$CMD], check=True)")

set_tool_input β€” fill a missing tool-input field, never clobbering an explicit one

set_tool_input("model", "sonnet", tool="Agent|Task", note="defaulted the subagent model")

🧭 Walk the command

evt.command β€” the parsed, walkable Bash line: match structure, judge blast radius, rewrite calls β€” the how-to

for call in evt.command.calls("rm"):
    return call.sub("rm", "trash", args=call.targets)

πŸ”‘ Answer permission dialogs

approve β€” auto-answer a permission dialog for a scoped, consented case

approve("teammate bash", only_if=[Tool("Bash"), FromSubagent(), SkipPermissions()])

πŸ’¬ Nudge in the moment

nudge β€” advisory message on an edit or stop, no block

nudge("Prefer the project logger over print()", only_if=[Tool("Edit")])

nudge(signals=…) β€” fire only when the transcript crosses a score

nudge("You keep retrying β€” stop and read the error", signals=RETRIES)

warn_command β€” flag a smell after a command has run

warn_command(r"rm -rf", message="Double-check that path next time")

πŸ” Lint the code

lint β€” run a string or AST check on every Python edit

lint(find_bare_excepts, message="Bare except swallows errors: {violations}")

diff_lint β€” flag only what this edit newly introduced

diff_lint(widened_to_any, message="New Any annotation: {violations}")

styleguide β€” run composable matcher rules on the lines you changed

styleguide(NoPrint, NoBareExcept)

πŸ€– Ask a model

llm_gate β€” block on a model’s judgment of intent or quality

llm_gate("Is the agent making excuses instead of finishing?",
         message=lambda r: r.reasoning, max_fires=1)

llm_nudge β€” warn on a model’s judgment, without blocking

llm_nudge("Does this edit assert behavior, or only that code exists?",
          message=lambda r: r.reasoning, only_if=[Tool("Edit")])

prompt_check β€” call the model from inside your own handler

prompt_check(evt, Prompt.from_template("Review {code}", code=evt.content),
             prefix="Test quality")

evt.llm β€” a typed answer from the model, from inside any handler

verdict = evt.llm("Rate the risk of this action.", RiskVerdict)

🎯 Decide when (conditions)

Stack conditions in only_if (all must match) or skip_if (any match skips).

Tool β€” the tool name matches

Tool("Edit|Write")

FilePath β€” the event’s file path matches a glob

FilePath("src/**/*.py")

Command / Content β€” the Bash command, or the written content, matches a regex

Command(r"git\s+push\s+--force")
Content(r"\bprint\(")

Runs β€” a parsed command’s argv starts with these tokens

Runs("git", "stash")

TestFile / SourceEdits β€” the edit hits a test, or a source file in a language

SourceEdits(lang="py", include_tests=False)

RanCommand / ReadFile / TouchedFile β€” something already happened this session

RanCommand("uv", "run", "pytest")

UsedSkill / UsedTool / InPlanMode / Waiting β€” session mode and history

only_if=[InPlanMode()]

πŸͺœ Multi-step gate

workflow β€” block a stop until an ordered checklist passes

workflow(label="QA-GATE", marker="QA COMPLETE", steps=[...], artifacts=[...])

Step β€” one checkpoint: a check predicate plus stop and next messages

Step(name="run tests", check=text_matches(r"pytest.*passed"),
     message="Tests have not run. Run: pytest -x")

Artifact β€” a file that must exist and parse against a Pydantic model

Artifact(path="reports/lint.json", model=LintReport)

text_matches(pattern) β€” a prose-regex check for Step.check (scans narration, not commands)

πŸ’Ύ Session state

evt.ctx.s[Model] β€” a typed slot keyed by a Pydantic model

state = evt.ctx.s[EditCounter].get() or EditCounter()
evt.ctx.s[EditCounter].set(state)

@workflow_state(β€œname”) β€” bundle one workflow’s reads and writes

@workflow_state("review")
class ReviewState(WorkflowState):
    ran_tests: bool = False

.load(evt) / .save(evt) / .reset(evt) β€” read-default, write, delete; .mutate(evt) β€” locked read-modify-write for racing writers @session_state β€” register a plain model for introspection

πŸ“ˆ Signal scoring

Signal β€” a regex worth weight points when it matches

Signal(pattern=r"\bretry\b", weight=2)

Signals β€” a bundle that fires when a single message in the last window events scores threshold; scope="window" pools signals across the window, origin="any" scores user text too (the default scores only the agent’s prose), window="turn" scores the whole current turn

Signals([Signal(pattern=r"retry", weight=2), Signal(pattern=r"again")], threshold=3, window=15)

🧱 AST matchers

For style rules. Compose with &, |, ~, refine with .where(...), run with .over(tree) or .violations(tree). Import as from captain_hook.style import matchers as M.

Node types β€” M.cls / M.func / M.definition Β· M.call Β· M.imports Β· M.assignment Β· M.control_flow Β· M.type_checking Β· M.kind(*types)

Calls & references β€” M.calls(name) (bare-name call) Β· M.kwarg(name) Β· M.ref(name)

Names β€” M.named(pattern) Β· M.private Β· M.dunder Β· M.constant

Annotations β€” M.annotated(inner=None) Β· M.forward_ref

Position β€” M.under(other) (any ancestor) Β· M.child_of(other) (immediate parent) Β· M.following(boundary)

Combine & run β€” a & b Β· a | b Β· ~a Β· a.where(pred) Β· m.over(tree) Β· m.violations(tree) Β· m.diff(pre, post) Β· m.exists(tree)

The matchers reference lists every matcher with signatures β€” and the canonical worked rule (NoNestedImports).

πŸͺ Register by hand

hook / on β€” when no primitive fits, target an event directly

@on(Event.PostToolUse, only_if=[Tool("Edit")])
def my_check(evt): ...

See also