diff --git a/src/grove/cli.py b/src/grove/cli.py index d686e2b..de28df0 100644 --- a/src/grove/cli.py +++ b/src/grove/cli.py @@ -4,6 +4,7 @@ import os import re +import shutil import subprocess import sys from pathlib import Path @@ -949,8 +950,13 @@ def _do_cleanup(ws_name: str) -> None: immediately without waiting for worktree removal to finish. If cleanup fails, ``gw doctor`` will catch the stale state. """ + gw_path = shutil.which("gw") + if gw_path: + cmd = [gw_path, "delete", "--force", ws_name] + else: + cmd = [sys.executable, "-m", "grove", "delete", "--force", ws_name] subprocess.Popen( - [sys.executable, "-m", "grove", "delete", "--force", ws_name], + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, @@ -1054,6 +1060,22 @@ def dash_main(ctx: typer.Context) -> None: run_dashboard() +@app.command("_hook", hidden=True) +def _hook_entrypoint( + event: str = typer.Option(..., "--event", help="Hook event type"), +) -> None: + """Internal hook handler invoked by Claude Code. Not for direct use.""" + import json as _json + + from grove.dash.hook import handle_event + + try: + input_data = _json.load(sys.stdin) + except (ValueError, _json.JSONDecodeError): + return + handle_event(event, input_data) + + @dash_app.command("install") def dash_install( dry_run: bool = typer.Option(False, "--dry-run", help="Show what would change"), diff --git a/src/grove/dash/installer.py b/src/grove/dash/installer.py index a2f36cc..133f1fa 100644 --- a/src/grove/dash/installer.py +++ b/src/grove/dash/installer.py @@ -28,19 +28,42 @@ ] # Marker to identify our hooks -_GROVE_MARKER = "grove.dash" +_GROVE_MARKER = "gw _hook" +# Legacy marker from pre-v0.13 (used sys.executable + python -m grove.dash). +# Needed so uninstall can clean up old hooks. Safe to remove in v0.14+. +_LEGACY_MARKER = "grove.dash" def _hook_command(event: str) -> str: - """Build the hook command string for a given event.""" - python = sys.executable - return f"GROVE_EVENT={event} {python} -m grove.dash --event {event}" + """Build the hook command string for a given event. + + Uses the ``gw`` console-script entry point (stable across upgrades) + rather than the versioned Python interpreter path, which breaks when + Homebrew or uv upgrades Grove to a new version. + """ + gw = _resolve_gw() + return f"GROVE_EVENT={event} {gw} _hook --event {event}" + + +def _resolve_gw() -> str: + """Find the stable ``gw`` binary path. + + Prefers ``shutil.which`` so we get the canonical PATH entry (e.g. + /opt/homebrew/bin/gw) rather than a version-specific Cellar path. + Falls back to ``sys.executable -m grove.dash`` for editable/dev installs + where ``gw`` might not be on PATH. + """ + gw_path = shutil.which("gw") + if gw_path: + return gw_path + # Fallback for dev installs — multi-token string, interpreted by shell + return f"{sys.executable} -m grove.dash" def _is_grove_hook(hook: dict) -> bool: """Check if a hook entry belongs to Grove.""" cmd = hook.get("command", "") - return _GROVE_MARKER in cmd + return _GROVE_MARKER in cmd or _LEGACY_MARKER in cmd def install_hooks(dry_run: bool = False) -> dict[str, list[str]]: diff --git a/tests/test_dash_installer.py b/tests/test_dash_installer.py index 764aaf6..140bea8 100644 --- a/tests/test_dash_installer.py +++ b/tests/test_dash_installer.py @@ -58,7 +58,7 @@ def test_install_preserves_existing_hooks(self, claude_settings: Path) -> None: assert len(pre_rules) == 2 all_cmds = [h["command"] for rule in pre_rules for h in rule.get("hooks", [])] assert "my-custom-hook" in all_cmds - assert any("grove.dash" in c for c in all_cmds) + assert any("gw _hook" in c for c in all_cmds) # Other settings preserved assert settings["other_setting"] is True @@ -73,7 +73,7 @@ def test_install_updates_existing_grove_hook(self, claude_settings: Path) -> Non grove_rules = [ rule for rule in pre_rules - if any("grove.dash" in h.get("command", "") for h in rule.get("hooks", [])) + if any("gw _hook" in h.get("command", "") for h in rule.get("hooks", [])) ] assert len(grove_rules) == 1 @@ -109,7 +109,10 @@ def test_uninstall_preserves_other_hooks(self, claude_settings: Path) -> None: "hooks": { "PreToolUse": [ {"matcher": "", "hooks": [{"type": "command", "command": "my-custom-hook"}]}, - {"matcher": "", "hooks": [{"type": "command", "command": "grove.dash x"}]}, + { + "matcher": "", + "hooks": [{"type": "command", "command": "gw _hook --event PreToolUse"}], + }, ] } }