@zenginui/hook
v0.3.0
Published
Claude Code PostToolUse hook that checks every file write against the design system.
Maintainers
Readme
@zenginui/hook
Claude Code PostToolUse hook. Every time the agent writes or edits a file, the engine checks it and the violations go straight back to the agent as the blocking reason. This is the surface that catches the agent that never called MCP.
What it does
- Claude Code runs
zengin-hookafterWrite,Edit,MultiEditorBash, with the tool payload on stdin. - For a file-writing tool the hook finds the nearest
zengin.config.yamlabove the written file, reads the file from disk, and runs the engine on it. 2b. ABashpayload names no file, so the hook asks the working tree instead: git for what changed, and a content hash per file for what changed since it last looked. Without this an agent told to usesedfor small edits leaves the loop entirely, and silently. - Clean file: exit 0, no output, nothing in the transcript.
- Violations at or above
--block-on(defaulterror): exit 2 with the violations on stderr. Claude Code shows them to the agent as the reason the write was flagged, and the agent corrects. - Violations below the threshold, or any violations with
--block-on never: exit 0 withadditionalContextJSON on stdout, so the agent sees them without being blocked.
On Edit and MultiEdit, only violations on the lines the edit touched are reported (--scope changed, the default). A one-line edit in a file with forty pre-existing problems is not blocked by the forty; the hook says how many it left out. --scope file reports everything. Write always reports the whole file, because the agent wrote all of it.
The hook never blocks for reasons unrelated to the design system: no config, a file outside scope, a non-code file, or an internal error all exit without blocking.
Install
pnpm build
npm link ./packages/hook # or reference dist/index.js by absolute pathPrint the settings fragment and merge it into .claude/settings.json (project) or ~/.claude/settings.json (user):
zengin-hook settings{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit|Bash",
"hooks": [
{ "type": "command", "command": "npx --no-install zengin-hook", "timeout": 30, "statusMessage": "Checking against the design system..." }
]
}
]
}
}The command goes through npx --no-install, and the whole command is one string. zengin-hook lives in
node_modules/.bin, which is on PATH inside an npm script and nowhere else, and an agent launches the hook
directly — so a bare zengin-hook fails to start and says nothing, which looks exactly like a project that
never drifts. The hook schema has one command field and no args, so a command split across command and
args runs node with the rest dropped, silently, the same way.
Working on Zengin itself, point it at the build in your checkout instead: "command": "node
packages/hook/dist/index.js". Options go on the end of that same string.
This is what zengin create writes and what zengin doctor checks, from one definition in the engine.
Options
| Option | Values | Default | Effect |
| --- | --- | --- | --- |
| --config <path> | | nearest above the file | Which zengin.config.yaml to use. |
| --block-on | error, warn, info, never | error | Lowest severity that blocks. never always reports as context. |
| --scope | changed, file | changed | On Edit, report only touched lines or the whole file. |
| --max <n> | | 50 | Maximum violations rendered. |
The three surfaces
| Surface | Fires | Bypassable | | --- | --- | --- | | MCP | when the agent asks | yes | | Hook | on every file write | not locally | | CLI and CI | on commit and pull request | no |
Same engine, same violation shape, same fixes. The hook and the MCP server render violations through the engine's shared formatter, so the agent sees identical text either way.
Tests
pnpm testTests copy the engine's plain-CSS fixture project to a temp directory, perform real writes and edits, and drive the hook in-process. The built binary is also exercised by hand from a foreign working directory before release.
