diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d17458557..1c0834257 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.11.2" + "version": "0.12.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.11.2", + "version": "0.12.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/actions/headroom-e2e-setup/action.yml b/.github/actions/headroom-e2e-setup/action.yml new file mode 100644 index 000000000..0651b9699 --- /dev/null +++ b/.github/actions/headroom-e2e-setup/action.yml @@ -0,0 +1,68 @@ +name: Headroom e2e setup +description: >- + Checkout-agnostic setup shared by native e2e workflows (init, install, wrap). + Installs Python, installs headroom in editable mode, and (optionally) drops + a noop shim onto PATH so ``headroom init -g `` can detect a tool + that isn't actually installed on the runner. +inputs: + python-version: + description: Python version to install + required: false + default: "3.11" + shim-target: + description: >- + Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave + empty to skip shim creation. + required: false + default: "" +outputs: + shim-dir: + description: Absolute path to the directory containing the dropped shim + value: ${{ steps.shim.outputs.shim-dir }} +runs: + using: composite + steps: + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install headroom (editable, with proxy extras) + shell: bash + run: | + python -m pip install --upgrade pip + # ``headroom/cli/__init__.py`` eagerly imports ``proxy.server`` (via + # ``cli/proxy.py``), which requires ``fastapi`` even for ``init``. + # Install with the ``[proxy]`` extras to match the Docker e2e image. + pip install -e ".[proxy]" + + - name: Drop shim (POSIX) + if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }} + id: shim-posix + shell: bash + run: | + shim_dir="${RUNNER_TEMP}/headroom-e2e-shims" + bash e2e/_lib/make_shim.sh "${{ inputs.shim-target }}" "$shim_dir" + echo "$shim_dir" >> "$GITHUB_PATH" + echo "shim-dir=$shim_dir" >> "$GITHUB_OUTPUT" + + - name: Drop shim (Windows) + if: ${{ inputs.shim-target != '' && runner.os == 'Windows' }} + id: shim-windows + shell: pwsh + run: | + $shimDir = Join-Path $env:RUNNER_TEMP "headroom-e2e-shims" + & pwsh -File e2e/_lib/make_shim.ps1 -Name "${{ inputs.shim-target }}" -Dir $shimDir + Add-Content -Path $env:GITHUB_PATH -Value $shimDir + "shim-dir=$shimDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Export shim dir to job output + if: ${{ inputs.shim-target != '' }} + id: shim + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + echo "shim-dir=${{ steps.shim-windows.outputs.shim-dir }}" >> "$GITHUB_OUTPUT" + else + echo "shim-dir=${{ steps.shim-posix.outputs.shim-dir }}" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index d17458557..1c0834257 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.11.2" + "version": "0.12.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.11.2", + "version": "0.12.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/workflows/init-native-e2e.yml b/.github/workflows/init-native-e2e.yml new file mode 100644 index 000000000..cc9a4cdcb --- /dev/null +++ b/.github/workflows/init-native-e2e.yml @@ -0,0 +1,132 @@ +name: Init Native E2E + +# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand +# ``headroom init -g `` flows. Each matrix cell drops a noop shim for +# the target agent onto PATH and asserts ``headroom init -g `` +# succeeds, writes the expected settings file, and (for claude/codex) places +# hooks in the right place. +# +# Deliberately scoped to pull_request + push-to-main + workflow_dispatch to +# avoid bloating CI minutes on every push to every feature branch. The Docker +# init-e2e.yml still runs on every PR and provides the deeper functional +# coverage; this workflow exists to catch platform-specific bugs (Windows +# path separators, macos keychain prompts, PowerShell-vs-bash hook matchers) +# that the single-platform Docker suite can miss. +# +# Extending to other commands (``headroom install``, ``headroom wrap``) is +# expected to be a near-copy of this file. The shared composite action at +# ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so +# each per-command workflow only supplies its matrix and assertion steps. + +on: + pull_request: + branches: [main] + paths: + - "headroom/cli/init.py" + - "headroom/install/**" + - "e2e/_lib/**" + - "e2e/init/**" + - ".github/actions/headroom-e2e-setup/**" + - ".github/workflows/init-native-e2e.yml" + push: + branches: [main] + workflow_dispatch: + +jobs: + init-native: + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + target: [claude, codex, copilot, openclaw] + exclude: + # openclaw delegates to ``headroom wrap openclaw`` which needs a + # running OpenClaw CLI; it can't be shimmed cheaply, so it's + # covered by the bundled Docker e2e instead. + - target: openclaw + + steps: + - uses: actions/checkout@v4 + + - name: Setup (shim=${{ matrix.target }}) + uses: ./.github/actions/headroom-e2e-setup + with: + python-version: "3.11" + shim-target: ${{ matrix.target }} + + - name: Verify shim is on PATH (POSIX) + if: runner.os != 'Windows' + shell: bash + run: | + which "${{ matrix.target }}" + + - name: Verify shim is on PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + # On Windows the shim is ``.cmd``; Get-Command resolves via + # PATHEXT (same as Python's ``shutil.which`` used by headroom init). + # Git Bash's ``which`` cannot find ``.cmd`` shims, so we use pwsh. + $cmd = Get-Command "${{ matrix.target }}" -ErrorAction Stop + Write-Output $cmd.Source + + - name: Run headroom init -g ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + headroom init -g "${{ matrix.target }}" + + - name: Assert settings file (POSIX) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + case "${{ matrix.target }}" in + claude) + test -f "$HOME/.claude/settings.json" + grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json" + ;; + codex) + test -f "$HOME/.codex/config.toml" + test -f "$HOME/.codex/hooks.json" + grep -q "headroom" "$HOME/.codex/config.toml" + ;; + copilot) + test -f "$HOME/.copilot/config.json" + grep -q "SessionStart" "$HOME/.copilot/config.json" + ;; + esac + + - name: Assert settings file (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $home_ = $env:USERPROFILE + switch ("${{ matrix.target }}") { + "claude" { + $p = Join-Path $home_ ".claude\settings.json" + if (-not (Test-Path $p)) { throw "Missing $p" } + if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) { + throw "settings.json missing ANTHROPIC_BASE_URL" + } + } + "codex" { + $c = Join-Path $home_ ".codex\config.toml" + $h = Join-Path $home_ ".codex\hooks.json" + if (-not (Test-Path $c)) { throw "Missing $c" } + if (-not (Test-Path $h)) { throw "Missing $h" } + if (-not ((Get-Content $c -Raw) -match "headroom")) { + throw "config.toml missing headroom provider" + } + } + "copilot" { + $p = Join-Path $home_ ".copilot\config.json" + if (-not (Test-Path $p)) { throw "Missing $p" } + if (-not ((Get-Content $p -Raw) -match "SessionStart")) { + throw "copilot config missing SessionStart hooks" + } + } + } diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 000000000..32bdd459a --- /dev/null +++ b/e2e/__init__.py @@ -0,0 +1,7 @@ +"""End-to-end test suites for Headroom CLI commands. + +Subpackages: + _lib — shared harness and helpers + init — ``headroom init`` coverage + wrap — ``headroom wrap`` coverage +""" diff --git a/e2e/_lib/__init__.py b/e2e/_lib/__init__.py new file mode 100644 index 000000000..92d9ef70c --- /dev/null +++ b/e2e/_lib/__init__.py @@ -0,0 +1,35 @@ +"""Shared helpers for Docker / CI e2e tests. + +This package centralizes utilities used by the per-command e2e harnesses +(`e2e/init/run.py`, future `e2e/install/run.py`, `e2e/wrap/run.py`, ...). +The goal is that each command test suite is a small declarative file that +imports from this package, so new commands can be covered with minimal +duplication. +""" + +from __future__ import annotations + +from .assertions import ( + assert_exit, + assert_stderr_contains, + assert_stdout_contains, + read_agent_settings, +) +from .harness import Case, CaseContext, run_case_sequence, run_cases +from .path_env import with_clean_path +from .paths import agent_settings_path +from .shims import make_shim + +__all__ = [ + "Case", + "CaseContext", + "agent_settings_path", + "assert_exit", + "assert_stderr_contains", + "assert_stdout_contains", + "make_shim", + "read_agent_settings", + "run_case_sequence", + "run_cases", + "with_clean_path", +] diff --git a/e2e/_lib/assertions.py b/e2e/_lib/assertions.py new file mode 100644 index 000000000..79118190c --- /dev/null +++ b/e2e/_lib/assertions.py @@ -0,0 +1,43 @@ +"""Shared assertion helpers for e2e cases. + +Assertions raise ``AssertionError`` with a descriptive message. The harness +catches them and attributes the failure to the owning ``Case``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .paths import Agent, Scope, agent_settings_path + + +def assert_exit(actual: int, expected: int, *, context: str = "") -> None: + if actual != expected: + suffix = f" ({context})" if context else "" + raise AssertionError(f"Expected exit code {expected}, got {actual}{suffix}") + + +def assert_stdout_contains(stdout: str, needle: str) -> None: + if needle not in stdout: + raise AssertionError(f"stdout missing {needle!r}:\n---\n{stdout}\n---") + + +def assert_stderr_contains(stderr: str, needle: str) -> None: + if needle not in stderr: + raise AssertionError(f"stderr missing {needle!r}:\n---\n{stderr}\n---") + + +def read_agent_settings( + agent: Agent, *, scope: Scope, home: Path, project: Path +) -> dict[str, Any] | str: + """Read an agent's settings file, returning dict for JSON and str for TOML/other.""" + + path = agent_settings_path(agent, scope=scope, home=home, project=project) + if not path.exists(): + raise AssertionError(f"Expected settings file at {path}, not found") + text = path.read_text(encoding="utf-8") + if path.suffix == ".json": + return json.loads(text) + return text diff --git a/e2e/_lib/harness.py b/e2e/_lib/harness.py new file mode 100644 index 000000000..4252c4884 --- /dev/null +++ b/e2e/_lib/harness.py @@ -0,0 +1,336 @@ +"""Declarative test-case harness for Docker e2e runners. + +Each command gets its own ``run.py`` file that builds a list of ``Case`` +objects and calls ``run_cases(cases)``. The harness handles: + +* creating a scratch HOME and project directory per case +* dropping the requested shims into a dedicated shim dir +* building a clean PATH that only exposes the shim dir + minimal system dirs +* invoking the ``headroom`` subprocess with the case's argv +* running the case's assertions against stdout / stderr / exit code / files +* reporting pass/fail per case and a final summary + +``run_cases`` returns a non-zero exit code if any case fails, so Docker +containers driving it can fail fast. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +from .assertions import assert_exit, assert_stderr_contains, assert_stdout_contains +from .path_env import with_clean_path +from .shims import ShimBehavior, make_shim + +CaseCallback = Callable[["CaseContext"], None] + + +@dataclass +class CaseContext: + """Runtime context passed to assertion callbacks.""" + + name: str + home: Path + project: Path + shim_dir: Path + shim_log: Path + stdout: str + stderr: str + exit_code: int + + +@dataclass +class Case: + """Declarative specification of a single e2e test case. + + Attributes: + name: Human-readable identifier, printed on success/failure. + argv: Arguments passed to ``headroom`` (e.g. ``["init", "-g", "claude"]``). + shims: Mapping of shim name -> behavior to drop into the shim dir. + env_extra: Extra env vars layered on top of the clean env. + expected_exit: Required exit code (default 0). + expected_stdout_contains: Substrings that must appear on stdout. + expected_stderr_contains: Substrings that must appear on stderr. + expected_files: Paths (relative to home or project) that must exist. + Use ``{home}/...`` or ``{project}/...`` placeholders. + extra_assertions: Optional list of callbacks invoked after exit-code / + stdout / stderr / file checks pass. Receives a + ``CaseContext``. Use for JSON-content assertions, + shim-log inspection, etc. + """ + + name: str + argv: list[str] + shims: dict[str, ShimBehavior] = field(default_factory=dict) + env_extra: dict[str, str] = field(default_factory=dict) + expected_exit: int = 0 + expected_stdout_contains: list[str] = field(default_factory=list) + expected_stderr_contains: list[str] = field(default_factory=list) + expected_files: list[str] = field(default_factory=list) + extra_assertions: list[CaseCallback] = field(default_factory=list) + + +def _log(message: str) -> None: + print(f"[e2e] {message}", flush=True) + + +def _resolve_placeholder(spec: str, *, home: Path, project: Path) -> Path: + return Path(spec.format(home=str(home), project=str(project))) + + +def _resolve_headroom_bin(name: str) -> str: + """Return the absolute path to the headroom binary before PATH is scrubbed. + + ``with_clean_path`` intentionally narrows PATH so agent shims dominate; + that would also hide the real ``headroom`` binary (typically at + ``/opt/*venv/bin/headroom`` or similar). Resolving up-front lets the + subprocess launch even after PATH is cleaned. + """ + + if os.sep in name or (os.altsep and os.altsep in name): + return name + import shutil + + resolved = shutil.which(name) + if resolved: + return resolved + # Fall back to the bare name; subprocess will raise a clear + # FileNotFoundError that the case output surfaces. + return name + + +def _run_single(case: Case, headroom_bin: str = "headroom") -> bool: + """Execute one case. Return True on pass, False on fail.""" + + with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{case.name}-") as temp_raw: + temp_root = Path(temp_raw) + home = temp_root / "home" + project = temp_root / "project" + shim_dir = temp_root / "bin" + shim_log = temp_root / "shim-log.jsonl" + home.mkdir(parents=True) + project.mkdir(parents=True) + + for shim_name, behavior in case.shims.items(): + make_shim(shim_name, shim_dir, behavior=behavior) + + # Resolve headroom to its absolute path BEFORE mutating PATH so the + # shim dir can dominate PATH without losing the headroom binary. + resolved_bin = _resolve_headroom_bin(headroom_bin) + + with with_clean_path([shim_dir]) as env: + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log) + env.update(case.env_extra) + + proc = subprocess.run( + [resolved_bin, *case.argv], + env=env, + cwd=str(project), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + ) + + ctx = CaseContext( + name=case.name, + home=home, + project=project, + shim_dir=shim_dir, + shim_log=shim_log, + stdout=proc.stdout, + stderr=proc.stderr, + exit_code=proc.returncode, + ) + + try: + assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}") + for needle in case.expected_stdout_contains: + assert_stdout_contains(proc.stdout, needle) + for needle in case.expected_stderr_contains: + assert_stderr_contains(proc.stderr, needle) + for spec in case.expected_files: + path = _resolve_placeholder(spec, home=home, project=project) + if not path.exists(): + raise AssertionError(f"Expected file {path} not found") + for callback in case.extra_assertions: + callback(ctx) + except AssertionError as exc: + _log(f"FAIL {case.name}: {exc}") + if proc.stdout.strip(): + _log(f" stdout: {proc.stdout.rstrip()}") + if proc.stderr.strip(): + _log(f" stderr: {proc.stderr.rstrip()}") + return False + + _log(f"PASS {case.name}") + return True + + +def _run_in_scratch( + case: Case, + *, + home: Path, + project: Path, + shim_dir: Path, + shim_log: Path, + headroom_bin: str, +) -> bool: + """Execute one case inside a pre-existing scratch layout. + + Shims are *added* to ``shim_dir`` (existing shims from prior sequence + steps are preserved). This enables sequence cases to build up shim state. + """ + + for shim_name, behavior in case.shims.items(): + make_shim(shim_name, shim_dir, behavior=behavior) + + resolved_bin = _resolve_headroom_bin(headroom_bin) + + with with_clean_path([shim_dir]) as env: + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log) + env.update(case.env_extra) + + proc = subprocess.run( + [resolved_bin, *case.argv], + env=env, + cwd=str(project), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + ) + + ctx = CaseContext( + name=case.name, + home=home, + project=project, + shim_dir=shim_dir, + shim_log=shim_log, + stdout=proc.stdout, + stderr=proc.stderr, + exit_code=proc.returncode, + ) + + try: + assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}") + for needle in case.expected_stdout_contains: + assert_stdout_contains(proc.stdout, needle) + for needle in case.expected_stderr_contains: + assert_stderr_contains(proc.stderr, needle) + for spec in case.expected_files: + path = _resolve_placeholder(spec, home=home, project=project) + if not path.exists(): + raise AssertionError(f"Expected file {path} not found") + for callback in case.extra_assertions: + callback(ctx) + except AssertionError as exc: + _log(f"FAIL {case.name}: {exc}") + if proc.stdout.strip(): + _log(f" stdout: {proc.stdout.rstrip()}") + if proc.stderr.strip(): + _log(f" stderr: {proc.stderr.rstrip()}") + return False + + _log(f"PASS {case.name}") + return True + + +def run_cases( + cases: list[Case], + *, + headroom_bin: str = "headroom", + fail_fast: bool = False, +) -> int: + """Run each case in its own scratch dir. Return exit code (0 = all pass).""" + + passed = 0 + failed = 0 + for case in cases: + ok = _run_single(case, headroom_bin=headroom_bin) + if ok: + passed += 1 + else: + failed += 1 + if fail_fast: + break + + _log(f"Summary: {passed} passed, {failed} failed, {len(cases)} total") + return 0 if failed == 0 else 1 + + +def run_case_sequence( + cases: list[Case], + *, + headroom_bin: str = "headroom", + label: str = "sequence", + fail_fast: bool = True, +) -> int: + """Run cases sequentially inside a single shared scratch dir. + + Useful when later cases must observe state left by earlier ones (e.g. + ``headroom init`` accumulating targets in a shared manifest across + successive calls). + """ + + passed = 0 + failed = 0 + with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{label}-") as temp_raw: + temp_root = Path(temp_raw) + home = temp_root / "home" + project = temp_root / "project" + shim_dir = temp_root / "bin" + shim_log = temp_root / "shim-log.jsonl" + home.mkdir(parents=True) + project.mkdir(parents=True) + + for case in cases: + ok = _run_in_scratch( + case, + home=home, + project=project, + shim_dir=shim_dir, + shim_log=shim_log, + headroom_bin=headroom_bin, + ) + if ok: + passed += 1 + else: + failed += 1 + if fail_fast: + break + + _log(f"Summary ({label}): {passed} passed, {failed} failed, {len(cases)} total") + return 0 if failed == 0 else 1 + + +# Allow callers to adopt a different exit strategy (e.g. raising) easily. +def main_from_cases(cases: list[Case]) -> None: + """Convenience entry point for ``run.py`` scripts.""" + + code = run_cases(cases) + sys.exit(code) + + +__all__ = [ + "Case", + "CaseContext", + "main_from_cases", + "run_case_sequence", + "run_cases", +] + +# Silence unused-import lint for re-exports used by callers. +_ = os diff --git a/e2e/_lib/make_shim.ps1 b/e2e/_lib/make_shim.ps1 new file mode 100644 index 000000000..4ed586af7 --- /dev/null +++ b/e2e/_lib/make_shim.ps1 @@ -0,0 +1,24 @@ +# Create a noop executable shim at $Dir\$Name.cmd for use in PATH during +# native (non-Docker) e2e tests on Windows. Mirrors e2e/_lib/shims.py +# make_shim(noop). +# +# Usage: make_shim.ps1 -Name -Dir + +param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Dir +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path $Dir)) { + New-Item -ItemType Directory -Path $Dir -Force | Out-Null +} + +$path = Join-Path $Dir "$Name.cmd" +$content = @" +@echo off +exit /b 0 +"@ +Set-Content -Path $path -Value $content -Encoding ASCII -NoNewline +Write-Output $path diff --git a/e2e/_lib/make_shim.sh b/e2e/_lib/make_shim.sh new file mode 100644 index 000000000..6820f3c58 --- /dev/null +++ b/e2e/_lib/make_shim.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Create a noop executable shim at $2/$1 suitable for use in PATH during +# native (non-Docker) e2e tests. Mirrors e2e/_lib/shims.py make_shim(noop). +# +# Usage: make_shim.sh +# +# Exit codes: +# 0 on success +# 2 on usage error + +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +name="$1" +dir="$2" + +mkdir -p "$dir" +path="$dir/$name" +cat >"$path" <<'EOS' +#!/usr/bin/env bash +exit 0 +EOS +chmod +x "$path" +echo "$path" diff --git a/e2e/_lib/path_env.py b/e2e/_lib/path_env.py new file mode 100644 index 000000000..e9baa1aea --- /dev/null +++ b/e2e/_lib/path_env.py @@ -0,0 +1,54 @@ +"""PATH environment helpers for e2e test isolation.""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + + +def _minimal_path_dirs() -> list[str]: + """Directories always needed so Python / basic shell utilities work.""" + + if os.name == "nt": + system_root = os.environ.get("SystemRoot", r"C:\Windows") + return [ + rf"{system_root}\System32", + system_root, + rf"{system_root}\System32\Wbem", + rf"{system_root}\System32\WindowsPowerShell\v1.0", + ] + # POSIX: keep enough for bash, python3, mkdir, chmod, etc. + return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] + + +@contextmanager +def with_clean_path(extra_dirs: list[Path] | None = None) -> Iterator[dict[str, str]]: + """Set PATH to a minimal known-good value plus ``extra_dirs``. + + Yields the (already-mutated) environment dict so callers can pass it + directly to ``subprocess.run(env=...)``. On exit, the previous PATH is + restored. + """ + + extras = [str(Path(p)) for p in (extra_dirs or [])] + new_path = os.pathsep.join(extras + _minimal_path_dirs()) + env = os.environ.copy() + previous = env.get("PATH") + env["PATH"] = new_path + # Also mutate the real environment so ``shutil.which`` inside this process + # sees the clean PATH. Restore on exit. + real_previous = os.environ.get("PATH") + os.environ["PATH"] = new_path + try: + yield env + finally: + if real_previous is None: + os.environ.pop("PATH", None) + else: + os.environ["PATH"] = real_previous + if previous is None: + env.pop("PATH", None) + else: + env["PATH"] = previous diff --git a/e2e/_lib/paths.py b/e2e/_lib/paths.py new file mode 100644 index 000000000..ccfc8a751 --- /dev/null +++ b/e2e/_lib/paths.py @@ -0,0 +1,47 @@ +"""Per-agent settings-file locators for e2e assertions. + +These paths mirror the logic in ``headroom.cli.init`` so e2e tests can +verify that the right file was written without importing private init +helpers. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +Agent = Literal["claude", "codex", "copilot", "openclaw"] +Scope = Literal["user", "local"] + + +def agent_settings_path(agent: Agent, *, scope: Scope, home: Path, project: Path) -> Path: + """Return the file that ``headroom init`` should have written for ``agent``. + + ``home`` is the test's simulated HOME directory and ``project`` is the cwd + used when invoking ``headroom init``. For global (``-g``) invocations only + ``home`` matters; for local invocations only ``project`` matters. + """ + + home = Path(home) + project = Path(project) + + if agent == "claude": + if scope == "user": + return home / ".claude" / "settings.json" + return project / ".claude" / "settings.local.json" + + if agent == "codex": + if scope == "user": + return home / ".codex" / "config.toml" + return project / ".codex" / "config.toml" + + if agent == "copilot": + # Copilot init requires -g; no local scope. + return home / ".copilot" / "config.json" + + if agent == "openclaw": + # OpenClaw init is delegated to `headroom wrap openclaw`; it writes + # the openclaw json under $HOME. + return home / ".openclaw" / "openclaw.json" + + raise ValueError(f"Unknown agent: {agent!r}") diff --git a/e2e/_lib/shims.py b/e2e/_lib/shims.py new file mode 100644 index 000000000..26a50be29 --- /dev/null +++ b/e2e/_lib/shims.py @@ -0,0 +1,96 @@ +"""Cross-platform agent binary shim factory for e2e tests. + +A "shim" is a tiny executable with a given name (e.g. `claude`, `codex`) that +the harness drops into a temporary directory and prepends to PATH. It lets +tests drive `headroom init` without requiring a real Claude/Codex install. + +Three behaviors are supported: + +* ``noop`` — exits 0 with no output. Default. +* ``fail`` — exits 1 with a short stderr message. +* ``record-args`` — appends a JSON record of (tool, argv, cwd) to the file at + ``$HEADROOM_E2E_SHIM_LOG``, then exits 0. Useful for + asserting that `init claude` invoked + `claude plugin install` with the right arguments. +""" + +from __future__ import annotations + +import os +import stat +import sys +from pathlib import Path +from typing import Literal + +ShimBehavior = Literal["noop", "fail", "record-args"] + +_NOOP_SH = """#!/usr/bin/env bash +exit 0 +""" + +_FAIL_SH = """#!/usr/bin/env bash +echo "${0##*/}: simulated failure" >&2 +exit 1 +""" + +_RECORD_SH = """#!/usr/bin/env bash +tool="${0##*/}" +log="${HEADROOM_E2E_SHIM_LOG:-/dev/null}" +mkdir -p "$(dirname "$log")" 2>/dev/null || true +python3 - "$tool" "$log" "$@" <<'PY' +import json, os, sys +tool, log, *argv = sys.argv[1:] +record = {"tool": tool, "argv": argv, "cwd": os.getcwd()} +if log != "/dev/null": + with open(log, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record) + "\\n") +print(f"{tool} shim executed") +PY +exit 0 +""" + +# Windows equivalents. Use `.cmd` so `shutil.which` and PATHEXT find them. +_NOOP_CMD = "@echo off\r\nexit /b 0\r\n" + +_FAIL_CMD = "@echo off\r\necho %~n0: simulated failure 1>&2\r\nexit /b 1\r\n" + +_RECORD_CMD = ( + "@echo off\r\n" + "setlocal\r\n" + 'if "%HEADROOM_E2E_SHIM_LOG%"=="" set HEADROOM_E2E_SHIM_LOG=NUL\r\n' + "python -c \"import json,os,sys; name=r'%~n0'; log=os.environ['HEADROOM_E2E_SHIM_LOG']; " + "rec={'tool':name,'argv':sys.argv[1:],'cwd':os.getcwd()};\r\n" + "open(log,'a',encoding='utf-8').write(json.dumps(rec)+chr(10)) if log!='NUL' else None;\r\n" + "print(f'{name} shim executed')\" %*\r\n" + "exit /b 0\r\n" +) + + +def _is_windows() -> bool: + return os.name == "nt" or sys.platform == "win32" + + +def make_shim(name: str, dir: Path, behavior: ShimBehavior = "noop") -> Path: + """Create an executable shim named ``name`` inside ``dir``. + + Returns the absolute path to the created shim. On POSIX this is a ``.sh`` + file made executable and named without extension (so ``shutil.which(name)`` + finds it). On Windows this is a ``.cmd`` file — again, ``shutil.which`` + honours ``PATHEXT`` and will find it. + """ + + dir = Path(dir) + dir.mkdir(parents=True, exist_ok=True) + + if _is_windows(): + body = {"noop": _NOOP_CMD, "fail": _FAIL_CMD, "record-args": _RECORD_CMD}[behavior] + path = dir / f"{name}.cmd" + path.write_text(body, encoding="utf-8") + return path + + body = {"noop": _NOOP_SH, "fail": _FAIL_SH, "record-args": _RECORD_SH}[behavior] + path = dir / name + path.write_text(body, encoding="utf-8") + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path diff --git a/e2e/init/Dockerfile b/e2e/init/Dockerfile index 5836d3ef4..e14acd4d5 100644 --- a/e2e/init/Dockerfile +++ b/e2e/init/Dockerfile @@ -24,10 +24,15 @@ COPY headroom ./headroom COPY .claude-plugin ./.claude-plugin COPY .github/plugin ./.github/plugin COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks +# The init e2e harness imports from e2e._lib; both directories must be +# present and each must contain an __init__.py so Python sees them as +# packages rooted at /workspace. +COPY e2e/__init__.py ./e2e/__init__.py +COPY e2e/_lib ./e2e/_lib COPY e2e/init ./e2e/init RUN python -m venv /opt/headroom-venv && \ - /opt/headroom-venv/bin/python -m pip install --upgrade pip && \ + /opt/headroom-venv/bin/python -m pip install --upgrade "pip<25" && \ /opt/headroom-venv/bin/python -m pip install -e ".[proxy]" CMD ["python", "e2e/init/run.py"] diff --git a/e2e/init/run.py b/e2e/init/run.py index 4a1b14b34..d7931704c 100644 --- a/e2e/init/run.py +++ b/e2e/init/run.py @@ -1,236 +1,336 @@ -from __future__ import annotations - -import json -import os -import stat -import subprocess -import sys -import tempfile -import textwrap -from pathlib import Path - -from headroom.cli import init as init_cli - -REPO_ROOT = Path("/workspace") -HEADROOM = "headroom" - - -def log(message: str) -> None: - print(f"[init-e2e] {message}", flush=True) - - -def run( - cmd: list[str], - *, - env: dict[str, str], - cwd: Path, - timeout: int = 180, -) -> subprocess.CompletedProcess[str]: - log(f"$ {' '.join(cmd)}") - result = subprocess.run( - cmd, - env=env, - cwd=str(cwd), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - ) - if result.stdout.strip(): - print(result.stdout.rstrip(), flush=True) - if result.stderr.strip(): - print(result.stderr.rstrip(), file=sys.stderr, flush=True) - if result.returncode != 0: - raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}") - return result - - -def assert_true(condition: bool, message: str) -> None: - if not condition: - raise AssertionError(message) - - -def write_executable(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - -def read_jsonl(path: Path) -> list[dict[str, object]]: - if not path.exists(): - return [] - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] - - -def create_agent_shims(shim_dir: Path, log_path: Path) -> None: - shim = textwrap.dedent( - """\ - #!/usr/bin/env python3 - from __future__ import annotations - - import json - import os - import sys - from pathlib import Path - - record = { - "tool": Path(sys.argv[0]).name, - "argv": sys.argv[1:], - "cwd": os.getcwd(), - } - log_path = Path(os.environ["HEADROOM_INIT_E2E_LOG"]) - log_path.parent.mkdir(parents=True, exist_ok=True) - with log_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(record) + "\\n") - print(f"{record['tool']} shim executed") - raise SystemExit(0) - """ - ) - shim_dir.mkdir(parents=True, exist_ok=True) - for name in ("claude", "copilot"): - write_executable(shim_dir / name, shim) - - -def expect_hook_command(command: str, profile: str) -> None: - assert_true("init hook ensure" in command, f"missing init hook ensure in: {command}") - assert_true(f"--profile {profile}" in command, f"missing profile {profile} in: {command}") - - -def read_manifest(home_dir: Path, profile: str) -> dict[str, object]: - path = home_dir / ".headroom" / "deploy" / profile / "manifest.json" - assert_true(path.exists(), f"Expected manifest at {path}") - return json.loads(path.read_text(encoding="utf-8")) - - -def verify_claude_local(home_dir: Path, project_dir: Path, shim_log: Path) -> None: - settings = json.loads( - (project_dir / ".claude" / "settings.local.json").read_text(encoding="utf-8") - ) - assert_true( - settings["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011", - "Claude local settings should point at the requested proxy port", - ) - session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] - pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - profile = init_cli._local_profile(project_dir) - expect_hook_command(session_start, profile) - expect_hook_command(pre_tool, profile) - - manifest = read_manifest(home_dir, profile) - assert_true("claude" in manifest["targets"], "Claude init should register the claude target") - - claude_calls = [record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "claude"] - assert_true( - claude_calls - == [ - ["plugin", "marketplace", "add", str(REPO_ROOT)], - ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"], - ], - f"Unexpected Claude install commands: {claude_calls}", - ) - - -def verify_copilot_global(home_dir: Path, shim_log: Path) -> None: - config = json.loads((home_dir / ".copilot" / "config.json").read_text(encoding="utf-8")) - assert_true( - "SessionStart" in config["hooks"], "Copilot config should include SessionStart hooks" - ) - assert_true("PreToolUse" in config["hooks"], "Copilot config should include PreToolUse hooks") - session_start = config["hooks"]["SessionStart"][0]["command"] - expect_hook_command(session_start, "init-user") - - for shell_file in (home_dir / ".bashrc", home_dir / ".zshrc", home_dir / ".profile"): - content = shell_file.read_text(encoding="utf-8") - assert_true( - 'export COPILOT_PROVIDER_TYPE="openai"' in content, - f"{shell_file.name} should contain the Copilot provider type", - ) - assert_true( - 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"' in content, - f"{shell_file.name} should contain the Copilot provider base URL", - ) - assert_true( - 'export COPILOT_PROVIDER_WIRE_API="completions"' in content, - f"{shell_file.name} should contain the Copilot wire API", - ) - - copilot_calls = [ - record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "copilot" - ] - assert_true( - copilot_calls - == [ - ["plugin", "marketplace", "add", str(REPO_ROOT)], - ["plugin", "install", "headroom@headroom-marketplace"], - ], - f"Unexpected Copilot install commands: {copilot_calls}", - ) - - -def verify_codex_local(home_dir: Path, project_dir: Path) -> None: - config_path = project_dir / ".codex" / "config.toml" - hooks_path = project_dir / ".codex" / "hooks.json" - config = config_path.read_text(encoding="utf-8") - hooks = json.loads(hooks_path.read_text(encoding="utf-8")) - profile = init_cli._local_profile(project_dir) - - assert_true( - 'base_url = "http://127.0.0.1:9012/v1"' in config, - "Codex config should point at the requested proxy port", - ) - assert_true( - config.count("[features]") == 1, "Codex config should keep a single [features] table" - ) - assert_true("codex_hooks = true" in config, "Codex config should enable codex_hooks") - command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] - expect_hook_command(command, profile) - - manifest = read_manifest(home_dir, profile) - targets = manifest["targets"] - assert_true(set(targets) == {"claude", "codex"}, f"Unexpected merged targets: {targets}") - - -def main() -> None: - with tempfile.TemporaryDirectory(prefix="headroom-init-e2e-") as temp_root_raw: - temp_root = Path(temp_root_raw) - home_dir = temp_root / "home" - project_dir = temp_root / "project" - shim_dir = temp_root / "bin" - shim_log = temp_root / "shim-log.jsonl" - home_dir.mkdir(parents=True) - project_dir.mkdir(parents=True) - create_agent_shims(shim_dir, shim_log) - - env = os.environ.copy() - env["HOME"] = str(home_dir) - env["USERPROFILE"] = str(home_dir) - env["HEADROOM_INIT_E2E_LOG"] = str(shim_log) - env["PATH"] = f"{shim_dir}:{env['PATH']}" - - run([HEADROOM, "init", "--port", "9011", "claude"], env=env, cwd=project_dir) - verify_claude_local(home_dir, project_dir, shim_log) - - run( - [ - HEADROOM, - "init", - "-g", - "--port", - "9005", - "--backend", - "openai", - "copilot", - ], - env=env, - cwd=project_dir, - ) - verify_copilot_global(home_dir, shim_log) - - run([HEADROOM, "init", "--port", "9012", "codex"], env=env, cwd=project_dir) - verify_codex_local(home_dir, project_dir) - - log("Init e2e completed successfully") - - -if __name__ == "__main__": - main() +"""Docker e2e cases for ``headroom init``. + +Every case is described declaratively with :class:`Case` from +``e2e/_lib/harness.py``. Three groups run in order: + +1. **existing sequence**: preserves the original scenario that exercised + ``headroom init claude`` (local) -> ``init -g copilot`` (global) -> + ``init codex`` (local), sharing scratch state so manifest-merge is + exercised end-to-end. +2. **bare ``init -g`` detection**: verifies the UX regression from #245 + stays fixed — both "no shims found" (friendly error, exit 1) and + "all shims found" (exit 0, all four agents configured). +3. **per-subcommand**: one case per ``init -g `` with only that + agent's shim on PATH, so the explicit path is covered independently. + +The fourth group covers ``--verbose`` output going to stderr. + +Run directly: ``python e2e/init/run.py`` (inside the Docker image built +from ``e2e/init/Dockerfile``). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Add repo root to sys.path so the harness import works whether the file is +# invoked as ``python e2e/init/run.py`` or ``python -m e2e.init.run``. +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from e2e._lib import ( # noqa: E402 + Case, + CaseContext, + run_case_sequence, + run_cases, +) +from headroom.cli import init as init_cli # noqa: E402 + +# ----- helpers reused across cases -------------------------------------------- + +# Docker image builds the workspace at /workspace; the marketplace source +# falls back to that repo checkout when a local marketplace manifest is found. +REPO_ROOT_IN_CONTAINER = Path("/workspace") + + +def _read_jsonl(path: Path) -> list[dict[str, object]]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + + +def _expect_hook_command(command: str, profile: str) -> None: + if "init hook ensure" not in command: + raise AssertionError(f"missing 'init hook ensure' in: {command}") + if f"--profile {profile}" not in command: + raise AssertionError(f"missing '--profile {profile}' in: {command}") + + +def _read_manifest(home: Path, profile: str) -> dict[str, object]: + path = home / ".headroom" / "deploy" / profile / "manifest.json" + if not path.exists(): + raise AssertionError(f"Expected manifest at {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +# ----- existing-flow assertions (ported verbatim from the old run.py) --------- + + +def _verify_claude_local(ctx: CaseContext) -> None: + settings_path = ctx.project / ".claude" / "settings.local.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:9011": + raise AssertionError( + f"Claude local settings should point at port 9011, got " + f"{settings['env']['ANTHROPIC_BASE_URL']!r}" + ) + session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + profile = init_cli._local_profile(ctx.project) + _expect_hook_command(session_start, profile) + _expect_hook_command(pre_tool, profile) + + manifest = _read_manifest(ctx.home, profile) + if "claude" not in manifest["targets"]: + raise AssertionError( + f"Claude init should register the claude target, got {manifest['targets']}" + ) + + claude_calls = [ + record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "claude" + ] + expected = [ + ["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)], + ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"], + ] + if claude_calls != expected: + raise AssertionError(f"Unexpected Claude install commands: {claude_calls}") + + +def _verify_copilot_global(ctx: CaseContext) -> None: + config = json.loads((ctx.home / ".copilot" / "config.json").read_text(encoding="utf-8")) + if "SessionStart" not in config["hooks"]: + raise AssertionError("Copilot config missing SessionStart hooks") + if "PreToolUse" not in config["hooks"]: + raise AssertionError("Copilot config missing PreToolUse hooks") + session_start = config["hooks"]["SessionStart"][0]["command"] + _expect_hook_command(session_start, "init-user") + + for shell_file in (ctx.home / ".bashrc", ctx.home / ".zshrc", ctx.home / ".profile"): + content = shell_file.read_text(encoding="utf-8") + for literal in ( + 'export COPILOT_PROVIDER_TYPE="openai"', + 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"', + 'export COPILOT_PROVIDER_WIRE_API="completions"', + ): + if literal not in content: + raise AssertionError(f"{shell_file.name} missing {literal!r}") + + copilot_calls = [ + record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "copilot" + ] + expected = [ + ["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)], + ["plugin", "install", "headroom@headroom-marketplace"], + ] + if copilot_calls != expected: + raise AssertionError(f"Unexpected Copilot install commands: {copilot_calls}") + + +def _verify_codex_local(ctx: CaseContext) -> None: + config = (ctx.project / ".codex" / "config.toml").read_text(encoding="utf-8") + hooks = json.loads((ctx.project / ".codex" / "hooks.json").read_text(encoding="utf-8")) + profile = init_cli._local_profile(ctx.project) + + if 'base_url = "http://127.0.0.1:9012/v1"' not in config: + raise AssertionError("Codex config should point at the requested proxy port (9012)") + if config.count("[features]") != 1: + raise AssertionError("Codex config should keep a single [features] table") + if "codex_hooks = true" not in config: + raise AssertionError("Codex config should enable codex_hooks") + command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + _expect_hook_command(command, profile) + + manifest = _read_manifest(ctx.home, profile) + targets = manifest["targets"] + if set(targets) != {"claude", "codex"}: + raise AssertionError(f"Unexpected merged targets: {targets}") + + +# ----- new cases (issue #245 fix + per-subcommand coverage) ------------------- + + +def _verify_claude_global(ctx: CaseContext) -> None: + settings = json.loads((ctx.home / ".claude" / "settings.json").read_text(encoding="utf-8")) + if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787": + raise AssertionError( + f"Claude user settings should default to port 8787, got " + f"{settings['env']['ANTHROPIC_BASE_URL']!r}" + ) + _expect_hook_command( + settings["hooks"]["SessionStart"][0]["hooks"][0]["command"], + init_cli._GLOBAL_PROFILE, + ) + + +def _verify_codex_global(ctx: CaseContext) -> None: + config = (ctx.home / ".codex" / "config.toml").read_text(encoding="utf-8") + if 'base_url = "http://127.0.0.1:8787/v1"' not in config: + raise AssertionError("Codex user config should point at port 8787 by default") + if "codex_hooks = true" not in config: + raise AssertionError("Codex user config should enable codex_hooks") + hooks = json.loads((ctx.home / ".codex" / "hooks.json").read_text(encoding="utf-8")) + _expect_hook_command( + hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"], + init_cli._GLOBAL_PROFILE, + ) + + +# ----- case tables ------------------------------------------------------------ + + +def existing_sequence_cases() -> list[Case]: + """Preserves the original run.py scenario in one shared scratch.""" + + return [ + Case( + name="seq_claude_local", + argv=["init", "--port", "9011", "claude"], + shims={"claude": "record-args", "copilot": "record-args"}, + expected_exit=0, + expected_stdout_contains=["Configured Claude Code (local scope)"], + extra_assertions=[_verify_claude_local], + ), + Case( + name="seq_copilot_global", + argv=["init", "-g", "--port", "9005", "--backend", "openai", "copilot"], + shims={}, # reuse shims from prior case in the sequence + expected_exit=0, + expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"], + extra_assertions=[_verify_copilot_global], + ), + Case( + name="seq_codex_local", + argv=["init", "--port", "9012", "codex"], + shims={}, + expected_exit=0, + expected_stdout_contains=["Configured Codex (local scope)"], + extra_assertions=[_verify_codex_local], + ), + ] + + +def bare_init_g_cases() -> list[Case]: + """Bare ``headroom init -g`` — the direct coverage of issue #245.""" + + return [ + Case( + name="bare_init_g_no_shims", + argv=["init", "-g"], + shims={}, # nothing on PATH + expected_exit=1, + expected_stderr_contains=[ + # every target should be listed so the user knows what was tried + "claude", + "codex", + "copilot", + "openclaw", + # concrete escape hatch — exactly what the user should type next + "headroom init -g claude", + # confirm -g itself is still the right flag + "-g", + ], + ), + Case( + name="bare_init_g_with_all_shims", + argv=["init", "-g"], + shims={ + "claude": "record-args", + "codex": "noop", + "copilot": "record-args", + "openclaw": "noop", + }, + expected_exit=0, + expected_stdout_contains=[ + "Configured Claude Code (user scope)", + "Configured GitHub Copilot CLI (user scope)", + "Configured Codex (user scope)", + ], + ), + ] + + +def per_subcommand_cases() -> list[Case]: + """One case per ``headroom init -g `` with only that agent's shim.""" + + return [ + Case( + name="init_g_claude_explicit", + argv=["init", "-g", "claude"], + shims={"claude": "record-args"}, + expected_exit=0, + expected_stdout_contains=["Configured Claude Code (user scope)"], + expected_files=["{home}/.claude/settings.json"], + extra_assertions=[_verify_claude_global], + ), + Case( + name="init_g_codex_explicit", + argv=["init", "-g", "codex"], + shims={"codex": "noop"}, + expected_exit=0, + expected_stdout_contains=["Configured Codex (user scope)"], + expected_files=[ + "{home}/.codex/config.toml", + "{home}/.codex/hooks.json", + ], + extra_assertions=[_verify_codex_global], + ), + Case( + name="init_g_copilot_explicit", + argv=["init", "-g", "copilot"], + shims={"copilot": "record-args"}, + expected_exit=0, + expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"], + expected_files=["{home}/.copilot/config.json"], + ), + # openclaw delegates to `headroom wrap openclaw` which has its own + # (more expensive) init path and isn't stubbable with a simple shim. + # We assert it fails fast with a clear error when not installed, and + # rely on the `bare_init_g_with_all_shims` case (which uses a noop + # openclaw shim + claude/codex/copilot shims) to cover the success + # path alongside the other agents. + Case( + name="init_g_openclaw_missing", + argv=["init", "-g", "openclaw"], + shims={}, + expected_exit=1, + ), + ] + + +def verbose_cases() -> list[Case]: + """Verbose flag smoke tests — debug lines should appear on stderr.""" + + return [ + Case( + name="init_verbose_no_shims", + argv=["init", "-v", "-g"], + shims={}, + expected_exit=1, + expected_stderr_contains=[ + # A few structural markers from the verbose log. Kept loose so + # minor wording tweaks don't break the test. + "detect_init_targets", + "claude", + "global_scope=True", + ], + ), + ] + + +def main() -> None: + rc = 0 + rc |= run_case_sequence(existing_sequence_cases(), label="existing-sequence") + rc |= run_cases(bare_init_g_cases()) + rc |= run_cases(per_subcommand_cases()) + rc |= run_cases(verbose_cases()) + if rc != 0: + raise SystemExit(rc) + print("[e2e] init e2e completed successfully", flush=True) + + +if __name__ == "__main__": + main() diff --git a/headroom/cli/init.py b/headroom/cli/init.py index 09767fc65..6c57581e9 100644 --- a/headroom/cli/init.py +++ b/headroom/cli/init.py @@ -1,679 +1,817 @@ -"""Durable agent initialization commands.""" - -from __future__ import annotations - -import json -import os -import shlex -import shutil -import subprocess -from hashlib import sha1 -from pathlib import Path -from typing import Any - -import click - -from headroom.install.models import ConfigScope, InstallPreset, RuntimeKind, SupervisorKind -from headroom.install.paths import claude_settings_path, codex_config_path, validate_profile_name -from headroom.install.planner import build_manifest -from headroom.install.providers import _apply_unix_env_scope, _apply_windows_env_scope -from headroom.install.runtime import ( - resolve_headroom_command, - start_detached_agent, - start_persistent_docker, - stop_runtime, - wait_ready, -) -from headroom.install.state import load_manifest, save_manifest -from headroom.install.supervisors import start_supervisor - -from .main import main - -_GLOBAL_PROFILE = "init-user" -_CLAUDE_HOOK_MARKER = "headroom-init-claude" -_COPILOT_HOOK_MARKER = "headroom-init-copilot" -_CODEX_HOOK_MARKER = "headroom-init-codex" -_CODEX_PROVIDER_MARKER_START = "# --- Headroom init provider ---" -_CODEX_PROVIDER_MARKER_END = "# --- end Headroom init provider ---" -_CODEX_FEATURE_MARKER_START = "# --- Headroom init features ---" -_CODEX_FEATURE_MARKER_END = "# --- end Headroom init features ---" -_SUPPORTED_TARGETS = ("claude", "copilot", "codex", "openclaw") -_LOCAL_TARGETS = {"claude", "codex"} -_GLOBAL_TARGETS = {"claude", "copilot", "codex", "openclaw"} - - -def _command_string(parts: list[str]) -> str: - if os.name == "nt": - return subprocess.list2cmdline(parts) - return shlex.join(parts) - - -def _hook_command(*parts: str) -> str: - return _command_string([*resolve_headroom_command(), "init", "hook", "ensure", *parts]) - - -def _powershell_matcher() -> str: - return "Bash|PowerShell" if os.name == "nt" else "Bash" - - -def _local_profile(cwd: Path | None = None) -> str: - root = (cwd or Path.cwd()).resolve() - slug = "".join(ch if ch.isalnum() or ch in "-._" else "-" for ch in root.name.lower()).strip( - "-" - ) - digest = sha1(str(root).encode("utf-8")).hexdigest()[:8] - return validate_profile_name(f"init-{slug or 'repo'}-{digest}") - - -def _runtime_profile(global_scope: bool, cwd: Path | None = None) -> str: - return _GLOBAL_PROFILE if global_scope else _local_profile(cwd) - - -def _copilot_config_path() -> Path: - return Path.home() / ".copilot" / "config.json" - - -def _codex_hooks_path(global_scope: bool) -> Path: - return (Path.home() if global_scope else Path.cwd()) / ".codex" / "hooks.json" - - -def _claude_scope_path(global_scope: bool) -> Path: - if global_scope: - return claude_settings_path() - return Path.cwd() / ".claude" / "settings.local.json" - - -def _codex_scope_path(global_scope: bool) -> Path: - if global_scope: - return codex_config_path() - return Path.cwd() / ".codex" / "config.toml" - - -def _json_file(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - content = path.read_text(encoding="utf-8").strip() - if not content: - return {} - payload = json.loads(content) - return payload if isinstance(payload, dict) else {} - - -def _write_json(path: Path, payload: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None: - payload = _json_file(path) - env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {} - env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}" - payload["env"] = env_map - - hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} - command = _hook_command("--profile", profile) - for event, matcher in ( - ("SessionStart", "startup|resume"), - ("PreToolUse", _powershell_matcher()), - ): - entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else [] - retained: list[dict[str, Any]] = [] - for entry in entries: - if not isinstance(entry, dict): - retained.append(entry) - continue - hook_items = entry.get("hooks") - if not isinstance(hook_items, list): - retained.append(entry) - continue - has_headroom = any( - isinstance(item, dict) - and item.get("command") - and _CLAUDE_HOOK_MARKER in str(item.get("command")) - for item in hook_items - ) - if not has_headroom: - retained.append(entry) - retained.append( - { - "matcher": matcher, - "hooks": [ - { - "type": "command", - "command": f"{command} --marker {_CLAUDE_HOOK_MARKER}", - "timeout": 15, - } - ], - } - ) - hooks[event] = retained - payload["hooks"] = hooks - _write_json(path, payload) - - -def _ensure_copilot_hooks(path: Path, profile: str) -> None: - payload = _json_file(path) - hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} - command = f"{_hook_command('--profile', profile)} --marker {_COPILOT_HOOK_MARKER}" - for event in ("SessionStart", "PreToolUse"): - entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else [] - retained = [ - entry - for entry in entries - if not ( - isinstance(entry, dict) and _COPILOT_HOOK_MARKER in str(entry.get("command", "")) - ) - ] - retained.append({"type": "command", "command": command, "cwd": ".", "timeout": 15}) - hooks[event] = retained - payload["hooks"] = hooks - _write_json(path, payload) - - -def _replace_marker_block(content: str, marker_start: str, marker_end: str, block: str) -> str: - if marker_start in content and marker_end in content: - start = content.index(marker_start) - end = content.index(marker_end) + len(marker_end) - content = content[:start].rstrip() + "\n\n" + content[end:].lstrip() - return (content.rstrip() + "\n\n" + block.strip() + "\n").lstrip() - - -def _ensure_codex_provider(path: Path, port: int) -> None: - block = ( - f"{_CODEX_PROVIDER_MARKER_START}\n" - 'model_provider = "headroom"\n\n' - "[model_providers.headroom]\n" - 'name = "Headroom init proxy"\n' - f'base_url = "http://127.0.0.1:{port}/v1"\n' - 'env_key = "OPENAI_API_KEY"\n' - "requires_openai_auth = true\n" - "supports_websockets = true\n" - f"{_CODEX_PROVIDER_MARKER_END}" - ) - content = path.read_text(encoding="utf-8") if path.exists() else "" - content = _replace_marker_block( - content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, block - ) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -def _ensure_codex_feature_flag(path: Path) -> None: - content = path.read_text(encoding="utf-8") if path.exists() else "" - if _CODEX_FEATURE_MARKER_START in content and _CODEX_FEATURE_MARKER_END in content: - block = f"{_CODEX_FEATURE_MARKER_START}\ncodex_hooks = true\n{_CODEX_FEATURE_MARKER_END}" - content = _replace_marker_block( - content, - _CODEX_FEATURE_MARKER_START, - _CODEX_FEATURE_MARKER_END, - block, - ) - elif "[features]" in content: - lines = content.splitlines() - inserted = False - for index, line in enumerate(lines): - if line.strip() != "[features]": - continue - section_end = index + 1 - while section_end < len(lines) and not ( - lines[section_end].startswith("[") and lines[section_end].endswith("]") - ): - if "codex_hooks" in lines[section_end]: - inserted = True - break - section_end += 1 - if not inserted: - lines[index + 1 : index + 1] = [ - _CODEX_FEATURE_MARKER_START, - "codex_hooks = true", - _CODEX_FEATURE_MARKER_END, - ] - inserted = True - break - content = "\n".join(lines).rstrip() + "\n" - if not inserted: - content = ( - content.rstrip() - + "\n\n[features]\n" - + _CODEX_FEATURE_MARKER_START - + "\n" - + "codex_hooks = true\n" - + _CODEX_FEATURE_MARKER_END - + "\n" - ) - else: - content = ( - content.rstrip() - + "\n\n[features]\n" - + _CODEX_FEATURE_MARKER_START - + "\n" - + "codex_hooks = true\n" - + _CODEX_FEATURE_MARKER_END - + "\n" - ).lstrip() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -def _ensure_codex_hooks(path: Path, profile: str) -> None: - command = f"{_hook_command('--profile', profile)} --marker {_CODEX_HOOK_MARKER}" - payload = { - "hooks": { - "SessionStart": [ - { - "matcher": "startup|resume", - "hooks": [{"type": "command", "command": command, "timeout": 15}], - } - ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{"type": "command", "command": command, "timeout": 15}], - } - ], - } - } - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def _manifest_changed( - existing: Any, - *, - port: int, - backend: str, - anyllm_provider: str | None, - region: str | None, - memory: bool, -) -> bool: - return any( - [ - getattr(existing, "port", port) != port, - getattr(existing, "backend", backend) != backend, - getattr(existing, "anyllm_provider", anyllm_provider) != anyllm_provider, - getattr(existing, "region", region) != region, - getattr(existing, "memory_enabled", memory) != memory, - ] - ) - - -def _ensure_runtime_manifest( - *, - global_scope: bool, - targets: list[str], - port: int, - backend: str, - anyllm_provider: str | None, - region: str | None, - memory: bool, -) -> str: - profile = _runtime_profile(global_scope) - existing = load_manifest(profile) - merged_targets = sorted(set(existing.targets if existing else []).union(targets)) - manifest = build_manifest( - profile=profile, - preset=InstallPreset.PERSISTENT_TASK.value, - runtime_kind=RuntimeKind.PYTHON.value, - scope=ConfigScope.USER.value, - provider_mode="manual", - targets=merged_targets, - port=port, - backend=backend, - anyllm_provider=anyllm_provider, - region=region, - proxy_mode="token", - memory_enabled=memory, - telemetry_enabled=True, - image="ghcr.io/chopratejas/headroom:latest", - ) - manifest.supervisor_kind = SupervisorKind.NONE.value - manifest.artifacts = [] - manifest.mutations = existing.mutations if existing else [] - if existing is not None and _manifest_changed( - existing, - port=port, - backend=backend, - anyllm_provider=anyllm_provider, - region=region, - memory=memory, - ): - try: - stop_runtime(existing) - except Exception: - pass - save_manifest(manifest) - return profile - - -def _env_manifest(values: dict[str, str]) -> Any: - return build_manifest( - profile="init-env", - preset=InstallPreset.PERSISTENT_TASK.value, - runtime_kind=RuntimeKind.PYTHON.value, - scope=ConfigScope.USER.value, - provider_mode="manual", - targets=["copilot"], - port=8787, - backend="anthropic", - anyllm_provider=None, - region=None, - proxy_mode="token", - memory_enabled=False, - telemetry_enabled=True, - image="ghcr.io/chopratejas/headroom:latest", - ) - - -def _apply_user_env(values: dict[str, str]) -> None: - manifest = _env_manifest(values) - manifest.base_env = {} - manifest.tool_envs = {"copilot": values} - if os.name == "nt": - _apply_windows_env_scope(manifest) - else: - _apply_unix_env_scope(manifest) - - -def _resolve_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 _marketplace_source() -> str: - override = os.environ.get("HEADROOM_MARKETPLACE_SOURCE") - if override: - return override - repo_root = Path(__file__).resolve().parents[2] - if (repo_root / ".claude-plugin" / "marketplace.json").exists(): - return str(repo_root) - return "chopratejas/headroom" - - -def _run_checked(command: list[str], *, action: str) -> None: - result = subprocess.run( - command, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if result.returncode == 0: - return - detail = "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part) - if "already" in detail.lower() or "exists" in detail.lower(): - return - raise click.ClickException(f"{action} failed: {detail or result.returncode}") - - -def _install_claude_marketplace(scope: str) -> None: - claude_bin = shutil.which("claude") - if not claude_bin: - raise click.ClickException("'claude' not found in PATH. Install Claude Code first.") - source = _marketplace_source() - _run_checked( - [claude_bin, "plugin", "marketplace", "add", source], action="claude marketplace add" - ) - _run_checked( - [claude_bin, "plugin", "install", "headroom@headroom-marketplace", "--scope", scope], - action="claude plugin install", - ) - - -def _install_copilot_marketplace() -> None: - copilot_bin = shutil.which("copilot") - if not copilot_bin: - raise click.ClickException("'copilot' not found in PATH. Install GitHub Copilot CLI first.") - source = _marketplace_source() - _run_checked( - [copilot_bin, "plugin", "marketplace", "add", source], - action="copilot marketplace add", - ) - _run_checked( - [copilot_bin, "plugin", "install", "headroom@headroom-marketplace"], - action="copilot plugin install", - ) - - -def _ensure_profile_running(profile: str) -> None: - manifest = load_manifest(profile) - if manifest is None: - return - if wait_ready(manifest, timeout_seconds=1): - return - try: - if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: - start_persistent_docker(manifest) - elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: - start_supervisor(manifest) - else: - start_detached_agent(manifest.profile) - wait_ready(manifest, timeout_seconds=45) - except Exception: - return - - -def detect_init_targets(global_scope: bool) -> list[str]: - allowed = _GLOBAL_TARGETS if global_scope else _LOCAL_TARGETS - detected: list[str] = [] - for target in _SUPPORTED_TARGETS: - if target not in allowed: - continue - if shutil.which(target): - detected.append(target) - return detected - - -def _init_claude(*, global_scope: bool, profile: str, port: int) -> None: - _ensure_claude_hooks(_claude_scope_path(global_scope), profile, port) - _install_claude_marketplace("user" if global_scope else "local") - click.echo(f"Configured Claude Code ({'user' if global_scope else 'local'} scope).") - click.echo("Restart Claude Code to activate Headroom hooks and provider routing.") - - -def _init_copilot(*, global_scope: bool, profile: str, port: int, backend: str) -> None: - if not global_scope: - raise click.ClickException( - "Copilot durable init currently requires -g (current-user scope)." - ) - _ensure_copilot_hooks(_copilot_config_path(), profile) - _apply_user_env(_resolve_copilot_env(port, backend)) - _install_copilot_marketplace() - click.echo("Configured GitHub Copilot CLI (user scope).") - click.echo("Restart Copilot CLI to activate Headroom hooks and provider routing.") - - -def _init_codex(*, global_scope: bool, profile: str, port: int) -> None: - config_path = _codex_scope_path(global_scope) - _ensure_codex_provider(config_path, port) - _ensure_codex_feature_flag(config_path) - _ensure_codex_hooks(_codex_hooks_path(global_scope), profile) - click.echo(f"Configured Codex ({'user' if global_scope else 'local'} scope).") - if os.name == "nt": - click.echo( - "Codex hooks are currently disabled upstream on Windows; provider routing was still installed." - ) - click.echo("Restart Codex to activate Headroom configuration.") - - -def _init_openclaw(*, global_scope: bool, port: int) -> None: - if not global_scope: - raise click.ClickException( - "OpenClaw durable init currently requires -g (current-user scope)." - ) - command = [*resolve_headroom_command(), "wrap", "openclaw", "--proxy-port", str(port)] - result = subprocess.run(command) - if result.returncode != 0: - raise SystemExit(result.returncode) - - -def _run_init_targets( - *, - targets: list[str], - global_scope: bool, - port: int, - backend: str, - anyllm_provider: str | None, - region: str | None, - memory: bool, -) -> None: - runtime_targets = [target for target in targets if target != "openclaw"] - profile = _ensure_runtime_manifest( - global_scope=global_scope, - targets=runtime_targets, - port=port, - backend=backend, - anyllm_provider=anyllm_provider, - region=region, - memory=memory, - ) - for target in targets: - if target == "claude": - _init_claude(global_scope=global_scope, profile=profile, port=port) - elif target == "copilot": - _init_copilot(global_scope=global_scope, profile=profile, port=port, backend=backend) - elif target == "codex": - _init_codex(global_scope=global_scope, profile=profile, port=port) - elif target == "openclaw": - _init_openclaw(global_scope=global_scope, port=port) - - -@main.group(invoke_without_command=True) -@click.option("-g", "--global", "global_scope", is_flag=True, help="Install for the current user.") -@click.option("--port", default=8787, type=int, show_default=True, help="Headroom proxy port.") -@click.option("--backend", default="anthropic", show_default=True, help="Proxy backend.") -@click.option("--anyllm-provider", default=None, help="Provider for any-llm backends.") -@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.") -@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.") -@click.pass_context -def init( - ctx: click.Context, - global_scope: bool, - port: int, - backend: str, - anyllm_provider: str | None, - region: str | None, - memory: bool, -) -> None: - """Install durable Headroom integrations for supported agents.""" - if ctx.invoked_subcommand is not None: - ctx.obj = { - "global_scope": global_scope, - "port": port, - "backend": backend, - "anyllm_provider": anyllm_provider, - "region": region, - "memory": memory, - } - return - - targets = detect_init_targets(global_scope) - if not targets: - scope_label = "user" if global_scope else "local" - raise click.ClickException( - f"No supported {scope_label} init targets were auto-detected. Specify one explicitly." - ) - _run_init_targets( - targets=targets, - global_scope=global_scope, - port=port, - backend=backend, - anyllm_provider=anyllm_provider, - region=region, - memory=memory, - ) - - -def _ctx_value(ctx: click.Context, key: str) -> Any: - return (ctx.obj or {}).get(key) - - -@init.command("claude") -@click.pass_context -def init_claude(ctx: click.Context) -> None: - """Install Claude Code durable hooks and provider routing.""" - _run_init_targets( - targets=["claude"], - global_scope=bool(_ctx_value(ctx, "global_scope")), - port=int(_ctx_value(ctx, "port") or 8787), - backend=str(_ctx_value(ctx, "backend") or "anthropic"), - anyllm_provider=_ctx_value(ctx, "anyllm_provider"), - region=_ctx_value(ctx, "region"), - memory=bool(_ctx_value(ctx, "memory")), - ) - - -@init.command("copilot") -@click.pass_context -def init_copilot(ctx: click.Context) -> None: - """Install GitHub Copilot CLI durable hooks and provider routing.""" - _run_init_targets( - targets=["copilot"], - global_scope=bool(_ctx_value(ctx, "global_scope")), - port=int(_ctx_value(ctx, "port") or 8787), - backend=str(_ctx_value(ctx, "backend") or "anthropic"), - anyllm_provider=_ctx_value(ctx, "anyllm_provider"), - region=_ctx_value(ctx, "region"), - memory=bool(_ctx_value(ctx, "memory")), - ) - - -@init.command("codex") -@click.pass_context -def init_codex(ctx: click.Context) -> None: - """Install Codex durable hooks and provider routing.""" - _run_init_targets( - targets=["codex"], - global_scope=bool(_ctx_value(ctx, "global_scope")), - port=int(_ctx_value(ctx, "port") or 8787), - backend=str(_ctx_value(ctx, "backend") or "anthropic"), - anyllm_provider=_ctx_value(ctx, "anyllm_provider"), - region=_ctx_value(ctx, "region"), - memory=bool(_ctx_value(ctx, "memory")), - ) - - -@init.command("openclaw") -@click.pass_context -def init_openclaw(ctx: click.Context) -> None: - """Install the durable OpenClaw Headroom plugin.""" - _run_init_targets( - targets=["openclaw"], - global_scope=bool(_ctx_value(ctx, "global_scope")), - port=int(_ctx_value(ctx, "port") or 8787), - backend=str(_ctx_value(ctx, "backend") or "anthropic"), - anyllm_provider=_ctx_value(ctx, "anyllm_provider"), - region=_ctx_value(ctx, "region"), - memory=bool(_ctx_value(ctx, "memory")), - ) - - -@init.group("hook", hidden=True) -def init_hook() -> None: - """Internal hook helpers.""" - - -@init_hook.command("ensure") -@click.option("--profile", default=None, help="Explicit deployment profile to ensure.") -@click.option("--marker", default=None, hidden=True) -def init_hook_ensure(profile: str | None, marker: str | None) -> None: - """Best-effort ensure used by installed agent hooks.""" - del marker - profiles: list[str] = [] - if profile: - profiles.append(profile) - else: - local_profile = _local_profile() - if load_manifest(local_profile) is not None: - profiles.append(local_profile) - elif load_manifest(_GLOBAL_PROFILE) is not None: - profiles.append(_GLOBAL_PROFILE) - for name in profiles: - _ensure_profile_running(name) +"""Durable agent initialization commands.""" + +from __future__ import annotations + +import json +import logging +import os +import shlex +import shutil +import subprocess +import sys +from hashlib import sha1 +from pathlib import Path +from typing import Any + +import click + +from headroom.install.models import ConfigScope, InstallPreset, RuntimeKind, SupervisorKind +from headroom.install.paths import claude_settings_path, codex_config_path, validate_profile_name +from headroom.install.planner import build_manifest +from headroom.install.providers import _apply_unix_env_scope, _apply_windows_env_scope +from headroom.install.runtime import ( + resolve_headroom_command, + start_detached_agent, + start_persistent_docker, + stop_runtime, + wait_ready, +) +from headroom.install.state import load_manifest, save_manifest +from headroom.install.supervisors import start_supervisor + +from .main import main + +logger = logging.getLogger(__name__) + +_VERBOSE_HANDLER_ATTR = "_headroom_init_verbose_handler" + +_GLOBAL_PROFILE = "init-user" +_CLAUDE_HOOK_MARKER = "headroom-init-claude" +_COPILOT_HOOK_MARKER = "headroom-init-copilot" +_CODEX_HOOK_MARKER = "headroom-init-codex" +_CODEX_PROVIDER_MARKER_START = "# --- Headroom init provider ---" +_CODEX_PROVIDER_MARKER_END = "# --- end Headroom init provider ---" +_CODEX_FEATURE_MARKER_START = "# --- Headroom init features ---" +_CODEX_FEATURE_MARKER_END = "# --- end Headroom init features ---" +_SUPPORTED_TARGETS = ("claude", "copilot", "codex", "openclaw") +_LOCAL_TARGETS = {"claude", "codex"} +_GLOBAL_TARGETS = {"claude", "copilot", "codex", "openclaw"} + + +def _command_string(parts: list[str]) -> str: + if os.name == "nt": + return subprocess.list2cmdline(parts) + return shlex.join(parts) + + +def _hook_command(*parts: str) -> str: + return _command_string([*resolve_headroom_command(), "init", "hook", "ensure", *parts]) + + +def _powershell_matcher() -> str: + return "Bash|PowerShell" if os.name == "nt" else "Bash" + + +def _enable_verbose_logging() -> None: + """Attach a stderr handler to the init logger at DEBUG level. + + Idempotent: calling this multiple times in one process (e.g. when nested + subcommands are invoked) leaves exactly one handler attached. Does NOT + mutate stdout; all verbose output goes to stderr so ``headroom init`` + can still be composed in pipes that consume stdout. + """ + + if getattr(logger, _VERBOSE_HANDLER_ATTR, None) is not None: + return + handler = logging.StreamHandler(stream=sys.stderr) + handler.setFormatter(logging.Formatter("[headroom init] %(message)s")) + handler.setLevel(logging.DEBUG) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + logger.propagate = False + setattr(logger, _VERBOSE_HANDLER_ATTR, handler) + + +def _local_profile(cwd: Path | None = None) -> str: + root = (cwd or Path.cwd()).resolve() + slug = "".join(ch if ch.isalnum() or ch in "-._" else "-" for ch in root.name.lower()).strip( + "-" + ) + digest = sha1(str(root).encode("utf-8")).hexdigest()[:8] + return validate_profile_name(f"init-{slug or 'repo'}-{digest}") + + +def _runtime_profile(global_scope: bool, cwd: Path | None = None) -> str: + return _GLOBAL_PROFILE if global_scope else _local_profile(cwd) + + +def _copilot_config_path() -> Path: + return Path.home() / ".copilot" / "config.json" + + +def _codex_hooks_path(global_scope: bool) -> Path: + return (Path.home() if global_scope else Path.cwd()) / ".codex" / "hooks.json" + + +def _claude_scope_path(global_scope: bool) -> Path: + if global_scope: + return claude_settings_path() + return Path.cwd() / ".claude" / "settings.local.json" + + +def _codex_scope_path(global_scope: bool) -> Path: + if global_scope: + return codex_config_path() + return Path.cwd() / ".codex" / "config.toml" + + +def _json_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + content = path.read_text(encoding="utf-8").strip() + if not content: + return {} + payload = json.loads(content) + return payload if isinstance(payload, dict) else {} + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + logger.debug("write json: %s (keys=%s)", path, sorted(payload.keys())) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None: + logger.debug("ensure claude hooks: %s (profile=%s, port=%s)", path, profile, port) + payload = _json_file(path) + env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {} + env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}" + payload["env"] = env_map + + hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} + command = _hook_command("--profile", profile) + for event, matcher in ( + ("SessionStart", "startup|resume"), + ("PreToolUse", _powershell_matcher()), + ): + entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else [] + retained: list[dict[str, Any]] = [] + for entry in entries: + if not isinstance(entry, dict): + retained.append(entry) + continue + hook_items = entry.get("hooks") + if not isinstance(hook_items, list): + retained.append(entry) + continue + has_headroom = any( + isinstance(item, dict) + and item.get("command") + and _CLAUDE_HOOK_MARKER in str(item.get("command")) + for item in hook_items + ) + if not has_headroom: + retained.append(entry) + retained.append( + { + "matcher": matcher, + "hooks": [ + { + "type": "command", + "command": f"{command} --marker {_CLAUDE_HOOK_MARKER}", + "timeout": 15, + } + ], + } + ) + hooks[event] = retained + payload["hooks"] = hooks + _write_json(path, payload) + + +def _ensure_copilot_hooks(path: Path, profile: str) -> None: + logger.debug("ensure copilot hooks: %s (profile=%s)", path, profile) + payload = _json_file(path) + hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} + command = f"{_hook_command('--profile', profile)} --marker {_COPILOT_HOOK_MARKER}" + for event in ("SessionStart", "PreToolUse"): + entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else [] + retained = [ + entry + for entry in entries + if not ( + isinstance(entry, dict) and _COPILOT_HOOK_MARKER in str(entry.get("command", "")) + ) + ] + retained.append({"type": "command", "command": command, "cwd": ".", "timeout": 15}) + hooks[event] = retained + payload["hooks"] = hooks + _write_json(path, payload) + + +def _replace_marker_block(content: str, marker_start: str, marker_end: str, block: str) -> str: + if marker_start in content and marker_end in content: + start = content.index(marker_start) + end = content.index(marker_end) + len(marker_end) + content = content[:start].rstrip() + "\n\n" + content[end:].lstrip() + return (content.rstrip() + "\n\n" + block.strip() + "\n").lstrip() + + +def _ensure_codex_provider(path: Path, port: int) -> None: + logger.debug("ensure codex provider block: %s (port=%s)", path, port) + block = ( + f"{_CODEX_PROVIDER_MARKER_START}\n" + 'model_provider = "headroom"\n\n' + "[model_providers.headroom]\n" + 'name = "Headroom init proxy"\n' + f'base_url = "http://127.0.0.1:{port}/v1"\n' + 'env_key = "OPENAI_API_KEY"\n' + "requires_openai_auth = true\n" + "supports_websockets = true\n" + f"{_CODEX_PROVIDER_MARKER_END}" + ) + content = path.read_text(encoding="utf-8") if path.exists() else "" + content = _replace_marker_block( + content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, block + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _ensure_codex_feature_flag(path: Path) -> None: + content = path.read_text(encoding="utf-8") if path.exists() else "" + if _CODEX_FEATURE_MARKER_START in content and _CODEX_FEATURE_MARKER_END in content: + block = f"{_CODEX_FEATURE_MARKER_START}\ncodex_hooks = true\n{_CODEX_FEATURE_MARKER_END}" + content = _replace_marker_block( + content, + _CODEX_FEATURE_MARKER_START, + _CODEX_FEATURE_MARKER_END, + block, + ) + elif "[features]" in content: + lines = content.splitlines() + inserted = False + for index, line in enumerate(lines): + if line.strip() != "[features]": + continue + section_end = index + 1 + while section_end < len(lines) and not ( + lines[section_end].startswith("[") and lines[section_end].endswith("]") + ): + if "codex_hooks" in lines[section_end]: + inserted = True + break + section_end += 1 + if not inserted: + lines[index + 1 : index + 1] = [ + _CODEX_FEATURE_MARKER_START, + "codex_hooks = true", + _CODEX_FEATURE_MARKER_END, + ] + inserted = True + break + content = "\n".join(lines).rstrip() + "\n" + if not inserted: + content = ( + content.rstrip() + + "\n\n[features]\n" + + _CODEX_FEATURE_MARKER_START + + "\n" + + "codex_hooks = true\n" + + _CODEX_FEATURE_MARKER_END + + "\n" + ) + else: + content = ( + content.rstrip() + + "\n\n[features]\n" + + _CODEX_FEATURE_MARKER_START + + "\n" + + "codex_hooks = true\n" + + _CODEX_FEATURE_MARKER_END + + "\n" + ).lstrip() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _ensure_codex_hooks(path: Path, profile: str) -> None: + logger.debug("ensure codex hooks: %s (profile=%s)", path, profile) + command = f"{_hook_command('--profile', profile)} --marker {_CODEX_HOOK_MARKER}" + payload = { + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [{"type": "command", "command": command, "timeout": 15}], + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": command, "timeout": 15}], + } + ], + } + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _manifest_changed( + existing: Any, + *, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> bool: + return any( + [ + getattr(existing, "port", port) != port, + getattr(existing, "backend", backend) != backend, + getattr(existing, "anyllm_provider", anyllm_provider) != anyllm_provider, + getattr(existing, "region", region) != region, + getattr(existing, "memory_enabled", memory) != memory, + ] + ) + + +def _ensure_runtime_manifest( + *, + global_scope: bool, + targets: list[str], + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> str: + profile = _runtime_profile(global_scope) + existing = load_manifest(profile) + merged_targets = sorted(set(existing.targets if existing else []).union(targets)) + manifest = build_manifest( + profile=profile, + preset=InstallPreset.PERSISTENT_TASK.value, + runtime_kind=RuntimeKind.PYTHON.value, + scope=ConfigScope.USER.value, + provider_mode="manual", + targets=merged_targets, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + proxy_mode="token", + memory_enabled=memory, + telemetry_enabled=True, + image="ghcr.io/chopratejas/headroom:latest", + ) + manifest.supervisor_kind = SupervisorKind.NONE.value + manifest.artifacts = [] + manifest.mutations = existing.mutations if existing else [] + if existing is not None and _manifest_changed( + existing, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + memory=memory, + ): + try: + stop_runtime(existing) + except Exception: + pass + save_manifest(manifest) + return profile + + +def _env_manifest(values: dict[str, str]) -> Any: + return build_manifest( + profile="init-env", + preset=InstallPreset.PERSISTENT_TASK.value, + runtime_kind=RuntimeKind.PYTHON.value, + scope=ConfigScope.USER.value, + provider_mode="manual", + targets=["copilot"], + port=8787, + backend="anthropic", + anyllm_provider=None, + region=None, + proxy_mode="token", + memory_enabled=False, + telemetry_enabled=True, + image="ghcr.io/chopratejas/headroom:latest", + ) + + +def _apply_user_env(values: dict[str, str]) -> None: + manifest = _env_manifest(values) + manifest.base_env = {} + manifest.tool_envs = {"copilot": values} + scope = "windows" if os.name == "nt" else "unix" + logger.debug("apply user env scope=%s keys=%s", scope, sorted(values.keys())) + if os.name == "nt": + _apply_windows_env_scope(manifest) + else: + _apply_unix_env_scope(manifest) + + +def _resolve_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 _marketplace_source() -> str: + override = os.environ.get("HEADROOM_MARKETPLACE_SOURCE") + if override: + return override + repo_root = Path(__file__).resolve().parents[2] + if (repo_root / ".claude-plugin" / "marketplace.json").exists(): + return str(repo_root) + return "chopratejas/headroom" + + +def _run_checked(command: list[str], *, action: str) -> None: + logger.debug("subprocess [%s]: %s", action, _command_string(command)) + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + logger.debug( + "subprocess [%s] exit=%s stdout=%r stderr=%r", + action, + result.returncode, + result.stdout[:200], + result.stderr[:200], + ) + if result.returncode == 0: + return + detail = "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part) + if "already" in detail.lower() or "exists" in detail.lower(): + logger.debug( + "subprocess [%s] non-zero exit tolerated ('already'/'exists' detected)", action + ) + return + raise click.ClickException(f"{action} failed: {detail or result.returncode}") + + +def _install_claude_marketplace(scope: str) -> None: + claude_bin = shutil.which("claude") + if not claude_bin: + raise click.ClickException("'claude' not found in PATH. Install Claude Code first.") + source = _marketplace_source() + _run_checked( + [claude_bin, "plugin", "marketplace", "add", source], action="claude marketplace add" + ) + _run_checked( + [claude_bin, "plugin", "install", "headroom@headroom-marketplace", "--scope", scope], + action="claude plugin install", + ) + + +def _install_copilot_marketplace() -> None: + copilot_bin = shutil.which("copilot") + if not copilot_bin: + raise click.ClickException("'copilot' not found in PATH. Install GitHub Copilot CLI first.") + source = _marketplace_source() + _run_checked( + [copilot_bin, "plugin", "marketplace", "add", source], + action="copilot marketplace add", + ) + _run_checked( + [copilot_bin, "plugin", "install", "headroom@headroom-marketplace"], + action="copilot plugin install", + ) + + +def _ensure_profile_running(profile: str) -> None: + manifest = load_manifest(profile) + if manifest is None: + return + if wait_ready(manifest, timeout_seconds=1): + return + try: + if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + start_persistent_docker(manifest) + elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: + start_supervisor(manifest) + else: + start_detached_agent(manifest.profile) + wait_ready(manifest, timeout_seconds=45) + except Exception: + return + + +def _probe_init_targets(global_scope: bool) -> list[tuple[str, str | None]]: + """Return ``[(target, which_result)]`` for every in-scope supported target. + + ``which_result`` is the absolute path reported by :func:`shutil.which`, or + ``None`` when the binary is not on PATH. Callers use the list both to + build an auto-detected target list and to produce a diagnostic error + message when nothing was found. + """ + + allowed = _GLOBAL_TARGETS if global_scope else _LOCAL_TARGETS + logger.debug( + "detect_init_targets: global_scope=%s allowed=%s", + global_scope, + sorted(allowed), + ) + probes: list[tuple[str, str | None]] = [] + for target in _SUPPORTED_TARGETS: + if target not in allowed: + continue + path = shutil.which(target) + logger.debug("detect_init_targets: shutil.which(%r) -> %s", target, path or "None") + probes.append((target, path)) + return probes + + +def detect_init_targets(global_scope: bool) -> list[str]: + """Return agent names in scope for which a binary was found on PATH.""" + + return [name for name, path in _probe_init_targets(global_scope) if path] + + +def _format_empty_detection_error(global_scope: bool) -> str: + """Build the error message shown when no in-scope targets were detected. + + Lists every agent that was probed, what ``shutil.which`` returned, and + confirms how to proceed explicitly — including that the ``-g`` / ``--global`` + flag the user tried is still valid. + """ + + probes = _probe_init_targets(global_scope) + scope_flag = "-g" if global_scope else "" + scope_label = "user" if global_scope else "local" + + lines: list[str] = [ + f"No supported {scope_label}-scope agents were found on PATH.", + "", + "Headroom probed the following agents via shutil.which():", + ] + for name, path in probes: + status = f"found at {path}" if path else "not found" + lines.append(f" - {name}: {status}") + + lines.extend( + [ + "", + f"The {scope_flag or '--local (no flag)'} option is still supported; " + "headroom init just needs to know which agent to target.", + "Install the agent you want first, then re-run with an explicit target:", + "", + ] + ) + for name, _path in probes: + flag = " -g" if global_scope else "" + lines.append(f" headroom init{flag} {name}") + + lines.extend( + [ + "", + "Tip: run `headroom init --help` to see all options.", + ] + ) + return "\n".join(lines) + + +def _init_claude(*, global_scope: bool, profile: str, port: int) -> None: + _ensure_claude_hooks(_claude_scope_path(global_scope), profile, port) + _install_claude_marketplace("user" if global_scope else "local") + click.echo(f"Configured Claude Code ({'user' if global_scope else 'local'} scope).") + click.echo("Restart Claude Code to activate Headroom hooks and provider routing.") + + +def _init_copilot(*, global_scope: bool, profile: str, port: int, backend: str) -> None: + if not global_scope: + raise click.ClickException( + "Copilot durable init currently requires -g (current-user scope)." + ) + _ensure_copilot_hooks(_copilot_config_path(), profile) + _apply_user_env(_resolve_copilot_env(port, backend)) + _install_copilot_marketplace() + click.echo("Configured GitHub Copilot CLI (user scope).") + click.echo("Restart Copilot CLI to activate Headroom hooks and provider routing.") + + +def _init_codex(*, global_scope: bool, profile: str, port: int) -> None: + config_path = _codex_scope_path(global_scope) + _ensure_codex_provider(config_path, port) + _ensure_codex_feature_flag(config_path) + _ensure_codex_hooks(_codex_hooks_path(global_scope), profile) + click.echo(f"Configured Codex ({'user' if global_scope else 'local'} scope).") + if os.name == "nt": + click.echo( + "Codex hooks are currently disabled upstream on Windows; provider routing was still installed." + ) + click.echo("Restart Codex to activate Headroom configuration.") + + +def _init_openclaw(*, global_scope: bool, port: int) -> None: + if not global_scope: + raise click.ClickException( + "OpenClaw durable init currently requires -g (current-user scope)." + ) + command = [*resolve_headroom_command(), "wrap", "openclaw", "--proxy-port", str(port)] + result = subprocess.run(command) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def _run_init_targets( + *, + targets: list[str], + global_scope: bool, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> None: + logger.debug( + "run_init_targets: targets=%s global_scope=%s port=%s backend=%s memory=%s", + targets, + global_scope, + port, + backend, + memory, + ) + runtime_targets = [target for target in targets if target != "openclaw"] + profile = _ensure_runtime_manifest( + global_scope=global_scope, + targets=runtime_targets, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + memory=memory, + ) + logger.debug("run_init_targets: using profile=%s", profile) + for target in targets: + logger.debug("run_init_targets: dispatching -> %s", target) + if target == "claude": + _init_claude(global_scope=global_scope, profile=profile, port=port) + elif target == "copilot": + _init_copilot(global_scope=global_scope, profile=profile, port=port, backend=backend) + elif target == "codex": + _init_codex(global_scope=global_scope, profile=profile, port=port) + elif target == "openclaw": + _init_openclaw(global_scope=global_scope, port=port) + + +@main.group(invoke_without_command=True) +@click.option("-g", "--global", "global_scope", is_flag=True, help="Install for the current user.") +@click.option("--port", default=8787, type=int, show_default=True, help="Headroom proxy port.") +@click.option("--backend", default="anthropic", show_default=True, help="Proxy backend.") +@click.option("--anyllm-provider", default=None, help="Provider for any-llm backends.") +@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.") +@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.") +@click.option( + "-v", + "--verbose", + is_flag=True, + help="Emit debug-level diagnostics to stderr (flag values, shutil.which results, " + "file paths touched, subprocess invocations and exit codes).", +) +@click.pass_context +def init( + ctx: click.Context, + global_scope: bool, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, + verbose: bool, +) -> None: + """Install durable Headroom integrations for supported agents.""" + if verbose: + _enable_verbose_logging() + logger.debug( + "init: global_scope=%s port=%s backend=%s anyllm_provider=%s region=%s memory=%s " + "invoked_subcommand=%s", + global_scope, + port, + backend, + anyllm_provider, + region, + memory, + ctx.invoked_subcommand, + ) + if ctx.invoked_subcommand is not None: + ctx.obj = { + "global_scope": global_scope, + "port": port, + "backend": backend, + "anyllm_provider": anyllm_provider, + "region": region, + "memory": memory, + "verbose": verbose, + } + return + + targets = detect_init_targets(global_scope) + if not targets: + logger.debug("init: detect_init_targets returned empty; exiting with guided error") + raise click.ClickException(_format_empty_detection_error(global_scope)) + logger.debug("init: detected targets=%s", targets) + _run_init_targets( + targets=targets, + global_scope=global_scope, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + memory=memory, + ) + + +def _ctx_value(ctx: click.Context, key: str) -> Any: + return (ctx.obj or {}).get(key) + + +@init.command("claude") +@click.pass_context +def init_claude(ctx: click.Context) -> None: + """Install Claude Code durable hooks and provider routing.""" + _run_init_targets( + targets=["claude"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.command("copilot") +@click.pass_context +def init_copilot(ctx: click.Context) -> None: + """Install GitHub Copilot CLI durable hooks and provider routing.""" + _run_init_targets( + targets=["copilot"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.command("codex") +@click.pass_context +def init_codex(ctx: click.Context) -> None: + """Install Codex durable hooks and provider routing.""" + _run_init_targets( + targets=["codex"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.command("openclaw") +@click.pass_context +def init_openclaw(ctx: click.Context) -> None: + """Install the durable OpenClaw Headroom plugin.""" + _run_init_targets( + targets=["openclaw"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.group("hook", hidden=True) +def init_hook() -> None: + """Internal hook helpers.""" + + +@init_hook.command("ensure") +@click.option("--profile", default=None, help="Explicit deployment profile to ensure.") +@click.option("--marker", default=None, hidden=True) +def init_hook_ensure(profile: str | None, marker: str | None) -> None: + """Best-effort ensure used by installed agent hooks.""" + del marker + profiles: list[str] = [] + if profile: + profiles.append(profile) + else: + local_profile = _local_profile() + if load_manifest(local_profile) is not None: + profiles.append(local_profile) + elif load_manifest(_GLOBAL_PROFILE) is not None: + profiles.append(_GLOBAL_PROFILE) + for name in profiles: + _ensure_profile_running(name) diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json index d3a6b2425..26c3a1d0f 100644 --- a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.11.2", + "version": "0.12.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json index 5d8ae816f..ff98868f5 100644 --- a/plugins/headroom-agent-hooks/.github/plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.11.2", + "version": "0.12.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/tests/test_cli/test_init_cli.py b/tests/test_cli/test_init_cli.py index ea19d45d7..151e4057f 100644 --- a/tests/test_cli/test_init_cli.py +++ b/tests/test_cli/test_init_cli.py @@ -1,829 +1,925 @@ -from __future__ import annotations - -import importlib -import json -import sys -import types -from pathlib import Path -from types import SimpleNamespace - -import click -import pytest -from click.testing import CliRunner - - -def _load_init_module(monkeypatch): - monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False) - monkeypatch.delitem(sys.modules, "headroom.cli.main", raising=False) - fake_main_module = types.ModuleType("headroom.cli.main") - - @click.group() - def fake_main() -> None: - pass - - fake_main_module.main = fake_main - monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module) - importlib.invalidate_caches() - init_cli = importlib.import_module("headroom.cli.init") - monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False) - return init_cli, fake_main - - -def test_init_auto_detects_targets(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - runner = CliRunner() - captured: dict[str, object] = {} - - monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: ["claude", "codex"]) - monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs)) - - result = runner.invoke(fake_main, ["init", "-g"]) - - assert result.exit_code == 0, result.output - assert captured["targets"] == ["claude", "codex"] - assert captured["global_scope"] is True - - -def test_init_fails_when_auto_detection_empty(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - runner = CliRunner() - monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: []) - - result = runner.invoke(fake_main, ["init"]) - - assert result.exit_code != 0 - assert "auto-detected" in result.output - - -def test_init_copilot_requires_global(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - runner = CliRunner() - monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-test") - - result = runner.invoke(fake_main, ["init", "copilot"]) - - assert result.exit_code != 0 - assert "requires -g" in result.output - - -def test_init_claude_local_writes_settings_and_installs_marketplace( - monkeypatch, tmp_path: Path -) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - runner = CliRunner() - monkeypatch.chdir(tmp_path) - marketplace_calls: list[str] = [] - monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-demo") - monkeypatch.setattr( - init_cli, - "_install_claude_marketplace", - lambda scope: marketplace_calls.append(scope), - ) - - result = runner.invoke(fake_main, ["init", "claude"]) - - assert result.exit_code == 0, result.output - settings_path = tmp_path / ".claude" / "settings.local.json" - payload = json.loads(settings_path.read_text(encoding="utf-8")) - assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" - assert marketplace_calls == ["local"] - assert any( - "--profile init-local-demo" in hook["command"] and "init hook ensure" in hook["command"] - for entry in payload["hooks"]["SessionStart"] - for hook in entry["hooks"] - ) - - -def test_init_codex_merges_feature_flag_into_existing_table(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.chdir(tmp_path) - config_path = tmp_path / ".codex" / "config.toml" - config_path.parent.mkdir(parents=True) - config_path.write_text("[features]\nshell_tool = true\n", encoding="utf-8") - - init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000) - - content = config_path.read_text(encoding="utf-8") - assert 'base_url = "http://127.0.0.1:9000/v1"' in content - assert content.count("[features]") == 1 - assert "codex_hooks = true" in content - hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) - assert "--profile init-local-demo" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] - assert "init hook ensure" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] - - -def test_init_claude_uses_custom_port(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(init_cli, "_install_claude_marketplace", lambda scope: None) - - init_cli._init_claude(global_scope=False, profile="init-local-demo", port=9011) - - payload = json.loads((tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8")) - assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011" - - -def test_init_copilot_global_writes_hooks_and_env(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - captured_env: dict[str, str] = {} - monkeypatch.setattr(init_cli, "_copilot_config_path", lambda: tmp_path / "copilot-config.json") - monkeypatch.setattr(init_cli, "_apply_user_env", lambda values: captured_env.update(values)) - monkeypatch.setattr(init_cli, "_install_copilot_marketplace", lambda: None) - - init_cli._init_copilot(global_scope=True, profile="init-user", port=9005, backend="openai") - - payload = json.loads((tmp_path / "copilot-config.json").read_text(encoding="utf-8")) - assert "SessionStart" in payload["hooks"] - assert "PreToolUse" in payload["hooks"] - assert "--profile init-user" in payload["hooks"]["SessionStart"][0]["command"] - assert captured_env == { - "COPILOT_PROVIDER_TYPE": "openai", - "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9005/v1", - "COPILOT_PROVIDER_WIRE_API": "completions", - } - - -def test_init_hook_ensure_prefers_local_profile(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - ensured: list[str] = [] - - def fake_load(profile: str): - return object() if profile == "init-repo-12345678" else None - - monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678") - monkeypatch.setattr(init_cli, "load_manifest", fake_load) - monkeypatch.setattr( - init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) - ) - - runner = CliRunner() - result = runner.invoke(fake_main, ["init", "hook", "ensure"]) - - assert result.exit_code == 0, result.output - assert ensured == ["init-repo-12345678"] - - -def test_init_openclaw_requires_global(monkeypatch) -> None: - _, fake_main = _load_init_module(monkeypatch) - runner = CliRunner() - - result = runner.invoke(fake_main, ["init", "openclaw"]) - - assert result.exit_code != 0 - assert "requires -g" in result.output - - -def test_init_openclaw_delegates_to_wrap(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - calls: list[list[str]] = [] - - class _Result: - returncode = 0 - - monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"]) - monkeypatch.setattr( - init_cli.subprocess, - "run", - lambda cmd: calls.append(cmd) or _Result(), - ) - - init_cli._init_openclaw(global_scope=True, port=9999) - - assert calls == [["headroom", "wrap", "openclaw", "--proxy-port", "9999"]] - - -def test_detect_init_targets_respects_scope(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.setattr( - init_cli.shutil, - "which", - lambda name: name if name in {"claude", "copilot", "codex", "openclaw"} else None, - ) - - assert init_cli.detect_init_targets(False) == ["claude", "codex"] - assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"] - - -def test_marketplace_source_prefers_env_override(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.setenv("HEADROOM_MARKETPLACE_SOURCE", "custom/source") - - assert init_cli._marketplace_source() == "custom/source" - - -def test_run_checked_treats_existing_install_as_success(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - - class _Result: - returncode = 1 - stderr = "plugin already exists" - stdout = "" - - monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result()) - - init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install") - - -def test_command_string_and_matcher_on_windows(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt")) - monkeypatch.setattr(init_cli.subprocess, "list2cmdline", lambda parts: "joined-command") - - assert init_cli._command_string(["headroom", "init"]) == "joined-command" - assert init_cli._powershell_matcher() == "Bash|PowerShell" - - -def test_json_file_handles_missing_empty_and_non_mapping(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - missing = tmp_path / "missing.json" - empty = tmp_path / "empty.json" - array_payload = tmp_path / "payload.json" - empty.write_text(" \n", encoding="utf-8") - array_payload.write_text('["value"]\n', encoding="utf-8") - - assert init_cli._json_file(missing) == {} - assert init_cli._json_file(empty) == {} - assert init_cli._json_file(array_payload) == {} - - -def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - settings_path = tmp_path / "settings.json" - settings_path.write_text( - json.dumps( - { - "env": {"KEEP": "1"}, - "hooks": { - "SessionStart": [ - "not-a-dict", - {"hooks": "not-a-list"}, - { - "matcher": "startup|resume", - "hooks": [{"type": "command", "command": "echo keep-me"}], - }, - { - "matcher": "startup|resume", - "hooks": [ - { - "type": "command", - "command": "headroom init hook ensure --marker headroom-init-claude", - } - ], - }, - ] - }, - } - ), - encoding="utf-8", - ) - monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure") - - init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001) - - payload = json.loads(settings_path.read_text(encoding="utf-8")) - assert payload["env"] == {"KEEP": "1", "ANTHROPIC_BASE_URL": "http://127.0.0.1:9001"} - session_entries = payload["hooks"]["SessionStart"] - assert session_entries[0] == "not-a-dict" - assert session_entries[1] == {"hooks": "not-a-list"} - assert session_entries[2]["hooks"][0]["command"] == "echo keep-me" - assert session_entries[-1]["hooks"][0]["command"].endswith("--marker headroom-init-claude") - - -def test_ensure_copilot_hooks_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - config_path = tmp_path / "copilot.json" - config_path.write_text( - json.dumps( - { - "hooks": { - "SessionStart": [ - {"type": "command", "command": "echo keep"}, - { - "type": "command", - "command": "headroom init hook ensure --marker headroom-init-copilot", - }, - ] - } - } - ), - encoding="utf-8", - ) - monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure") - - init_cli._ensure_copilot_hooks(config_path, "init-user") - - payload = json.loads(config_path.read_text(encoding="utf-8")) - commands = [entry["command"] for entry in payload["hooks"]["SessionStart"]] - assert commands == ["echo keep", "headroom init hook ensure --marker headroom-init-copilot"] - - -def test_replace_marker_block_replaces_existing_block(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - content = "before\n# start\nold\n# end\nafter\n" - - replaced = init_cli._replace_marker_block(content, "# start", "# end", "# start\nnew\n# end") - - assert replaced == "before\n\nafter\n\n# start\nnew\n# end\n" - - -def test_ensure_codex_provider_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - path = tmp_path / "config.toml" - path.write_text( - f"prefix\n{init_cli._CODEX_PROVIDER_MARKER_START}\nold = true\n{init_cli._CODEX_PROVIDER_MARKER_END}\n", - encoding="utf-8", - ) - - init_cli._ensure_codex_provider(path, 9100) - - content = path.read_text(encoding="utf-8") - assert content.count(init_cli._CODEX_PROVIDER_MARKER_START) == 1 - assert 'base_url = "http://127.0.0.1:9100/v1"' in content - assert "old = true" not in content - - -def test_ensure_codex_feature_flag_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None: - init_cli, _ = _load_init_module(monkeypatch) - path = tmp_path / "config.toml" - path.write_text( - f"[features]\n{init_cli._CODEX_FEATURE_MARKER_START}\ncodex_hooks = false\n{init_cli._CODEX_FEATURE_MARKER_END}\n", - encoding="utf-8", - ) - - init_cli._ensure_codex_feature_flag(path) - - content = path.read_text(encoding="utf-8") - assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1 - assert "codex_hooks = true" in content - - -def test_ensure_codex_feature_flag_skips_duplicate_existing_setting( - monkeypatch, tmp_path: Path -) -> None: - init_cli, _ = _load_init_module(monkeypatch) - path = tmp_path / "config.toml" - path.write_text("[features]\ncodex_hooks = true\nshell_tool = true\n", encoding="utf-8") - - init_cli._ensure_codex_feature_flag(path) - - content = path.read_text(encoding="utf-8") - assert content.count("codex_hooks = true") == 1 - assert init_cli._CODEX_FEATURE_MARKER_START not in content - - -def test_ensure_codex_feature_flag_creates_features_section_when_missing( - monkeypatch, tmp_path: Path -) -> None: - init_cli, _ = _load_init_module(monkeypatch) - path = tmp_path / "config.toml" - path.write_text('model = "gpt-5"\n', encoding="utf-8") - - init_cli._ensure_codex_feature_flag(path) - - content = path.read_text(encoding="utf-8") - assert "[features]" in content - assert "codex_hooks = true" in content - - -def test_manifest_changed_detects_differences(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - existing = SimpleNamespace( - port=8787, - backend="anthropic", - anyllm_provider=None, - region=None, - memory_enabled=False, - ) - - assert not init_cli._manifest_changed( - existing, - port=8787, - backend="anthropic", - anyllm_provider=None, - region=None, - memory=False, - ) - assert init_cli._manifest_changed( - existing, - port=9000, - backend="anthropic", - anyllm_provider=None, - region=None, - memory=False, - ) - - -def test_ensure_runtime_manifest_merges_targets_and_stops_changed_runtime(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - existing = SimpleNamespace( - targets=["claude"], - mutations=["mutation"], - port=8787, - backend="anthropic", - anyllm_provider=None, - region=None, - memory_enabled=False, - ) - saved: list[object] = [] - stopped: list[object] = [] - built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[]) - - monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user") - monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing) - monkeypatch.setattr( - init_cli, - "build_manifest", - lambda **kwargs: built.__dict__.update(kwargs) or built, - ) - monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest)) - monkeypatch.setattr(init_cli, "stop_runtime", lambda manifest: stopped.append(manifest)) - - profile = init_cli._ensure_runtime_manifest( - global_scope=True, - targets=["codex"], - port=9001, - backend="anthropic", - anyllm_provider=None, - region=None, - memory=False, - ) - - assert profile == "init-user" - assert stopped == [existing] - assert saved == [built] - assert built.targets == ["claude", "codex"] - assert built.mutations == ["mutation"] - assert built.supervisor_kind == init_cli.SupervisorKind.NONE.value - assert built.artifacts == [] - - -def test_ensure_runtime_manifest_ignores_stop_runtime_errors(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - existing = SimpleNamespace( - targets=[], - mutations=[], - port=8787, - backend="anthropic", - anyllm_provider=None, - region=None, - memory_enabled=False, - ) - saved: list[object] = [] - built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[]) - - monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user") - monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing) - monkeypatch.setattr( - init_cli, - "build_manifest", - lambda **kwargs: built.__dict__.update(kwargs) or built, - ) - monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest)) - monkeypatch.setattr( - init_cli, "stop_runtime", lambda manifest: (_ for _ in ()).throw(RuntimeError("boom")) - ) - - init_cli._ensure_runtime_manifest( - global_scope=True, - targets=["claude"], - port=9001, - backend="anthropic", - anyllm_provider=None, - region=None, - memory=False, - ) - - assert saved == [built] - - -def test_apply_user_env_routes_by_platform(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - manifest = SimpleNamespace(base_env={"OLD": "1"}, tool_envs={}) - windows_calls: list[object] = [] - unix_calls: list[object] = [] - monkeypatch.setattr(init_cli, "_env_manifest", lambda values: manifest) - monkeypatch.setattr( - init_cli, "_apply_windows_env_scope", lambda value: windows_calls.append(value) - ) - monkeypatch.setattr(init_cli, "_apply_unix_env_scope", lambda value: unix_calls.append(value)) - - monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt")) - init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "openai"}) - monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="posix")) - init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "anthropic"}) - - assert manifest.base_env == {} - assert manifest.tool_envs == {"copilot": {"COPILOT_PROVIDER_TYPE": "anthropic"}} - assert windows_calls == [manifest] - assert unix_calls == [manifest] - - -def test_resolve_copilot_env_supports_anthropic(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - - assert init_cli._resolve_copilot_env(9010, "anthropic") == { - "COPILOT_PROVIDER_TYPE": "anthropic", - "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9010", - } - - -def test_marketplace_source_prefers_repo_checkout(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.delenv("HEADROOM_MARKETPLACE_SOURCE", raising=False) - - assert init_cli._marketplace_source() == str(Path(init_cli.__file__).resolve().parents[2]) - - -def test_run_checked_raises_on_failure(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - - class _Result: - returncode = 2 - stderr = "bad stderr" - stdout = "bad stdout" - - monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result()) - - with pytest.raises( - click.ClickException, match="claude plugin install failed: bad stderr\nbad stdout" - ): - init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install") - - -def test_install_claude_marketplace_errors_without_binary(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) - - with pytest.raises(click.ClickException, match="'claude' not found"): - init_cli._install_claude_marketplace("local") - - -def test_install_claude_marketplace_runs_expected_commands(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - calls: list[tuple[list[str], str]] = [] - monkeypatch.setattr(init_cli.shutil, "which", lambda name: "claude") - monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source") - monkeypatch.setattr( - init_cli, "_run_checked", lambda command, action: calls.append((command, action)) - ) - - init_cli._install_claude_marketplace("user") - - assert calls == [ - (["claude", "plugin", "marketplace", "add", "repo/source"], "claude marketplace add"), - ( - ["claude", "plugin", "install", "headroom@headroom-marketplace", "--scope", "user"], - "claude plugin install", - ), - ] - - -def test_install_copilot_marketplace_handles_missing_binary(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) - - with pytest.raises(click.ClickException, match="'copilot' not found"): - init_cli._install_copilot_marketplace() - - -def test_install_copilot_marketplace_runs_expected_commands(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - calls: list[tuple[list[str], str]] = [] - monkeypatch.setattr(init_cli.shutil, "which", lambda name: "copilot") - monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source") - monkeypatch.setattr( - init_cli, "_run_checked", lambda command, action: calls.append((command, action)) - ) - - init_cli._install_copilot_marketplace() - - assert calls == [ - (["copilot", "plugin", "marketplace", "add", "repo/source"], "copilot marketplace add"), - ( - ["copilot", "plugin", "install", "headroom@headroom-marketplace"], - "copilot plugin install", - ), - ] - - -def test_ensure_profile_running_covers_runtime_modes(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - docker_manifest = SimpleNamespace( - preset=init_cli.InstallPreset.PERSISTENT_DOCKER.value, - supervisor_kind=init_cli.SupervisorKind.NONE.value, - profile="docker-profile", - ) - service_manifest = SimpleNamespace( - preset=init_cli.InstallPreset.PERSISTENT_TASK.value, - supervisor_kind=init_cli.SupervisorKind.SERVICE.value, - profile="service-profile", - ) - task_manifest = SimpleNamespace( - preset=init_cli.InstallPreset.PERSISTENT_TASK.value, - supervisor_kind=init_cli.SupervisorKind.NONE.value, - profile="task-profile", - ) - manifests = { - "docker-profile": docker_manifest, - "service-profile": service_manifest, - "task-profile": task_manifest, - } - docker_calls: list[object] = [] - service_calls: list[object] = [] - detached_calls: list[str] = [] - wait_calls: list[tuple[str, int]] = [] - - monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifests.get(profile)) - - def fake_wait_ready(manifest, timeout_seconds: int) -> bool: - wait_calls.append((manifest.profile, timeout_seconds)) - return False - - monkeypatch.setattr(init_cli, "wait_ready", fake_wait_ready) - monkeypatch.setattr( - init_cli, "start_persistent_docker", lambda manifest: docker_calls.append(manifest) - ) - monkeypatch.setattr( - init_cli, "start_supervisor", lambda manifest: service_calls.append(manifest) - ) - monkeypatch.setattr( - init_cli, - "start_detached_agent", - lambda profile: detached_calls.append(profile), - ) - - init_cli._ensure_profile_running("missing") - init_cli._ensure_profile_running("docker-profile") - init_cli._ensure_profile_running("service-profile") - init_cli._ensure_profile_running("task-profile") - - assert docker_calls == [docker_manifest] - assert service_calls == [service_manifest] - assert detached_calls == ["task-profile"] - assert ("docker-profile", 1) in wait_calls - assert ("docker-profile", 45) in wait_calls - - -def test_ensure_profile_running_returns_when_ready_or_on_exception(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - manifest = SimpleNamespace( - preset=init_cli.InstallPreset.PERSISTENT_TASK.value, - supervisor_kind=init_cli.SupervisorKind.NONE.value, - profile="task-profile", - ) - detached_calls: list[str] = [] - monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest) - monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: True) - monkeypatch.setattr( - init_cli, - "start_detached_agent", - lambda profile: detached_calls.append(profile), - ) - - init_cli._ensure_profile_running("task-profile") - assert detached_calls == [] - - monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False) - monkeypatch.setattr( - init_cli, - "start_detached_agent", - lambda profile: (_ for _ in ()).throw(RuntimeError("boom")), - ) - init_cli._ensure_profile_running("task-profile") - - -def test_init_codex_windows_warns_about_upstream_hook_limitation(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - messages: list[str] = [] - monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt")) - monkeypatch.setattr(init_cli, "_codex_scope_path", lambda global_scope: Path("config.toml")) - monkeypatch.setattr(init_cli, "_codex_hooks_path", lambda global_scope: Path("hooks.json")) - monkeypatch.setattr(init_cli, "_ensure_codex_provider", lambda path, port: None) - monkeypatch.setattr(init_cli, "_ensure_codex_feature_flag", lambda path: None) - monkeypatch.setattr(init_cli, "_ensure_codex_hooks", lambda path, profile: None) - monkeypatch.setattr(init_cli.click, "echo", lambda message: messages.append(message)) - - init_cli._init_codex(global_scope=True, profile="init-user", port=9000) - - assert any("disabled upstream on Windows" in message for message in messages) - - -def test_init_openclaw_propagates_nonzero_exit(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - - class _Result: - returncode = 9 - - monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"]) - monkeypatch.setattr(init_cli.subprocess, "run", lambda command: _Result()) - - with pytest.raises(SystemExit) as exc: - init_cli._init_openclaw(global_scope=True, port=9999) - - assert exc.value.code == 9 - - -def test_run_init_targets_dispatches_supported_targets(monkeypatch) -> None: - init_cli, _ = _load_init_module(monkeypatch) - calls: list[tuple[str, tuple[object, ...]]] = [] - monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-profile") - monkeypatch.setattr( - init_cli, - "_init_claude", - lambda **kwargs: calls.append( - ("claude", (kwargs["global_scope"], kwargs["profile"], kwargs["port"])) - ), - ) - monkeypatch.setattr( - init_cli, - "_init_copilot", - lambda **kwargs: calls.append( - ("copilot", (kwargs["global_scope"], kwargs["profile"], kwargs["port"])) - ), - ) - monkeypatch.setattr( - init_cli, - "_init_codex", - lambda **kwargs: calls.append( - ("codex", (kwargs["global_scope"], kwargs["profile"], kwargs["port"])) - ), - ) - monkeypatch.setattr( - init_cli, - "_init_openclaw", - lambda **kwargs: calls.append(("openclaw", (kwargs["global_scope"], kwargs["port"]))), - ) - - init_cli._run_init_targets( - targets=["claude", "copilot", "codex", "openclaw"], - global_scope=True, - port=9000, - backend="openai", - anyllm_provider="provider", - region="us-east-1", - memory=True, - ) - - assert calls == [ - ("claude", (True, "init-profile", 9000)), - ("copilot", (True, "init-profile", 9000)), - ("codex", (True, "init-profile", 9000)), - ("openclaw", (True, 9000)), - ] - - -def test_init_subcommand_uses_group_options(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - runner = CliRunner() - captured: dict[str, object] = {} - monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs)) - - result = runner.invoke( - fake_main, - ["init", "-g", "--port", "9007", "--backend", "openai", "--memory", "claude"], - ) - - assert result.exit_code == 0, result.output - assert captured == { - "targets": ["claude"], - "global_scope": True, - "port": 9007, - "backend": "openai", - "anyllm_provider": None, - "region": None, - "memory": True, - } - - -def test_init_hook_ensure_prefers_global_when_local_missing(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - ensured: list[str] = [] - monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678") - monkeypatch.setattr( - init_cli, - "load_manifest", - lambda profile: object() if profile == init_cli._GLOBAL_PROFILE else None, - ) - monkeypatch.setattr( - init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) - ) - - runner = CliRunner() - result = runner.invoke(fake_main, ["init", "hook", "ensure"]) - - assert result.exit_code == 0, result.output - assert ensured == [init_cli._GLOBAL_PROFILE] - - -def test_init_hook_ensure_uses_explicit_profile(monkeypatch) -> None: - init_cli, fake_main = _load_init_module(monkeypatch) - ensured: list[str] = [] - monkeypatch.setattr( - init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) - ) - - runner = CliRunner() - result = runner.invoke(fake_main, ["init", "hook", "ensure", "--profile", "init-explicit"]) - - assert result.exit_code == 0, result.output - assert ensured == ["init-explicit"] +from __future__ import annotations + +import importlib +import json +import sys +import types +from pathlib import Path +from types import SimpleNamespace + +import click +import pytest +from click.testing import CliRunner + + +def _load_init_module(monkeypatch): + monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False) + monkeypatch.delitem(sys.modules, "headroom.cli.main", raising=False) + fake_main_module = types.ModuleType("headroom.cli.main") + + @click.group() + def fake_main() -> None: + pass + + fake_main_module.main = fake_main + monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module) + importlib.invalidate_caches() + init_cli = importlib.import_module("headroom.cli.init") + monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False) + return init_cli, fake_main + + +def test_init_auto_detects_targets(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + captured: dict[str, object] = {} + + monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: ["claude", "codex"]) + monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs)) + + result = runner.invoke(fake_main, ["init", "-g"]) + + assert result.exit_code == 0, result.output + assert captured["targets"] == ["claude", "codex"] + assert captured["global_scope"] is True + + +def test_init_fails_when_auto_detection_empty(monkeypatch) -> None: + """Bare ``headroom init`` with no agents on PATH prints a guided error. + + Regression guard for issue #245: the error must list every target that + was probed, confirm that -g / --global is a valid flag, and show the + explicit per-target invocation so the user knows how to proceed. + """ + + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) + + result = runner.invoke(fake_main, ["init", "-g"]) + + assert result.exit_code != 0 + assert "No supported user-scope agents were found on PATH" in result.output + assert "probed the following agents" in result.output + # Every in-scope target is listed with its lookup status. + for target in ("claude", "codex", "copilot", "openclaw"): + assert target in result.output + # The user is told that -g is still valid and given a concrete next step. + assert "-g" in result.output + assert "headroom init -g claude" in result.output + + +def test_format_empty_detection_error_local_scope(monkeypatch) -> None: + """Local-scope variant of the guided error only lists local-scope agents.""" + + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) + + message = init_cli._format_empty_detection_error(global_scope=False) + + assert "local-scope agents" in message + assert "claude" in message and "codex" in message + # Copilot / openclaw are global-only; must not be suggested for local. + assert "headroom init copilot" not in message + assert "headroom init openclaw" not in message + assert "headroom init claude" in message + assert "headroom init codex" in message + + +def test_format_empty_detection_error_reports_found_paths(monkeypatch, tmp_path) -> None: + """When a binary IS present, the error still surfaces its path for debugging.""" + + init_cli, _ = _load_init_module(monkeypatch) + fake_claude = tmp_path / "claude" + fake_claude.write_text("") + monkeypatch.setattr( + init_cli.shutil, + "which", + lambda name: str(fake_claude) if name == "claude" else None, + ) + + message = init_cli._format_empty_detection_error(global_scope=True) + + assert f"claude: found at {fake_claude}" in message + assert "codex: not found" in message + + +def test_init_verbose_enables_debug_logging_on_stderr(monkeypatch) -> None: + """``headroom init -v`` should emit diagnostic lines to stderr. + + Different Click 8.x versions expose stderr on ``CliRunner`` results + differently (``mix_stderr`` was removed in 8.2, and ``result.stderr`` + appeared around the same time). To stay compatible with any Click 8.x + the repo targets, the test reads ``result.stderr`` when the attribute + exists AND contains data, otherwise falls back to ``result.output`` + (which is the combined stream when stderr isn't captured separately). + """ + + init_cli, fake_main = _load_init_module(monkeypatch) + monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) + runner = CliRunner() + + result = runner.invoke(fake_main, ["init", "-v", "-g"]) + + # Newer Click: stderr captured separately. + stderr = getattr(result, "stderr", None) or "" + if not stderr: + # Older Click: everything in result.output. + stderr = result.output + + assert result.exit_code != 0, f"output: {result.output!r}" + assert "[headroom init]" in stderr + assert "detect_init_targets" in stderr + assert "global_scope=True" in stderr + for target in ("claude", "codex", "copilot", "openclaw"): + assert target in stderr + + +def test_init_verbose_is_idempotent(monkeypatch) -> None: + """Calling _enable_verbose_logging repeatedly keeps one handler attached.""" + + init_cli, _ = _load_init_module(monkeypatch) + # Clear any prior handler state on the dedicated init logger. + init_cli.logger.handlers.clear() + if hasattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR): + delattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR) + + init_cli._enable_verbose_logging() + init_cli._enable_verbose_logging() + init_cli._enable_verbose_logging() + + assert len(init_cli.logger.handlers) == 1 + + +def test_init_copilot_requires_global(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-test") + + result = runner.invoke(fake_main, ["init", "copilot"]) + + assert result.exit_code != 0 + assert "requires -g" in result.output + + +def test_init_claude_local_writes_settings_and_installs_marketplace( + monkeypatch, tmp_path: Path +) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + monkeypatch.chdir(tmp_path) + marketplace_calls: list[str] = [] + monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-demo") + monkeypatch.setattr( + init_cli, + "_install_claude_marketplace", + lambda scope: marketplace_calls.append(scope), + ) + + result = runner.invoke(fake_main, ["init", "claude"]) + + assert result.exit_code == 0, result.output + settings_path = tmp_path / ".claude" / "settings.local.json" + payload = json.loads(settings_path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + assert marketplace_calls == ["local"] + assert any( + "--profile init-local-demo" in hook["command"] and "init hook ensure" in hook["command"] + for entry in payload["hooks"]["SessionStart"] + for hook in entry["hooks"] + ) + + +def test_init_codex_merges_feature_flag_into_existing_table(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.chdir(tmp_path) + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True) + config_path.write_text("[features]\nshell_tool = true\n", encoding="utf-8") + + init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000) + + content = config_path.read_text(encoding="utf-8") + assert 'base_url = "http://127.0.0.1:9000/v1"' in content + assert content.count("[features]") == 1 + assert "codex_hooks = true" in content + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + assert "--profile init-local-demo" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert "init hook ensure" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + + +def test_init_claude_uses_custom_port(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(init_cli, "_install_claude_marketplace", lambda scope: None) + + init_cli._init_claude(global_scope=False, profile="init-local-demo", port=9011) + + payload = json.loads((tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011" + + +def test_init_copilot_global_writes_hooks_and_env(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + captured_env: dict[str, str] = {} + monkeypatch.setattr(init_cli, "_copilot_config_path", lambda: tmp_path / "copilot-config.json") + monkeypatch.setattr(init_cli, "_apply_user_env", lambda values: captured_env.update(values)) + monkeypatch.setattr(init_cli, "_install_copilot_marketplace", lambda: None) + + init_cli._init_copilot(global_scope=True, profile="init-user", port=9005, backend="openai") + + payload = json.loads((tmp_path / "copilot-config.json").read_text(encoding="utf-8")) + assert "SessionStart" in payload["hooks"] + assert "PreToolUse" in payload["hooks"] + assert "--profile init-user" in payload["hooks"]["SessionStart"][0]["command"] + assert captured_env == { + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9005/v1", + "COPILOT_PROVIDER_WIRE_API": "completions", + } + + +def test_init_hook_ensure_prefers_local_profile(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + ensured: list[str] = [] + + def fake_load(profile: str): + return object() if profile == "init-repo-12345678" else None + + monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678") + monkeypatch.setattr(init_cli, "load_manifest", fake_load) + monkeypatch.setattr( + init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) + ) + + runner = CliRunner() + result = runner.invoke(fake_main, ["init", "hook", "ensure"]) + + assert result.exit_code == 0, result.output + assert ensured == ["init-repo-12345678"] + + +def test_init_openclaw_requires_global(monkeypatch) -> None: + _, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + + result = runner.invoke(fake_main, ["init", "openclaw"]) + + assert result.exit_code != 0 + assert "requires -g" in result.output + + +def test_init_openclaw_delegates_to_wrap(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + calls: list[list[str]] = [] + + class _Result: + returncode = 0 + + monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"]) + monkeypatch.setattr( + init_cli.subprocess, + "run", + lambda cmd: calls.append(cmd) or _Result(), + ) + + init_cli._init_openclaw(global_scope=True, port=9999) + + assert calls == [["headroom", "wrap", "openclaw", "--proxy-port", "9999"]] + + +def test_detect_init_targets_respects_scope(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setattr( + init_cli.shutil, + "which", + lambda name: name if name in {"claude", "copilot", "codex", "openclaw"} else None, + ) + + assert init_cli.detect_init_targets(False) == ["claude", "codex"] + assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"] + + +def test_marketplace_source_prefers_env_override(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setenv("HEADROOM_MARKETPLACE_SOURCE", "custom/source") + + assert init_cli._marketplace_source() == "custom/source" + + +def test_run_checked_treats_existing_install_as_success(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + + class _Result: + returncode = 1 + stderr = "plugin already exists" + stdout = "" + + monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result()) + + init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install") + + +def test_command_string_and_matcher_on_windows(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(init_cli.subprocess, "list2cmdline", lambda parts: "joined-command") + + assert init_cli._command_string(["headroom", "init"]) == "joined-command" + assert init_cli._powershell_matcher() == "Bash|PowerShell" + + +def test_json_file_handles_missing_empty_and_non_mapping(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + missing = tmp_path / "missing.json" + empty = tmp_path / "empty.json" + array_payload = tmp_path / "payload.json" + empty.write_text(" \n", encoding="utf-8") + array_payload.write_text('["value"]\n', encoding="utf-8") + + assert init_cli._json_file(missing) == {} + assert init_cli._json_file(empty) == {} + assert init_cli._json_file(array_payload) == {} + + +def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + settings_path = tmp_path / "settings.json" + settings_path.write_text( + json.dumps( + { + "env": {"KEEP": "1"}, + "hooks": { + "SessionStart": [ + "not-a-dict", + {"hooks": "not-a-list"}, + { + "matcher": "startup|resume", + "hooks": [{"type": "command", "command": "echo keep-me"}], + }, + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "headroom init hook ensure --marker headroom-init-claude", + } + ], + }, + ] + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure") + + init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001) + + payload = json.loads(settings_path.read_text(encoding="utf-8")) + assert payload["env"] == {"KEEP": "1", "ANTHROPIC_BASE_URL": "http://127.0.0.1:9001"} + session_entries = payload["hooks"]["SessionStart"] + assert session_entries[0] == "not-a-dict" + assert session_entries[1] == {"hooks": "not-a-list"} + assert session_entries[2]["hooks"][0]["command"] == "echo keep-me" + assert session_entries[-1]["hooks"][0]["command"].endswith("--marker headroom-init-claude") + + +def test_ensure_copilot_hooks_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + config_path = tmp_path / "copilot.json" + config_path.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + {"type": "command", "command": "echo keep"}, + { + "type": "command", + "command": "headroom init hook ensure --marker headroom-init-copilot", + }, + ] + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure") + + init_cli._ensure_copilot_hooks(config_path, "init-user") + + payload = json.loads(config_path.read_text(encoding="utf-8")) + commands = [entry["command"] for entry in payload["hooks"]["SessionStart"]] + assert commands == ["echo keep", "headroom init hook ensure --marker headroom-init-copilot"] + + +def test_replace_marker_block_replaces_existing_block(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + content = "before\n# start\nold\n# end\nafter\n" + + replaced = init_cli._replace_marker_block(content, "# start", "# end", "# start\nnew\n# end") + + assert replaced == "before\n\nafter\n\n# start\nnew\n# end\n" + + +def test_ensure_codex_provider_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + path = tmp_path / "config.toml" + path.write_text( + f"prefix\n{init_cli._CODEX_PROVIDER_MARKER_START}\nold = true\n{init_cli._CODEX_PROVIDER_MARKER_END}\n", + encoding="utf-8", + ) + + init_cli._ensure_codex_provider(path, 9100) + + content = path.read_text(encoding="utf-8") + assert content.count(init_cli._CODEX_PROVIDER_MARKER_START) == 1 + assert 'base_url = "http://127.0.0.1:9100/v1"' in content + assert "old = true" not in content + + +def test_ensure_codex_feature_flag_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + path = tmp_path / "config.toml" + path.write_text( + f"[features]\n{init_cli._CODEX_FEATURE_MARKER_START}\ncodex_hooks = false\n{init_cli._CODEX_FEATURE_MARKER_END}\n", + encoding="utf-8", + ) + + init_cli._ensure_codex_feature_flag(path) + + content = path.read_text(encoding="utf-8") + assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1 + assert "codex_hooks = true" in content + + +def test_ensure_codex_feature_flag_skips_duplicate_existing_setting( + monkeypatch, tmp_path: Path +) -> None: + init_cli, _ = _load_init_module(monkeypatch) + path = tmp_path / "config.toml" + path.write_text("[features]\ncodex_hooks = true\nshell_tool = true\n", encoding="utf-8") + + init_cli._ensure_codex_feature_flag(path) + + content = path.read_text(encoding="utf-8") + assert content.count("codex_hooks = true") == 1 + assert init_cli._CODEX_FEATURE_MARKER_START not in content + + +def test_ensure_codex_feature_flag_creates_features_section_when_missing( + monkeypatch, tmp_path: Path +) -> None: + init_cli, _ = _load_init_module(monkeypatch) + path = tmp_path / "config.toml" + path.write_text('model = "gpt-5"\n', encoding="utf-8") + + init_cli._ensure_codex_feature_flag(path) + + content = path.read_text(encoding="utf-8") + assert "[features]" in content + assert "codex_hooks = true" in content + + +def test_manifest_changed_detects_differences(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + existing = SimpleNamespace( + port=8787, + backend="anthropic", + anyllm_provider=None, + region=None, + memory_enabled=False, + ) + + assert not init_cli._manifest_changed( + existing, + port=8787, + backend="anthropic", + anyllm_provider=None, + region=None, + memory=False, + ) + assert init_cli._manifest_changed( + existing, + port=9000, + backend="anthropic", + anyllm_provider=None, + region=None, + memory=False, + ) + + +def test_ensure_runtime_manifest_merges_targets_and_stops_changed_runtime(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + existing = SimpleNamespace( + targets=["claude"], + mutations=["mutation"], + port=8787, + backend="anthropic", + anyllm_provider=None, + region=None, + memory_enabled=False, + ) + saved: list[object] = [] + stopped: list[object] = [] + built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[]) + + monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user") + monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing) + monkeypatch.setattr( + init_cli, + "build_manifest", + lambda **kwargs: built.__dict__.update(kwargs) or built, + ) + monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest)) + monkeypatch.setattr(init_cli, "stop_runtime", lambda manifest: stopped.append(manifest)) + + profile = init_cli._ensure_runtime_manifest( + global_scope=True, + targets=["codex"], + port=9001, + backend="anthropic", + anyllm_provider=None, + region=None, + memory=False, + ) + + assert profile == "init-user" + assert stopped == [existing] + assert saved == [built] + assert built.targets == ["claude", "codex"] + assert built.mutations == ["mutation"] + assert built.supervisor_kind == init_cli.SupervisorKind.NONE.value + assert built.artifacts == [] + + +def test_ensure_runtime_manifest_ignores_stop_runtime_errors(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + existing = SimpleNamespace( + targets=[], + mutations=[], + port=8787, + backend="anthropic", + anyllm_provider=None, + region=None, + memory_enabled=False, + ) + saved: list[object] = [] + built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[]) + + monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user") + monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing) + monkeypatch.setattr( + init_cli, + "build_manifest", + lambda **kwargs: built.__dict__.update(kwargs) or built, + ) + monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest)) + monkeypatch.setattr( + init_cli, "stop_runtime", lambda manifest: (_ for _ in ()).throw(RuntimeError("boom")) + ) + + init_cli._ensure_runtime_manifest( + global_scope=True, + targets=["claude"], + port=9001, + backend="anthropic", + anyllm_provider=None, + region=None, + memory=False, + ) + + assert saved == [built] + + +def test_apply_user_env_routes_by_platform(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + manifest = SimpleNamespace(base_env={"OLD": "1"}, tool_envs={}) + windows_calls: list[object] = [] + unix_calls: list[object] = [] + monkeypatch.setattr(init_cli, "_env_manifest", lambda values: manifest) + monkeypatch.setattr( + init_cli, "_apply_windows_env_scope", lambda value: windows_calls.append(value) + ) + monkeypatch.setattr(init_cli, "_apply_unix_env_scope", lambda value: unix_calls.append(value)) + + monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt")) + init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "openai"}) + monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="posix")) + init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "anthropic"}) + + assert manifest.base_env == {} + assert manifest.tool_envs == {"copilot": {"COPILOT_PROVIDER_TYPE": "anthropic"}} + assert windows_calls == [manifest] + assert unix_calls == [manifest] + + +def test_resolve_copilot_env_supports_anthropic(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + + assert init_cli._resolve_copilot_env(9010, "anthropic") == { + "COPILOT_PROVIDER_TYPE": "anthropic", + "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9010", + } + + +def test_marketplace_source_prefers_repo_checkout(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.delenv("HEADROOM_MARKETPLACE_SOURCE", raising=False) + + assert init_cli._marketplace_source() == str(Path(init_cli.__file__).resolve().parents[2]) + + +def test_run_checked_raises_on_failure(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + + class _Result: + returncode = 2 + stderr = "bad stderr" + stdout = "bad stdout" + + monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result()) + + with pytest.raises( + click.ClickException, match="claude plugin install failed: bad stderr\nbad stdout" + ): + init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install") + + +def test_install_claude_marketplace_errors_without_binary(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) + + with pytest.raises(click.ClickException, match="'claude' not found"): + init_cli._install_claude_marketplace("local") + + +def test_install_claude_marketplace_runs_expected_commands(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + calls: list[tuple[list[str], str]] = [] + monkeypatch.setattr(init_cli.shutil, "which", lambda name: "claude") + monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source") + monkeypatch.setattr( + init_cli, "_run_checked", lambda command, action: calls.append((command, action)) + ) + + init_cli._install_claude_marketplace("user") + + assert calls == [ + (["claude", "plugin", "marketplace", "add", "repo/source"], "claude marketplace add"), + ( + ["claude", "plugin", "install", "headroom@headroom-marketplace", "--scope", "user"], + "claude plugin install", + ), + ] + + +def test_install_copilot_marketplace_handles_missing_binary(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setattr(init_cli.shutil, "which", lambda name: None) + + with pytest.raises(click.ClickException, match="'copilot' not found"): + init_cli._install_copilot_marketplace() + + +def test_install_copilot_marketplace_runs_expected_commands(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + calls: list[tuple[list[str], str]] = [] + monkeypatch.setattr(init_cli.shutil, "which", lambda name: "copilot") + monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source") + monkeypatch.setattr( + init_cli, "_run_checked", lambda command, action: calls.append((command, action)) + ) + + init_cli._install_copilot_marketplace() + + assert calls == [ + (["copilot", "plugin", "marketplace", "add", "repo/source"], "copilot marketplace add"), + ( + ["copilot", "plugin", "install", "headroom@headroom-marketplace"], + "copilot plugin install", + ), + ] + + +def test_ensure_profile_running_covers_runtime_modes(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + docker_manifest = SimpleNamespace( + preset=init_cli.InstallPreset.PERSISTENT_DOCKER.value, + supervisor_kind=init_cli.SupervisorKind.NONE.value, + profile="docker-profile", + ) + service_manifest = SimpleNamespace( + preset=init_cli.InstallPreset.PERSISTENT_TASK.value, + supervisor_kind=init_cli.SupervisorKind.SERVICE.value, + profile="service-profile", + ) + task_manifest = SimpleNamespace( + preset=init_cli.InstallPreset.PERSISTENT_TASK.value, + supervisor_kind=init_cli.SupervisorKind.NONE.value, + profile="task-profile", + ) + manifests = { + "docker-profile": docker_manifest, + "service-profile": service_manifest, + "task-profile": task_manifest, + } + docker_calls: list[object] = [] + service_calls: list[object] = [] + detached_calls: list[str] = [] + wait_calls: list[tuple[str, int]] = [] + + monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifests.get(profile)) + + def fake_wait_ready(manifest, timeout_seconds: int) -> bool: + wait_calls.append((manifest.profile, timeout_seconds)) + return False + + monkeypatch.setattr(init_cli, "wait_ready", fake_wait_ready) + monkeypatch.setattr( + init_cli, "start_persistent_docker", lambda manifest: docker_calls.append(manifest) + ) + monkeypatch.setattr( + init_cli, "start_supervisor", lambda manifest: service_calls.append(manifest) + ) + monkeypatch.setattr( + init_cli, + "start_detached_agent", + lambda profile: detached_calls.append(profile), + ) + + init_cli._ensure_profile_running("missing") + init_cli._ensure_profile_running("docker-profile") + init_cli._ensure_profile_running("service-profile") + init_cli._ensure_profile_running("task-profile") + + assert docker_calls == [docker_manifest] + assert service_calls == [service_manifest] + assert detached_calls == ["task-profile"] + assert ("docker-profile", 1) in wait_calls + assert ("docker-profile", 45) in wait_calls + + +def test_ensure_profile_running_returns_when_ready_or_on_exception(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + manifest = SimpleNamespace( + preset=init_cli.InstallPreset.PERSISTENT_TASK.value, + supervisor_kind=init_cli.SupervisorKind.NONE.value, + profile="task-profile", + ) + detached_calls: list[str] = [] + monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest) + monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: True) + monkeypatch.setattr( + init_cli, + "start_detached_agent", + lambda profile: detached_calls.append(profile), + ) + + init_cli._ensure_profile_running("task-profile") + assert detached_calls == [] + + monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False) + monkeypatch.setattr( + init_cli, + "start_detached_agent", + lambda profile: (_ for _ in ()).throw(RuntimeError("boom")), + ) + init_cli._ensure_profile_running("task-profile") + + +def test_init_codex_windows_warns_about_upstream_hook_limitation(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + messages: list[str] = [] + monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(init_cli, "_codex_scope_path", lambda global_scope: Path("config.toml")) + monkeypatch.setattr(init_cli, "_codex_hooks_path", lambda global_scope: Path("hooks.json")) + monkeypatch.setattr(init_cli, "_ensure_codex_provider", lambda path, port: None) + monkeypatch.setattr(init_cli, "_ensure_codex_feature_flag", lambda path: None) + monkeypatch.setattr(init_cli, "_ensure_codex_hooks", lambda path, profile: None) + monkeypatch.setattr(init_cli.click, "echo", lambda message: messages.append(message)) + + init_cli._init_codex(global_scope=True, profile="init-user", port=9000) + + assert any("disabled upstream on Windows" in message for message in messages) + + +def test_init_openclaw_propagates_nonzero_exit(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + + class _Result: + returncode = 9 + + monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"]) + monkeypatch.setattr(init_cli.subprocess, "run", lambda command: _Result()) + + with pytest.raises(SystemExit) as exc: + init_cli._init_openclaw(global_scope=True, port=9999) + + assert exc.value.code == 9 + + +def test_run_init_targets_dispatches_supported_targets(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + calls: list[tuple[str, tuple[object, ...]]] = [] + monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-profile") + monkeypatch.setattr( + init_cli, + "_init_claude", + lambda **kwargs: calls.append( + ("claude", (kwargs["global_scope"], kwargs["profile"], kwargs["port"])) + ), + ) + monkeypatch.setattr( + init_cli, + "_init_copilot", + lambda **kwargs: calls.append( + ("copilot", (kwargs["global_scope"], kwargs["profile"], kwargs["port"])) + ), + ) + monkeypatch.setattr( + init_cli, + "_init_codex", + lambda **kwargs: calls.append( + ("codex", (kwargs["global_scope"], kwargs["profile"], kwargs["port"])) + ), + ) + monkeypatch.setattr( + init_cli, + "_init_openclaw", + lambda **kwargs: calls.append(("openclaw", (kwargs["global_scope"], kwargs["port"]))), + ) + + init_cli._run_init_targets( + targets=["claude", "copilot", "codex", "openclaw"], + global_scope=True, + port=9000, + backend="openai", + anyllm_provider="provider", + region="us-east-1", + memory=True, + ) + + assert calls == [ + ("claude", (True, "init-profile", 9000)), + ("copilot", (True, "init-profile", 9000)), + ("codex", (True, "init-profile", 9000)), + ("openclaw", (True, 9000)), + ] + + +def test_init_subcommand_uses_group_options(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + captured: dict[str, object] = {} + monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs)) + + result = runner.invoke( + fake_main, + ["init", "-g", "--port", "9007", "--backend", "openai", "--memory", "claude"], + ) + + assert result.exit_code == 0, result.output + assert captured == { + "targets": ["claude"], + "global_scope": True, + "port": 9007, + "backend": "openai", + "anyllm_provider": None, + "region": None, + "memory": True, + } + + +def test_init_hook_ensure_prefers_global_when_local_missing(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + ensured: list[str] = [] + monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678") + monkeypatch.setattr( + init_cli, + "load_manifest", + lambda profile: object() if profile == init_cli._GLOBAL_PROFILE else None, + ) + monkeypatch.setattr( + init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) + ) + + runner = CliRunner() + result = runner.invoke(fake_main, ["init", "hook", "ensure"]) + + assert result.exit_code == 0, result.output + assert ensured == [init_cli._GLOBAL_PROFILE] + + +def test_init_hook_ensure_uses_explicit_profile(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + ensured: list[str] = [] + monkeypatch.setattr( + init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) + ) + + runner = CliRunner() + result = runner.invoke(fake_main, ["init", "hook", "ensure", "--profile", "init-explicit"]) + + assert result.exit_code == 0, result.output + assert ensured == ["init-explicit"]