Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 56 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<abs exe path> <subcommand> [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)}"
)