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)}" + )