diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 3041f4fa7..ea2662779 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -18,6 +18,8 @@ import importlib.util import io import json import os +import re +import shlex import shutil import signal import socket @@ -445,12 +447,64 @@ 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"(? Path | None: """Run lean-ctx agent setup for the requested coding tool.""" @@ -4401,4 +4455,4 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None: click.echo("✓ Codex is no longer routed through the Headroom proxy.") if not no_stop_proxy and status != "noop": _echo_unwrap_proxy_stop_status(_stop_local_proxy_for_unwrap(port), port) - click.echo() + click.echo() \ No newline at end of file diff --git a/tests/test_cli/test_wrap_rtk_hook_patch.py b/tests/test_cli/test_wrap_rtk_hook_patch.py new file mode 100644 index 000000000..9d4322dc5 --- /dev/null +++ b/tests/test_cli/test_wrap_rtk_hook_patch.py @@ -0,0 +1,101 @@ +"""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\n" + "if command -v rtk >/dev/null 2>&1; then\n" + " exec rtk rewrite \"$@\"\n" + "fi\n" + ) + + rtk_path = Path("/home/user/.headroom/bin/rtk") + changed = _patch_rtk_hook_absolute_path(rtk_path, hook_script) + + content = hook_script.read_text() + assert changed is True + assert f"exec {rtk_path} 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\n" + "if command -v rtk >/dev/null 2>&1; then\n" + " exec rtk rewrite \"$@\"\n" + "fi\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\n" + "echo rtkfoo\n" + "exec /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