DEV Community

Cover image for Adding Gemini CLI to polyhook: what it took
tupe12334
tupe12334

Posted on

Adding Gemini CLI to polyhook: what it took

I've been working on polyhook for a while now. The short version: it's a library that lets you write one hook binary and have it work across Claude Code, Cursor, Windsurf, Cline, and Amp. Each of those tools has hooks, and each of them sends completely different JSON to your hook script. polyhook sits in the middle and normalizes it.

Someone pointed out that Gemini CLI has hooks too and polyhook doesn't support it. Fair. So I sat down to add it.


I didn't want to just guess at the format, so before touching any code I actually went and read through the Gemini CLI docs. Their hook system is pretty solid — 11 lifecycle events that cover everything from session start/end to individual tool calls to the model layer itself. They even have a BeforeToolSelection event that fires before the model picks which tool to use. None of the other tools polyhook supports have that.

The protocol is clean: your hook script reads JSON from stdin, writes JSON to stdout, stderr is for logs only. Exit 0 means the response on stdout is your answer. Exit 2 means block whatever was about to happen. That's it.

What I was not expecting was the field name collision.

Both Gemini CLI and Claude Code send hook_event_name in their payloads. Both send tool_name. Both send tool_input. If you look at either payload in isolation without the event name value, you genuinely can't tell them apart just from the field names.

The difference is in what hook_event_name is set to. Claude Code uses "PreToolUse", "PostToolUse", "Startup". Gemini CLI uses "BeforeTool", "AfterTool", "SessionStart". Same key, different vocabulary.

polyhook's detection logic already had a heuristic that said "if I see tool_name and tool_input, it's probably Claude Code." That heuristic was written before Gemini CLI was a thing, and it would've silently mislabeled every Gemini CLI event as Claude Code.

The fix was to check hook_event_name's value before checking for the field combination. If the value is one of Gemini CLI's event names, label it Gemini CLI and stop there. The Claude Code field-name heuristic only runs if that first check doesn't fire. Order matters a lot here — flip it and you break Gemini CLI support silently, which is the worst kind of bug.

In practice the env var detection handles most cases anyway. Gemini CLI always sets GEMINI_PROJECT_DIR when it invokes a hook, same way Claude Code always sets CLAUDE_CODE_VERSION. So the JSON heuristic is really just a fallback for when someone's testing their hook outside the actual tool.


Once detection was sorted, the rest was fairly mechanical. Map Gemini CLI's event names to polyhook's canonical ones (BeforeTooltool:before, SessionStartsession:start, and so on). Map Gemini CLI's tool names (run_shell_commandbash, replaceedit_file, google_web_searchweb_search). Add a response serializer that outputs the format Gemini CLI expects — {"decision": "allow"} to approve, {"decision": "deny", "reason": "..."} to block.

The whole thing touched six source files. Because polyhook's core is compiled to WASM and all the language SDKs (Rust, TypeScript, Go, Python, .NET) are just thin wrappers over that binary, adding "gemini-cli" to the schema enum automatically regenerated the types in every SDK. Nobody had to touch SDK-specific code.


If you're using polyhook and also happen to use Gemini CLI, your existing hook binary now works there too. Just point .gemini/settings.json at it.

The library is at github.com/tupe12334/polyhook. The PR that landed this is #10 if you want to see the diff.

Top comments (0)