diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 846f38a58..d0bc0f76d 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1663,6 +1663,34 @@ def _inject_rtk_instructions(file_path: Path, verbose: bool = False) -> bool: return True +def _remove_rtk_instructions(file_path: Path) -> bool: + """Remove Headroom's marker-fenced rtk guidance from an instruction file.""" + if not file_path.exists(): + return False + + content = file_path.read_text(encoding="utf-8") + end_marker = "" + start = content.find(_RTK_MARKER) + if start < 0: + return False + + end = content.find(end_marker, start) + if end < 0: + return False + end += len(end_marker) + prefix = content[:start].rstrip() + suffix = content[end:].lstrip("\r\n") + cleaned = "\n\n".join(part for part in (prefix, suffix) if part) + if cleaned: + cleaned = cleaned.rstrip() + "\n" + + if cleaned: + file_path.write_text(cleaned, encoding="utf-8") + else: + file_path.unlink() + return True + + def _inject_memory_mcp_config(db_path: str, user_id: str) -> None: """Register headroom memory as an MCP server in Codex's config.toml. @@ -3632,6 +3660,26 @@ def copilot( ) +# ============================================================================= +# GitHub Copilot CLI (unwrap) +# ============================================================================= + + +@unwrap.command("copilot") +@click.option("--port", "-p", default=8787, type=int, help="Proxy port (default: 8787)") +@click.option("--no-stop-proxy", is_flag=True, help="Do not stop the local Headroom proxy") +def unwrap_copilot(port: int, no_stop_proxy: bool) -> None: + """Undo durable setup from ``headroom wrap copilot``.""" + instructions = Path.cwd() / ".github" / "copilot-instructions.md" + if _remove_rtk_instructions(instructions): + click.echo(" Removed Headroom rtk instructions from Copilot.") + else: + click.echo(" No Headroom rtk instructions found for Copilot.") + + if not no_stop_proxy: + _echo_unwrap_proxy_stop_status(_stop_local_proxy_for_unwrap(port), port) + + # ============================================================================= # OpenAI Codex CLI # ============================================================================= diff --git a/tests/test_cli/test_wrap_copilot.py b/tests/test_cli/test_wrap_copilot.py index f42903e5e..568e0aac2 100644 --- a/tests/test_cli/test_wrap_copilot.py +++ b/tests/test_cli/test_wrap_copilot.py @@ -668,6 +668,117 @@ def test_wrap_copilot_fails_when_binary_missing( assert "Install GitHub Copilot CLI" in result.output +def test_unwrap_copilot_removes_rtk_instructions_and_stops_proxy( + runner: CliRunner, + wrap_modules: tuple[types.ModuleType, click.Group], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + wrap_cli, main = wrap_modules + monkeypatch.chdir(tmp_path) + instructions = tmp_path / ".github" / "copilot-instructions.md" + instructions.parent.mkdir() + instructions.write_text( + "Keep user guidance.\n\n" + wrap_cli.RTK_INSTRUCTIONS_BLOCK, + encoding="utf-8", + ) + + with patch( + "headroom.cli.wrap._stop_local_proxy_for_unwrap", + return_value="stopped", + ) as stop_proxy: + result = runner.invoke(main, ["unwrap", "copilot", "--port", "9999"]) + + assert result.exit_code == 0, result.output + assert instructions.read_text(encoding="utf-8") == "Keep user guidance.\n" + stop_proxy.assert_called_once_with(9999) + assert "Removed Headroom rtk instructions from Copilot." in result.output + assert "Stopped local Headroom proxy on port 9999" in result.output + + +def test_unwrap_copilot_preserves_instructions_after_rtk_block( + runner: CliRunner, + wrap_modules: tuple[types.ModuleType, click.Group], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + wrap_cli, main = wrap_modules + monkeypatch.chdir(tmp_path) + instructions = tmp_path / ".github" / "copilot-instructions.md" + instructions.parent.mkdir() + instructions.write_text( + wrap_cli.RTK_INSTRUCTIONS_BLOCK + "\nKeep trailing guidance.\n", + encoding="utf-8", + ) + + result = runner.invoke(main, ["unwrap", "copilot", "--no-stop-proxy"]) + + assert result.exit_code == 0, result.output + assert instructions.read_text(encoding="utf-8") == "Keep trailing guidance.\n" + + +def test_unwrap_copilot_leaves_malformed_marker_content_unchanged( + runner: CliRunner, + wrap_modules: tuple[types.ModuleType, click.Group], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + wrap_cli, main = wrap_modules + monkeypatch.chdir(tmp_path) + instructions = tmp_path / ".github" / "copilot-instructions.md" + instructions.parent.mkdir() + content = f"\nKeep user guidance.\n{wrap_cli._RTK_MARKER}\n" + instructions.write_text(content, encoding="utf-8") + + result = runner.invoke(main, ["unwrap", "copilot", "--no-stop-proxy"]) + + assert result.exit_code == 0, result.output + assert instructions.read_text(encoding="utf-8") == content + assert "No Headroom rtk instructions found for Copilot." in result.output + + +def test_unwrap_copilot_deletes_generated_only_instruction_file( + runner: CliRunner, + wrap_modules: tuple[types.ModuleType, click.Group], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + wrap_cli, main = wrap_modules + monkeypatch.chdir(tmp_path) + instructions = tmp_path / ".github" / "copilot-instructions.md" + instructions.parent.mkdir() + instructions.write_text(wrap_cli.RTK_INSTRUCTIONS_BLOCK, encoding="utf-8") + + result = runner.invoke(main, ["unwrap", "copilot", "--no-stop-proxy"]) + + assert result.exit_code == 0, result.output + assert not instructions.exists() + + +@pytest.mark.parametrize("create_user_file", [False, True]) +def test_unwrap_copilot_is_noop_without_managed_instructions( + runner: CliRunner, + wrap_modules: tuple[types.ModuleType, click.Group], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + create_user_file: bool, +) -> None: + _wrap_cli, main = wrap_modules + monkeypatch.chdir(tmp_path) + instructions = tmp_path / ".github" / "copilot-instructions.md" + if create_user_file: + instructions.parent.mkdir() + instructions.write_text("Keep user guidance.\n", encoding="utf-8") + + result = runner.invoke(main, ["unwrap", "copilot", "--no-stop-proxy"]) + + assert result.exit_code == 0, result.output + assert instructions.exists() is create_user_file + if create_user_file: + assert instructions.read_text(encoding="utf-8") == "Keep user guidance.\n" + assert "No Headroom rtk instructions found for Copilot." in result.output + + # --------------------------------------------------------------------------- # Regression suite for #610 — GitHub Copilot endpoint routing per auth mode. #