clooks
v0.3.0

TypeScript hooks
for your agents.

Write hooks as small TypeScript files.
Clooks runs them in Claude and Codex for you

Claude Code Codex Cursor* Windsurf* JetBrains*
* via Claude Code or Codex IDE integrations
~/projects/my-repo
$claude plugin marketplace add codestripes-dev/clooks-marketplace
→ Added marketplace codestripes-dev/clooks-marketplace
✓ added.
$claude plugin install clooks@clooks-marketplace
→ Installed clooks@clooks-marketplace
✓ enabled.
$claude /clooks:setup
→ Set up Clooks in this project
✓ ready.
macOS · LinuxMIT 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 './types'
 
export const hook: ClooksHook = {
meta: {
name: 'no-rm-rf',
description: 'Block destructive rm commands.',
},
 
PreToolUse(ctx) {
if (ctx.toolName !== 'Bash') return ctx.skip()
 
const cmd = ctx.toolInput.command ?? ''
const dangerous = /rm\s+-rf?\s+(\/|~|\$HOME)/.test(cmd)
 
return dangerous
? ctx.block({ reason: `refusing: ${cmd}` })
: ctx.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.

An agent ran rm -rf tests/ patches/ plan/ ~/ and the trailing ~/ wiped the Mac. A guard hook was meant to catch it, but threw an exception and exited with a error code. Some agents read errors as success, so the command ran.
In Clooks, a crashed hook blocks the action by default.

claude-code docs — Hooks reference
"For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code."
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 your hook.json. Every hook is a one-liner you quote by hand or write a new bash script for.

03 — No composition

All agents run native hooks differently, some in parallel, some sequentially. There's no standard way for ordering, no pipeline, no way for one hook to modify input before another sees it.

04 — Tricky portability

A hook you wrote for one repo lives in that repo's settings file. Copying it to the next project means repasting bash strings and recommitting script files. And you might not want every hook enabled.

05 — No discoverability

The best hooks are gists linked in Discord threads. Sharing only works through Claude or Codex Marketplaces, which can open up update injection vectors.

Hooks in action

See how a hook prevents a disaster.
You gave the agent a bad instruction.

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 './types'
 
export const hook: ClooksHook = {
meta: {
name: 'no-rm-rf',
description: 'Block destructive rm commands.',
},
 
PreToolUse(ctx) {
if (ctx.toolName !== 'Bash') return ctx.skip()
 
const cmd = ctx.toolInput.command ?? ''
const dangerous = /rm\s+-rf?\s+(\/|~|\$HOME)/.test(cmd)
 
return dangerous
? ctx.block({ reason: `refusing: ${cmd}` })
: ctx.allow()
},
}
waiting for PreToolUse
Hook API

Hooks are just simple TS code.
Reuse them as needed.

Each file exports one ClooksHook object, which can handle one or more events. Every event you handle is a method with a typed context and a typed return. 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
33
34
35
36
// .clooks/hooks/no-bare-mv.ts
import type { ClooksHook } from './types'
 
export const hook: ClooksHook = {
meta: {
name: 'no-bare-mv',
description: 'Rewrite bare mv to git mv.',
config: {
autoFix: true,
},
},
 
beforeHook(event) { // runs before every event method
if (!event.meta.gitRoot) {
return event.skip()
}
},
 
PreToolUse(ctx, config) {
if (ctx.toolName !== 'Bash') return ctx.skip()
if (!BARE_MV_REGEX.test(ctx.toolInput.command)) {
return ctx.skip()
}
 
if (!config.autoFix) {
return ctx.block({
reason: 'Use git mv to preserve history.',
})
}
 
const rewritten = rewrite(ctx.toolInput.command)
return ctx.allow({
updatedInput: { command: rewritten },
})
},
}
01
meta
Static configurations for your hook.
02
config
Set up configurations that you can later change in the clooks.yml file.
03
Lifecycles
Runs before/after every event on this hook. Allows set up or short circuiting without duplicaton.
04
Event methods
Subscribe to hooks by event name. Implement PreToolUse, you handle PreToolUse.
05
Typed ctx, decision methods
Typed input in. Decisions depend on event and provider: Claude supports native ask/defer on PreToolUse; Codex handler ask uses Clooks confirmations and defer is unsupported.
Write once. Configure everywhere.

Same hook. Three repos. Three dials.

A hook's meta.config is its public interface. Here's no-destructive-git configured to suit each repositories use case without having to write three separate hooks.

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

Demos from real sessions.

Recorded from actual Claude Code transcripts.

A hook refuses a destructive command.
The no-rm-rf hook returns ctx.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
Config

Everything lives in .clooks/
Your team benefits, too.

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 as they're checked in.

After clooks init
your-project/
├── .clooks/
│ ├── clooks.yml # hooks + config
│ ├── clooks.schema.json # editor validation
│ ├── 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
Ordering

Short-circuit complex hooks if other conditions aren't being met.

Claude Code runs every matching hook in parallel. Nothing stops a slow hook when a fast one already said no — and any short-circuit logic has to be duplicated into every hook that needs it.

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."
.clooks/clooks.yml
validate-schema-names:
parallel: true
validate-schema-registration:
parallel: true
validate-index-accessors:
parallel: true
 
verify-server-running: {}
no-outdated-schema: {}
 
PreToolUse:
order:
- validate-schema-names
- validate-schema-registration
- validate-index-accessors
- verify-server-running
- no-outdated-schema

Clooks parallelizes and orders hooks. Expensive checks only run when the cheap ones passed — no duplicated short-circuit logic in every hook.

Scoped config

Global, project and local configuration.
Customize as needed.

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
Hook demos

Tell your agent they're wrong,
let them retry.

from clooks-project-hooks

js-package-manager-guard stops agents from reaching for the wrong package manager. The block reason tells agents 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/[email protected]
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
// .clooks/hooks/js-package-manager-guard.ts
import type { ClooksHook } from './types'
 
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 ctx.skip()
 
const tool = firstWord(String(ctx.toolInput.command ?? ''))
const allowed = new Set(config.allowed)
 
if (!KNOWN.has(tool) || allowed.has(tool)) {
return ctx.skip()
}
 
const use = config.allowed[0] ?? '<none>'
return ctx.block({
reason: `This project uses '${use}'. Use '${use}' instead of '${tool}'.`,
})
},
}
Simplified for display.full source →
vs. native hooks

