From 93a1f2113f17f6fef0140580498f33ede77d8f41 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 21 Apr 2026 22:35:24 -0500 Subject: [PATCH] refactor: move install init logic into provider slices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- headroom/install/planner.py | 38 +------ headroom/install/providers.py | 144 ++----------------------- headroom/providers/aider/install.py | 12 +++ headroom/providers/claude/install.py | 63 +++++++++++ headroom/providers/codex/install.py | 68 ++++++++++++ headroom/providers/copilot/install.py | 25 +++++ headroom/providers/cursor/install.py | 15 +++ headroom/providers/install_registry.py | 86 +++++++++++++++ headroom/providers/openclaw/install.py | 50 +++++++++ tests/test_install/test_planner.py | 35 ++++++ tests/test_install/test_providers.py | 58 +++++----- 11 files changed, 398 insertions(+), 196 deletions(-) create mode 100644 headroom/providers/aider/install.py create mode 100644 headroom/providers/claude/install.py create mode 100644 headroom/providers/codex/install.py create mode 100644 headroom/providers/copilot/install.py create mode 100644 headroom/providers/cursor/install.py create mode 100644 headroom/providers/install_registry.py create mode 100644 headroom/providers/openclaw/install.py diff --git a/headroom/install/planner.py b/headroom/install/planner.py index fad523036..3aac1b7b2 100644 --- a/headroom/install/planner.py +++ b/headroom/install/planner.py @@ -8,6 +8,7 @@ from collections.abc import Iterable import click from headroom import paths as _paths +from headroom.providers.install_registry import build_install_target_envs from .models import ( ConfigScope, @@ -95,44 +96,9 @@ def resolve_targets( return normalized -def _copilot_env(port: int, backend: str) -> dict[str, str]: - if backend == "anthropic": - return { - "COPILOT_PROVIDER_TYPE": "anthropic", - "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}", - } - return { - "COPILOT_PROVIDER_TYPE": "openai", - "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1", - "COPILOT_PROVIDER_WIRE_API": "completions", - } - - def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]: """Build per-target environment variables for the selected tools.""" - - target_envs: dict[str, dict[str, str]] = {} - if ToolTarget.CLAUDE.value in targets: - target_envs[ToolTarget.CLAUDE.value] = { - "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}", - } - if ToolTarget.CODEX.value in targets: - target_envs[ToolTarget.CODEX.value] = { - "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1", - } - if ToolTarget.AIDER.value in targets: - target_envs[ToolTarget.AIDER.value] = { - "OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1", - "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}", - } - if ToolTarget.COPILOT.value in targets: - target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend) - if ToolTarget.CURSOR.value in targets: - target_envs[ToolTarget.CURSOR.value] = { - "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1", - "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}", - } - return target_envs + return build_install_target_envs(port, backend, targets) def build_manifest( diff --git a/headroom/install/providers.py b/headroom/install/providers.py index 7dffae4a0..5c410d9ba 100644 --- a/headroom/install/providers.py +++ b/headroom/install/providers.py @@ -2,23 +2,21 @@ from __future__ import annotations -import json import os import re import subprocess from pathlib import Path -import click +from headroom.providers.install_registry import ( + apply_provider_scope_mutations, + revert_provider_scope_mutation, +) -from .models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget +from .models import ConfigScope, DeploymentManifest, ManagedMutation from .paths import ( - claude_settings_path, - codex_config_path, - openclaw_config_path, unix_system_env_targets, unix_user_env_targets, ) -from .runtime import resolve_headroom_command _ENV_MARKER_START = "# >>> headroom persistent env >>>" _ENV_MARKER_END = "# <<< headroom persistent env <<<" @@ -26,12 +24,6 @@ _ENV_PATTERN = re.compile( re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END), re.DOTALL, ) -_CODEX_MARKER_START = "# --- Headroom persistent provider ---" -_CODEX_MARKER_END = "# --- end Headroom persistent provider ---" -_CODEX_PATTERN = re.compile( - re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END), - re.DOTALL, -) def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str: @@ -153,112 +145,6 @@ def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None: subprocess.run(command, check=True) -def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation: - path = claude_settings_path() - path.parent.mkdir(parents=True, exist_ok=True) - payload: dict[str, object] = {} - if path.exists(): - payload = json.loads(path.read_text()) - env = payload.get("env") - env_map = dict(env) if isinstance(env, dict) else {} - previous = { - name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {}) - } - env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value]) - payload["env"] = env_map - path.write_text(json.dumps(payload, indent=2) + "\n") - return ManagedMutation( - target=ToolTarget.CLAUDE.value, - kind="json-env", - path=str(path), - data={"previous": previous}, - ) - - -def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None: - if not mutation.path: - return - path = Path(mutation.path) - if not path.exists(): - return - payload = json.loads(path.read_text()) - env = payload.get("env") - env_map = dict(env) if isinstance(env, dict) else {} - previous: dict[str, object] = mutation.data.get("previous", {}) - for name in values: - if previous.get(name) is None: - env_map.pop(name, None) - else: - env_map[name] = previous[name] - payload["env"] = env_map - path.write_text(json.dumps(payload, indent=2) + "\n") - - -def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation: - path = codex_config_path() - path.parent.mkdir(parents=True, exist_ok=True) - section = ( - f"{_CODEX_MARKER_START}\n" - 'model_provider = "headroom"\n\n' - "[model_providers.headroom]\n" - 'name = "Headroom persistent proxy"\n' - f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n' - 'env_key = "OPENAI_API_KEY"\n' - "requires_openai_auth = true\n" - "supports_websockets = true\n" - f"{_CODEX_MARKER_END}\n" - ) - merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START) - path.write_text(merged) - return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path)) - - -def _revert_codex_provider_scope(mutation: ManagedMutation) -> None: - if not mutation.path: - return - path = Path(mutation.path) - if not path.exists(): - return - content = path.read_text() - if _CODEX_MARKER_START not in content: - return - path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n") - - -def _invoke_openclaw(command: list[str]) -> None: - subprocess.run(command, check=True) - - -def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation: - if not shutil_which("openclaw"): - raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.") - command = [ - *resolve_headroom_command(), - "wrap", - "openclaw", - "--no-auto-start", - "--proxy-port", - str(manifest.port), - ] - _invoke_openclaw(command) - return ManagedMutation( - target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path()) - ) - - -def _revert_openclaw_provider_scope() -> None: - if not shutil_which("openclaw"): - return - command = [*resolve_headroom_command(), "unwrap", "openclaw"] - _invoke_openclaw(command) - - -def shutil_which(name: str) -> str | None: - from shutil import which - - return which(name) - - def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]: """Apply provider/user/system configuration for a deployment.""" @@ -268,17 +154,10 @@ def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]: mutations.extend(_apply_windows_env_scope(manifest)) else: mutations.extend(_apply_unix_env_scope(manifest)) - if ToolTarget.OPENCLAW.value in manifest.targets: - mutations.append(_apply_openclaw_provider_scope(manifest)) + mutations.extend(apply_provider_scope_mutations(manifest)) return mutations - if ToolTarget.CLAUDE.value in manifest.targets: - mutations.append(_apply_claude_provider_scope(manifest)) - if ToolTarget.CODEX.value in manifest.targets: - mutations.append(_apply_codex_provider_scope(manifest)) - if ToolTarget.OPENCLAW.value in manifest.targets: - mutations.append(_apply_openclaw_provider_scope(manifest)) - return mutations + return [*mutations, *apply_provider_scope_mutations(manifest)] def revert_mutations(manifest: DeploymentManifest) -> None: @@ -292,11 +171,4 @@ def revert_mutations(manifest: DeploymentManifest) -> None: _remove_unix_env_scope(shell_mutations) for mutation in manifest.mutations: - if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env": - _revert_claude_provider_scope( - mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {}) - ) - elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block": - _revert_codex_provider_scope(mutation) - elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap": - _revert_openclaw_provider_scope() + revert_provider_scope_mutation(manifest, mutation) diff --git a/headroom/providers/aider/install.py b/headroom/providers/aider/install.py new file mode 100644 index 000000000..ceb326129 --- /dev/null +++ b/headroom/providers/aider/install.py @@ -0,0 +1,12 @@ +"""Aider install-time helpers.""" + +from __future__ import annotations + +from .runtime import build_launch_env + + +def build_install_env(*, port: int, backend: str) -> dict[str, str]: + """Build the persistent install environment for Aider.""" + del backend + env, _lines = build_launch_env(port=port, environ={}) + return {key: env[key] for key in ("OPENAI_API_BASE", "ANTHROPIC_BASE_URL")} diff --git a/headroom/providers/claude/install.py b/headroom/providers/claude/install.py new file mode 100644 index 000000000..db5d03059 --- /dev/null +++ b/headroom/providers/claude/install.py @@ -0,0 +1,63 @@ +"""Claude install-time helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget +from headroom.install.paths import claude_settings_path + +from .runtime import proxy_base_url + + +def build_install_env(*, port: int, backend: str) -> dict[str, str]: + """Build the persistent install environment for Claude.""" + del backend + return {"ANTHROPIC_BASE_URL": proxy_base_url(port)} + + +def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None: + """Apply Claude provider-scope configuration when requested.""" + if manifest.scope != ConfigScope.PROVIDER.value: + return None + + path = claude_settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload: dict[str, object] = {} + if path.exists(): + payload = json.loads(path.read_text()) + env = payload.get("env") + env_map = dict(env) if isinstance(env, dict) else {} + values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {}) + previous = {name: env_map.get(name) for name in values} + env_map.update(values) + payload["env"] = env_map + path.write_text(json.dumps(payload, indent=2) + "\n") + return ManagedMutation( + target=ToolTarget.CLAUDE.value, + kind="json-env", + path=str(path), + data={"previous": previous}, + ) + + +def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None: + """Revert Claude provider-scope configuration.""" + if not mutation.path: + return + path = Path(mutation.path) + if not path.exists(): + return + payload = json.loads(path.read_text()) + env = payload.get("env") + env_map = dict(env) if isinstance(env, dict) else {} + previous: dict[str, object] = mutation.data.get("previous", {}) + values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {}) + for name in values: + if previous.get(name) is None: + env_map.pop(name, None) + else: + env_map[name] = previous[name] + payload["env"] = env_map + path.write_text(json.dumps(payload, indent=2) + "\n") diff --git a/headroom/providers/codex/install.py b/headroom/providers/codex/install.py new file mode 100644 index 000000000..0bdbbbc20 --- /dev/null +++ b/headroom/providers/codex/install.py @@ -0,0 +1,68 @@ +"""Codex install-time helpers.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget +from headroom.install.paths import codex_config_path + +from .runtime import proxy_base_url + +_CODEX_MARKER_START = "# --- Headroom persistent provider ---" +_CODEX_MARKER_END = "# --- end Headroom persistent provider ---" +_CODEX_PATTERN = re.compile( + re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END), + re.DOTALL, +) + + +def build_install_env(*, port: int, backend: str) -> dict[str, str]: + """Build the persistent install environment for Codex.""" + del backend + return {"OPENAI_BASE_URL": proxy_base_url(port)} + + +def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None: + """Apply Codex provider-scope configuration when requested.""" + if manifest.scope != ConfigScope.PROVIDER.value: + return None + + path = codex_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + section = ( + f"{_CODEX_MARKER_START}\n" + 'model_provider = "headroom"\n\n' + "[model_providers.headroom]\n" + 'name = "Headroom persistent proxy"\n' + f'base_url = "{proxy_base_url(manifest.port)}"\n' + 'env_key = "OPENAI_API_KEY"\n' + "requires_openai_auth = true\n" + "supports_websockets = true\n" + f"{_CODEX_MARKER_END}\n" + ) + if path.exists(): + existing = path.read_text() + if _CODEX_MARKER_START in existing: + merged = _CODEX_PATTERN.sub(section, existing) + else: + merged = existing.rstrip() + "\n\n" + section + "\n" + else: + merged = section + "\n" + path.write_text(merged) + return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path)) + + +def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None: + """Revert Codex provider-scope configuration.""" + del manifest + if not mutation.path: + return + path = Path(mutation.path) + if not path.exists(): + return + content = path.read_text() + if _CODEX_MARKER_START not in content: + return + path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n") diff --git a/headroom/providers/copilot/install.py b/headroom/providers/copilot/install.py new file mode 100644 index 000000000..5b0df8e32 --- /dev/null +++ b/headroom/providers/copilot/install.py @@ -0,0 +1,25 @@ +"""Copilot install-time helpers.""" + +from __future__ import annotations + +from .wrap import build_launch_env, resolve_provider_type + + +def build_install_env(*, port: int, backend: str) -> dict[str, str]: + """Build the persistent install environment for Copilot.""" + provider_type = resolve_provider_type(backend, "auto", {"HEADROOM_BACKEND": backend}) + env, _lines = build_launch_env( + port=port, + provider_type=provider_type, + wire_api=None, + environ={}, + ) + return { + key: env[key] + for key in ( + "COPILOT_PROVIDER_TYPE", + "COPILOT_PROVIDER_BASE_URL", + "COPILOT_PROVIDER_WIRE_API", + ) + if key in env + } diff --git a/headroom/providers/cursor/install.py b/headroom/providers/cursor/install.py new file mode 100644 index 000000000..6724f496f --- /dev/null +++ b/headroom/providers/cursor/install.py @@ -0,0 +1,15 @@ +"""Cursor install-time helpers.""" + +from __future__ import annotations + +from .runtime import build_proxy_targets + + +def build_install_env(*, port: int, backend: str) -> dict[str, str]: + """Build the persistent install environment for Cursor.""" + del backend + targets = build_proxy_targets(port) + return { + "OPENAI_BASE_URL": targets.openai_base_url, + "ANTHROPIC_BASE_URL": targets.anthropic_base_url, + } diff --git a/headroom/providers/install_registry.py b/headroom/providers/install_registry.py new file mode 100644 index 000000000..416046655 --- /dev/null +++ b/headroom/providers/install_registry.py @@ -0,0 +1,86 @@ +"""Install-time provider registry helpers.""" + +from __future__ import annotations + +from collections.abc import Callable + +from headroom.install.models import DeploymentManifest, ManagedMutation +from headroom.providers.aider.install import build_install_env as _build_aider_install_env +from headroom.providers.claude.install import ( + apply_provider_scope as _apply_claude_provider_scope, +) +from headroom.providers.claude.install import ( + build_install_env as _build_claude_install_env, +) +from headroom.providers.claude.install import ( + revert_provider_scope as _revert_claude_provider_scope, +) +from headroom.providers.codex.install import ( + apply_provider_scope as _apply_codex_provider_scope, +) +from headroom.providers.codex.install import build_install_env as _build_codex_install_env +from headroom.providers.codex.install import ( + revert_provider_scope as _revert_codex_provider_scope, +) +from headroom.providers.copilot.install import ( + build_install_env as _build_copilot_install_env, +) +from headroom.providers.cursor.install import build_install_env as _build_cursor_install_env +from headroom.providers.openclaw.install import ( + apply_provider_scope as _apply_openclaw_provider_scope, +) +from headroom.providers.openclaw.install import ( + revert_provider_scope as _revert_openclaw_provider_scope, +) + +_InstallEnvBuilder = Callable[..., dict[str, str]] +_ProviderScopeApplier = Callable[[DeploymentManifest], ManagedMutation | None] +_ProviderScopeReverter = Callable[[ManagedMutation, DeploymentManifest], None] + +_ENV_BUILDERS: dict[str, _InstallEnvBuilder] = { + "claude": _build_claude_install_env, + "copilot": _build_copilot_install_env, + "codex": _build_codex_install_env, + "aider": _build_aider_install_env, + "cursor": _build_cursor_install_env, +} + +_PROVIDER_SCOPE_HANDLERS: dict[str, tuple[_ProviderScopeApplier, _ProviderScopeReverter]] = { + "claude": (_apply_claude_provider_scope, _revert_claude_provider_scope), + "codex": (_apply_codex_provider_scope, _revert_codex_provider_scope), + "openclaw": (_apply_openclaw_provider_scope, _revert_openclaw_provider_scope), +} + + +def build_install_target_envs( + port: int, backend: str, targets: list[str] +) -> dict[str, dict[str, str]]: + """Build per-target install environment values via provider slices.""" + target_envs: dict[str, dict[str, str]] = {} + for target in targets: + builder = _ENV_BUILDERS.get(target) + if builder is None: + continue + target_envs[target] = builder(port=port, backend=backend) + return target_envs + + +def apply_provider_scope_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]: + """Apply provider-scope mutations owned by provider slices.""" + mutations: list[ManagedMutation] = [] + for target in manifest.targets: + handlers = _PROVIDER_SCOPE_HANDLERS.get(target) + if handlers is None: + continue + mutation = handlers[0](manifest) + if mutation is not None: + mutations.append(mutation) + return mutations + + +def revert_provider_scope_mutation(manifest: DeploymentManifest, mutation: ManagedMutation) -> None: + """Revert a provider-scope mutation via the owning provider slice.""" + handlers = _PROVIDER_SCOPE_HANDLERS.get(mutation.target) + if handlers is None: + return + handlers[1](mutation, manifest) diff --git a/headroom/providers/openclaw/install.py b/headroom/providers/openclaw/install.py new file mode 100644 index 000000000..e7e2b6f59 --- /dev/null +++ b/headroom/providers/openclaw/install.py @@ -0,0 +1,50 @@ +"""OpenClaw install-time helpers.""" + +from __future__ import annotations + +import click + +from headroom.install.models import DeploymentManifest, ManagedMutation, ToolTarget +from headroom.install.paths import openclaw_config_path +from headroom.install.runtime import resolve_headroom_command + + +def shutil_which(name: str) -> str | None: + from shutil import which + + return which(name) + + +def _invoke_openclaw(command: list[str]) -> None: + import subprocess + + subprocess.run(command, check=True) + + +def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation: + """Configure OpenClaw to route through the persistent proxy.""" + if not shutil_which("openclaw"): + raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.") + command = [ + *resolve_headroom_command(), + "wrap", + "openclaw", + "--no-auto-start", + "--proxy-port", + str(manifest.port), + ] + _invoke_openclaw(command) + return ManagedMutation( + target=ToolTarget.OPENCLAW.value, + kind="openclaw-wrap", + path=str(openclaw_config_path()), + ) + + +def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifest) -> None: + """Undo OpenClaw persistent proxy configuration.""" + del mutation, manifest + if not shutil_which("openclaw"): + return + command = [*resolve_headroom_command(), "unwrap", "openclaw"] + _invoke_openclaw(command) diff --git a/tests/test_install/test_planner.py b/tests/test_install/test_planner.py index 0a672c78d..6a84369b1 100644 --- a/tests/test_install/test_planner.py +++ b/tests/test_install/test_planner.py @@ -44,6 +44,41 @@ def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None: assert "--memory" in manifest.proxy_args +def test_build_manifest_uses_provider_slice_env_builders_for_all_supported_targets() -> None: + manifest = build_manifest( + profile="default", + preset=InstallPreset.PERSISTENT_SERVICE.value, + runtime_kind="python", + scope="user", + provider_mode="manual", + targets=["claude", "copilot", "codex", "aider", "cursor"], + port=9999, + backend="anyllm", + anyllm_provider="groq", + region=None, + proxy_mode="token", + memory_enabled=False, + telemetry_enabled=True, + image="ghcr.io/chopratejas/headroom:latest", + ) + + assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9999" + assert manifest.tool_envs["codex"]["OPENAI_BASE_URL"] == "http://127.0.0.1:9999/v1" + assert manifest.tool_envs["aider"] == { + "OPENAI_API_BASE": "http://127.0.0.1:9999/v1", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:9999", + } + assert manifest.tool_envs["cursor"] == { + "OPENAI_BASE_URL": "http://127.0.0.1:9999/v1", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:9999", + } + assert manifest.tool_envs["copilot"] == { + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9999/v1", + "COPILOT_PROVIDER_WIRE_API": "completions", + } + + def test_resolve_targets_provider_scope_auto_excludes_copilot(monkeypatch) -> None: monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: []) diff --git a/tests/test_install/test_providers.py b/tests/test_install/test_providers.py index 40350c3ce..9c10f29a8 100644 --- a/tests/test_install/test_providers.py +++ b/tests/test_install/test_providers.py @@ -1,17 +1,15 @@ from __future__ import annotations import json +import os from pathlib import Path from headroom.install.models import DeploymentManifest, ManagedMutation -from headroom.install.providers import ( - _apply_claude_provider_scope, - _apply_codex_provider_scope, - _apply_windows_env_scope, - _remove_windows_env_scope, - _revert_claude_provider_scope, - _revert_codex_provider_scope, -) +from headroom.install.providers import _apply_windows_env_scope, _remove_windows_env_scope +from headroom.providers.claude.install import apply_provider_scope as apply_claude_provider_scope +from headroom.providers.claude.install import revert_provider_scope as revert_claude_provider_scope +from headroom.providers.codex.install import apply_provider_scope as apply_codex_provider_scope +from headroom.providers.codex.install import revert_provider_scope as revert_codex_provider_scope def _manifest(tmp_path: Path) -> DeploymentManifest: @@ -39,15 +37,18 @@ def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) -> settings_path.write_text( json.dumps({"env": {"ANTHROPIC_API_KEY": "keep", "ANTHROPIC_BASE_URL": "https://old"}}) ) - monkeypatch.setattr("headroom.install.providers.claude_settings_path", lambda: settings_path) + monkeypatch.setattr( + "headroom.providers.claude.install.claude_settings_path", lambda: settings_path + ) manifest = _manifest(tmp_path) - mutation = _apply_claude_provider_scope(manifest) + mutation = apply_claude_provider_scope(manifest) payload = json.loads(settings_path.read_text()) assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" assert payload["env"]["ANTHROPIC_API_KEY"] == "keep" - _revert_claude_provider_scope(mutation, manifest.tool_envs["claude"]) + assert mutation is not None + revert_claude_provider_scope(mutation, manifest) reverted = json.loads(settings_path.read_text()) assert reverted["env"]["ANTHROPIC_BASE_URL"] == "https://old" assert reverted["env"]["ANTHROPIC_API_KEY"] == "keep" @@ -56,15 +57,16 @@ def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) -> def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> None: config_path = tmp_path / "config.toml" config_path.write_text('model = "gpt-4o"\n') - monkeypatch.setattr("headroom.install.providers.codex_config_path", lambda: config_path) + monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) - mutation = _apply_codex_provider_scope(manifest) + mutation = apply_codex_provider_scope(manifest) content = config_path.read_text() assert 'model_provider = "headroom"' in content assert 'base_url = "http://127.0.0.1:8787/v1"' in content - _revert_codex_provider_scope(mutation) + assert mutation is not None + revert_codex_provider_scope(mutation, manifest) reverted = config_path.read_text() assert 'model_provider = "headroom"' not in reverted assert reverted.strip() == 'model = "gpt-4o"' @@ -72,25 +74,27 @@ def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> N def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path: Path) -> None: recorded: list[list[str]] = [] - monkeypatch.setattr("headroom.install.providers.shutil_which", lambda name: "openclaw") + monkeypatch.setattr("headroom.providers.openclaw.install.shutil_which", lambda name: "openclaw") monkeypatch.setattr( - "headroom.install.providers.resolve_headroom_command", + "headroom.providers.openclaw.install.resolve_headroom_command", lambda: ["headroom"], ) monkeypatch.setattr( - "headroom.install.providers._invoke_openclaw", + "headroom.providers.openclaw.install._invoke_openclaw", lambda command: recorded.append(command), ) monkeypatch.setattr( - "headroom.install.providers.openclaw_config_path", + "headroom.providers.openclaw.install.openclaw_config_path", lambda: tmp_path / "openclaw.json", ) manifest = _manifest(tmp_path) manifest.port = 9999 - from headroom.install.providers import _apply_openclaw_provider_scope + from headroom.providers.openclaw.install import ( + apply_provider_scope as apply_openclaw_provider_scope, + ) - _apply_openclaw_provider_scope(manifest) + apply_openclaw_provider_scope(manifest) assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]] @@ -165,11 +169,17 @@ def test_apply_mutations_runs_openclaw_for_user_scope(monkeypatch, tmp_path: Pat manifest.base_env = {"HEADROOM_PORT": "8787"} manifest.tool_envs = {} - monkeypatch.setattr("headroom.install.providers.os.name", "posix") - monkeypatch.setattr("headroom.install.providers._apply_unix_env_scope", lambda deployment: []) + if os.name == "nt": + monkeypatch.setattr( + "headroom.install.providers._apply_windows_env_scope", lambda deployment: [] + ) + else: + monkeypatch.setattr( + "headroom.install.providers._apply_unix_env_scope", lambda deployment: [] + ) monkeypatch.setattr( - "headroom.install.providers._apply_openclaw_provider_scope", - lambda deployment: ManagedMutation(target="openclaw", kind="openclaw-wrap"), + "headroom.install.providers.apply_provider_scope_mutations", + lambda deployment: [ManagedMutation(target="openclaw", kind="openclaw-wrap")], ) from headroom.install.providers import apply_mutations