// 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 (
{comment ? {cmd} : <>{slash ? '>' : '$'}{cmd}}
{!comment && ( )}
); } // ---------- Problem: rm -rf story + pain list ---------- function ProblemSection({ accent }) { const pains = [ { n: '01', k: 'Silent failures', d: 'Claude Code only blocks on exit code 2. A guard hook that crashes — a typo, a missing dep — doesn\'t prevent the action. The dangerous op proceeds as if the hook were never there.' }, { n: '02', k: 'Bash inside JSON', d: 'Native hooks are strings in .claude/settings.json. No schema, no types, no imports. You escape quotes until your jq pipeline works, then you pray.' }, { n: '03', k: 'No composition', d: 'All native hooks run in parallel. No ordering, no pipeline, no way for one hook to modify input before another sees it. (Open issue claude-code#15897.)' }, { n: '04', k: 'No portability', d: 'A hook that works on your machine lives in your settings. A teammate clones the repo and gets nothing — or a different version.' }, { n: '05', k: 'No discoverability', d: 'The best hooks are gists linked in Discord threads. There is no registry, no pinning, no lockfile.' }, ]; return (
The gap

The hook that was supposed
to stop 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.

{/* Quote-style incident card */}
claude-code · issue #15897
"All hooks run in parallel. There is no ordering guarantee, no way to chain modifications, no way to know which one blocked."
{pains.map((p, i) => { const row = Math.floor(i / 3); const col = i % 3; const totalRows = Math.ceil(pains.length / 3); return (
{p.n} — {p.k}

{p.d}

); })}
); } // ---------- Hook API anatomy ---------- function HookAnatomySection({ accent }) { const items = [ { n: '01', k: 'meta', d: 'A name and optional description. Nothing else — no version, author, or permissions fields.' }, { n: '02', k: 'Event methods', d: 'One method per event. Subscribing is as simple as defining the method. 22 events available.' }, { n: '03', k: 'Typed ctx, tagged result', d: 'ctx is narrowed per event. Return { result: "allow" | "block" | "skip" } — or "ask" | "defer" on PreToolUse. Unknown values are treated as failures.' }, ]; return (
Hook API

Typed hooks as code.
One object, or many per file.

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.

{items.map(item => (
{item.n}
{item.k}
{item.d}
))}
); } // ---------- Config: .clooks/ layout + clooks.yml ---------- function ConfigSection({ accent }) { const treeLines = [ [[TK.fn, 'your-project/']], [[TK.op, '├── '], [TK.fn, '.clooks/']], [[TK.op, '│ ├── '], [TK.prop, 'clooks.yml'], [TK.com, ' # hooks + config (committed)']], [[TK.op, '│ ├── '], [TK.prop, 'clooks.schema.json'], [TK.com, ' # editor validation']], [[TK.op, '│ ├── '], [TK.prop, 'hooks.lock'], [TK.com, ' # pinned SHAs (committed)']], [[TK.op, '│ ├── '], [TK.fn, 'bin/entrypoint.sh'], [TK.com, ' # registered into .claude/settings.json']], [[TK.op, '│ └── '], [TK.fn, 'hooks/'], [TK.com, ' # your .ts hooks + types.d.ts']], [[TK.op, '└── '], [TK.fn, '.claude/settings.json'], [TK.com, ' # auto-managed']], ]; const ymlLines = [ [[TK.prop, 'version'], [TK.op, ': '], [TK.str, '"1.0.0"']], '', [[TK.prop, 'config'], [TK.op, ':']], [' ', [TK.prop, 'timeout'], [TK.op, ': '], [TK.num, '30000']], [' ', [TK.prop, 'onError'], [TK.op, ': '], [TK.str, '"block"'], ' ', [TK.com, '# or "continue" | "trace"']], [' ', [TK.prop, 'maxFailures'], [TK.op, ': '], [TK.num, '3']], '', [[TK.prop, 'no-rm-rf'], [TK.op, ': {}']], '', [[TK.prop, 'log-bash-commands'], [TK.op, ':']], [' ', [TK.prop, 'config'], [TK.op, ':']], [' ', [TK.prop, 'logDir'], [TK.op, ': '], [TK.str, '"logs"']], [' ', [TK.prop, 'parallel'], [TK.op, ': '], [TK.num, 'true']], [' ', [TK.prop, 'onError'], [TK.op, ': '], [TK.str, '"continue"']], '', [[TK.prop, 'PreToolUse'], [TK.op, ':']], [' ', [TK.prop, 'order'], [TK.op, ':']], [' - ', [TK.str, 'no-rm-rf']], [' - ', [TK.str, 'log-bash-commands']], ]; return (
Config

