Merge pull request #256 from JerrettDavis/fix/init-g-regression

fix(init): guide users when no agents are auto-detected + expand e2e
This commit is contained in:
Tejas Chopra 2026-04-23 15:27:24 -07:00 committed by GitHub
commit 7b99b05e0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2960 additions and 1751 deletions

View file

@ -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"

View file

@ -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 <target>`` 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

View file

@ -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"

132
.github/workflows/init-native-e2e.yml vendored Normal file
View file

@ -0,0 +1,132 @@
name: Init Native E2E
# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand
# ``headroom init -g <target>`` flows. Each matrix cell drops a noop shim for
# the target agent onto PATH and asserts ``headroom init -g <target>``
# 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 ``<target>.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"
}
}
}

7
e2e/__init__.py Normal file
View file

@ -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
"""

35
e2e/_lib/__init__.py Normal file
View file

@ -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",
]

43
e2e/_lib/assertions.py Normal file
View file

@ -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

336
e2e/_lib/harness.py Normal file
View file

@ -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

24
e2e/_lib/make_shim.ps1 Normal file
View file

@ -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 <name> -Dir <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

28
e2e/_lib/make_shim.sh Normal file
View file

@ -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 <name> <dir>
#
# Exit codes:
# 0 on success
# 2 on usage error
set -euo pipefail
if [ $# -ne 2 ]; then
echo "usage: $0 <name> <dir>" >&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"

54
e2e/_lib/path_env.py Normal file
View file

@ -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

47
e2e/_lib/paths.py Normal file
View file

@ -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}")

96
e2e/_lib/shims.py Normal file
View file

@ -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

View file

@ -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"]

View file

@ -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 <agent>`` 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 <agent>`` 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()

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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",

File diff suppressed because it is too large Load diff