clooks
v0.1.0-rc.1 · pre-release

TypeScript hooks
for Claude Code.

Write hooks as small TypeScript files. Clooks runs them when Claude Code edits files or runs commands, and blocks the action if a hook crashes.

~/projects/my-repo
$claude plugin marketplace add codestripes-dev/cloo
macOS · LinuxCompiled Bun binaryMIT license
no-rm-rf.tstypescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// .clooks/hooks/no-rm-rf.ts
import type { ClooksHook } from 'clooks'
 
export const hook: ClooksHook = {
meta: {
name: 'no-rm-rf',
description: 'Block destructive rm commands.',
},
 
PreToolUse(ctx) {
if (ctx.tool !== 'Bash') return { result: 'skip' }
 
const cmd = ctx.input.command ?? ''
const dangerous = /rm\s+-rf?\s+(\/|~|\$HOME)/.test(cmd)
 
return dangerous
? { result: 'block', reason: `refusing: ${cmd}` }
: { result: 'allow' }
},
}
claude
PreToolUseblocked
clean up stale build artifacts
I'll remove them now.
Bash · rm -rf /tmp/build ~
⎿ PreToolUse:Bash hook returned blocking error
refusing: rm -rf /tmp/build ~
The no-rm-rf hook blocked that — the trailing ~ would have wiped your home. Run just /tmp/build instead?
Problem

The hook that was supposed
to stop rm -rf ~/ crashed.

Somebody's agent ran rm -rf tests/ patches/ plan/ ~/ — the trailing ~/ wiped the Mac. A guard hook was meant to catch it, but threw an exception and exited with a non-2 code. Claude Code treats anything other than exit 2 as success, so the command ran. In Clooks, a crashed hook blocks the action by default.

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."
no-rm-rf.shbash
1
2
3
4
5
6
7
8
9
#!/bin/bash
# .claude/hooks/no-rm-rf.sh
 
cmd=$(jq -r '.tool_input.command')
 
if echo "$cmd" | rg '^rm\s+-rf'; then
echo "refusing rm -rf" >&2
exit 2
fi
claude
PreToolUseexit 127
clean up stale artifacts
Removing them now.
Bash · rm -rf /tmp/build ~
⎿ Hook execution failed: rg: command not found
removed /tmp/build
Done — and cleared your home directory too.
01 — Silent failures

Claude Code only blocks on exit code 2. A guard hook that crashes — a typo, a missing dep — doesn't prevent the action. The action runs as if the hook never ran.

02 — Bash inside JSON

Native hooks are bash strings inside .claude/settings.json. No schema, no types, no imports — every hook is a one-liner you quote by hand.

03 — No composition

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.)

04 — No portability

A hook that works on your machine lives in your settings. A teammate clones the repo and gets nothing — or a different version.

05 — No discoverability

The best hooks are gists linked in Discord threads. There is no registry, no pinning, no lockfile.

Hook in action

A hook decides.
Step by step.

On the left, a Claude Code session. On the right, the hook file.

claude
idle
no-rm-rf.tstypescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// .clooks/hooks/no-rm-rf.ts
import type { ClooksHook } from 'clooks'
 
export const hook: ClooksHook = {
meta: {
name: 'no-rm-rf',
description: 'Block destructive rm commands.',
},
 
PreToolUse(ctx) {
if (ctx.tool !== 'Bash') return { result: 'skip' }
 
const cmd = ctx.input.command ?? ''
const dangerous = /rm\s+-rf?\s+(\/|~|\$HOME)/.test(cmd)
 
return dangerous
? { result: 'block', reason: `refusing: ${cmd}` }
: { result: 'allow' }
},
}
waiting for PreToolUse
Hook API

A hook is an object.
One file. One or more hooks.

Each file exports one or more ClooksHook objects. Every event you handle is a method with a typed context and a typed return. Config is validated with Zod and merged over your defaults. Hover a row below to see where it lives in the source.

no-bare-mv.tstypescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// .clooks/hooks/no-bare-mv.ts
import { existsSync } from 'fs'
import type { ClooksHook } from 'clooks'
 
