diff --git a/CHANGELOG.md b/CHANGELOG.md index de1f589..1fa09a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +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 ` 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: `--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. diff --git a/graphify/__main__.py b/graphify/__main__.py index 3a81135..4b50234 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -434,62 +434,70 @@ def _print_project_git_add_hint(paths: list[Path]) -> None: print("Project-scoped install. Add to version control:") print(f" git add {' '.join(unique)}") -_SETTINGS_HOOK = { - # Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash. - # We match on Bash and inspect the command string to avoid firing on every shell call. - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": ( - "CMD=$(python3 -c \"" - "import json,sys; d=json.load(sys.stdin); " - "print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); " - "case \"$CMD\" in " - r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) " - " [ -f graphify-out/graph.json ] && " - r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \"\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines."}}' """ - " || true ;; " - "esac" - ), - } - ], -} +# PreToolUse nudge payloads, emitted verbatim by the shell-agnostic +# `graphify hook-guard` subcommand (see _run_hook_guard). The previous hooks +# inlined POSIX bash (case/esac, [ -f ], single-quoted echo) which Windows +# cmd.exe/PowerShell cannot parse, so on Windows the hook failed and the nudge +# silently vanished — users had to invoke /graphify by hand (#522). Moving the +# logic into a Python subcommand invoked via an absolute exe path makes the hook +# parse identically under sh, cmd.exe and PowerShell. Claude Code accepts +# additionalContext on PreToolUse (Codex Desktop does not — that path stays a +# no-op via `hook-check`). Compact separators keep the payload byte-for-byte the +# same JSON the old `echo` emitted. +_SEARCH_NUDGE = json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + 'MANDATORY: graphify-out/graph.json exists. You MUST run ' + '`graphify query ""` before grepping raw files. Only grep ' + 'after graphify has oriented you, or to modify/debug specific lines.' + ), + } +}, ensure_ascii=False, separators=(",", ":")) + "\n" -_READ_SETTINGS_HOOK = { - # The Bash hook above never sees a file read through the native Read tool or a - # Glob, which is the most common way an agent skips the graph: answering a - # codebase question by Read-ing many source files one by one (issue #1114). - # Match Read|Glob, inspect the target path, and nudge (never block) only for a - # source/doc file outside graphify-out/ when a graph exists. The parser is - # python3 (already a graphify dependency), the shell is POSIX, and every branch - # fails open, so a legitimate read always goes through. Reading the graph's own - # report under graphify-out/ is suppressed so it never starts a feedback loop. - # The extension test compares each value's real trailing extension (segment - # after the last '/' then after the last '.') against exts -- not a substring - # scan, which both missed framework files like .astro and false-matched .json - # against .js (the substring '.js' is inside '.json'). - "matcher": "Read|Glob", - "hooks": [ - { - "type": "command", - "command": ( - "HIT=$(python3 -c \"" - "import json,sys;" - "d=json.load(sys.stdin);" - "t=d.get('tool_input',d);" - "exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');" - "vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];" - "j=' '.join(vals).lower().replace(chr(92),'/');" - "tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];" - "sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); " - "if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then " - r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \"\"` (scoped subgraph), `graphify explain \"\"`, or `graphify path \"\" \"\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too — include it in every subagent prompt involving code exploration."}}'; """ - "fi || true" - ), - } - ], -} +_READ_NUDGE = json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + 'MANDATORY: graphify-out/graph.json exists. You MUST run graphify ' + 'before reading source files. Use: `graphify query ""` ' + '(scoped subgraph), `graphify explain ""`, or ' + '`graphify path "" ""`. Only read raw files after graphify has ' + 'oriented you, or to modify/debug specific lines. This rule applies to ' + 'subagents too — include it in every subagent prompt involving code ' + 'exploration.' + ), + } +}, ensure_ascii=False, separators=(",", ":")) + "\n" + +# Source/doc extensions the Read|Glob guard nudges on (verbatim from the old hook). +# The trailing-extension test (real final path segment, then its last '.') means +# '.json' never false-matches '.js', and framework files like '.astro' are kept. +_HOOK_SOURCE_EXTS = ( + '.py', '.js', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go', + '.rs', '.java', '.rb', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.kt', + '.swift', '.php', '.scala', '.lua', '.sh', '.md', '.rst', '.txt', '.mdx', +) + + +def _claude_pretooluse_hooks() -> "list[dict]": + """graphify's Claude/Codebuddy PreToolUse hooks, resolved at install time. + + The command invokes `graphify hook-guard ` via the absolute exe + path (`_resolve_graphify_exe`), so it parses under sh, cmd.exe and PowerShell + alike — this is the #522 fix, and mirrors the codex hook. Matchers stay "Bash" + and "Read|Glob" and the command always contains "graphify", so the existing + install/uninstall filters find and replace both old bash hooks and these. + """ + exe = _resolve_graphify_exe() + if " " in exe and not exe.startswith('"'): + exe = f'"{exe}"' + return [ + {"matcher": "Bash", + "hooks": [{"type": "command", "command": f"{exe} hook-guard search"}]}, + {"matcher": "Read|Glob", + "hooks": [{"type": "command", "command": f"{exe} hook-guard read"}]}, + ] def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str: return ( @@ -1618,6 +1626,51 @@ def _resolve_graphify_exe() -> str: return "graphify" +def _run_hook_guard(kind: str) -> None: + """Shell-agnostic PreToolUse guard (#522). + + Reads the tool-call JSON from stdin and, when a knowledge graph exists in the + current output dir, prints a nudge (`additionalContext`) telling the agent to + use graphify instead of grepping/reading raw files. Replaces the old inline + bash hooks that failed to parse on Windows. Always fails open: any error, or a + non-matching tool call, prints nothing and the caller exits 0, so a legitimate + tool call is never blocked. Detection mirrors the previous hooks exactly. + """ + from graphify.paths import out_path, GRAPHIFY_OUT_NAME + try: + d = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace")) + except Exception: + return + if not isinstance(d, dict): + return + t = d.get("tool_input", d) + if not isinstance(t, dict): + return + try: + if kind == "search": + cmd_str = str(t.get("command", "") or "") + # Same set the old `case` matched: *grep*, *ripgrep*, and rg/find/fd/ + # ack/ag as a token (name followed by a space). + if any(tok in cmd_str for tok in ("grep", "ripgrep", "rg ", "find ", "fd ", "ack ", "ag ")) \ + and out_path("graph.json").is_file(): + sys.stdout.write(_SEARCH_NUDGE) + elif kind == "read": + vals = [str(t.get("file_path") or ""), str(t.get("pattern") or ""), str(t.get("path") or "")] + j = " ".join(vals).lower().replace("\\", "/") + tails = [ + "." + seg.rsplit(".", 1)[-1] + for v in vals if v + for seg in [v.lower().replace("\\", "/").rsplit("/", 1)[-1]] + if "." in seg + ] + under_out = "graphify-out/" in j or (GRAPHIFY_OUT_NAME.lower() + "/") in j + if not under_out and any(tl in _HOOK_SOURCE_EXTS for tl in tails) \ + and out_path("graph.json").is_file(): + sys.stdout.write(_READ_NUDGE) + except Exception: + pass + + def _install_codex_hook(project_dir: Path) -> None: """Add graphify PreToolUse hook to .codex/hooks.json.""" hooks_path = project_dir / ".codex" / "hooks.json" @@ -1975,8 +2028,7 @@ def _install_claude_hook(project_dir: Path) -> None: pre_tool = hooks.setdefault("PreToolUse", []) hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))] - hooks["PreToolUse"].append(_SETTINGS_HOOK) - hooks["PreToolUse"].append(_READ_SETTINGS_HOOK) + hooks["PreToolUse"].extend(_claude_pretooluse_hooks()) settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob)") @@ -2125,8 +2177,7 @@ def _install_codebuddy_hook(project_dir: Path) -> None: pre_tool = hooks.setdefault("PreToolUse", []) hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))] - hooks["PreToolUse"].append(_SETTINGS_HOOK) - hooks["PreToolUse"].append(_READ_SETTINGS_HOOK) + hooks["PreToolUse"].extend(_claude_pretooluse_hooks()) settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") print(f" .codebuddy/settings.json -> PreToolUse hooks registered") @@ -2250,7 +2301,7 @@ def main() -> None: # Skip during install/uninstall (hook writes trigger a fresh check anyway). # Skip during hook-check — it runs on every editor tool use and must be silent. # Deduplicate paths so platforms sharing the same install dir don't warn twice. - _silent_cmds = {"install", "uninstall", "hook-check"} + _silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"} if not any(arg in _silent_cmds for arg in sys.argv): # Resolve each platform's real user-scope destination so per-platform # overrides (gemini, opencode, devin, antigravity, amp) check the dir @@ -3777,6 +3828,12 @@ def main() -> None: # Keep this as a cross-platform no-op so installed hooks never break Bash # tool calls. Graph guidance reaches the agent via AGENTS.md / skill instead. sys.exit(0) + elif cmd == "hook-guard": + # Shell-agnostic Claude/Codebuddy PreToolUse guard (#522). Replaces the old + # inline-bash hooks that failed on Windows. Prints an additionalContext nudge + # toward graphify when a graph exists; always exits 0 (never blocks a tool). + _run_hook_guard(sys.argv[2] if len(sys.argv) > 2 else "") + sys.exit(0) elif cmd == "check-update": if len(sys.argv) < 3: print("Usage: graphify check-update ", file=sys.stderr) diff --git a/tests/test_install.py b/tests/test_install.py index fc1a4e9..b1bcaa7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -359,6 +359,41 @@ def test_codebuddy_install_writes_hook(tmp_path): assert any("graphify" in str(h) for h in hooks) +def test_claude_hook_is_shell_agnostic(tmp_path): + # #522: the installed PreToolUse hooks must be plain exe invocations, not + # POSIX bash (which fails on Windows cmd.exe/PowerShell). + import json as _json + from graphify.__main__ import _install_claude_hook + _install_claude_hook(tmp_path) + hooks = _json.loads((tmp_path / ".claude" / "settings.json").read_text())["hooks"]["PreToolUse"] + matchers = {h["matcher"] for h in hooks} + assert {"Bash", "Read|Glob"} <= matchers + for h in hooks: + cmd = h["hooks"][0]["command"] + for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"): + assert token not in cmd, f"shell syntax {token!r} in {cmd!r}" + assert "graphify" in cmd and "hook-guard" in cmd + + +def test_claude_hook_install_idempotent_and_replaces_old_bash_hook(tmp_path): + import json as _json + from graphify.__main__ import _install_claude_hook + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + # Pre-seed a legacy bash-style graphify hook (the thing #522 shipped before). + settings_path.write_text(_json.dumps({"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", + "command": "[ -f graphify-out/graph.json ] && echo '{...}' || true"}]}, + ]}}), encoding="utf-8") + _install_claude_hook(tmp_path) + _install_claude_hook(tmp_path) # second install must not duplicate + hooks = _json.loads(settings_path.read_text())["hooks"]["PreToolUse"] + graphify_hooks = [h for h in hooks if "graphify" in str(h)] + assert len(graphify_hooks) == 2, "exactly the Bash + Read|Glob guards, no dupes" + # the legacy bash payload must be gone + assert not any("[ -f graphify-out" in h["hooks"][0]["command"] for h in graphify_hooks) + + def test_codebuddy_install_idempotent(tmp_path): from graphify.__main__ import codebuddy_install codebuddy_install(tmp_path) diff --git a/tests/test_install_strings.py b/tests/test_install_strings.py index 38bec1e..36c5f56 100644 --- a/tests/test_install_strings.py +++ b/tests/test_install_strings.py @@ -12,8 +12,8 @@ from __future__ import annotations import json from graphify.__main__ import ( - _SETTINGS_HOOK, - _READ_SETTINGS_HOOK, + _SEARCH_NUDGE, + _READ_NUDGE, _skill_registration, _CLAUDE_MD_SECTION, _AGENTS_MD_SECTION, @@ -32,8 +32,8 @@ from graphify.__main__ import ( # Hook constants are dicts/JSON; serialize them so we can do substring checks # against the actual payload text the assistant will receive. _INSTALL_TEXTS: dict[str, str] = { - "_SETTINGS_HOOK": json.dumps(_SETTINGS_HOOK), - "_READ_SETTINGS_HOOK": json.dumps(_READ_SETTINGS_HOOK), + "_SEARCH_NUDGE": _SEARCH_NUDGE, + "_READ_NUDGE": _READ_NUDGE, "_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION, "_AGENTS_MD_SECTION": _AGENTS_MD_SECTION, "_GEMINI_MD_SECTION": _GEMINI_MD_SECTION, diff --git a/tests/test_install_upgrade.py b/tests/test_install_upgrade.py index 13e7c55..e85add4 100644 --- a/tests/test_install_upgrade.py +++ b/tests/test_install_upgrade.py @@ -145,9 +145,14 @@ def test_claude_install_upgrades_stale_hook_payload(tmp_path, monkeypatch): assert _OLD_HOOK_PAYLOAD_SNIPPET not in new_settings_text, ( "stale hook payload survived upgrade" ) - assert "graphify query" in new_settings_text, ( - "new hook payload should route to `graphify query`" + # Since #522 the nudge text lives in the `graphify hook-guard` subcommand, not + # inline in settings.json (so the command parses on Windows). The upgraded hook + # must therefore route to that shell-agnostic subcommand, and the old bash + # pipeline must be gone. + assert "hook-guard" in new_settings_text, ( + "new hook payload should route to the `graphify hook-guard` subcommand" ) + assert "case x in" not in new_settings_text, "stale bash pipeline survived upgrade" def test_agents_install_upgrades_stale_section(tmp_path, monkeypatch): diff --git a/tests/test_read_hook.py b/tests/test_read_hook.py index d3bf58a..82a514d 100644 --- a/tests/test_read_hook.py +++ b/tests/test_read_hook.py @@ -1,17 +1,31 @@ -"""The Read|Glob PreToolUse hook nudges toward the graph instead of raw reads. +"""The Read|Glob PreToolUse guard nudges toward the graph instead of raw reads. Closes the issue #1114 gap: the Bash search hook never sees a file read through -the native Read tool or a Glob. These tests run the hook command the way Claude -Code does - via `sh -c` with crafted stdin JSON - and assert it nudges only for -a source/doc file outside graphify-out/ when a graph exists, and otherwise stays -silent and fails open. +the native Read tool or a Glob. Since #522 the guard runs as the shell-agnostic +`graphify hook-guard read` subcommand (not inline bash), so it works on Windows +too. These tests invoke that subcommand with crafted stdin JSON and assert it +nudges only for a source/doc file outside graphify-out/ when a graph exists, and +otherwise stays silent and fails open. """ import json +import os import subprocess +import sys -from graphify.__main__ import _READ_SETTINGS_HOOK +from graphify.__main__ import _claude_pretooluse_hooks -CMD = _READ_SETTINGS_HOOK["hooks"][0]["command"] + +def _read_matcher(): + hooks = _claude_pretooluse_hooks() + return next(h for h in hooks if h["matcher"] == "Read|Glob") + + +def _env(): + # The guard resolves the graph via GRAPHIFY_OUT (default "graphify-out", + # relative to cwd). Drop any inherited override so the tmp_path graph is found. + e = dict(os.environ) + e.pop("GRAPHIFY_OUT", None) + return e def _run(tool_input, cwd, *, graph: bool): @@ -20,12 +34,21 @@ def _run(tool_input, cwd, *, graph: bool): (cwd / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") stdin = json.dumps({"tool_input": tool_input}) return subprocess.run( - ["sh", "-c", CMD], input=stdin, capture_output=True, text=True, cwd=cwd + [sys.executable, "-m", "graphify", "hook-guard", "read"], + input=stdin, capture_output=True, text=True, cwd=cwd, env=_env(), ) def test_matcher_targets_read_and_glob(): - assert _READ_SETTINGS_HOOK["matcher"] == "Read|Glob" + assert _read_matcher()["matcher"] == "Read|Glob" + + +def test_command_has_no_shell_syntax(): + # #522: the command must be a plain exe invocation, not POSIX bash. + cmd = _read_matcher()["hooks"][0]["command"] + for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"): + assert token not in cmd, f"shell syntax {token!r} leaked into the hook" + assert "graphify" in cmd and "hook-guard read" in cmd def test_silent_without_graph(tmp_path): @@ -106,14 +129,15 @@ def test_fails_open_on_malformed_stdin(tmp_path): (tmp_path / "graphify-out").mkdir() (tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") r = subprocess.run( - ["sh", "-c", CMD], input="this is not json", capture_output=True, text=True, cwd=tmp_path + [sys.executable, "-m", "graphify", "hook-guard", "read"], + input="this is not json", capture_output=True, text=True, cwd=tmp_path, env=_env(), ) assert r.returncode == 0 assert r.stdout.strip() == "" def test_never_blocks(tmp_path): - """A nudge is additionalContext only - the hook must exit 0, never deny.""" + """A nudge is additionalContext only - the guard must exit 0, never deny.""" r = _run({"file_path": "src/app.py"}, tmp_path, graph=True) assert r.returncode == 0 assert '"permissionDecision"' not in r.stdout diff --git a/tests/test_search_hook.py b/tests/test_search_hook.py new file mode 100644 index 0000000..4c34fce --- /dev/null +++ b/tests/test_search_hook.py @@ -0,0 +1,111 @@ +"""The Bash PreToolUse guard nudges toward the graph before grep/find searches. + +Since #522 it runs as the shell-agnostic `graphify hook-guard search` subcommand +(not inline bash), so it works on Windows too. These tests invoke the subcommand +with crafted stdin JSON and assert it nudges only for a search command when a +graph exists, and otherwise stays silent and fails open. +""" +import json +import os +import subprocess +import sys + +from graphify.__main__ import _claude_pretooluse_hooks + + +def _search_matcher(): + hooks = _claude_pretooluse_hooks() + return next(h for h in hooks if h["matcher"] == "Bash") + + +def _env(): + e = dict(os.environ) + e.pop("GRAPHIFY_OUT", None) + return e + + +def _run(command, cwd, *, graph: bool): + if graph: + (cwd / "graphify-out").mkdir(parents=True, exist_ok=True) + (cwd / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") + stdin = json.dumps({"tool_input": {"command": command}}) + return subprocess.run( + [sys.executable, "-m", "graphify", "hook-guard", "search"], + input=stdin, capture_output=True, text=True, cwd=cwd, env=_env(), + ) + + +def test_matcher_targets_bash(): + assert _search_matcher()["matcher"] == "Bash" + + +def test_command_has_no_shell_syntax(): + # #522: no POSIX bash that Windows cmd.exe/PowerShell can't parse. + cmd = _search_matcher()["hooks"][0]["command"] + for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"): + assert token not in cmd, f"shell syntax {token!r} leaked into the hook" + assert "graphify" in cmd and "hook-guard search" in cmd + + +def test_nudges_on_search_commands_with_graph(tmp_path): + for command in ( + "grep -rn foo .", + "rg pattern src/", + "ripgrep thing", + "find . -name '*.py'", + "fd bar", + "ack needle", + "ag needle", + ): + out = _run(command, tmp_path, graph=True).stdout + assert "graphify query" in out, f"{command!r} should nudge" + + +def test_silent_without_graph(tmp_path): + out = _run("grep -rn foo .", tmp_path, graph=False).stdout + assert out.strip() == "" + + +def test_silent_on_non_search_commands(tmp_path): + for command in ("ls -la", "git status", "cat README.md", "python app.py"): + out = _run(command, tmp_path, graph=True).stdout + assert out.strip() == "", f"{command!r} should not nudge" + + +def test_nudge_payload_is_valid_pretooluse_json(tmp_path): + out = _run("grep -rn foo .", tmp_path, graph=True).stdout + payload = json.loads(out) + assert payload["hookSpecificOutput"]["hookEventName"] == "PreToolUse" + assert "graphify query" in payload["hookSpecificOutput"]["additionalContext"] + + +def test_fails_open_on_malformed_stdin(tmp_path): + (tmp_path / "graphify-out").mkdir() + (tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") + r = subprocess.run( + [sys.executable, "-m", "graphify", "hook-guard", "search"], + input="not json", capture_output=True, text=True, cwd=tmp_path, env=_env(), + ) + assert r.returncode == 0 + assert r.stdout.strip() == "" + + +def test_never_blocks(tmp_path): + r = _run("grep -rn foo .", tmp_path, graph=True) + assert r.returncode == 0 + assert '"permissionDecision"' not in r.stdout + assert '"deny"' not in r.stdout + + +def test_honors_graphify_out_override(tmp_path): + """The guard resolves the graph via GRAPHIFY_OUT, not a hardcoded path.""" + custom = tmp_path / "custom-out" + custom.mkdir() + (custom / "graph.json").write_text("{}", encoding="utf-8") + env = dict(os.environ, GRAPHIFY_OUT=str(custom)) + stdin = json.dumps({"tool_input": {"command": "grep -rn foo ."}}) + r = subprocess.run( + [sys.executable, "-m", "graphify", "hook-guard", "search"], + input=stdin, capture_output=True, text=True, cwd=tmp_path, env=env, + ) + assert "graphify query" in r.stdout