fix: patch rtk hook script to use absolute path after register_claude_hooks (#571)

```markdown
## Description

When `headroom wrap claude` registers RTK hooks, the generated `~/.claude/hooks/rtk-rewrite.sh` script uses a bare `rtk` command that depends on PATH lookup. Since `~/.headroom/bin` is not automatically added to PATH, the hook fails silently and token compression never occurs.

After `register_claude_hooks()` succeeds, a new helper `_patch_rtk_hook_absolute_path()` reads the generated hook script and replaces bare `rtk` references with the absolute binary path (e.g. `/home/user/.headroom/bin/rtk`). The patch is idempotent and only writes back if content actually changed. Paths containing spaces or shell-special characters are safely quoted via `shlex.quote()` before being inserted into the script.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Added `_patch_rtk_hook_absolute_path(rtk_path, hook_script_path)` in `headroom/cli/wrap.py`
- Called it immediately after `register_claude_hooks()` succeeds in `_setup_rtk()`
- Uses `shlex.quote()` to safely handle absolute paths containing spaces or shell-special characters
- Added regression test `tests/test_cli/test_wrap_rtk_hook_patch.py` covering the basic patch, the space-in-path case, idempotency, missing hook file, and non-bare `rtk` tokens

## Testing

- [x] Manual testing performed

### Test Output

```
python3 -m pytest tests/test_cli/test_wrap_rtk_hook_patch.py -v
============ test session starts ============
collected 5 items

tests/test_cli/test_wrap_rtk_hook_patch.py::test_patches_bare_rtk_to_absolute_path
PASSED [ 20%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_quotes_path_containing_spaces
PASSED [ 40%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_idempotent_second_run_is_noop
PASSED [ 60%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_missing_hook_script_is_noop
PASSED [ 80%]

tests/test_cli/test_wrap_rtk_hook_patch.py::test_does_not_touch_words_containing_rtk
PASSED [100%]
============= 5 passed in 0.73s =============
```

## Real Behavior Proof

- Environment: Linux, Python 3.14.4, pytest 9.1.0, headroom repo at commit d5987fb2
- Exact command / steps: `python3 -m pytest tests/test_cli/test_wrap_rtk_hook_patch.py -v`
- Observed result: All 5 tests passed — test_patches_bare_rtk_to_absolute_path, test_quotes_path_containing_spaces, test_idempotent_second_run_is_noop, test_missing_hook_script_is_noop, test_does_not_touch_words_containing_rtk (5 passed in 0.73s)
- Not tested: End-to-end test against a real `rtk init --global --auto-patch` run on macOS/Windows; only the patch function itself is unit-tested

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Fixes #487
```

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
Ello_ 2026-06-27 00:39:37 +05:30 committed by GitHub
parent 26e1253df0
commit b618d2d11a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 151 additions and 0 deletions

View file

@ -19,6 +19,8 @@ import importlib.util
import io
import json
import os
import re
import shlex
import shutil
import signal
import socket
@ -518,12 +520,68 @@ def _setup_rtk(verbose: bool = False) -> Path | None:
if register_claude_hooks(rtk_path):
if verbose:
click.echo(" rtk hooks registered in Claude Code")
try:
patched = _patch_rtk_hook_absolute_path(rtk_path)
if patched and verbose:
click.echo(" rtk hook script patched to use absolute path")
except Exception as e:
if verbose:
click.echo(f" rtk hook absolute-path patch skipped: {e}")
else:
click.echo(" rtk hook registration failed — continuing without it")
return rtk_path
def _patch_rtk_hook_absolute_path(rtk_path: Path, hook_script_path: Path | None = None) -> bool:
"""Rewrite bare ``rtk`` invocations in the generated Claude hook script
to use the absolute path to the RTK binary Headroom manages.
``rtk init --global --auto-patch`` writes ``~/.claude/hooks/rtk-rewrite.sh``
with a bare ``rtk`` command that depends on PATH lookup. Since
``~/.headroom/bin`` (where Headroom installs rtk) is not automatically
added to PATH, that lookup fails and the hook silently does nothing.
This rewrites bare ``rtk`` command tokens to the absolute, shell-quoted
path of the rtk binary so the hook works regardless of PATH.
Idempotent: only rewrites bare ``rtk`` tokens (not paths that already
point elsewhere), and only writes the file back if content changed.
Returns True if the hook script was modified.
"""
if hook_script_path is None:
hook_script_path = Path.home() / ".claude" / "hooks" / "rtk-rewrite.sh"
if not hook_script_path.exists():
return False
original = hook_script_path.read_text()
# Quote the absolute path safely for POSIX shells. This matters because
# paths containing spaces or other shell-special characters (e.g.
# "/Users/Alice Smith/.headroom/bin/rtk") must be quoted, or the
# generated script will break when the shell splits on whitespace.
quoted_path = shlex.quote(str(rtk_path))
# Replace bare `rtk` command tokens with the quoted absolute path.
# Matches `rtk` as a standalone word (preceded by start-of-line or
# whitespace/operators, followed by whitespace or end-of-line), so it
# won't touch things like "rtkfoo" or "/some/path/rtk" that are already
# absolute.
patched, count = re.subn(
r"(?<![\w/-])rtk(?=\s|$)",
lambda _match: quoted_path,
original,
)
if count and patched != original:
hook_script_path.write_text(patched)
return True
return False
def _setup_lean_ctx_agent(agent: str, verbose: bool = False) -> Path | None:
"""Run lean-ctx agent setup for the requested coding tool."""

View file

@ -0,0 +1,93 @@
"""Tests for ``_patch_rtk_hook_absolute_path``.
``rtk init --global --auto-patch`` writes ``~/.claude/hooks/rtk-rewrite.sh``
with a bare ``rtk`` command that depends on PATH lookup. Since
``~/.headroom/bin`` is not automatically added to PATH, that lookup fails
silently and token compression never occurs (see issue #487).
``_patch_rtk_hook_absolute_path`` rewrites bare ``rtk`` tokens in the
generated hook script to the absolute, shell-quoted path of the rtk binary
that Headroom manages.
"""
from __future__ import annotations
import shlex
from pathlib import Path
from headroom.cli.wrap import _patch_rtk_hook_absolute_path
def test_patches_bare_rtk_to_absolute_path(tmp_path: Path) -> None:
hook_script = tmp_path / "rtk-rewrite.sh"
hook_script.write_text(
'#!/bin/sh\nif command -v rtk >/dev/null 2>&1; then\n exec rtk rewrite "$@"\nfi\n'
)
rtk_path = Path("/home/user/.headroom/bin/rtk")
changed = _patch_rtk_hook_absolute_path(rtk_path, hook_script)
content = hook_script.read_text()
quoted = shlex.quote(str(rtk_path))
assert changed is True
assert f"exec {quoted} rewrite" in content
def test_quotes_path_containing_spaces(tmp_path: Path) -> None:
"""Paths with spaces (e.g. /Users/Alice Smith/...) must be shell-quoted."""
hook_script = tmp_path / "rtk-rewrite.sh"
hook_script.write_text(
'#!/bin/sh\nif command -v rtk >/dev/null 2>&1; then\n exec rtk rewrite "$@"\nfi\n'
)
rtk_path = Path("/Users/Alice Smith/.headroom/bin/rtk")
changed = _patch_rtk_hook_absolute_path(rtk_path, hook_script)
content = hook_script.read_text()
quoted = shlex.quote(str(rtk_path))
assert changed is True
assert f"exec {quoted} rewrite" in content
# The raw, unquoted path must never appear unescaped in the script.
assert "exec /Users/Alice Smith/.headroom/bin/rtk rewrite" not in content
def test_idempotent_second_run_is_noop(tmp_path: Path) -> None:
hook_script = tmp_path / "rtk-rewrite.sh"
hook_script.write_text('exec rtk rewrite "$@"\n')
rtk_path = Path("/home/user/.headroom/bin/rtk")
first = _patch_rtk_hook_absolute_path(rtk_path, hook_script)
content_after_first = hook_script.read_text()
second = _patch_rtk_hook_absolute_path(rtk_path, hook_script)
content_after_second = hook_script.read_text()
assert first is True
assert second is False
assert content_after_first == content_after_second
def test_missing_hook_script_is_noop(tmp_path: Path) -> None:
missing = tmp_path / "does-not-exist.sh"
rtk_path = Path("/home/user/.headroom/bin/rtk")
changed = _patch_rtk_hook_absolute_path(rtk_path, missing)
assert changed is False
assert not missing.exists()
def test_does_not_touch_words_containing_rtk(tmp_path: Path) -> None:
"""Tokens like 'rtkfoo' or an already-absolute '/some/path/rtk' are left alone."""
hook_script = tmp_path / "rtk-rewrite.sh"
original = '#!/bin/sh\necho rtkfoo\nexec /already/absolute/rtk rewrite "$@"\n'
hook_script.write_text(original)
rtk_path = Path("/home/user/.headroom/bin/rtk")
changed = _patch_rtk_hook_absolute_path(rtk_path, hook_script)
assert changed is False
assert hook_script.read_text() == original