export const hook: ClooksHook = {
meta: {
name: 'no-bare-mv',
description: 'Rewrite bare mv to git mv.',
},
 
beforeHook(event) { // runs before every event method
if (!existsSync('.git')) {
return event.respond({ result: 'skip' })
}
},
 
PreToolUse(ctx) {
if (ctx.toolName !== 'Bash') return { result: 'skip' }
 
const cmd = String(ctx.toolInput.command ?? '')
if (!/^\s*mv\b/.test(cmd)) return { result: 'skip' }
 
return {
result: 'updateInput',
updatedInput: {
...ctx.toolInput,
command: cmd.replace(/^\s*mv\b/, 'git mv'),
},
note: 'rewrote mv → git mv',
}
},
}
01
meta
A name and optional description.
02
Lifecycle: beforeHook
Optional. Runs before every event method on this hook. Return event.respond(...) to short-circuit with a tagged result — otherwise fall through to the event method.
03
Event methods
One method per event. Define the method to subscribe. 22 events available.
04
Typed ctx, tagged result
ctx is narrowed per event. Return { result: "allow" | "block" | "skip" | "updateInput" } — or "ask" | "defer" on PreToolUse. Unknown values are treated as failures.
Write once. Configure everywhere.

Same hook. Three repos. Three dials.

A hook's meta.config is its public dial. Here's no-destructive-git — thirteen toggles and a rule array — reshaped in three repos without touching a line of source.

platform-api.clooks/clooks.yml
# All 13 rules default to true.
no-destructive-git: {}
 
 
 
Shared repo. Ship the defaults — every rule on.
scratch-pad.clooks/clooks.yml
no-destructive-git:
config:
reset-hard: false
clean-force: false
stash-drop: false
Solo repo. Trust local ops; keep the blast-radius blocks.
acme-corp/monorepo.clooks/clooks.yml
no-destructive-git:
config:
additionalRules:
- match: 'push.*\s(main|master)\b'
message: 'Open a PR first.'
Team default plus one house rule: open a PR, don't push to main.
Config

Everything lives in .clooks/.
Committed with the rest of your code.

clooks init writes a self-contained folder. Only the entrypoint script is registered into .claude/settings.json. A teammate cloning the repo gets the same hooks at the same SHAs.

After clooks init
your-project/
├── .clooks/
│ ├── clooks.yml # hooks + config
│ ├── clooks.schema.json # editor validation
│ ├── hooks.lock # pinned SHAs (committed)
│ ├── bin/entrypoint.sh # bash launcher
│ ├── hooks/ # your .ts hooks
│ │ ├── no-rm-rf.ts
│ │ ├── log-bash-commands.ts
│ │ └── types.d.ts # generated
│ └── vendor/ # installed marketplace hooks
│ ├── clooks-core-hooks/
│ │ ├── no-bare-mv.ts
│ │ └── tmux-notifications.ts
│ └── clooks-project-hooks/
│ └── js-package-manager-guard.ts
└── .claude/settings.json # auto-managed
.clooks/clooks.yml
version: "1.0.0"
 
config:
timeout: 30000
onError: "block" # or "continue" | "trace"
maxFailures: 3
 
no-rm-rf: {}
 
log-bash-commands:
config:
logDir: "logs"
parallel: true
onError: "continue"
 
PreToolUse:
order:
- no-rm-rf
- log-bash-commands
Captures

Three scenarios.

Recorded from a Claude Code session.

A hook refuses a destructive command.
The no-rm-rf hook returns { result: "block", reason }. Claude reads the reason, stops, and surfaces it back to the user.
claude
PreToolUseBash
Use the Bash tool to clear stale cache by running: rm -rf /tmp/stale-cache-demo
 
Ran 1 bash command
PreToolUse:Bash hook returned blocking error
Blocked `rm -rf` by policy. Ask the user to run destructive deletes manually.
 
A hook blocked the rm -rf command by policy. Please run it manually:
rm -rf /tmp/stale-cache-demo
What to look at
Clooks output
reason string from the hook appears as the blocking error
Claude's reply
reads the reason and relays it back to the user unprompted
Hook demos

Block with a reason,
let Claude retry.

from clooks-project-hooks

js-package-manager-guard stops Claude from reaching for the wrong package manager. The block reason tells Claude what to do instead — it self-corrects on the next tool call.

01 · PreToolUse · Bash
Wrong package manager. Blocked and retried.
add react-query to the project
I'll install @tanstack/react-query.
Bash · npm install @tanstack/react-query
⎿ PreToolUse:Bash hook returned blocking error
This project uses 'bun'. Use 'bun' instead of 'npm'.
Got it — this project uses bun. Let me retry.
Bash · bun add @tanstack/react-query
installed @tanstack/react-query@5.62.7
Reason string travels back. Claude self-corrects.
02 · PreToolUse · Bash
Same guard catches the runtime.
run the migration script
Running scripts/migrate.js.
Bash · node scripts/migrate.js
⎿ PreToolUse:Bash hook returned blocking error
This project uses 'bun'. Use 'bun' instead of 'node'.
Using bun instead.
Bash · bun scripts/migrate.js
Migrated 1,482 rows in 340ms
One rule, two symptoms — node is in the known set.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// .clooks/hooks/js-package-manager-guard.ts
import type { ClooksHook } from 'clooks'
 
