headroom/tests/test_cli/test_wrap_vscode_claude.py
JD Davis 13a310a00d
feat(claude): support Claude Code in VS Code (#2752)
## Description Add first-class Headroom support for the official Claude
Code extension in VS Code. The new wrapper starts the local proxy,
configures the Claude Code user settings consumed by the embedded
extension process, preserves authentication and model selection, and
provides a conflict-safe reversible unwrap lifecycle. Closes # ## Type
of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x]
New feature (non-breaking change that adds functionality) - [ ] Breaking
change (fix or feature that would cause existing functionality to
change) - [x] Documentation update - [ ] Performance improvement - [ ]
Code refactoring (no functional changes) ## Changes Made - Add `headroom
wrap vscode-claude` and `headroom unwrap vscode-claude`. - Configure
project-scoped `ANTHROPIC_BASE_URL` plus `ENABLE_TOOL_SEARCH=true` in
Claude Code user settings while preserving existing values. - Respect
`CLAUDE_CONFIG_DIR`, macOS/Linux home paths, Windows `USERPROFILE`,
custom `--settings-file`, and `--no-configure`. - Add durable
Headroom-owned restore state and refuse malformed settings or
conflicting user edits. - Add unit, CLI, and Docker-harness e2e coverage
for configuration, real proxy forwarding, and restoration. - Document
setup, remote development, undo, and troubleshooting. ## Testing - [x]
Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x]
Type checking passes (`mypy headroom`) - [x] New tests added for new
functionality - [x] Manual testing performed ### Test Output ```text $
UV_NO_SYNC=1 uv run pytest -q
tests/test_provider_claude_vscode_config.py
tests/test_cli/test_wrap_vscode_claude.py
tests/test_cli/test_wrap_vscode.py
tests/test_cli/test_wrap_claude_base_url.py
tests/test_provider_copilot_vscode_config.py tests/test_copilot_auth.py
160 passed in 0.45s $ UV_NO_SYNC=1 uv run ruff check . All checks
passed! $ UV_NO_SYNC=1 uv run mypy headroom Success: no issues found in
512 source files $ npm run build # from docs/ Compiled successfully;
generated 155 static pages ``` ## Real Behavior Proof - Environment:
macOS, Python 3.13 editable install, isolated temporary HOME and Claude
settings, local mock Anthropic Messages upstream. - Exact command /
steps: invoked the new `verify_vscode_claude_wrap` e2e function, which
launched real `headroom wrap vscode-claude`, waited for proxy readiness,
POSTed an Anthropic `/v1/messages` request through the generated
project-scoped URL, stopped the wrapper, then ran `headroom unwrap
vscode-claude`. - Observed result: HTTP 200 with the mock Claude
response through Headroom; generated settings retained unrelated values
and enabled tool deferral; unwrap restored the original Claude settings.
- Not tested: real Anthropic account traffic or the full Docker image
locally because Docker Desktop was unavailable. The same e2e function is
wired into the existing Docker wrap CI job. ## Review Readiness - [x] I
have performed a self-review - [x] This PR is ready for human review ##
Checklist - [x] My code follows the project style guidelines - [x] I
have performed a self-review of my code - [x] I have commented my code,
particularly in hard-to-understand areas - [x] I have made corresponding
changes to the documentation - [x] My changes generate no new warnings -
[x] I have added tests that prove my fix is effective or that my feature
works - [x] New and existing unit tests pass locally with my changes -
[x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this) ## Screenshots (if applicable) Not applicable; this adds CLI
configuration and proxy routing without changing VS Code UI. ##
Additional Notes The wrapper deliberately leaves the endpoint configured
when stopped so requests fail closed instead of silently bypassing
Headroom. `headroom unwrap vscode-claude` restores the exact prior
managed values and preserves unrelated settings.

---------

Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
2026-08-03 20:14:13 -07:00

62 lines
2.2 KiB
Python

"""CLI coverage for Claude Code inside VS Code."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from headroom.cli.main import main
def test_wrap_vscode_claude_configures_actual_port(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
captured = {}
def fake_watcher(**kwargs): # noqa: ANN003, ANN202
captured.update(kwargs)
kwargs["print_setup_lines"](9999)
with patch("headroom.cli.wrap._run_proxy_only_watcher", side_effect=fake_watcher):
result = CliRunner().invoke(main, ["wrap", "vscode-claude", "--settings-file", str(path)])
assert result.exit_code == 0, result.output
env = json.loads(path.read_text(encoding="utf-8"))["env"]
assert env["ANTHROPIC_BASE_URL"].startswith("http://127.0.0.1:9999/p/")
assert env["ENABLE_TOOL_SEARCH"] == "true"
assert "Reload VS Code" in result.output
assert captured["agent_type"] == "claude"
def test_wrap_vscode_claude_no_configure_prints_settings(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
def fake_watcher(**kwargs): # noqa: ANN003, ANN202
kwargs["print_setup_lines"](8787)
with patch("headroom.cli.wrap._run_proxy_only_watcher", side_effect=fake_watcher):
result = CliRunner().invoke(
main,
["wrap", "vscode-claude", "--no-configure", "--settings-file", str(path)],
)
assert result.exit_code == 0, result.output
assert not path.exists()
assert "ANTHROPIC_BASE_URL" in result.output
assert "ENABLE_TOOL_SEARCH" in result.output
def test_unwrap_vscode_claude_restores_previous_settings(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
original = {"env": {"KEEP": "1"}, "permissions": {"allow": ["Read"]}}
path.write_text(json.dumps(original), encoding="utf-8")
from headroom.providers.claude.vscode import configure_vscode_claude_settings
configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo")
result = CliRunner().invoke(main, ["unwrap", "vscode-claude", "--settings-file", str(path)])
assert result.exit_code == 0, result.output
assert json.loads(path.read_text(encoding="utf-8")) == original