From b25521a4007fae9a7a84ff1914f34f0835908322 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Wed, 11 Mar 2026 13:53:00 +0100 Subject: [PATCH 1/4] Fix hook commands breaking on Homebrew/uv upgrades Use stable `gw` entry point instead of versioned Python interpreter path in Claude Code hooks. The old approach hardcoded sys.executable (e.g. Cellar/grove/0.12.0/...) which broke when upgrading versions. Co-Authored-By: Claude Opus 4.6 --- src/grove/cli.py | 25 ++++++++++++++++++++++++- src/grove/dash/installer.py | 30 +++++++++++++++++++++++++----- tests/test_dash_installer.py | 7 +++++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/grove/cli.py b/src/grove/cli.py index d686e2b..663e953 100644 --- a/src/grove/cli.py +++ b/src/grove/cli.py @@ -949,8 +949,15 @@ 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. """ + import shutil + + 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 +1061,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..97959c8 100644 --- a/src/grove/dash/installer.py +++ b/src/grove/dash/installer.py @@ -32,15 +32,35 @@ 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 + return f"{sys.executable} -m grove.cli" def _is_grove_hook(hook: dict) -> bool: - """Check if a hook entry belongs to Grove.""" + """Check if a hook entry belongs to Grove (old or new format).""" cmd = hook.get("command", "") - return _GROVE_MARKER in cmd + return _GROVE_MARKER in cmd or "gw _hook" 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..c6fa16a 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("grove.dash" in c or "gw _hook" in c for c in all_cmds) # Other settings preserved assert settings["other_setting"] is True @@ -73,7 +73,10 @@ 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( + "grove.dash" in h.get("command", "") or "gw _hook" in h.get("command", "") + for h in rule.get("hooks", []) + ) ] assert len(grove_rules) == 1 From 4e033afe6f890c90b8d35c8839f3eddb4334f1f5 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Wed, 11 Mar 2026 13:55:16 +0100 Subject: [PATCH 2/4] Address code review: fix fallback path and cleanup - Fix fallback module path from grove.cli to grove.dash (has __main__.py) - Move shutil import to module level in cli.py - Extract "gw _hook" magic string to _GROVE_HOOK_CMD constant Co-Authored-By: Claude Opus 4.6 --- src/grove/cli.py | 3 +-- src/grove/dash/installer.py | 11 +++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/grove/cli.py b/src/grove/cli.py index 663e953..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,6 @@ 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. """ - import shutil - gw_path = shutil.which("gw") if gw_path: cmd = [gw_path, "delete", "--force", ws_name] diff --git a/src/grove/dash/installer.py b/src/grove/dash/installer.py index 97959c8..459ea87 100644 --- a/src/grove/dash/installer.py +++ b/src/grove/dash/installer.py @@ -27,8 +27,11 @@ "TaskCompleted", ] -# Marker to identify our hooks +# Markers to identify our hooks. +# _GROVE_MARKER matches the legacy format (python -m grove.dash) used before v0.13. +# Can be removed once all users have re-run `gw dash install`. _GROVE_MARKER = "grove.dash" +_GROVE_HOOK_CMD = "gw _hook" def _hook_command(event: str) -> str: @@ -53,14 +56,14 @@ def _resolve_gw() -> str: gw_path = shutil.which("gw") if gw_path: return gw_path - # Fallback for dev installs - return f"{sys.executable} -m grove.cli" + # 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 (old or new format).""" cmd = hook.get("command", "") - return _GROVE_MARKER in cmd or "gw _hook" in cmd + return _GROVE_MARKER in cmd or _GROVE_HOOK_CMD in cmd def install_hooks(dry_run: bool = False) -> dict[str, list[str]]: From e54f27c660ffcb6b23ef6175d4b9be72fd9fe662 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Wed, 11 Mar 2026 14:00:38 +0100 Subject: [PATCH 3/4] Drop legacy hook format detection Users will uninstall+reinstall hooks when upgrading, so no need to detect the old python-path format. Single _GROVE_MARKER is enough. Co-Authored-By: Claude Opus 4.6 --- src/grove/dash/installer.py | 11 ++++------- tests/test_dash_installer.py | 12 ++++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/grove/dash/installer.py b/src/grove/dash/installer.py index 459ea87..25134d1 100644 --- a/src/grove/dash/installer.py +++ b/src/grove/dash/installer.py @@ -27,11 +27,8 @@ "TaskCompleted", ] -# Markers to identify our hooks. -# _GROVE_MARKER matches the legacy format (python -m grove.dash) used before v0.13. -# Can be removed once all users have re-run `gw dash install`. -_GROVE_MARKER = "grove.dash" -_GROVE_HOOK_CMD = "gw _hook" +# Marker to identify our hooks +_GROVE_MARKER = "gw _hook" def _hook_command(event: str) -> str: @@ -61,9 +58,9 @@ def _resolve_gw() -> str: def _is_grove_hook(hook: dict) -> bool: - """Check if a hook entry belongs to Grove (old or new format).""" + """Check if a hook entry belongs to Grove.""" cmd = hook.get("command", "") - return _GROVE_MARKER in cmd or _GROVE_HOOK_CMD in cmd + return _GROVE_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 c6fa16a..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 or "gw _hook" 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,10 +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", "") or "gw _hook" 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 @@ -112,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"}], + }, ] } } From 819545691f04dccf4f8cbd3e68b74f18a3b2d0be Mon Sep 17 00:00:00 2001 From: Nick Song Date: Wed, 11 Mar 2026 14:01:23 +0100 Subject: [PATCH 4/4] Keep legacy marker for uninstall to clean up old-format hooks Without this, `gw dash uninstall` after upgrading would leave orphaned old hooks (grove.dash format) in settings.json. Co-Authored-By: Claude Opus 4.6 --- src/grove/dash/installer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/grove/dash/installer.py b/src/grove/dash/installer.py index 25134d1..133f1fa 100644 --- a/src/grove/dash/installer.py +++ b/src/grove/dash/installer.py @@ -29,6 +29,9 @@ # Marker to identify our hooks _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: @@ -60,7 +63,7 @@ def _resolve_gw() -> str: 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]]: