diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 9113afae6..86d2373c6 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -515,12 +515,26 @@ def _setup_lean_ctx_agent(agent: str, verbose: bool = False) -> Path | None: return lean_ctx -def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool: - """Remove Headroom/rtk-managed Claude hook entries from settings.json. +# Hook-command markers Headroom manages in Claude settings.json. unwrap drops +# any hook entry whose command contains one of these. +_HEADROOM_HOOK_MARKERS = ("rtk-rewrite", "headroom-init-claude") - `rtk init --global --auto-patch` installs a Claude PreToolUse hook that - points at an ``rtk-rewrite`` script. Unwrap should remove that hook without - touching unrelated Claude settings or user-authored hooks. +# Env vars Headroom's init/wrap inject into Claude settings.json; unwrap removes +# them. ENABLE_TOOL_SEARCH keeps Claude Code's tool deferral on behind the proxy +# (GH #746), paired with init/wrap setting it. +_HEADROOM_ENV_KEYS = ("ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH") + + +def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool: + """Remove Headroom-managed entries from Claude settings.json. + + Reverses what ``headroom init claude`` and ``rtk init --auto-patch`` add: + * PreToolUse / SessionStart hooks whose command contains a Headroom marker + (``rtk-rewrite`` or ``headroom-init-claude``), and + * the ``ANTHROPIC_BASE_URL`` proxy-routing env var. + Unrelated settings and user-authored hooks are left untouched. (Previously + this only matched ``rtk-rewrite`` and returned early when no hooks existed, + so init's env + hooks survived unwrap.) """ path = settings_path or (Path.home() / ".claude" / "settings.json") @@ -534,51 +548,68 @@ def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool: if not isinstance(payload, dict): return False - hooks = payload.get("hooks") - if not isinstance(hooks, dict): - return False - changed = False - for event, entries in list(hooks.items()): - if not isinstance(entries, list): - continue - retained_entries: list[Any] = [] - for entry in entries: - if not isinstance(entry, dict): - retained_entries.append(entry) + + hooks = payload.get("hooks") + if isinstance(hooks, dict): + for event, entries in list(hooks.items()): + if not isinstance(entries, list): continue - hook_items = entry.get("hooks") - if not isinstance(hook_items, list): - retained_entries.append(entry) - continue - retained_hooks = [ - item - for item in hook_items - if not ( - isinstance(item, dict) and "rtk-rewrite" in str(item.get("command", "")).lower() - ) - ] - if len(retained_hooks) != len(hook_items): - changed = True - if retained_hooks: - retained_entries.append({**entry, "hooks": retained_hooks}) - elif len(retained_hooks) == len(hook_items): - retained_entries.append(entry) + retained_entries: list[Any] = [] + for entry in entries: + if not isinstance(entry, dict): + retained_entries.append(entry) + continue + hook_items = entry.get("hooks") + if not isinstance(hook_items, list): + retained_entries.append(entry) + continue + retained_hooks = [ + item + for item in hook_items + if not ( + isinstance(item, dict) + and any( + marker in str(item.get("command", "")).lower() + for marker in _HEADROOM_HOOK_MARKERS + ) + ) + ] + if len(retained_hooks) != len(hook_items): + changed = True + if retained_hooks: + retained_entries.append({**entry, "hooks": retained_hooks}) + elif len(retained_hooks) == len(hook_items): + retained_entries.append(entry) + else: + changed = True + if retained_entries: + hooks[event] = retained_entries else: + del hooks[event] changed = True - if retained_entries: - hooks[event] = retained_entries + + if hooks: + payload["hooks"] = hooks else: - del hooks[event] + payload.pop("hooks", None) + + # Remove the proxy-routing env that init/wrap injected (ANTHROPIC_BASE_URL and + # ENABLE_TOOL_SEARCH), even when no hooks remain (the early-return bug skipped + # this). List-comp, not any(), so every key is popped (no short-circuit). + env = payload.get("env") + if isinstance(env, dict): + removed_keys = [k for k in _HEADROOM_ENV_KEYS if env.pop(k, None) is not None] + if removed_keys: changed = True + if env: + payload["env"] = env + else: + payload.pop("env", None) if not changed: return False - if hooks: - payload["hooks"] = hooks - else: - payload.pop("hooks", None) path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") return True diff --git a/tests/test_cli/test_unwrap_claude.py b/tests/test_cli/test_unwrap_claude.py index 90cc6fcb4..0af53809a 100644 --- a/tests/test_cli/test_unwrap_claude.py +++ b/tests/test_cli/test_unwrap_claude.py @@ -208,3 +208,116 @@ def test_unwrap_claude_keep_flags_skip_cleanup( registrar.assert_not_called() remove_rtk.assert_not_called() stop_proxy.assert_not_called() + + +def test_remove_claude_rtk_hooks_removes_init_hooks_and_env(tmp_path: Path) -> None: + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps( + { + "model": "opus", + "env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787", "FOO": "bar"}, + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": ( + "/home/u/.local/bin/headroom init hook ensure " + "--profile init-user --marker headroom-init-claude" + ), + "timeout": 15, + } + ], + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "headroom init hook ensure --marker headroom-init-claude", + }, + {"type": "command", "command": "echo keep-me"}, + ], + } + ], + }, + } + ) + + "\n", + encoding="utf-8", + ) + + assert wrap_cli._remove_claude_rtk_hooks(settings) is True + + payload = json.loads(settings.read_text(encoding="utf-8")) + # ANTHROPIC_BASE_URL stripped; unrelated env var preserved + assert payload.get("env") == {"FOO": "bar"} + # SessionStart removed entirely (its only hook was the init marker) + assert "SessionStart" not in payload.get("hooks", {}) + # PreToolUse: init-marker hook gone, unrelated hook kept + assert payload["hooks"]["PreToolUse"][0]["hooks"] == [ + {"type": "command", "command": "echo keep-me"} + ] + assert payload["model"] == "opus" + + +def test_remove_claude_rtk_hooks_strips_env_without_hooks(tmp_path: Path) -> None: + # Regression: unwrap previously returned early when no hooks existed, + # leaving init's ANTHROPIC_BASE_URL behind in settings.json. + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}) + "\n", + encoding="utf-8", + ) + + assert wrap_cli._remove_claude_rtk_hooks(settings) is True + + payload = json.loads(settings.read_text(encoding="utf-8")) + assert "env" not in payload # emptied env dict is dropped + + +def test_remove_claude_rtk_hooks_noop_when_nothing_managed(tmp_path: Path) -> None: + settings = tmp_path / "settings.json" + original = { + "model": "opus", + "env": {"FOO": "bar"}, + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "echo hi"}]} + ] + }, + } + settings.write_text(json.dumps(original) + "\n", encoding="utf-8") + + assert wrap_cli._remove_claude_rtk_hooks(settings) is False + # nothing managed -> file untouched + assert json.loads(settings.read_text(encoding="utf-8")) == original + + +def test_remove_claude_rtk_hooks_strips_enable_tool_search(tmp_path: Path) -> None: + # unwrap must remove BOTH env vars init writes (ANTHROPIC_BASE_URL + + # ENABLE_TOOL_SEARCH, GH #746), leaving user-set vars intact. + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps( + { + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787", + "ENABLE_TOOL_SEARCH": "true", + "KEEP": "1", + } + } + ) + + "\n", + encoding="utf-8", + ) + + assert wrap_cli._remove_claude_rtk_hooks(settings) is True + + payload = json.loads(settings.read_text(encoding="utf-8")) + assert payload["env"] == {"KEEP": "1"}