Fix: port the Gemini CLI BeforeTool graph-nudge hook to be shell-agnostic too (#522)

The Gemini hook was a `python -c "..."` one-liner that (a) depended on a bare
`python` being on PATH (frequently `python`/`py` or absent on Windows) and
(b) embedded backticks + escaped quotes that Windows PowerShell mangles. Same
fix as the Claude/Codebuddy hooks: it now invokes `graphify hook-guard gemini`
via the absolute exe path.

The gemini mode always returns {"decision":"allow"} (never blocks a tool) and
appends the graph nudge as additionalContext only when graph.json exists — the
BeforeTool contract Gemini expects, byte-identical to the old payload. It takes
no stdin, honors GRAPHIFY_OUT, and the matcher stays "read_file|list_directory"
so install/uninstall find and replace old hooks unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-06 16:43:21 +01:00
co-authored by Claude Opus 4.8
parent f7911fd1b3
commit 85f9fac7f7
4 changed files with 111 additions and 21 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: the Claude Code / Codebuddy `PreToolUse` graph-nudge hooks now work on Windows (#522). The hooks were inline POSIX bash (`case/esac`, `[ -f ]`, single-quoted `echo`), which Windows cmd.exe/PowerShell cannot parse — so on Windows the hook failed silently, no "run `graphify query` before grepping/reading raw files" context was injected, and users had to invoke `/graphify` by hand. The detection logic (grep-command match, source-file extension match, skip-if-under-output-dir, graph-exists check) moved into a shell-agnostic `graphify hook-guard <search|read>` subcommand invoked via the absolute exe path (the same pattern the codex hook already uses), so the hook parses and runs identically on Windows, macOS, and Linux. Behavior on macOS/Linux is unchanged (byte-identical nudge payload); the graph path now also honors `GRAPHIFY_OUT`. Codex stays a no-op there because Codex Desktop rejects `additionalContext`.
- Fix: the Claude Code / Codebuddy `PreToolUse` and Gemini CLI `BeforeTool` graph-nudge hooks now work on Windows (#522). The hooks were inline POSIX bash (`case/esac`, `[ -f ]`, single-quoted `echo`), which Windows cmd.exe/PowerShell cannot parse — so on Windows the hook failed silently, no "run `graphify query` before grepping/reading raw files" context was injected, and users had to invoke `/graphify` by hand. The detection logic (grep-command match, source-file extension match, skip-if-under-output-dir, graph-exists check) moved into a shell-agnostic `graphify hook-guard <search|read>` subcommand invoked via the absolute exe path (the same pattern the codex hook already uses), so the hook parses and runs identically on Windows, macOS, and Linux. Behavior on macOS/Linux is unchanged (byte-identical nudge payload); the graph path now also honors `GRAPHIFY_OUT`. The Gemini `BeforeTool` hook got the same treatment (`graphify hook-guard gemini`), which also removes its dependency on a bare `python` being on PATH. Codex stays a no-op there because Codex Desktop rejects `additionalContext`.
- Fix: `--update`-style section writes to `CLAUDE.md`/`AGENTS.md` no longer corrupt or drop content (#1688, thanks @bdfinst). `_replace_or_append_section` located its managed block by substring (`marker in content`) and `next(... if marker in line)`, so a heading that appeared as a substring of another line (or duplicate headings) matched the wrong offset and the rewrite could truncate the file. It now matches the section heading exactly (`line.strip() == marker`), appends when absent, and prefers the last exact match when several exist, so unrelated content is preserved.
- Fix: token estimation no longer crashes on files containing tiktoken special-token text like `<|endoftext|>` (#1685, thanks @Kyzcreig). `_TOKENIZER.encode(content)` raises `ValueError` by default when the text contains a special token, which aborted packing on docs/corpora that merely mention these strings. Both `encode` sites now pass `disallowed_special=()` so such text is tokenized as ordinary bytes.
- Fix: the Ollama backend no longer multiplies a hang by the retry count (#1686, thanks @Kyzcreig). A stalled local model would wedge for `timeout * (max_retries + 1)`, which with the default 6 retries turned one long stall into a very long one. Ollama now defaults to zero client-side retries (a local model that stalls will not un-stall on retry); set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged. Note: the underlying stall is non-deterministic and driven by the model server, so this bounds the wait rather than eliminating the hang.
+37 -18
View File
@@ -852,23 +852,30 @@ _AGENTS_MD_MARKER = "## graphify"
_GEMINI_MD_MARKER = "## graphify"
_GEMINI_HOOK = {
"matcher": "read_file|list_directory",
"hooks": [
{
"type": "command",
"command": (
'python -c "'
"import sys,pathlib,json;"
"e=pathlib.Path('graphify-out/graph.json').exists();"
"d={'decision':'allow'};"
"e and d.update({'additionalContext':'graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"<question>\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context.'});"
"sys.stdout.write(json.dumps(d))"
'"'
),
}
],
}
# Gemini CLI BeforeTool hook nudge text. The hook always returns
# {"decision":"allow"} (never blocks a tool) and appends this as additionalContext
# when a graph exists. Emitted by `graphify hook-guard gemini`. The old hook was a
# `python -c "..."` one-liner that depended on a bare `python` on PATH (often
# `python`/`py` or absent on Windows) and embedded backticks + escaped quotes that
# Windows PowerShell mangles (#522 follow-up); the subcommand form has no such
# dependency and parses under every shell.
_GEMINI_NUDGE_TEXT = (
'graphify: knowledge graph at graphify-out/. For focused questions, run '
'`graphify query "<question>"` (scoped subgraph, usually much smaller than '
'GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only '
'for broad architecture context.'
)
def _gemini_hook() -> dict:
"""Gemini CLI BeforeTool hook, resolved to a shell-agnostic `graphify` call."""
exe = _resolve_graphify_exe()
if " " in exe and not exe.startswith('"'):
exe = f'"{exe}"'
return {
"matcher": "read_file|list_directory",
"hooks": [{"type": "command", "command": f"{exe} hook-guard gemini"}],
}
def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None:
@@ -917,7 +924,7 @@ def _install_gemini_hook(project_dir: Path) -> None:
settings["hooks"]["BeforeTool"] = [
h for h in before_tool if "graphify" not in str(h)
]
settings["hooks"]["BeforeTool"].append(_GEMINI_HOOK)
settings["hooks"]["BeforeTool"].append(_gemini_hook())
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(" .gemini/settings.json -> BeforeTool hook registered")
@@ -1637,6 +1644,18 @@ def _run_hook_guard(kind: str) -> None:
tool call is never blocked. Detection mirrors the previous hooks exactly.
"""
from graphify.paths import out_path, GRAPHIFY_OUT_NAME
# Gemini's BeforeTool hook takes no stdin and must ALWAYS return a decision so
# the tool is never blocked; the graph nudge is appended only when a graph
# exists. Handled before the stdin read below (which the search/read guards need).
if kind == "gemini":
payload = {"decision": "allow"}
try:
if out_path("graph.json").is_file():
payload["additionalContext"] = _GEMINI_NUDGE_TEXT
except Exception:
pass
sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
return
try:
d = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
except Exception:
+71
View File
@@ -0,0 +1,71 @@
"""The Gemini CLI BeforeTool guard nudges toward the graph, shell-agnostically.
Since #522 it runs as `graphify hook-guard gemini` (not a `python -c` one-liner
that depended on a bare `python` on PATH and embedded PowerShell-hostile
backticks). It always returns {"decision":"allow"} so a tool is never blocked,
and appends additionalContext only when a graph exists.
"""
import json
import os
import subprocess
import sys
from graphify.__main__ import _gemini_hook
def _env():
e = dict(os.environ)
e.pop("GRAPHIFY_OUT", None)
return e
def _run(cwd, *, graph: bool):
if graph:
(cwd / "graphify-out").mkdir(parents=True, exist_ok=True)
(cwd / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
return subprocess.run(
[sys.executable, "-m", "graphify", "hook-guard", "gemini"],
input="", capture_output=True, text=True, cwd=cwd, env=_env(),
)
def test_matcher_and_command_shape():
h = _gemini_hook()
assert h["matcher"] == "read_file|list_directory"
cmd = h["hooks"][0]["command"]
# #522: no bare `python` dependency, no embedded quote/backtick soup.
assert "python -c" not in cmd
assert "graphify" in cmd and "hook-guard gemini" in cmd
def test_allows_and_nudges_with_graph(tmp_path):
out = _run(tmp_path, graph=True).stdout
payload = json.loads(out)
assert payload["decision"] == "allow"
assert "graphify query" in payload["additionalContext"]
def test_allows_without_nudge_when_no_graph(tmp_path):
out = _run(tmp_path, graph=False).stdout
payload = json.loads(out)
assert payload["decision"] == "allow"
assert "additionalContext" not in payload
def test_never_blocks(tmp_path):
r = _run(tmp_path, graph=True)
assert r.returncode == 0
payload = json.loads(r.stdout)
assert payload["decision"] == "allow"
def test_honors_graphify_out_override(tmp_path):
custom = tmp_path / "custom-out"
custom.mkdir()
(custom / "graph.json").write_text("{}", encoding="utf-8")
env = dict(os.environ, GRAPHIFY_OUT=str(custom))
r = subprocess.run(
[sys.executable, "-m", "graphify", "hook-guard", "gemini"],
input="", capture_output=True, text=True, cwd=tmp_path, env=env,
)
assert "graphify query" in json.loads(r.stdout).get("additionalContext", "")
+2 -2
View File
@@ -18,7 +18,7 @@ from graphify.__main__ import (
_CLAUDE_MD_SECTION,
_AGENTS_MD_SECTION,
_GEMINI_MD_SECTION,
_GEMINI_HOOK,
_GEMINI_NUDGE_TEXT,
_VSCODE_INSTRUCTIONS_SECTION,
_ANTIGRAVITY_RULES,
_KIRO_STEERING,
@@ -37,7 +37,7 @@ _INSTALL_TEXTS: dict[str, str] = {
"_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION,
"_AGENTS_MD_SECTION": _AGENTS_MD_SECTION,
"_GEMINI_MD_SECTION": _GEMINI_MD_SECTION,
"_GEMINI_HOOK": json.dumps(_GEMINI_HOOK),
"_GEMINI_NUDGE_TEXT": _GEMINI_NUDGE_TEXT,
"_VSCODE_INSTRUCTIONS_SECTION": _VSCODE_INSTRUCTIONS_SECTION,
"_ANTIGRAVITY_RULES": _ANTIGRAVITY_RULES,
"_KIRO_STEERING": _KIRO_STEERING,