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:
co-authored by
Claude Opus 4.8
parent
f7911fd1b3
commit
85f9fac7f7
+1
-1
@@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
|||||||
|
|
||||||
## Unreleased
|
## 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: `--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: 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.
|
- 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
@@ -852,23 +852,30 @@ _AGENTS_MD_MARKER = "## graphify"
|
|||||||
|
|
||||||
_GEMINI_MD_MARKER = "## graphify"
|
_GEMINI_MD_MARKER = "## graphify"
|
||||||
|
|
||||||
_GEMINI_HOOK = {
|
# Gemini CLI BeforeTool hook nudge text. The hook always returns
|
||||||
"matcher": "read_file|list_directory",
|
# {"decision":"allow"} (never blocks a tool) and appends this as additionalContext
|
||||||
"hooks": [
|
# 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
|
||||||
"type": "command",
|
# `python`/`py` or absent on Windows) and embedded backticks + escaped quotes that
|
||||||
"command": (
|
# Windows PowerShell mangles (#522 follow-up); the subcommand form has no such
|
||||||
'python -c "'
|
# dependency and parses under every shell.
|
||||||
"import sys,pathlib,json;"
|
_GEMINI_NUDGE_TEXT = (
|
||||||
"e=pathlib.Path('graphify-out/graph.json').exists();"
|
'graphify: knowledge graph at graphify-out/. For focused questions, run '
|
||||||
"d={'decision':'allow'};"
|
'`graphify query "<question>"` (scoped subgraph, usually much smaller than '
|
||||||
"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.'});"
|
'GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only '
|
||||||
"sys.stdout.write(json.dumps(d))"
|
'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:
|
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"] = [
|
settings["hooks"]["BeforeTool"] = [
|
||||||
h for h in before_tool if "graphify" not in str(h)
|
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")
|
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
||||||
print(" .gemini/settings.json -> BeforeTool hook registered")
|
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.
|
tool call is never blocked. Detection mirrors the previous hooks exactly.
|
||||||
"""
|
"""
|
||||||
from graphify.paths import out_path, GRAPHIFY_OUT_NAME
|
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:
|
try:
|
||||||
d = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
|
d = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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", "")
|
||||||
@@ -18,7 +18,7 @@ from graphify.__main__ import (
|
|||||||
_CLAUDE_MD_SECTION,
|
_CLAUDE_MD_SECTION,
|
||||||
_AGENTS_MD_SECTION,
|
_AGENTS_MD_SECTION,
|
||||||
_GEMINI_MD_SECTION,
|
_GEMINI_MD_SECTION,
|
||||||
_GEMINI_HOOK,
|
_GEMINI_NUDGE_TEXT,
|
||||||
_VSCODE_INSTRUCTIONS_SECTION,
|
_VSCODE_INSTRUCTIONS_SECTION,
|
||||||
_ANTIGRAVITY_RULES,
|
_ANTIGRAVITY_RULES,
|
||||||
_KIRO_STEERING,
|
_KIRO_STEERING,
|
||||||
@@ -37,7 +37,7 @@ _INSTALL_TEXTS: dict[str, str] = {
|
|||||||
"_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION,
|
"_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION,
|
||||||
"_AGENTS_MD_SECTION": _AGENTS_MD_SECTION,
|
"_AGENTS_MD_SECTION": _AGENTS_MD_SECTION,
|
||||||
"_GEMINI_MD_SECTION": _GEMINI_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,
|
"_VSCODE_INSTRUCTIONS_SECTION": _VSCODE_INSTRUCTIONS_SECTION,
|
||||||
"_ANTIGRAVITY_RULES": _ANTIGRAVITY_RULES,
|
"_ANTIGRAVITY_RULES": _ANTIGRAVITY_RULES,
|
||||||
"_KIRO_STEERING": _KIRO_STEERING,
|
"_KIRO_STEERING": _KIRO_STEERING,
|
||||||
|
|||||||
Reference in New Issue
Block a user