Clooks vs. native hooks.

Native hooksClooks
Failure modeProvider- and event-defined behaviorConfigurable error policy; refusal depends on the native event
LanguageProvider-defined handler contractsTypeScript with typed event contracts
CompositionProvider-defined execution and orderingParallel or sequential with explicit order
Input modificationProvider- and tool-specific rewritesSequential pipeline; validated updates reach later hooks
RetriesPer invocation onlyCircuit breaker auto-disables after N failures
DistributionProvider-specific packaging and settingsVendor GitHub hook files or root-manifest packs
PortabilityLives in your settingsVendored into .clooks/, committed
Install

Installing clooks is easy.

Add the plugin, then run setup inside Codex to install Clooks and configure your project.

One liner
$codex plugin marketplace add codestripes-dev/clooks-marketplace && codex plugin add clooks@clooks-marketplace && codex '$clooks:setup'
01
Add the marketplace
Adds the Clooks marketplace to Codex.
$codex plugin marketplace add codestripes-dev/clooks-marketplace
02
Install the Clooks plugin
Adds $clooks:setup and a startup reminder. Review the plugin hooks when Codex prompts you.
$codex plugin add clooks@clooks-marketplace
03
In Codex: run setup
Send this as a Codex message, not a shell command. Setup reuses or installs Clooks and configures your project. Review the generated hooks when prompted. You can also ask setup to configure both agents or user-wide hooks.
>$clooks:setup
04
Optional — install a hook pack
Adds ready-made hooks, including no-rm-rf, to your setup.
$codex plugin add clooks-core-hooks@clooks-marketplace
Heads up

Global mode: run clooks init --global --agent all to use hooks from ~/.clooks/ across Claude Code and Codex projects.

On the plugin system

Why isn't Clooks just a plugin?

Claude Code and Codex plugins help you install and configure Clooks. The runtime is a standalone binary with shared configuration.

At startup, the plugin reminds you if setup is needed. Install optional hook packs through your agent's plugin manager; update their hooks with clooks update.

FAQ

Common questions.

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.
Bun lets Clooks run TypeScript hooks without a separate build step. Clooks ships as a single executable, so you do not need Bun installed to use it.
By default, Clooks blocks the action when the event supports it. A hook that runs after a tool cannot undo its work, and a session-end hook cannot prevent shutdown. Set onError to "continue" or "trace" to keep going after errors. A hook is disabled after three consecutive failures by default; a successful run resets the counter.
Yes: browse clooks-core-hooks and clooks-project-hooks in codestripes-dev/clooks-marketplace. Install packs through your agent's plugin manager, or use clooks add with an individual hook URL. Both agents use the same vendored hooks and configuration. Each hook documents its supported tools and configuration.
Claude Code and Codex. Available events, tools, and hook decisions differ between agents; see the README for details. Other agents are not currently supported.