type Config = { allowed: string[] }
 
const KNOWN = new Set([
'npm', 'npx', 'node',
'yarn', 'pnpm', 'pnpx',
'bun', 'bunx', 'deno',
])
 
const firstWord = (cmd: string) =>
cmd.trim().split(/\s+/)[0] ?? ''
 
export const hook: ClooksHook<Config> = {
meta: {
name: 'js-package-manager-guard',
config: { allowed: [] },
},
 
PreToolUse(ctx, config) {
if (ctx.toolName !== 'Bash') return { result: 'skip' }
 
const tool = firstWord(String(ctx.toolInput.command ?? ''))
const allowed = new Set(config.allowed)
 
if (!KNOWN.has(tool) || allowed.has(tool)) {
return { result: 'skip' }
}
 
const use = config.allowed[0] ?? '<none>'
return {
result: 'block',
reason: `This project uses '${use}'. Use '${use}' instead of '${tool}'.`,
}
},
}
Simplified for display.full source →
Scoped config

Three files merge top-down.
Last write wins.

Each layer adds its own hooks and can override the ones beneath. Personal defaults in home, team rules in the repo, and a gitignored local file for the exceptions only you need.

HOME~/.clooks/clooks.yml
config:
timeout: 30000
onError: block
 
no-bare-mv: {}
 
Machine-wide defaults plus your personal tooling.
PROJECT.clooks/clooks.yml
js-package-manager-guard:
config:
allowed: ["pnpm"]
 
secret-scanner:
uses: no-public-secrets
Committed. Team picks a package manager and pins a shared secret scanner.
LOCAL.clooks/clooks.local.yml
js-package-manager-guard:
config:
allowed: ["pnpm", "npm"]
 
secret-scanner:
enabled: false
Gitignored. Loosen a team rule or mute a hook just for you.
HOMEHooks you always want, available in every repo.
PROJECTTeam-owned hooks committed with the repo.
LOCALPersonal overrides that never leave your box.
Resolvedwhat Clooks sees
config:
timeout: 30000
onError: block
 
no-bare-mv: {}
 
js-package-manager-guard:
config:
allowed: ["pnpm", "npm"]
 
secret-scanner:
uses: no-public-secrets
enabled: false
Install

Three ways to install.

Each ends the same way: a committed .clooks/ directory and the Clooks binary on your PATH.

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.

01
Add the marketplace
A separate repo that hosts plugin metadata and points at source.
$claude plugin marketplace add codestripes-dev/clooks-marketplace
02
Install the clooks plugin
Bootstrap only. Drops a SessionStart hook and the /clooks:setup skill. Does not put the runtime on PATH — reload Claude Code to activate.
$claude plugin install clooks@clooks-marketplace
03
Run /clooks:setup
Runs inside Claude Code. Downloads the latest release binary for your platform, places it on PATH, and runs clooks init in the current project.
>/clooks:setup
04
Optional — install a hook pack
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.
$claude plugin install clooks-core-hooks --scope user
Heads up
Global mode: add clooks init --global to register hooks under ~/.clooks/ for every Claude Code session.
On the plugin system

Why isn't Clooks just a Claude Code plugin?

Clooks uses the plugin system for distribution. The runtime itself lives outside the plugin sandbox.

A plugin can quietly download and install binaries on your machine, then wire them into your agent. You shouldn't be surprised by a binary landing on your machine just because you cloned a repo or installed a plugin. Clooks keeps that surface visible — the bash entrypoint sits in .claude/settings.json, the .clooks/ directory is committed alongside your code, and pulling the runtime binary is an explicit step, not something the plugin does behind your back.

vs. native hooks

Clooks vs. native hooks.

Native hooksClooks
Failure modeLets the action through on anything but exit 2Blocks the action when a hook errors (configurable)
LanguageBash strings in JSONTypeScript, typed end to end
CompositionAll hooks parallel, no orderingParallel or sequential with explicit order
Input modificationNot supportedSequential pipeline; hooks see previous updatedInput
RetriesPer invocation onlyCircuit breaker auto-disables after N failures
DistributionCopy-paste from gistsMarketplace, SHA-pinned, lockfile-verified
PortabilityLives in your settingsVendored into .clooks/, committed
FAQ

Common questions.