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
28 changes: 18 additions & 10 deletions graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1286,23 +1286,31 @@ def _uninstall_opencode_plugin(project_dir: Path) -> None:
config_file.write_text(json.dumps(config, indent=2), encoding="utf-8")
print(f" {_OPENCODE_CONFIG_PATH} -> plugin deregistered")
def _resolve_graphify_exe() -> str:
"""Return the absolute path to the graphify executable.
"""Return the absolute path to the graphify executable, with forward slashes.

Falls back to bare 'graphify' if resolution fails. Using an absolute path
ensures the hook works in environments where the venv Scripts/ directory is
not on PATH (e.g. VS Code Codex extension on Windows).

The path is normalized to forward slashes so it survives every shell that
runs the hook command. On Windows, Claude Code runs command-type hooks
through Git Bash by default, where an unquoted backslash is an escape
character: a raw ``C:\\Users\\me\\graphify.EXE`` collapses to
``C:Usersmegraphify.EXE: command not found`` and the guard silently fails.
Forward slashes are accepted by Git Bash, cmd.exe, and PowerShell alike, and
``.replace`` is a no-op on POSIX where paths already use forward slashes.
"""
import shutil
found = shutil.which("graphify")
if found:
return found
# Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
scripts_dir = Path(sys.executable).parent
for name in ("graphify.exe", "graphify"):
candidate = scripts_dir / name
if candidate.exists():
return str(candidate)
return "graphify"
if not found:
# Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir
scripts_dir = Path(sys.executable).parent
for name in ("graphify.exe", "graphify"):
candidate = scripts_dir / name
if candidate.exists():
found = str(candidate)
break
return (found or "graphify").replace("\\", "/")
def _install_codex_hook(project_dir: Path) -> None:
"""Add graphify PreToolUse hook to .codex/hooks.json."""
hooks_path = project_dir / ".codex" / "hooks.json"
Expand Down
12 changes: 12 additions & 0 deletions tests/test_search_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ def test_matcher_targets_bash():
assert _search_matcher()["matcher"] == "Bash"


def test_hook_command_has_no_backslashes(monkeypatch):
# On Windows the resolved exe is a backslash path; Claude Code runs command
# hooks through Git Bash by default, which treats an unquoted backslash as an
# escape character and strips it (C:\Users\me\graphify.EXE -> C:Usersme...),
# breaking every guard. The emitted command must use forward slashes.
from graphify.__main__ import _resolve_graphify_exe
monkeypatch.setattr("shutil.which", lambda _name: r"C:\Users\me\graphify.EXE")
assert _resolve_graphify_exe() == "C:/Users/me/graphify.EXE"
for h in _claude_pretooluse_hooks():
assert "\\" not in h["hooks"][0]["command"]


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"]
Expand Down
Loading