Everything lives in .clooks/.
Committed. Portable. Reviewable.

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.

After clooks init
.clooks/clooks.yml
); } // ---------- Install flow (replaces old Quickstart) ---------- function InstallSection({ accent, tweaks }) { const [path, setPath] = React.useState('plugin'); const paths = { plugin: { label: 'Plugin (fastest)', blurb: 'Install through the Claude Code plugin system. The plugin is a bootstrap — it registers a SessionStart hook that tells Claude to run /clooks:setup, which downloads the binary and runs clooks init in your project.', steps: [ { t: 'Add the marketplace', cmd: 'claude plugin marketplace add codestripes-dev/clooks-marketplace', d: 'A separate repo that hosts plugin metadata and points at source.' }, { t: 'Install the clooks plugin', cmd: 'claude plugin install clooks@clooks-marketplace', d: 'Bootstrap only. Drops a SessionStart hook and the /clooks:setup skill. Does not put the runtime on PATH — reload Claude Code to activate.' }, { t: 'Run /clooks:setup', cmd: '/clooks:setup', d: 'Runs inside Claude Code. Downloads the latest release binary for your platform, places it on PATH, and runs clooks init in the current project.', slash: true }, { t: 'Optional — install a hook pack', cmd: 'claude plugin install clooks-core-hooks --scope user', d: 'Six ready-made safety and quality hooks, including no-rm-rf. Use --scope user to apply them everywhere on this machine; use --scope project to commit the plugin entry to this repo so teammates get it too.' }, ], }, binary: { label: 'Manual binary', blurb: 'Skip the plugin and install the runtime yourself. Works the same post-init — the plugin path is just a convenience wrapper.', steps: [ { t: 'Download the binary', cmd: 'open https://github.com/codestripes-dev/clooks/releases/latest', d: 'Prebuilt for darwin-arm64, darwin-x64, linux-x64, linux-x64-baseline, linux-arm64. Grab the binary for your platform, chmod +x it, drop it on your PATH.' }, { t: 'Initialize in your repo', cmd: 'clooks init', d: 'Writes .clooks/ (clooks.yml, schema, entrypoint.sh, hooks/types.d.ts), updates .gitignore, registers the entrypoint in .claude/settings.json (project). Safe to re-run.' }, { t: 'Commit', cmd: 'git add .clooks .claude/settings.json && git commit -m "add clooks"', d: 'Everything you need is in the repo. A teammate cloning just needs the binary.' }, ], }, clone: { label: 'Cloning a repo', blurb: 'Somebody on your team already ran init and committed .clooks/. You just need the runtime.', steps: [ { t: 'Clone the repo', cmd: 'git clone && cd ', d: '.clooks/bin/entrypoint.sh and .claude/settings.json are already committed. The project hook config comes with the repo.' }, { t: 'Open it in Claude Code', cmd: 'claude', d: 'If clooks-marketplace and the clooks plugin are declared as a project-level Claude dependency, you\'ll be prompted automatically to run /clooks:setup on first session.' }, { t: 'Run /clooks:setup', cmd: '/clooks:setup', d: 'Pulls the runtime binary for your platform and wires it into this checkout. If you missed the prompt above, run it manually — idempotent, safe to re-run.', slash: true }, { t: 'Install the binary', cmd: '# Fallback: github.com/codestripes-dev/clooks/releases/latest', d: 'If you\'re not using the plugin — grab the binary from GitHub releases and put it on PATH.', comment: true }, ], }, }; const active = paths[path]; return (
Install

