Skip to content

Commit 44136ed

Browse files
fix(wrap): make RTK opt-in (off by default) across wrap subcommands (headroomlabs-ai#2344)
## Description RTK CLI-command filtering was set up **by default** across ~16 `wrap` subcommands (copilot, codex, aider, cursor, cline, continue, goose, openhands, opencode, grok, omp, openclaude, vibe, …) via `if not no_rtk:` — so users got rtk hooks / instruction injection without opting in. `wrap claude` was the lone exception (already gated on `--context-tool`). This makes RTK **opt-in (off by default)** everywhere, so Headroom's own savings are what's measured unless a user explicitly wants rtk. Closes # ## Type of Change - [x] Bug fix (behavior change: default flip) ## Changes Made - **Central gate** `_rtk_opt_in()` — RTK runs only when explicitly enabled via `--rtk` or `HEADROOM_RTK=1`. Guards the 3 RTK entry points (`_setup_rtk`, `_ensure_rtk_binary`, `_inject_rtk_instructions`) so they no-op by default — one small change instead of editing ~30 call sites. - **`--rtk` opt-in flag** on all 18 tool subcommands via a shared eager-callback option (`expose_value=False`, sets `HEADROOM_RTK=1`; no subcommand signature changes). - `wrap claude`'s legacy `--context-tool` still opts in (mirrored into the gate). - **`--no-rtk` kept** as an accepted, now-redundant no-op (back-compat). - lean-ctx and all non-RTK behavior untouched. ## Testing ```text pytest tests/test_wrap_rtk_opt_in.py -> 4 passed ruff check / format -> clean mypy headroom -> Success: no issues found in 504 source files ``` Verified `--rtk` appears in `wrap {claude,codex,copilot} --help`; `_rtk_opt_in()` is False by default, True with `HEADROOM_RTK=1`; entry points no-op + write nothing when off. ## Real Behavior Proof - Env: local `.venv`, click CliRunner. - Steps: import wrap; assert gate default-off / env-on; assert `_setup_rtk`/`_ensure_rtk_binary` return None and `_inject_rtk_instructions` returns False + writes no file when not opted in; assert `--rtk` in subcommand help. - Observed: all pass. Not tested: a live end-to-end wrap launch (proxy spawn). ## Notes Part 2 of 3 (RTK opt-in). Separate PRs cover the code-graph MCP engine and the proxy-option cleanup. No `CHANGELOG.md` edit — release-please generates it from the PR title (per the changelog guard). --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
1 parent 1b8c11e commit 44136ed

12 files changed

Lines changed: 165 additions & 5 deletions

e2e/wrap/run.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,9 @@ def main() -> None:
953953
"PATH": f"{shim_dir}{os.pathsep}{base_env['PATH']}",
954954
"HEADROOM_E2E_LOG_DIR": str(log_dir),
955955
"OPENAI_TARGET_API_URL": "http://127.0.0.1:19001/v1",
956+
# RTK is opt-in (off by default). These wrap smoke tests assert
957+
# RTK-instruction injection, so exercise the RTK-on path.
958+
"HEADROOM_RTK": "1",
956959
}
957960
)
958961

