From 84f3364e2e40e3d1d85112761770a64622e4dcab Mon Sep 17 00:00:00 2001 From: Souptik Chakraborty Date: Sat, 25 Jul 2026 20:10:13 +0530 Subject: [PATCH] fix: document the Codex PreToolUse hook as an intentional no-op (#2165) `graphify codex install` registers `graphify hook-check` in .codex/hooks.json, and #2165 reported that as a stale/unrecognized subcommand producing a silent no-op. `hook-check` is in fact a real, deliberate no-op command: Codex Desktop rejects hookSpecificOutput.additionalContext on PreToolUse, so the Codex hook intentionally does nothing and AGENTS.md carries the always-on guidance (cli.py dispatches `hook-check`; __main__.py lists it in _silent_cmds). Repointing the installer at `hook-guard` would reintroduce the #522-class breakage on Codex Desktop, so the behavior is left as is. What actually misled the report was the documentation and the installer's own output, which both describe the Codex hook as if it enforced graph usage: - README: the Codex row claimed a PreToolUse hook that "fires before every Bash tool call, same always-on mechanism as Claude Code". It now states that the hook is a deliberate no-op, why (Codex Desktop rejects additionalContext), and that AGENTS.md is the always-on mechanism on this platform. - `_install_codex_hook` printed "PreToolUse hook registered (... hook-check)" with no hint that the entry is inert. It now says so inline. Also adds the regression guard the issue implicitly asks for: a test that reads the command out of the generated .codex/hooks.json and asserts its subcommand is one the CLI actually dispatches. A genuinely renamed/stale hook command now fails the suite instead of shipping a permanently dead hook. Note: contrary to the report, an unrecognized subcommand already exits non-zero (`graphify totally-bogus-subcommand` -> "error: unknown command", exit 1), so no change was needed there. --- README.md | 2 +- graphify/install.py | 6 ++++- tests/test_install.py | 56 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0459f57cf..91590aeb1 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ This writes a small config file that tells your assistant to consult the knowled **CodeBuddy** does the same two things as Claude Code: writes a `CODEBUDDY.md` section telling CodeBuddy to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs `PreToolUse` hooks (`.codebuddy/settings.json`) that fire before Bash search commands and file reads, nudging toward `graphify query` instead. -**Codex** writes to `AGENTS.md` and also installs a `PreToolUse` hook in `.codex/hooks.json` that fires before every Bash tool call, same always-on mechanism as Claude Code. +**Codex** writes to `AGENTS.md`, which is what actually carries the always-on graph guidance on this platform. `graphify codex install` also registers a `PreToolUse` hook in `.codex/hooks.json` (`graphify hook-check`), but that entry is deliberately a **no-op**: Codex Desktop rejects `hookSpecificOutput.additionalContext` on `PreToolUse`, so emitting a nudge there would break Bash tool calls. Unlike Claude Code — where the hook (`graphify hook-guard`) does the nudging — on Codex the hook fires and intentionally does nothing, and `AGENTS.md` is the always-on mechanism. **Kilo Code** installs the Graphify skill to `~/.config/kilo/skills/graphify/SKILL.md` and a native `/graphify` command to `~/.config/kilo/command/graphify.md`. `graphify kilo install` also writes `AGENTS.md` plus a native `tool.execute.before` plugin (`.kilo/plugins/graphify.js` + `.kilo/kilo.json` or `.kilo/kilo.jsonc` registration) so Kilo gets the same always-on graph reminder behavior through native `.kilo` config. diff --git a/graphify/install.py b/graphify/install.py index 1a8d3e3d3..2df3b72c2 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -1388,7 +1388,11 @@ def _install_codex_hook(project_dir: Path) -> None: existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)] existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"]) hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") - print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)") + print( + f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check" + " - intentional no-op; Codex Desktop rejects additionalContext on PreToolUse," + " so graph guidance comes from AGENTS.md)" + ) def _uninstall_codex_hook(project_dir: Path) -> None: """Remove graphify PreToolUse hook from .codex/hooks.json.""" hooks_path = project_dir / ".codex" / "hooks.json" diff --git a/tests/test_install.py b/tests/test_install.py index ac4497cff..8cea17b4c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1116,3 +1116,59 @@ def test_hermes_skill_destination_posix_uses_home(): with patch("graphify.__main__.platform.system", return_value="Linux"): dst = _platform_skill_destination("hermes", project=False) assert str(dst).endswith(".hermes/skills/graphify/SKILL.md"), dst + + +def _cli_dispatched_commands() -> set[str]: + """Subcommand names the CLI actually dispatches. + + `graphify`'s dispatcher is an `elif cmd == "..."` chain rather than a declarative + table, so the set is read back out of the source. Used to prove a hook command + written by an installer is not a stale/renamed subcommand (#2165). + """ + import re + from graphify import cli + + source = Path(cli.__file__).read_text(encoding="utf-8") + names = set(re.findall(r'cmd\s*==\s*"([a-z0-9][a-z0-9-]*)"', source)) + names |= { + m + for group in re.findall(r'cmd\s+in\s+\(([^)]*)\)', source) + for m in re.findall(r'"([a-z0-9][a-z0-9-]*)"', group) + } + return names + + +def test_codex_hook_command_is_a_real_cli_subcommand(tmp_path): + """#2165: the PreToolUse command in .codex/hooks.json must be a command the CLI + dispatches, so a renamed subcommand can never leave a permanently dead hook. + + `hook-check` is intentionally a no-op on Codex (Codex Desktop rejects + additionalContext on PreToolUse), but it must still be a *recognized* command -- + an unrecognized one exits non-zero and would break every Bash tool call. + """ + import json + + from graphify.install import _install_codex_hook + + _install_codex_hook(tmp_path) + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + + entries = [ + h + for group in hooks["hooks"]["PreToolUse"] + for h in group["hooks"] + if "graphify" in h.get("command", "") + ] + assert entries, "codex install must register a graphify PreToolUse hook" + + dispatched = _cli_dispatched_commands() + assert "hook-check" in dispatched, "sanity: parser must find known commands" + + for entry in entries: + # command is " [args...]" + parts = entry["command"].split() + subcommand = parts[1] if len(parts) > 1 else "" + assert subcommand in dispatched, ( + f"codex hook registers {subcommand!r}, which the CLI does not dispatch " + f"(#2165). Known commands: {sorted(dispatched)}" + )