Three paths, one destination.

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.

{Object.entries(paths).map(([key, p]) => ( ))}

{active.blurb}

{active.steps.map((s, i) => (
0{i + 1}
{s.t}
{s.d}
))}
Heads up
• Global mode: add clooks init --global to register hooks under ~/.clooks/ for every Claude Code session.
• Windows is deferred — Bun's compiled-binary story isn't stable there yet.
); } // ---------- Comparison table ---------- function ComparisonSection({ accent }) { const rows = [ ['Failure mode', 'Fail-open on anything but exit 2', 'Fail-closed by default (configurable)'], ['Language', 'Bash strings in JSON', 'TypeScript, typed end to end'], ['Composition', 'All hooks parallel, no ordering', 'Parallel or sequential with explicit order'], ['Input modification', 'Not supported', 'Sequential pipeline; hooks see previous updatedInput'], ['Retries', 'Per invocation only', 'Circuit breaker auto-disables after N failures'], ['Distribution', 'Copy-paste from gists', 'Marketplace, SHA-pinned, lockfile-verified'], ['Portability', 'Lives in your settings', 'Vendored into .clooks/, committed'], ]; return (
vs. native hooks

Seven concrete differences.

Native hooks Clooks
{rows.map(([k, a, b], i) => (
{k} {a} {b}
))}
); } // ---------- Why not a plugin? ---------- function WhyNotPluginSection({ accent }) { return (
Clarification

Why isn't Clooks just a Claude Code plugin?

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.

); } // ---------- Roadmap ---------- function RoadmapSection({ accent }) { const items = [ { k: 'Claude Code', status: 'shipping', note: 'primary target, working today' }, { k: 'clooks test', status: 'wip', note: 'runner in progress — co-located .test.ts files today' }, { k: 'clooks manage (TUI)', status: 'planned', note: 'interactive marketplace browser' }, { k: 'curl | sh installer', status: 'planned', note: 'clooks.cc/install — plugin path works today' }, { k: 'Prebuilt binaries', status: 'shipping', note: 'GitHub releases for darwin/linux, arm64 + x64' }, { k: 'Cursor', status: 'planned', note: 'event mapping researched' }, { k: 'Windsurf', status: 'planned', note: 'event mapping researched' }, { k: 'VS Code Copilot', status: 'planned', note: 'event mapping researched' }, { k: 'Windows', status: 'deferred', note: 'Bun compiled-binary issues' }, ]; const color = (s) => s === 'shipping' ? COL.green : s === 'wip' ? accent : s === 'planned' ? COL.fgMute : COL.fgFaint; return (
Roadmap

Where things stand at v0.0.1.

{items.map((it, i) => (
{it.status}
{it.k}
{it.note}
))}
); } // ---------- FAQ ---------- function FAQSection({ accent }) { const faqs = [ { q: 'Why not just write bash?', a: 'Bash is great for 3 lines. Past that you want imports, types, and tests — and you want them to keep working when the agent does something surprising. Clooks gives you TypeScript with typed event contracts; you can still shell out from inside a hook.', }, { q: 'Why Bun?', a: 'Compiled static binaries, fast startup, TypeScript without a build step. The runtime needs to cost nothing on every tool call — and it needs to be a single file that a plugin install can drop onto your PATH. Bun ticks both.', }, { q: 'What happens when a hook crashes?', a: 'Default is onError: "block" — the action is refused and the agent is told why. Configurable per-hook to "continue" (pass through) or "trace" (log and continue). After three consecutive failures the hook is auto-disabled; a success resets the counter.', }, { q: 'Is there a registry of hooks I can browse?', a: 'We only have two core sets of Claude hooks right now — clooks-core-hooks and clooks-project-hooks, both living in codestripes-dev/clooks-marketplace. However, everyone can create their own clooks-hooks repositories and marketplaces Feel free to open up PRs if you have further hooks you\'d like to see added!', }, { q: 'What about other agents — Cursor, Windsurf, Copilot?', a: 'Planned. We\'d like clooks to be cross-agent down the line, but we need to research how to fit all APIs under one umbrella first.', }, ]; return (
FAQ

