diff --git a/graphify/install.py b/graphify/install.py index b34290eec..aa9b2f904 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -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" diff --git a/tests/test_search_hook.py b/tests/test_search_hook.py index 4c34fcea1..bcca886c4 100644 --- a/tests/test_search_hook.py +++ b/tests/test_search_hook.py @@ -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"]