Post
A first Claude Code mod: write, check, test and run it
Building a mod that blocks npm commands that skip a wrapper script and adds a /node-guard command: the files, claude plugin validate, a test, running it, letting Claude write it, and the pitfalls.
In this blog every npm command must go through ./scripts/with-node.sh, because the system Node is too old. This mod blocks every npm or npx that skips the script, and adds a /node-guard command that counts the blocks. Every example ran with Claude Code 2.1.295.
Three files
node-guard/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
├── hooks.json
└── register.ts
plugin.json is an ordinary plugin manifest:
{
"name": "node-guard",
"version": "0.1.0",
"description": "Blocks npm and npx commands that skip ./scripts/with-node.sh, and adds a /node-guard command",
"author": { "name": "Moris Maor Zakay" }
}
In hooks.json, the modules field is what makes a plugin a mod:
{
"modules": ["./register.ts"]
}
hooks/register.ts:
import type { Register } from 'claude-code'
// npm or npx at the start of a command, or after ; && || |
const NPM = /(^|[;&|]\s*)(npm|npx)\s/
let blocked = 0
export const register: Register = on => {
// Add the /node-guard command when the session starts
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'node-guard',
description: 'Show how many npm commands the guard blocked',
})
return next(e)
})
// Runs before every Bash command Claude wants to run
on('tool.call', { tool: 'Bash' }, ($, e, next) => {
if (NPM.test(e.command) && !e.command.includes('with-node.sh')) {
blocked += 1
$.ui.status(`node-guard: blocked ${blocked}`)
return { deny: 'Run npm through ./scripts/with-node.sh (this project needs Node 24).' }
}
return next(e)
}).catch(($, e, next) =>
// If the hook itself failed, refuse rather than let the command through
next.called ? next(e) : { deny: 'node-guard failed, so the command was not run.' },
)
// Answers /node-guard without starting a Claude turn
on('command.run', { command: 'node-guard' }, async () => ({
text: `Blocked ${blocked} npm commands since the mod loaded`,
}))
}
{ tool: 'Bash' }is a matcher: the hook runs only on Bash calls.- A hook that throws is skipped, so the command it guards would run. Every guarding hook needs a
.catch. command.runanswers withoutnext, so/node-guardreplies with no Claude turn and no tokens.- Claude Code loads
.tsdirectly, with no Node, bundler or build;import typefromclaude-codegives types only.
Check before you run
claude plugin validate analyzes the code without running it:
claude plugin validate ./node-guard
❯ ./register.ts hooks: session.start, tool.call{tool=Bash}, command.run{command=node-guard}
❯ ./register.ts answers its own command: command.run{command=node-guard}
❯ ./register.ts gating hook with .catch: tool.call{tool=Bash}
❯ ./register.ts calls: $.command.register, $.ui.status
✔ Validation passed
hooks: lists the events, calls: what the mod asks Claude Code to do. Run it on someone else’s mod before installing to see whether it reads files, sends requests or runs processes.
A test
claude plugin test runs *.test.ts files with no session, sign-in or network. Hooks the test registers sit beneath the mod and play Claude Code. node-guard/tests/node-guard.test.ts:
import { expect, test } from 'claude-code/testing'
test('blocks npm without with-node.sh and lets the wrapped one run', async ($, on) => {
// Stand in for Claude Code: every tool call that gets this far "runs"
on('tool.call', () => ({ result: 'ran' }))
const bare = await $.tool.call({ tool: 'Bash', command: 'npm test' })
expect(bare.deny).toContain('with-node.sh')
const wrapped = await $.tool.call({ tool: 'Bash', command: './scripts/with-node.sh npm test' })
expect(wrapped.deny).toBeUndefined()
const answer = await $.command.run({ command: 'node-guard', args: '' })
expect(answer.text).toBe('Blocked 1 npm commands since the mod loaded')
})
cd node-guard && claude plugin test
tests/node-guard.test.ts:
(pass) blocks npm without with-node.sh and lets the wrapped one run [18.72ms]
1 pass
0 fail
Ran 1 test across 1 file. [0.17s]
Run it
--plugin-dir loads the folder for one session without installing it, and reloads the mod on every save:
claude --plugin-dir ./node-guard
When Claude tries npm test, it gets the mod’s message instead of the output. The command, without an interactive session:
claude -p "/node-guard" --plugin-dir ./node-guard
node-guard: Blocked 0 npm commands since the mod loaded
The counter resets on every reload. A value that must survive goes in $.state (the session) or $.store (across sessions). Installing and sharing are in the previous post.
Let Claude write it
Ask in a normal session, for example “make a mod that shows the current git branch above the prompt”. Claude uses the built-in plugin-authoring skill (or /plugin-authoring), which knows your installed version’s events and methods.
- The mod goes to
~/.claude/dev-mods/<session-id>/<mod-name>/. - On the first save, Claude Code asks whether to enable hot reloading. After Enable for this session, the mod loads at the end of the turn and reloads after every turn that changes it.
- The folder is deleted after
cleanupPeriodDays. To keep the mod, copy it elsewhere and run it with--plugin-dir.
On every load Claude Code writes TypeScript declarations for your version into the mod’s .claude-plugin/types/. When they and the website disagree, the files are right.
Pitfalls
- Write
$out in full.validatereads the code statically, soconst ui = $.uior destructuring$fails. - The event name is a string literal, not a variable or a loop over names.
- Plain
importonly, norequireor dynamicimport(); files from inside the plugin folder, types and helpers fromclaude-code. - No DOM and no Node. Everything outside goes through
$:$.fs,$.process,$.http. - 10 seconds per hook, or it is skipped. Time inside
nextand$calls doesn’t count, except$.clock.sleep. A.catchhandler gets one second. - A mod that does nothing:
claude --debuglogs why a hook was skipped or a UI tree refused.
Every event, method and element is in the Mods reference.