Answers that come up often.

{faqs.map((f, i) => )}
); } function FAQItem({ q, a, accent, last }) { const [open, setOpen] = React.useState(false); return (
{open && (
{a}
)}
); } // ---------- Footer ---------- function Footer({ accent }) { return (

A TypeScript hook runtime for Claude Code. Open source under MIT.

clooks v0.0.1 · built with bun · by Joe Degler
{[ { h: 'Project', links: [ ['GitHub', 'https://github.com/codestripes-dev/clooks'], ['Marketplace', 'https://github.com/codestripes-dev/clooks-marketplace'], ['Core hooks', '#'], ['Project hooks', '#'], ]}, { h: 'Docs', links: [ ['Install', '#install'], ['Hook API', '#hook'], ['Config', '#config'], ['FAQ', '#faq'], ]}, ].map(col => (
{col.h}
{col.links.map(([label, href]) => ( {label} ))}
))}
MIT License · joe@clooks.cc © {new Date().getFullYear()} Joe Degler
); } // ---------- Real captures: three annotated TUI transcripts ---------- function CapturesSection({ accent }) { const [active, setActive] = React.useState(0); // Token shorthands for terminal lines. Each line is an array of // [kind, text] segments. Kinds: 'u' user prompt, 'd' dim/meta, // 'r' red/error, 'g' green/ok, 'a' accent, 'y' yellow-warning, // 'f' plain fg, 'mono' mono same-as-f, 'm' muted. const captures = [ { id: 'block', tab: '01 · Intended block', title: 'A hook refuses a destructive command.', blurb: <>The 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 (
Real captures

Three scenarios, captured from the TUI.

Recorded against a real Claude Code session.

{/* Tabs */}
{captures.map((c, i) => ( ))}
{/* Title + blurb */}
{cap.title}
{cap.blurb}
{/* Main two-column: terminal on the left, annotations on the right */}
What to look at
{cap.annotations.map((a, i) => (
{a.label}
{a.note}
))}
); } const codeInline = { fontFamily: 'JetBrains Mono, monospace', fontSize: '0.88em', color: '#f5f5f2', background: 'rgba(255,255,255,0.05)', padding: '1px 6px', borderRadius: 0, }; function TerminalTranscript({ cap, accent }) { const colorFor = (k) => ({ a: accent, d: COL.fgDim, m: COL.fgMute, r: COL.red, g: COL.green, y: COL.yellow, f: COL.fg, mono: COL.fg, }[k] || COL.fg); const fontFor = (k) => (k === 'mono' ? 'JetBrains Mono, monospace' : 'inherit'); return (
{/* Title bar */}
claude
{cap.meta.map(([label, tone], i) => { const c = tone === 'red' ? COL.red : tone === 'yellow' ? COL.yellow : tone === 'dim' ? COL.fgDim : tone === 'fg' ? COL.fgMute : tone; // accent string return ( {label} ); })}
{/* Body */}
{cap.lines.map((ln, i) => (
{ln === null ? '\u00a0' : ln.map(([k, t], j) => ( {t} ))}
))}
); } Object.assign(window, { ProblemSection, HookAnatomySection, ConfigSection, InstallSection, ComparisonSection, WhyNotPluginSection, RoadmapSection, FAQSection, CapturesSection, Footer, });