// Body sections: problem, hook anatomy, config, install flow, comparison, // why-not-plugin, roadmap, faq, footer // ---------- Copyable command box (used in Install tabs) ---------- function CmdBox({ accent, cmd, slash, comment }) { const [copied, setCopied] = React.useState(false); const copy = async () => { let ok = false; try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(cmd); ok = true; } } catch {} if (!ok) { try { const ta = document.createElement('textarea'); ta.value = cmd; ta.style.position = 'fixed'; ta.style.top = '-9999px'; ta.setAttribute('readonly', ''); document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); document.body.removeChild(ta); } catch {} } if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1800); } }; return (
rm -rf ~/ crashed.
Somebody's agent ran rm -rf tests/ patches/ plan/ ~/.
The trailing ~/ wiped the Mac.
There was a guard hook for exactly this. It threw an exception, exited non-zero-but-not-2, and Claude ran the command anyway.
Native hooks pass through on anything that isn't a clean exit 2 — fail-open by default. That's the first thing Clooks changes.
{p.d}
Export one or more ClooksHook objects per file — group them by concern or split one-per-file, your call.
Each event is a method with a typed context and a tagged-result return. Config is validated with Zod, merged shallowly over your defaults.
.clooks/.
clooks init writes a
self-contained folder. The entrypoint script is the only thing registered into
.claude/settings.json.
Clone the repo on another machine — same hooks, same SHAs.
All three paths converge on a repo with a committed .clooks/ directory and the Clooks binary on your PATH. Pick the one that fits your situation.
{active.blurb}
clooks init --global to register hooks under ~/.clooks/ for every Claude Code session.
It uses the plugin system for distribution — that's how you install it. But the runtime itself can't live inside the plugin sandbox.
Plugins can't install binaries. Plugins can't rewrite
.claude/settings.json to register
an entrypoint. Plugins can't run a TypeScript hook pipeline with a circuit breaker and a lockfile.
Clooks has to be a standalone binary that the plugin drops onto your PATH.
The upside: the same binary works outside Claude Code. Cursor, Windsurf, and Copilot support is mapped out but not yet implemented — tracking as planned.
v0.0.1.
no-rm-rf hook returns {`{ result: "block", reason }`}. Claude reads the reason, stops, and surfaces it back to the user.>,
meta: [
['PreToolUse', accent], ['Bash', 'fg'],
],
lines: [
[['a', '❯ '], ['f', 'Use the Bash tool to clear stale cache by running: '], ['mono', 'rm -rf /tmp/stale-cache-demo']],
null,
[['d', ' Ran 1 bash command']],
[['d', ' ⎿ '], ['r', 'PreToolUse:Bash hook returned blocking error']],
[['d', ' ⎿ '], ['f', 'Blocked '], ['mono', '`rm -rf`'], ['f', ' by policy. Ask the user to run destructive deletes manually.']],
null,
[['a', '● '], ['f', 'A hook blocked the '], ['mono', 'rm -rf'], ['f', ' command by policy. Please run it manually:']],
[['f', ' '], ['mono', 'rm -rf /tmp/stale-cache-demo']],
],
annotations: [
{ label: 'Clooks output', color: accent, note: 'reason string from the hook appears as the blocking error' },
{ label: 'Claude\'s reply', color: COL.green, note: 'reads the reason and relays it back to the user unprompted' },
],
},
{
id: 'crash',
tab: '02 · Crash, fail-closed',
title: 'A hook crashes. The action is blocked.',
blurb: <>With onError: "block" — the default — a runtime error aborts the tool call. Native hooks pass through on anything but a clean exit 2. Clooks doesn't.>,
meta: [
['onError: block', accent], ['TypeError', 'red'],
],
lines: [
[['a', '❯ '], ['f', 'Use the Bash tool to run exactly this command, nothing else: '], ['mono', 'eslint src/']],
null,
[['d', ' Ran 1 bash command']],
[['d', ' ⎿ '], ['r', 'PreToolUse:Bash hook returned blocking error']],
[['d', ' ⎿ '], ['mono', '[clooks] Hook "crashy-linter" failed on PreToolUse']],
[['d', ' '], ['mono', "(TypeError: undefined is not an object (evaluating '(void 0)[0]'))."]],
[['d', ' '], ['r', 'Action blocked (onError: block).']],
null,
[['a', '● '], ['f', 'The "crashy-linter" hook crashed with a TypeError and blocked the']],
[['f', ' action per fail-closed behavior.']],
],
annotations: [
{ label: 'Structured failure', color: accent, note: 'hook name, event, exception class, message — all captured' },
{ label: 'Fail-closed', color: COL.red, note: 'action refused. Native hooks would have passed through.' },
],
},
{
id: 'trace',
tab: '03 · onError: trace',
title: 'A broken hook warns instead of blocking.',
blurb: <>A hook marked onError: "trace" injects its failure into additionalContext, allows the action, and Claude narrates the error back to the user unprompted.>,
meta: [
['onError: trace', accent], ['SyntaxError', 'yellow'],
],
lines: [
[['a', '❯ '], ['f', 'Use the Bash tool to run exactly this command, nothing else: '], ['mono', 'debug-me --now']],
null,
[['a', '● '], ['f', 'Running the command now.']],
null,
[['d', ' Ran 1 bash command']],
null,
[['a', '● '], ['f', 'The hook '], ['mono', 'broken-dev-hook'], ['f', ' errored with SyntaxError: JSON Parse error:']],
[['f', ' Expected \'}\' but was configured as '], ['mono', 'onError: trace'], ['f', ', so it did not']],
[['f', ' block the action. The command itself failed separately — '], ['mono', 'debug-me']],
[['f', ' was not found on the PATH.']],
],
annotations: [
{ label: 'Developer-delight', color: accent, note: 'hook failures surface in the agent loop — no silent passthrough' },
{ label: 'Context injection', color: COL.green, note: 'the error is data the agent can reason about, not a dead exit code' },
],
},
];
const cap = captures[active];
return (
Recorded against a real Claude Code session.
{/* Tabs */}