headroom/cli/wrap.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,8 +647,42 @@ def _start_proxy(
647647
stdio_log_file.close()
648648

649649

650+
def _rtk_opt_in() -> bool:
651+
"""Whether RTK CLI-command filtering was explicitly enabled.
652+
653+
RTK is opt-in (off by default): turn it on with ``--rtk`` (which sets
654+
``HEADROOM_RTK=1``) or by exporting ``HEADROOM_RTK=1``. ``--no-rtk`` remains
655+
accepted as a deprecated no-op.
656+
"""
657+
return os.environ.get("HEADROOM_RTK", "").strip().lower() in ("1", "true", "yes", "on")
658+
659+
660+
def _rtk_flag_callback(ctx: Any, param: Any, value: bool) -> bool:
661+
"""Click eager callback: ``--rtk`` sets HEADROOM_RTK so the central RTK gate
662+
(:func:`_rtk_opt_in`) sees the opt-in without threading a param through every
663+
wrap subcommand."""
664+
if value:
665+
os.environ["HEADROOM_RTK"] = "1"
666+
return value
667+
668+
669+
# Shared opt-in flag applied to every ``wrap`` subcommand. ``expose_value=False``
670+
# so no subcommand signature changes; it works purely through HEADROOM_RTK.
671+
_rtk_option = click.option(
672+
"--rtk",
673+
is_flag=True,
674+
default=False,
675+
expose_value=False,
676+
is_eager=True,
677+
callback=_rtk_flag_callback,
678+
help="Enable RTK CLI-command filtering (opt-in; off by default). Also enabled by HEADROOM_RTK=1.",
679+
)
680+
681+
650682
def _setup_rtk(verbose: bool = False) -> Path | None:
651683
"""Ensure rtk is installed and hooks are registered."""
684+
if not _rtk_opt_in():
685+
return None
652686
from headroom.rtk import get_rtk_path
653687
from headroom.rtk.installer import ensure_rtk, register_claude_hooks
654688

@@ -2123,6 +2157,8 @@ def _snapshot_codex_config_if_unwrapped(config_file: Path, backup_file: Path) ->
21232157

21242158
def _ensure_rtk_binary(verbose: bool = False) -> Path | None:
21252159
"""Ensure rtk binary is installed (download if needed). No hook registration."""
2160+
if not _rtk_opt_in():
2161+
return None
21262162
from headroom.rtk import get_rtk_path
21272163
from headroom.rtk.installer import ensure_rtk
21282164

@@ -2693,6 +2729,8 @@ def _inject_rtk_instructions(file_path: Path, verbose: bool = False) -> bool:
26932729
Idempotent — skips if marker already present. Appends to existing content.
26942730
Returns True if instructions were written.
26952731
"""
2732+
if not _rtk_opt_in():
2733+
return False
26962734
if file_path.exists():
26972735
existing = _read_text(file_path)
26982736
if _RTK_MARKER in existing:
@@ -4396,6 +4434,7 @@ def wrap_selfheal(marker: str | None) -> None:
43964434

43974435

43984436
@wrap.command(context_settings={"ignore_unknown_options": True})
4437+
@_rtk_option
43994438
@click.option(
44004439
# no "-p" short alias here: claude's own -p/--print must fall through to CLAUDE_ARGS
44014440
"--port",
@@ -4523,7 +4562,12 @@ def claude(
45234562
headroom wrap claude --no-serena # Never register the Serena backup
45244563
headroom wrap claude --1m # Preserve the 1M context window
45254564
"""
4526-
setup_context_tool = context_tool and not no_rtk
4565+
# RTK/context-tool is opt-in (off by default): --context-tool (legacy) and
4566+
# --rtk both enable it. Mirror --context-tool into HEADROOM_RTK so the central
4567+
# RTK gate (_rtk_opt_in) fires for the legacy flag too.
4568+
if context_tool:
4569+
os.environ["HEADROOM_RTK"] = "1"
4570+
setup_context_tool = (context_tool or _rtk_opt_in()) and not no_rtk
45274571
if prepare_only:
45284572
if setup_context_tool:
45294573
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
@@ -4936,6 +4980,7 @@ def unwrap_claude(
49364980

49374981

49384982
@wrap.command(context_settings={"ignore_unknown_options": True})
4983+
@_rtk_option
49394984
@click.option(
49404985
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
49414986
)
@@ -5459,6 +5504,7 @@ def configure_codex_launch(
54595504

54605505

54615506
@wrap.command(context_settings={"ignore_unknown_options": True})
5507+
@_rtk_option
54625508
@click.option(
54635509
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
54645510
)
@@ -5577,6 +5623,7 @@ def codex(
55775623

55785624

55795625
@wrap.command(context_settings={"ignore_unknown_options": True})
5626+
@_rtk_option
55805627
@click.option(
55815628
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
55825629
)
@@ -5681,6 +5728,7 @@ def aider(
56815728

56825729

56835730
@wrap.command(context_settings={"ignore_unknown_options": True})
5731+
@_rtk_option
56845732
@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)")
56855733
@click.option(
56865734
"--no-context-tool",
@@ -5783,6 +5831,7 @@ def openclaude(
57835831

57845832

57855833
@wrap.command(context_settings={"ignore_unknown_options": True})
5834+
@_rtk_option
57865835
@click.option(
57875836
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
57885837
)
@@ -5862,6 +5911,7 @@ def vibe(
58625911

58635912

58645913
@wrap.command(context_settings={"ignore_unknown_options": True})
5914+
@_rtk_option
58655915
@click.option(
58665916
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
58675917
)
@@ -5949,6 +5999,7 @@ def kimi(
59495999

59506000

59516001
@wrap.command(context_settings={"ignore_unknown_options": True})
6002+
@_rtk_option
59526003
@click.option(
59536004
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
59546005
)
@@ -6092,6 +6143,7 @@ def grok(
60926143

60936144

60946145
@wrap.command(context_settings={"ignore_unknown_options": True})
6146+
@_rtk_option
60956147
@click.option(
60966148
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
60976149
)
@@ -6198,6 +6250,7 @@ def _print_cursor_setup(actual_port: int) -> None:
61986250

61996251

62006252
@wrap.command("grok-build", context_settings={"ignore_unknown_options": True})
6253+
@_rtk_option
62016254
@click.option(
62026255
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
62036256
)
@@ -6294,6 +6347,7 @@ def _print_grok_build_setup(actual_port: int) -> None:
62946347

62956348

62966349
@wrap.command(context_settings={"ignore_unknown_options": True})
6350+
@_rtk_option
62976351
@click.option(
62986352
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
62996353
)
@@ -6396,6 +6450,7 @@ def _print_cline_setup(actual_port: int) -> None:
63966450

63976451

63986452
@wrap.command(context_settings={"ignore_unknown_options": True})
6453+
@_rtk_option
63996454
@click.option(
64006455
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
64016456
)
@@ -6491,6 +6546,7 @@ def _print_zcode_setup(actual_port: int = port) -> None:
64916546

64926547

64936548
@wrap.command("continue", context_settings={"ignore_unknown_options": True})
6549+
@_rtk_option
64946550
@click.option(
64956551
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
64966552
)
@@ -6615,6 +6671,7 @@ def _print_continue_setup(actual_port: int) -> None:
66156671

66166672

66176673
@wrap.command(context_settings={"ignore_unknown_options": True})
6674+
@_rtk_option
66186675
@click.option(
66196676
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
66206677
)
@@ -6740,6 +6797,7 @@ def goose(
67406797

67416798

67426799
@wrap.command(context_settings={"ignore_unknown_options": True})
6800+
@_rtk_option
67436801
@click.option(
67446802
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
67456803
)
@@ -6883,6 +6941,7 @@ def openhands(
68836941

68846942

68856943
@wrap.command("openclaw")
6944+
@_rtk_option
68866945
@click.option(
68876946
"--plugin-path",
68886947
type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
@@ -7124,6 +7183,7 @@ def openclaw(
71247183

71257184

71267185
@wrap.command(context_settings={"ignore_unknown_options": True})
7186+
@_rtk_option
71277187
@click.option(
71287188
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
71297189
)
@@ -7648,6 +7708,7 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
76487708

76497709

76507710
@wrap.command(context_settings={"ignore_unknown_options": True})
7711+
@_rtk_option
76517712
@click.option(
76527713
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"
76537714
)

tests/test_cli/test_wrap_bridge.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def test_wrap_codex_prepare_only_accepts_no_context_tool_alias(monkeypatch, tmp_
164164

165165
def test_wrap_aider_prepare_only_injects_conventions(monkeypatch, tmp_path: Path) -> None:
166166
_set_test_home(monkeypatch, tmp_path)
167+
monkeypatch.setenv("HEADROOM_RTK", "1") # RTK is opt-in; exercise the RTK-on path
167168
runner = CliRunner()
168169

169170
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
@@ -180,6 +181,7 @@ def test_wrap_cursor_prepare_only_registers_native_hook(monkeypatch, tmp_path: P
180181
# GH #756: when rtk's own `--agent cursor` hook registers successfully,
181182
# headroom must not also inject RTK_INSTRUCTIONS_BLOCK into .cursorrules.
182183
_set_test_home(monkeypatch, tmp_path)
184+
monkeypatch.setenv("HEADROOM_RTK", "1") # RTK is opt-in; exercise the RTK-on path
183185
runner = CliRunner()
184186

185187
# headroom trusts the on-disk hook, not rtk's exit code, so simulate rtk
@@ -206,6 +208,7 @@ def test_wrap_cursor_prepare_only_falls_back_to_cursorrules_when_hook_fails(
206208
monkeypatch, tmp_path: Path
207209
) -> None:
208210
_set_test_home(monkeypatch, tmp_path)
211+
monkeypatch.setenv("HEADROOM_RTK", "1") # RTK is opt-in; exercise the RTK-on path
209212
runner = CliRunner()
210213

211214
with runner.isolated_filesystem(temp_dir=str(tmp_path)):

tests/test_cli/test_wrap_codex.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ def test_unwrap_removes_rtk_block_from_global_agents(
470470
"""`wrap codex` injects the rtk block into the Codex global AGENTS.md;
471471
`unwrap codex` must take it back out (regression for #1421)."""
472472
_set_test_home(monkeypatch, tmp_path)
473+
monkeypatch.setenv("HEADROOM_RTK", "1")
473474
codex_home = tmp_path / ".codex"
474475
codex_home.mkdir()
475476
agents = codex_home / "AGENTS.md"
@@ -487,6 +488,7 @@ def test_unwrap_preserves_user_content_in_global_agents(
487488
"""Only the marker-fenced rtk block is removed; the user's own AGENTS.md
488489
prose survives the unwrap."""
489490
_set_test_home(monkeypatch, tmp_path)
491+
monkeypatch.setenv("HEADROOM_RTK", "1")
490492
codex_home = tmp_path / ".codex"
491493
codex_home.mkdir()
492494
agents = codex_home / "AGENTS.md"
@@ -1275,6 +1277,7 @@ def test_wrap_codex_injects_rtk_globally_without_changing_project_agents(
12751277
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
12761278
) -> None:
12771279
_set_test_home(monkeypatch, tmp_path)
1280+
monkeypatch.setenv("HEADROOM_RTK", "1")
12781281
project_dir = tmp_path / "project"
12791282
project_dir.mkdir()
12801283
project_agents = project_dir / "AGENTS.md"
@@ -1308,6 +1311,7 @@ def test_wrap_codex_launch_injects_rtk_globally_without_changing_project_agents(
13081311
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
13091312
) -> None:
13101313
_set_test_home(monkeypatch, tmp_path)
1314+
monkeypatch.setenv("HEADROOM_RTK", "1")
13111315
project_dir = tmp_path / "project"
13121316
project_dir.mkdir()
13131317
project_agents = project_dir / "AGENTS.md"

tests/test_cli/test_wrap_copilot.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ def _expected_project_prefix() -> str:
2121
return f"/p/{quote(Path.cwd().name, safe='')}"
2222

2323

24+
@pytest.fixture(autouse=True)
25+
def _enable_rtk(monkeypatch: pytest.MonkeyPatch) -> None:
26+
# RTK is opt-in (off by default); these tests exercise the RTK-on injection path.
27+
monkeypatch.setenv("HEADROOM_RTK", "1")
28+
29+
2430
@pytest.fixture
2531
def runner() -> CliRunner:
2632
return CliRunner()

tests/test_cli/test_wrap_encoding.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@
3131

3232

3333
@pytest.mark.parametrize("inject, marker", INJECTORS)
34-
def test_inject_appends_into_file_with_non_ascii_and_stray_byte(inject, marker, tmp_path: Path):
34+
def test_inject_appends_into_file_with_non_ascii_and_stray_byte(
35+
inject, marker, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
36+
):
37+
if marker == _RTK_MARKER:
38+
monkeypatch.setenv("HEADROOM_RTK", "1")
3539
target = tmp_path / "AGENTS.md"
3640
target.write_bytes(_EXISTING)
3741

@@ -45,15 +49,21 @@ def test_inject_appends_into_file_with_non_ascii_and_stray_byte(inject, marker,
4549

4650

4751
@pytest.mark.parametrize("inject, marker", INJECTORS)
48-
def test_inject_creates_file_when_absent(inject, marker, tmp_path: Path):
52+
def test_inject_creates_file_when_absent(
53+
inject, marker, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
54+
):
55+
if marker == _RTK_MARKER:
56+
monkeypatch.setenv("HEADROOM_RTK", "1")
4957
target = tmp_path / "nested" / "AGENTS.md"
5058

5159
assert inject(target) is True
5260
assert marker in target.read_text(encoding="utf-8")
5361

5462

5563
@pytest.mark.parametrize("inject, marker", INJECTORS)
56-
def test_inject_is_idempotent(inject, marker, tmp_path: Path):
64+
def test_inject_is_idempotent(inject, marker, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
65+
if marker == _RTK_MARKER:
66+
monkeypatch.setenv("HEADROOM_RTK", "1")
5767
target = tmp_path / "AGENTS.md"
5868
target.write_bytes(_EXISTING)
5969

tests/test_cli/test_wrap_hintfile_agents.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
]
3232

3333

34+
@pytest.fixture(autouse=True)
35+
def _enable_rtk(monkeypatch: pytest.MonkeyPatch) -> None:
36+
# RTK is opt-in (off by default); these tests exercise the RTK-on injection path.
37+
monkeypatch.setenv("HEADROOM_RTK", "1")
38+
39+
3440
@pytest.fixture
3541
def runner() -> CliRunner:
3642
return CliRunner()

tests/test_cli/test_wrap_omp.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ def test_wrap_omp_rtk_injects_into_cwd_agents_md(
241241
runner: CliRunner, omp_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
242242
) -> None:
243243
monkeypatch.setenv("HEADROOM_CONTEXT_TOOL", "rtk")
244+
monkeypatch.setenv("HEADROOM_RTK", "1")
244245
with (
245246
patch("headroom.cli.wrap.shutil.which", return_value="omp"),
246247
patch("headroom.cli.wrap._launch_tool"),
@@ -260,8 +261,9 @@ def test_wrap_omp_rtk_injects_into_cwd_agents_md(
260261

261262

262263
def test_unwrap_omp_restored_and_cleans_agents_md(
263-
runner: CliRunner, omp_home: Path, tmp_path: Path
264+
runner: CliRunner, omp_home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
264265
) -> None:
266+
monkeypatch.setenv("HEADROOM_RTK", "1")
265267
original = "providers:\n anthropic:\n apiKey: sk-user-secret\n"
266268
omp_home.write_bytes(original.encode("utf-8"))
267269
inject_models_override(8787, "proj")

tests/test_cli/test_wrap_openclaude.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ def _expected_project_prefix() -> str:
2424
return f"/p/{quote(Path.cwd().name, safe='')}"
2525

2626

27+
@pytest.fixture(autouse=True)
28+
def _enable_rtk(monkeypatch: pytest.MonkeyPatch) -> None:
29+
# RTK is opt-in (off by default); these tests exercise the RTK-on injection path.
30+
monkeypatch.setenv("HEADROOM_RTK", "1")
31+
32+
2733
@pytest.fixture
2834
def runner() -> CliRunner:
2935
return CliRunner()

tests/test_cli/test_wrap_opencode.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
from headroom.cli.main import main
1414

1515

16+
@pytest.fixture(autouse=True)
17+
def _enable_rtk(monkeypatch: pytest.MonkeyPatch) -> None:
18+
# RTK is opt-in (off by default); these tests exercise the RTK-on injection path.
19+
monkeypatch.setenv("HEADROOM_RTK", "1")
20+
21+
1622
@pytest.fixture
1723
def runner() -> CliRunner:
1824
return CliRunner()

0 commit comments

Comments
 (0)