diff --git a/headroom/cli/__init__.py b/headroom/cli/__init__.py index cc774f527..ff68c82ed 100644 --- a/headroom/cli/__init__.py +++ b/headroom/cli/__init__.py @@ -1,5 +1,42 @@ """Headroom CLI - Command-line interface for memory and proxy management.""" +import sys +from importlib import import_module + from .main import main +_LAZY_SUBMODULES = { + "evals", + "init", + "install", + "learn", + "mcp", + "memory", + "perf", + "proxy", + "tools", + "wrap", +} + __all__ = ["main"] + +for _name in _LAZY_SUBMODULES: + _module = sys.modules.get(f"{__name__}.{_name}") + if _module is not None: + globals()[_name] = _module + + +def __getattr__(name: str) -> object: + if name == "__path__": + raise AttributeError(name) + + if name in _LAZY_SUBMODULES: + module = import_module(f"{__name__}.{name}") + globals()[name] = module + return module + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__) | _LAZY_SUBMODULES) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 6fbec6551..41b7321ff 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -26,7 +26,7 @@ import time import urllib.error import urllib.request from pathlib import Path -from typing import Any +from typing import Any, cast # Fix Windows cp1252 encoding — box-drawing characters require UTF-8 if sys.platform == "win32" and hasattr(sys.stdout, "buffer"): @@ -42,6 +42,18 @@ from headroom.copilot_auth import has_oauth_auth, resolve_client_bearer_token from .main import main +def _live_wrap_module() -> Any: + """Return the current live wrap module instance. + + CLI tests sometimes reload `headroom.cli.wrap` while still invoking Click + command callbacks that were registered from an older module instance. By + resolving helper calls through `sys.modules[__name__]`, patched helpers on + the live module continue to affect those callbacks. + """ + + return cast(Any, sys.modules[__name__]) + + def _print_telemetry_notice() -> None: """Print a telemetry notice when anonymous telemetry is enabled. @@ -651,7 +663,8 @@ def _recover_persistent_proxy(port: int) -> bool: from headroom.install.runtime import start_detached_agent, start_persistent_docker, wait_ready from headroom.install.supervisors import start_supervisor - manifest = _find_persistent_manifest(port) + helpers = _live_wrap_module() + manifest = helpers._find_persistent_manifest(port) if manifest is None: return False @@ -735,24 +748,30 @@ def _ensure_proxy( openai_api_url: str | None = None, ) -> subprocess.Popen | None: """Start or verify proxy. Returns process handle if we started it.""" + helpers = _live_wrap_module() if not no_proxy: - manifest = _find_persistent_manifest(port) + manifest = helpers._find_persistent_manifest(port) if manifest is not None: from headroom.install.health import probe_ready if probe_ready(manifest.health_url): click.echo(f" Proxy already running on port {port}") return None - if _recover_persistent_proxy(port): + if helpers._recover_persistent_proxy(port): return None - raise click.ClickException( - f"Persistent deployment '{manifest.profile}' on port {port} is not healthy." + if helpers._check_proxy(port): + raise click.ClickException( + f"Persistent deployment '{manifest.profile}' on port {port} is not healthy." + ) + click.echo( + f" Warning: persistent deployment '{manifest.profile}' on port {port} " + "is stale; starting a fresh proxy instead." ) - if _check_proxy(port): + if helpers._check_proxy(port): # Proxy is running — check if it has the features we need needs_restart = False - running_config = _query_proxy_config(port) + running_config = helpers._query_proxy_config(port) if running_config is not None: missing = [] @@ -776,7 +795,7 @@ def _ensure_proxy( proxy_pid = running_config.get("pid") if proxy_pid is not None: - if not _kill_proxy_by_pid(int(proxy_pid), port): + if not helpers._kill_proxy_by_pid(int(proxy_pid), port): raise click.ClickException( f"Failed to stop existing proxy (PID {proxy_pid}) on port {port}. " "Stop it manually and retry." @@ -799,7 +818,7 @@ def _ensure_proxy( # Start (or restart) the proxy with the requested flags click.echo(f" Starting Headroom proxy on port {port}...") try: - proc = _start_proxy( + proc = helpers._start_proxy( port, learn=learn, memory=memory, @@ -816,7 +835,7 @@ def _ensure_proxy( click.echo(f" Error: {e}") raise SystemExit(1) from e else: - if not _check_proxy(port): + if not helpers._check_proxy(port): click.echo(f" Warning: No proxy detected on port {port}") return None @@ -1234,9 +1253,11 @@ def claude( headroom wrap claude --code-graph # With code graph intelligence headroom wrap claude --no-rtk # Skip rtk (proxy only) """ + helpers = _live_wrap_module() + if prepare_only: if not no_rtk: - _prepare_wrap_rtk(verbose=verbose, label="Claude") + helpers._prepare_wrap_rtk(verbose=verbose, label="Claude") return claude_bin = shutil.which("claude") @@ -1247,7 +1268,7 @@ def claude( # Setup rtk before launching (Claude-specific) proxy_holder: list[subprocess.Popen | None] = [None] - cleanup = _make_cleanup(proxy_holder, port) + cleanup = helpers._make_cleanup(proxy_holder, port) signal.signal(signal.SIGINT, cleanup) signal.signal(signal.SIGTERM, cleanup) @@ -1300,25 +1321,30 @@ def claude( click.echo(" ╚═══════════════════════════════════════════════╝") click.echo() - proxy_holder[0] = _ensure_proxy( - port, no_proxy, learn=learn, memory=memory, agent_type="claude", code_graph=code_graph + proxy_holder[0] = helpers._ensure_proxy( + port, + no_proxy, + learn=learn, + memory=memory, + agent_type="claude", + code_graph=code_graph, ) if not no_rtk: click.echo(" Setting up rtk...") - _setup_rtk(verbose=verbose) + helpers._setup_rtk(verbose=verbose) elif verbose: click.echo(" Skipping rtk (--no-rtk)") if code_graph: - _setup_code_graph(verbose=verbose) + helpers._setup_code_graph(verbose=verbose) click.echo() click.echo(" Launching Claude Code (API routed through Headroom)...") click.echo(f" ANTHROPIC_BASE_URL=http://127.0.0.1:{port}") if claude_args: click.echo(f" Extra args: {' '.join(claude_args)}") - _print_telemetry_notice() + helpers._print_telemetry_notice() click.echo() env = os.environ.copy() diff --git a/tests/test_cli/test_wrap_copilot.py b/tests/test_cli/test_wrap_copilot.py index dc8bafbab..028bfe997 100644 --- a/tests/test_cli/test_wrap_copilot.py +++ b/tests/test_cli/test_wrap_copilot.py @@ -14,29 +14,45 @@ from click.testing import CliRunner from headroom.copilot_auth import DEFAULT_API_URL -fake_main_module = types.ModuleType("headroom.cli.main") -fake_main_module.main = click.Group() -sys.modules["headroom.cli.main"] = fake_main_module -sys.modules.pop("headroom.cli", None) -sys.modules.pop("headroom.cli.wrap", None) - -wrap_cli = importlib.import_module("headroom.cli.wrap") -main = fake_main_module.main - @pytest.fixture def runner() -> CliRunner: return CliRunner() +def _load_wrap_cli(monkeypatch: pytest.MonkeyPatch) -> tuple[types.ModuleType, click.Group]: + fake_main_module = types.ModuleType("headroom.cli.main") + fake_main_module.main = click.Group() + + monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module) + monkeypatch.delitem(sys.modules, "headroom.cli", raising=False) + monkeypatch.delitem(sys.modules, "headroom.cli.wrap", raising=False) + importlib.invalidate_caches() + + wrap_cli = importlib.import_module("headroom.cli.wrap") + return wrap_cli, fake_main_module.main + + +@pytest.fixture +def copilot_cli(monkeypatch: pytest.MonkeyPatch) -> tuple[types.ModuleType, click.Group]: + return _load_wrap_cli(monkeypatch) + + @pytest.fixture(autouse=True) -def no_running_proxy(monkeypatch: pytest.MonkeyPatch) -> None: +def no_running_proxy( + monkeypatch: pytest.MonkeyPatch, copilot_cli: tuple[types.ModuleType, click.Group] +) -> None: + wrap_cli, _ = copilot_cli monkeypatch.setattr(wrap_cli, "_check_proxy", lambda _port: False) def test_wrap_copilot_auto_anthropic_injects_instructions( - runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + runner: CliRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + copilot_cli: tuple[types.ModuleType, click.Group], ) -> None: + wrap_cli, main = copilot_cli monkeypatch.chdir(tmp_path) monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-dummy") captured: dict[str, object] = {} @@ -70,8 +86,11 @@ def test_wrap_copilot_auto_anthropic_injects_instructions( def test_wrap_copilot_openai_backend_sets_completions_env( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + copilot_cli: tuple[types.ModuleType, click.Group], ) -> None: + _, main = copilot_cli monkeypatch.setenv("OPENAI_API_KEY", "sk-test-dummy") captured: dict[str, object] = {} @@ -112,8 +131,11 @@ def test_wrap_copilot_openai_backend_sets_completions_env( def test_wrap_copilot_auto_detects_running_proxy_backend( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + copilot_cli: tuple[types.ModuleType, click.Group], ) -> None: + _, main = copilot_cli monkeypatch.setenv("OPENAI_API_KEY", "sk-test-dummy") captured: dict[str, object] = {} @@ -138,8 +160,11 @@ def test_wrap_copilot_auto_detects_running_proxy_backend( def test_wrap_copilot_prefers_existing_oauth_session( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + copilot_cli: tuple[types.ModuleType, click.Group], ) -> None: + _, main = copilot_cli monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-dummy") captured: dict[str, object] = {} @@ -168,7 +193,9 @@ def test_wrap_copilot_prefers_existing_oauth_session( def test_wrap_copilot_translated_backend_still_requires_byok( runner: CliRunner, + copilot_cli: tuple[types.ModuleType, click.Group], ) -> None: + _, main = copilot_cli with patch("headroom.cli.wrap.shutil.which", return_value="copilot"): with patch("headroom.cli.wrap.has_oauth_auth", return_value=True): result = runner.invoke( @@ -188,7 +215,11 @@ def test_wrap_copilot_translated_backend_still_requires_byok( assert "Copilot BYOK mode requires a provider API key" in result.output -def test_wrap_copilot_rejects_wire_api_for_anthropic_provider(runner: CliRunner) -> None: +def test_wrap_copilot_rejects_wire_api_for_anthropic_provider( + runner: CliRunner, + copilot_cli: tuple[types.ModuleType, click.Group], +) -> None: + _, main = copilot_cli with patch("headroom.cli.wrap.shutil.which", return_value="copilot"): result = runner.invoke( main, @@ -207,7 +238,11 @@ def test_wrap_copilot_rejects_wire_api_for_anthropic_provider(runner: CliRunner) assert "--wire-api is only valid" in result.output -def test_wrap_copilot_rejects_responses_for_translated_backends(runner: CliRunner) -> None: +def test_wrap_copilot_rejects_responses_for_translated_backends( + runner: CliRunner, + copilot_cli: tuple[types.ModuleType, click.Group], +) -> None: + _, main = copilot_cli with patch("headroom.cli.wrap.shutil.which", return_value="copilot"): result = runner.invoke( main, @@ -229,8 +264,11 @@ def test_wrap_copilot_rejects_responses_for_translated_backends(runner: CliRunne def test_wrap_copilot_clears_stale_wire_api_in_anthropic_mode( - runner: CliRunner, monkeypatch: pytest.MonkeyPatch + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + copilot_cli: tuple[types.ModuleType, click.Group], ) -> None: + _, main = copilot_cli monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-dummy") captured: dict[str, object] = {} @@ -255,7 +293,11 @@ def test_wrap_copilot_clears_stale_wire_api_in_anthropic_mode( assert "COPILOT_PROVIDER_WIRE_API" not in env -def test_wrap_copilot_fails_when_binary_missing(runner: CliRunner) -> None: +def test_wrap_copilot_fails_when_binary_missing( + runner: CliRunner, + copilot_cli: tuple[types.ModuleType, click.Group], +) -> None: + _, main = copilot_cli with patch("headroom.cli.wrap.shutil.which", return_value=None): result = runner.invoke(main, ["wrap", "copilot", "--", "--model", "gpt-4o"]) diff --git a/tests/test_cli/test_wrap_persistent.py b/tests/test_cli/test_wrap_persistent.py index 31acdd78c..ddc574668 100644 --- a/tests/test_cli/test_wrap_persistent.py +++ b/tests/test_cli/test_wrap_persistent.py @@ -2,7 +2,7 @@ from __future__ import annotations import click -from headroom.cli.wrap import _ensure_proxy, _find_persistent_manifest, _recover_persistent_proxy +import headroom.cli.wrap as wrap_cli class _Manifest: @@ -15,8 +15,8 @@ class _Manifest: def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None: calls: list[str] = [] - monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: False) - monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False) monkeypatch.setattr( "headroom.install.supervisors.start_supervisor", @@ -26,13 +26,14 @@ def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> No "headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True ) monkeypatch.setattr( - "headroom.cli.wrap._start_proxy", + wrap_cli, + "_start_proxy", lambda *args, **kwargs: (_ for _ in ()).throw( AssertionError("ephemeral proxy should not start") ), ) - result = _ensure_proxy(8787, False) + result = wrap_cli._ensure_proxy(8787, False) assert result is None assert calls == ["start:default"] @@ -41,8 +42,8 @@ def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> No def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None: calls: list[str] = [] - monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True) - monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False) monkeypatch.setattr( "headroom.install.supervisors.start_supervisor", @@ -52,26 +53,41 @@ def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkey "headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True ) - result = _ensure_proxy(8787, False) + result = wrap_cli._ensure_proxy(8787, False) assert result is None assert calls == ["start:default"] def test_ensure_proxy_rejects_unhealthy_persistent_deployment(monkeypatch) -> None: - monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True) - monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False) - monkeypatch.setattr("headroom.cli.wrap._recover_persistent_proxy", lambda port: False) + monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False) try: - _ensure_proxy(8787, False) + wrap_cli._ensure_proxy(8787, False) except click.ClickException as exc: assert "is not healthy" in str(exc) else: raise AssertionError("expected unhealthy persistent deployment to raise") +def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch) -> None: + calls: list[str] = [] + + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False) + monkeypatch.setattr(wrap_cli, "_recover_persistent_proxy", lambda port: False) + monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start")) + + result = wrap_cli._ensure_proxy(8787, False) + + assert result is None + assert calls == ["start"] + + def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None: class DefaultManifest: profile = "default" @@ -86,23 +102,23 @@ def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None: lambda: [OtherManifest(), DefaultManifest()], ) - manifest = _find_persistent_manifest(8787) + manifest = wrap_cli._find_persistent_manifest(8787) assert manifest.profile == "default" def test_recover_persistent_proxy_reuses_healthy_deployment(monkeypatch) -> None: - monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True) - assert _recover_persistent_proxy(8787) is True + assert wrap_cli._recover_persistent_proxy(8787) is True def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None: class TaskManifest(_Manifest): supervisor_kind = "task" - monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: TaskManifest()) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: TaskManifest()) monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False) - assert _recover_persistent_proxy(8787) is False + assert wrap_cli._recover_persistent_proxy(8787) is False diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 2340b7260..c753ff882 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -53,6 +53,8 @@ def test_macos_native_wrapper_dependency_install_retries_pypi_downloads() -> Non content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") assert "python -m pip install --retries 10 --timeout 60 pytest" in content + + def test_ci_commitlint_skips_default_github_merge_commits() -> None: content = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")