diff --git a/README.md b/README.md
index cff50434d..0126679e7 100644
--- a/README.md
+++ b/README.md
@@ -301,6 +301,34 @@ upstream Copilot token only in the proxy process.
See the [cross-platform VS Code Copilot guide](https://headroom-docs.vercel.app/docs/vscode-copilot)
for paths, credential flow, remote-development notes, undo steps, and troubleshooting.
+### Claude Code in Visual Studio Code
+
+The official Claude Code extension embeds Claude Code and reads the same user
+settings as the CLI. Install Headroom's proxy dependencies, then run the wrapper
+from the project you plan to open in VS Code:
+
+```bash
+pip install "headroom-ai[proxy]"
+headroom wrap vscode-claude
+```
+
+On the first run, reload the VS Code window. Keep the wrapper terminal running
+while you use the Claude Code panel; inspect the dashboard or proxy log printed
+at startup to see requests and savings.
+Headroom preserves your Anthropic authentication and selected model.
+
+Press `Ctrl+C` to stop the proxy. Restart the same command before using Claude
+Code again, or completely restore the settings that existed before setup:
+
+```bash
+headroom unwrap vscode-claude
+```
+
+See the
+[VS Code Claude Code guide](https://headroom-docs.vercel.app/docs/vscode-claude-code)
+for verification, configuration paths, custom profiles, remote development, and
+troubleshooting.
+
## When to use · When to skip
**Great fit if you…**
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index a35f135cf..6a45b0b4c 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -37,6 +37,7 @@
"litellm",
"claude-code-vertex",
"claude-code-azure-foundry",
+ "vscode-claude-code",
"vscode-copilot",
"opencode",
"grok-build",
diff --git a/docs/content/docs/proxy.mdx b/docs/content/docs/proxy.mdx
index aa9c1dc61..b443be7bd 100644
--- a/docs/content/docs/proxy.mdx
+++ b/docs/content/docs/proxy.mdx
@@ -567,6 +567,9 @@ Use `headroom wrap` to launch supported CLI agents through the local proxy:
# Claude Code
headroom wrap claude
+# Claude Code extension in VS Code (configures settings, then starts the proxy)
+headroom wrap vscode-claude
+
# OpenAI Codex
headroom wrap codex
@@ -588,6 +591,12 @@ Grok Build reads model endpoints from `~/.grok/config.toml`. `headroom wrap grok
injects or updates `[model.grok-build] base_url` to point at the local proxy, then
run `grok` from the same project directory. See [Grok Build Integration](/docs/grok-build).
+The official Claude Code extension reads Claude Code's user settings rather than
+the terminal environment. Use `headroom wrap vscode-claude`, reload VS Code after
+the first run, and keep the wrapper running. See the
+[VS Code Claude Code guide](/docs/vscode-claude-code) for verification and undo
+steps.
+
For environment-driven clients, you can also set the base URL manually:
```bash
diff --git a/docs/content/docs/quickstart.mdx b/docs/content/docs/quickstart.mdx
index 053a837d4..36615f526 100644
--- a/docs/content/docs/quickstart.mdx
+++ b/docs/content/docs/quickstart.mdx
@@ -203,6 +203,13 @@ Transforms: ['smart_crusher', 'cache_aligner']
## Alternative: proxy mode (zero code changes)
+
+Run `headroom wrap vscode-claude`, reload the VS Code window once, and keep the
+wrapper running while you use the official Claude Code extension. See the
+[complete VS Code Claude Code guide](/docs/vscode-claude-code) for verification,
+undo steps, custom profiles, and remote development.
+
+
If you do not want to change any code, run Headroom as a proxy and point your existing client at it:
```bash
diff --git a/docs/content/docs/vscode-claude-code.mdx b/docs/content/docs/vscode-claude-code.mdx
new file mode 100644
index 000000000..ae57056ce
--- /dev/null
+++ b/docs/content/docs/vscode-claude-code.mdx
@@ -0,0 +1,127 @@
+---
+title: Use Headroom with Claude Code in VS Code
+description: Route the official Claude Code extension through Headroom's local compression proxy.
+---
+
+The official Claude Code extension for VS Code embeds Claude Code. Headroom can
+route its Anthropic API requests through the same local compression proxy used by
+`headroom wrap claude`, without changing your Anthropic sign-in or selected model.
+
+
+Use `headroom wrap claude`. This page is specifically for Anthropic's official
+Claude Code extension inside VS Code.
+
+
+## Requirements
+
+- VS Code 1.98 or newer
+- Anthropic's official Claude Code extension, signed in and working
+- Headroom with proxy dependencies: `pip install "headroom-ai[proxy]"`
+- Loopback access to `127.0.0.1` from the VS Code extension host
+
+Confirm that Claude Code works normally in VS Code before adding Headroom. This
+makes authentication or extension problems easier to distinguish from proxy
+configuration problems.
+
+## Quick start
+
+1. Open a terminal in the project you use with Claude Code.
+2. Start Headroom:
+
+```bash
+headroom wrap vscode-claude
+```
+
+Headroom starts its proxy and adds two entries under `env` in the Claude Code
+user settings file:
+
+```json
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/p/your-project",
+ "ENABLE_TOOL_SEARCH": "true"
+ }
+}
+```
+
+`ANTHROPIC_BASE_URL` changes the endpoint, not the selected model.
+`ENABLE_TOOL_SEARCH` keeps Claude Code's on-demand tool loading enabled when it
+uses a custom endpoint. Existing settings and prior values for both variables are
+preserved for restoration. Headroom does not store or replace your Anthropic
+credentials.
+
+3. After the first configuration, run **Developer: Reload Window** from the VS
+Code Command Palette.
+4. Keep the wrapper terminal running and use the Claude Code panel normally.
+
+## Verify that it is working
+
+While the wrapper is running:
+
+1. Open `http://127.0.0.1:8787/health`; it should report a healthy proxy.
+2. Send a message in the Claude Code panel.
+3. Open the dashboard or proxy log whose locations are printed by the wrapper.
+ Confirm that the request appears there; savings are recorded with each
+ completed request.
+
+If the health check succeeds but no request appears in the dashboard or proxy
+log, reload the VS Code window and confirm that the extension host can reach the
+same `127.0.0.1` as Headroom.
+
+## Settings location
+
+The default user settings file is `~/.claude/settings.json` on macOS and Linux,
+or `%USERPROFILE%\.claude\settings.json` on Windows. `CLAUDE_CONFIG_DIR` is
+respected when set. To target another profile explicitly:
+
+```bash
+headroom wrap vscode-claude --settings-file /path/to/.claude/settings.json
+```
+
+Use `--no-configure` to print the settings without editing a file.
+
+The proxy URL includes the current directory as the project attribution name.
+Run the wrapper from the intended project directory. If you select another port,
+for example `--port 8788`, Headroom writes that same port to the settings file.
+
+## Stop and undo
+
+Press `Ctrl+C` to stop the proxy. The endpoint remains configured so requests
+fail closed rather than silently bypassing Headroom while it is stopped. Restart
+it with `headroom wrap vscode-claude` before using Claude Code again.
+
+Restore the values that existed before Headroom configured the extension:
+
+```bash
+headroom unwrap vscode-claude
+```
+
+Headroom records only the two values it owns in a sidecar next to the Claude
+settings file. It refuses malformed settings or conflicting edits rather than
+overwriting them. Unrelated Claude settings are preserved.
+
+If you used `--settings-file` during setup, pass the same option when undoing it:
+
+```bash
+headroom unwrap vscode-claude --settings-file /path/to/.claude/settings.json
+```
+
+## Remote development
+
+For Dev Containers, SSH, or WSL, `127.0.0.1` must refer to the environment where
+the Claude Code process runs. Run Headroom there or forward the selected port,
+and pass that environment's Claude settings file with `--settings-file` when
+automatic discovery does not match it.
+
+## Troubleshooting
+
+- Check `http://127.0.0.1:8787/health` while the wrapper is running.
+- Run `headroom wrap vscode-claude --port 8788` if port 8787 is occupied.
+- Reload the VS Code window after changing Claude Code settings.
+- Keep the wrapper process running for the entire Claude Code session. A stopped
+ proxy intentionally does not fall back to a direct Anthropic connection.
+- If configuration reports a conflict, inspect `~/.claude/settings.json`; Headroom
+ will not replace a managed value that changed after setup.
+- If you use `CLAUDE_CONFIG_DIR`, launch Headroom from an environment where it is
+ set to the same value used by Claude Code.
+- This integration is for the Claude Code extension, not the Claude desktop app.
diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py
index 08bc41efb..0120adc00 100644
--- a/e2e/wrap/run.py
+++ b/e2e/wrap/run.py
@@ -37,6 +37,7 @@ OPENHANDS_PORT = 28895
OPENCODE_PORT = 28896
CLAUDE_PORT = 28897
VSCODE_PORT = 28898
+VSCODE_CLAUDE_PORT = 28899
MOCK_UPSTREAM_PORT = 19001
@@ -174,6 +175,20 @@ class MockOpenAIHandler(BaseHTTPRequestHandler):
},
)
return
+ if self.path == "/v1/messages":
+ self._write_json(
+ 200,
+ {
+ "id": "msg_e2e",
+ "type": "message",
+ "role": "assistant",
+ "model": payload.get("model", "claude-sonnet-4-6"),
+ "content": [{"type": "text", "text": "mock Claude response"}],
+ "stop_reason": "end_turn",
+ "usage": {"input_tokens": 12, "output_tokens": 4},
+ },
+ )
+ return
self._write_json(404, {"error": {"message": "not found"}})
@@ -804,6 +819,67 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None:
)
+def verify_vscode_claude_wrap(base_env: dict[str, str], project_dir: Path) -> None:
+ """Exercise Claude Code's settings, proxy request, and restore lifecycle."""
+ port = VSCODE_CLAUDE_PORT
+ settings_path = Path(base_env["HOME"]) / ".claude" / "settings.json"
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
+ original = {"env": {"KEEP": "yes"}, "permissions": {"allow": ["Read"]}}
+ settings_path.write_text(json.dumps(original), encoding="utf-8")
+
+ env = base_env.copy()
+ env["ANTHROPIC_TARGET_API_URL"] = f"http://127.0.0.1:{MOCK_UPSTREAM_PORT}"
+ proc = subprocess.Popen(
+ ["headroom", "wrap", "vscode-claude", "--port", str(port)],
+ env=env,
+ cwd=str(project_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ try:
+ output = wait_for_output(proc, "Press Ctrl+C to stop the proxy.", timeout=30)
+ project_prefix = f"/p/{quote(project_dir.name, safe='')}"
+ configured = json.loads(settings_path.read_text(encoding="utf-8"))
+ proxy_url = configured["env"]["ANTHROPIC_BASE_URL"]
+ assert_true(
+ proxy_url == f"http://127.0.0.1:{port}{project_prefix}",
+ "VS Code Claude wrap should configure the project-scoped Anthropic URL",
+ )
+ assert_true(
+ configured["env"]["ENABLE_TOOL_SEARCH"] == "true",
+ "VS Code Claude wrap should retain Claude Code tool deferral",
+ )
+ assert_true(configured["env"]["KEEP"] == "yes", "Existing Claude env must remain")
+ assert_true(str(settings_path) in output, "Wrap output should identify Claude settings")
+
+ response = httpx.post(
+ f"{proxy_url}/v1/messages",
+ headers={"x-api-key": "synthetic-anthropic-key", "anthropic-version": "2023-06-01"},
+ json={
+ "model": "claude-sonnet-4-6",
+ "max_tokens": 16,
+ "messages": [{"role": "user", "content": "Reply briefly"}],
+ },
+ timeout=30,
+ )
+ assert_true(response.status_code == 200, "Claude request should traverse Headroom")
+ assert_true(
+ response.json()["content"][0]["text"] == "mock Claude response",
+ "Claude response should return through Headroom",
+ )
+ finally:
+ stop_process(proc)
+
+ run(["headroom", "unwrap", "vscode-claude"], env=env, cwd=project_dir, timeout=60)
+ assert_true(
+ json.loads(settings_path.read_text(encoding="utf-8")) == original,
+ "VS Code Claude unwrap should restore existing Claude settings",
+ )
+
+
def verify_cline_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap cline --prepare-only` exits clean.
@@ -986,6 +1062,7 @@ def main() -> None:
verify_aider_wrap(base_env, project_dir, log_dir)
verify_cursor_wrap(base_env, project_dir)
verify_vscode_wrap(base_env, project_dir)
+ verify_vscode_claude_wrap(base_env, project_dir)
verify_cline_wrap(base_env, project_dir)
verify_continue_wrap(base_env, project_dir)
verify_goose_wrap(base_env, project_dir)
diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py
index 28c460aaa..af22dc45c 100644
--- a/headroom/cli/wrap.py
+++ b/headroom/cli/wrap.py
@@ -4,6 +4,7 @@ Usage:
headroom wrap claude # Start proxy + claude
headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI
headroom wrap vscode # Transparently proxy VS Code Copilot
+ headroom wrap vscode-claude # Transparently proxy VS Code Claude Code
headroom wrap codex # Start proxy + OpenAI Codex CLI
headroom wrap aider # Start proxy + aider
headroom wrap openclaude # Start proxy + OpenClaude
@@ -73,11 +74,15 @@ from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
+ claude_user_settings_path,
+ configure_vscode_claude_settings,
detect_claude_code_version,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message,
remote_control_sibling_gate_note,
+ remove_vscode_claude_settings,
+ vscode_claude_proxy_url,
)
from headroom.providers.claude import (
proxy_base_url as _claude_proxy_base_url,
@@ -2726,6 +2731,12 @@ def _run_proxy_only_watcher(
signal.signal(signal.SIGINT, _signal_shutdown)
signal.signal(signal.SIGTERM, _signal_shutdown)
+ # Windows exposes Ctrl+Break as SIGBREAK rather than SIGINT. Test runners,
+ # IDE terminals, and process supervisors commonly use Ctrl+Break to target
+ # a newly created process group, so route it through the same graceful
+ # cleanup path as an interactive Ctrl+C.
+ if sys.platform == "win32" and hasattr(signal, "SIGBREAK"):
+ signal.signal(signal.SIGBREAK, _signal_shutdown)
try:
_print_wrap_banner(agent_label)
@@ -3935,15 +3946,28 @@ def _make_cleanup(proxy_proc_holder: list, port: int | list[int] = 8787) -> Any:
p = port[0] if isinstance(port, list) else port
_unregister_proxy_client(p)
proc = proxy_proc_holder[0] if proxy_proc_holder else None
- if proc and proc.poll() is None:
+ if proc:
if _other_clients_exist():
# Other clients still using the proxy — leave it running.
return
- proc.terminate()
- try:
- proc.wait(timeout=5)
- except subprocess.TimeoutExpired:
- proc.kill()
+ if proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ # On Windows the proxy launcher can exit while its detached
+ # serving child remains alive (the native runtime uses a child
+ # process). The detachment is intentional so an ungraceful
+ # terminal close cannot disrupt other wrappers, but a graceful
+ # Ctrl+C from the last wrapper must still stop the listener.
+ if sys.platform == "win32" and _check_proxy(p):
+ stop_status = _stop_local_proxy_for_unwrap(p)
+ if stop_status not in {"stopped", "not_running"}:
+ click.echo(
+ f" Warning: proxy on port {p} remained running "
+ f"after shutdown ({stop_status})."
+ )
return cleanup
@@ -4250,6 +4274,7 @@ def wrap(ctx: click.Context) -> None:
headroom wrap codex # OpenAI Codex CLI
headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap vscode # VS Code Copilot (preserves model picker)
+ headroom wrap vscode-claude # VS Code Claude Code extension
headroom wrap aider # Aider
headroom wrap openclaude # OpenClaude
headroom wrap vibe # Mistral Vibe
@@ -5213,6 +5238,86 @@ def unwrap_vscode_copilot(settings_file: Path | None) -> None:
click.echo(f"No Headroom Copilot proxy settings found in {target_settings}")
+# =============================================================================
+# Claude Code for VS Code
+# =============================================================================
+
+
+@wrap.command("vscode-claude")
+@click.option("--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port")
+@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
+@click.option(
+ "--settings-file",
+ type=click.Path(path_type=Path, dir_okay=False),
+ default=None,
+ help="Override Claude Code user settings.json path",
+)
+@click.option(
+ "--configure/--no-configure",
+ default=True,
+ help="Safely add/update Claude Code's proxy environment settings",
+)
+def vscode_claude(
+ port: int,
+ memory: bool,
+ settings_file: Path | None,
+ configure: bool,
+) -> None:
+ """Route VS Code's official Claude Code extension through Headroom.
+
+ Run this from your project, reload VS Code after first setup, and keep this
+ command running while using Claude Code. Authentication and model selection
+ remain unchanged. Run `headroom unwrap vscode-claude` to restore settings.
+ """
+ target_settings = settings_file or claude_user_settings_path()
+
+ def _print_setup(actual_port: int) -> None:
+ proxy_url = vscode_claude_proxy_url(actual_port, _project_name_from_cwd())
+ if configure:
+ action = configure_vscode_claude_settings(target_settings, proxy_url)
+ click.echo(f" VS Code Claude Code proxy settings {action}: {target_settings}")
+ click.echo(" Next: Reload VS Code, then use the Claude Code panel.")
+ click.echo(" Keep this command running. Press Ctrl+C to stop the proxy.")
+ click.echo(" Authentication and the selected Claude model are preserved.")
+ click.echo(" Undo later with: headroom unwrap vscode-claude")
+ click.echo(" Guide: https://headroom-docs.vercel.app/docs/vscode-claude-code")
+ return
+ click.echo(f" Add these values under 'env' in {target_settings}:")
+ click.echo(f' "ANTHROPIC_BASE_URL": "{proxy_url}",')
+ click.echo(f' "{_TOOL_SEARCH_ENV}": "{_TOOL_SEARCH_DEFAULT}"')
+
+ _run_proxy_only_watcher(
+ agent_label="VS CODE CLAUDE",
+ port=port,
+ no_proxy=False,
+ learn=False,
+ memory=memory,
+ agent_type="claude",
+ print_setup_lines=_print_setup,
+ )
+
+
+@unwrap.command("vscode-claude")
+@click.option(
+ "--settings-file",
+ type=click.Path(path_type=Path, dir_okay=False),
+ default=None,
+ help="Override Claude Code user settings.json path",
+)
+def unwrap_vscode_claude(settings_file: Path | None) -> None:
+ """Restore settings saved by `headroom wrap vscode-claude`.
+
+ Reload the VS Code window afterward. If setup used --settings-file, pass the
+ same path here.
+ """
+ target_settings = settings_file or claude_user_settings_path()
+ if remove_vscode_claude_settings(target_settings):
+ click.echo(f"Restored Claude Code settings in {target_settings}")
+ click.echo("Reload the VS Code window to apply the restored settings.")
+ else:
+ click.echo(f"No Headroom VS Code Claude settings found for {target_settings}")
+
+
# =============================================================================
# GitHub Copilot CLI (unwrap)
# =============================================================================
diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py
index e15e1b2c3..9fad1fbfb 100644
--- a/headroom/providers/claude/__init__.py
+++ b/headroom/providers/claude/__init__.py
@@ -17,8 +17,18 @@ from .runtime import (
remote_control_gate_message,
remote_control_sibling_gate_note,
)
+from .vscode import (
+ claude_user_settings_path,
+ configure_vscode_claude_settings,
+ remove_vscode_claude_settings,
+ vscode_claude_proxy_url,
+)
__all__ = [
+ "claude_user_settings_path",
+ "configure_vscode_claude_settings",
+ "remove_vscode_claude_settings",
+ "vscode_claude_proxy_url",
"DEFAULT_API_URL",
"REMOTE_CONTROL_BASE_URL_ENV",
"REMOTE_CONTROL_GATED_MIN_VERSION",
diff --git a/headroom/providers/claude/vscode.py b/headroom/providers/claude/vscode.py
new file mode 100644
index 000000000..8f7e62486
--- /dev/null
+++ b/headroom/providers/claude/vscode.py
@@ -0,0 +1,170 @@
+"""Persistent configuration for Claude Code's VS Code extension."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+import click
+
+from headroom import fsutil
+from headroom.proxy.project_context import with_project_prefix
+
+_BASE_URL_KEY = "ANTHROPIC_BASE_URL"
+_TOOL_SEARCH_KEY = "ENABLE_TOOL_SEARCH"
+_MANAGED_KEYS = (_BASE_URL_KEY, _TOOL_SEARCH_KEY)
+_STATE_FILENAME = ".headroom-vscode-claude.json"
+_STATE_VERSION = 1
+
+
+def claude_user_settings_path(
+ environ: Mapping[str, str] | None = None, *, platform: str | None = None
+) -> Path:
+ """Return the Claude Code user settings path, respecting CLAUDE_CONFIG_DIR."""
+ env = environ if environ is not None else os.environ
+ config_dir = env.get("CLAUDE_CONFIG_DIR")
+ if config_dir:
+ return Path(config_dir).expanduser() / "settings.json"
+ current_platform = platform or sys.platform
+ home_var = "USERPROFILE" if current_platform == "win32" else "HOME"
+ home = Path(env.get(home_var) or Path.home())
+ return home / ".claude" / "settings.json"
+
+
+def vscode_claude_proxy_url(port: int, project: str | None = None) -> str:
+ """Return the project-scoped Anthropic endpoint for Claude Code in VS Code."""
+ return str(with_project_prefix(f"http://127.0.0.1:{port}", project))
+
+
+def _state_path(settings_path: Path) -> Path:
+ return settings_path.with_name(_STATE_FILENAME)
+
+
+def _read_object(path: Path, *, label: str) -> dict[str, Any]:
+ try:
+ raw = fsutil.read_text(path)
+ except OSError as exc:
+ raise click.ClickException(f"Could not read {label} {path}: {exc}") from exc
+ if not raw.strip():
+ return {}
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise click.ClickException(
+ f"{label.capitalize()} {path} is not valid JSON ({exc}); refusing to overwrite it."
+ ) from exc
+ if not isinstance(payload, dict):
+ raise click.ClickException(
+ f"{label.capitalize()} {path} must contain a JSON object; refusing to overwrite it."
+ )
+ return payload
+
+
+def _read_settings(path: Path) -> dict[str, Any]:
+ return _read_object(path, label="Claude settings") if path.exists() else {}
+
+
+def _env_map(payload: dict[str, Any], path: Path) -> dict[str, Any]:
+ env = payload.get("env")
+ if env is None:
+ return {}
+ if not isinstance(env, dict):
+ raise click.ClickException(
+ f"Claude settings {path} has a non-object 'env' value; refusing to overwrite it."
+ )
+ return dict(env)
+
+
+def _write_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fsutil.write_text(path, json.dumps(payload, indent=2) + "\n")
+
+
+def configure_vscode_claude_settings(path: Path, proxy_url: str) -> str:
+ """Route Claude Code's VS Code process through Headroom, reversibly."""
+ payload = _read_settings(path)
+ env = _env_map(payload, path)
+ state_path = _state_path(path)
+ managed = {_BASE_URL_KEY: proxy_url, _TOOL_SEARCH_KEY: "true"}
+
+ if state_path.exists():
+ state = _read_object(state_path, label="Headroom state")
+ if state.get("version") != _STATE_VERSION or not isinstance(state.get("previous"), dict):
+ raise click.ClickException(
+ f"Headroom state {state_path} is unsupported or incomplete; refusing to edit."
+ )
+ old_managed = state.get("managed")
+ if not isinstance(old_managed, dict) or any(
+ env.get(key) != old_managed.get(key) for key in _MANAGED_KEYS
+ ):
+ raise click.ClickException(
+ "Claude settings changed one of Headroom's managed values. Run "
+ "`headroom unwrap vscode-claude` or resolve the conflict before retrying."
+ )
+ action = "updated"
+ else:
+ state = {
+ "version": _STATE_VERSION,
+ "settings_existed": path.exists(),
+ "previous": {
+ key: {"present": key in env, "value": env.get(key)} for key in _MANAGED_KEYS
+ },
+ }
+ action = "added"
+
+ env.update(managed)
+ payload["env"] = env
+ state["managed"] = managed
+ _write_json(path, payload)
+ _write_json(state_path, state)
+ return action
+
+
+def remove_vscode_claude_settings(path: Path) -> bool:
+ """Restore the values saved before Headroom configured the VS Code extension."""
+ state_path = _state_path(path)
+ if not state_path.exists():
+ return False
+ state = _read_object(state_path, label="Headroom state")
+ previous = state.get("previous")
+ managed = state.get("managed")
+ if state.get("version") != _STATE_VERSION or not isinstance(previous, dict):
+ raise click.ClickException(
+ f"Headroom state {state_path} is unsupported or incomplete; refusing to edit."
+ )
+ if not isinstance(managed, dict):
+ raise click.ClickException(f"Headroom state {state_path} has no managed values.")
+
+ payload = _read_settings(path)
+ env = _env_map(payload, path)
+ if any(env.get(key) != managed.get(key) for key in _MANAGED_KEYS):
+ raise click.ClickException(
+ "Claude settings changed one of Headroom's managed values; refusing to overwrite "
+ f"the user's change. Resolve the conflict in {path}, then retry."
+ )
+
+ for key in _MANAGED_KEYS:
+ saved = previous.get(key)
+ if not isinstance(saved, dict) or not isinstance(saved.get("present"), bool):
+ raise click.ClickException(
+ f"Headroom state {state_path} is incomplete; refusing to edit."
+ )
+ if saved["present"]:
+ env[key] = saved.get("value")
+ else:
+ env.pop(key, None)
+ if env:
+ payload["env"] = env
+ else:
+ payload.pop("env", None)
+
+ if payload or state.get("settings_existed"):
+ _write_json(path, payload)
+ elif path.exists():
+ path.unlink()
+ state_path.unlink()
+ return True
diff --git a/tests/test_cli/test_wrap_helpers.py b/tests/test_cli/test_wrap_helpers.py
index b2311b653..a1366b47e 100644
--- a/tests/test_cli/test_wrap_helpers.py
+++ b/tests/test_cli/test_wrap_helpers.py
@@ -213,7 +213,7 @@ def test_run_proxy_only_watcher_keyboardinterrupt_shuts_down_cleanly(
def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """The installed SIGINT handler must not misreport its own proxy stop as a crash."""
+ """Windows console stop handlers must use the clean shutdown path."""
handlers: dict[int, Any] = {}
cleanup_calls = {"n": 0}
@@ -235,6 +235,9 @@ def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown(
monkeypatch.setattr(wrap_mod.time, "sleep", trigger_sigint)
monkeypatch.setattr(wrap_mod, "_make_cleanup", lambda holder, port: cleanup)
monkeypatch.setattr(wrap_mod.signal, "signal", capture_handler)
+ monkeypatch.setattr(wrap_mod.sys, "platform", "win32")
+ sigbreak = 999
+ monkeypatch.setattr(wrap_mod.signal, "SIGBREAK", sigbreak, raising=False)
runner = CliRunner()
@@ -254,6 +257,7 @@ def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown(
assert inv.exit_code == 0, inv.output
assert "Shutting down..." in inv.output
assert "Proxy process exited unexpectedly" not in inv.output
+ assert sigbreak in handlers
assert cleanup_calls["n"] >= 2 # signal handler plus finally (idempotent)
@@ -538,6 +542,27 @@ class TestProxyClientRefCounting:
# Our own marker is removed before we count.
assert wrap_mod._live_proxy_clients(self.PORT, exclude_self=False) == []
+ def test_cleanup_stops_detached_windows_serving_child(
+ self, clients_dir: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Ctrl+C must stop the listener even when its launcher already exited."""
+ wrap_mod._register_proxy_client(self.PORT)
+ proc = _FakeProxyProc()
+ proc.poll = lambda: 0 # type: ignore[method-assign]
+ stopped: list[int] = []
+ monkeypatch.setattr(wrap_mod.sys, "platform", "win32")
+ monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: port == self.PORT)
+ monkeypatch.setattr(
+ wrap_mod,
+ "_stop_local_proxy_for_unwrap",
+ lambda port: stopped.append(port) or "stopped",
+ )
+
+ wrap_mod._make_cleanup([proc], self.PORT)()
+
+ assert not proc.terminated
+ assert stopped == [self.PORT]
+
def test_cleanup_leaves_proxy_running_when_other_client_alive(self, clients_dir: Path) -> None:
"""A second live client (here: the test's parent) keeps the proxy up."""
wrap_mod._register_proxy_client(self.PORT)
diff --git a/tests/test_cli/test_wrap_vscode_claude.py b/tests/test_cli/test_wrap_vscode_claude.py
new file mode 100644
index 000000000..ec3a37662
--- /dev/null
+++ b/tests/test_cli/test_wrap_vscode_claude.py
@@ -0,0 +1,62 @@
+"""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
diff --git a/tests/test_provider_claude_vscode_config.py b/tests/test_provider_claude_vscode_config.py
new file mode 100644
index 000000000..61f9e73de
--- /dev/null
+++ b/tests/test_provider_claude_vscode_config.py
@@ -0,0 +1,182 @@
+"""Tests for reversible Claude Code VS Code configuration."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import click
+import pytest
+
+from headroom.providers.claude.vscode import (
+ claude_user_settings_path,
+ configure_vscode_claude_settings,
+ remove_vscode_claude_settings,
+ vscode_claude_proxy_url,
+)
+
+
+def test_settings_path_honors_claude_config_dir(tmp_path: Path) -> None:
+ assert claude_user_settings_path({"CLAUDE_CONFIG_DIR": str(tmp_path)}) == (
+ tmp_path / "settings.json"
+ )
+
+
+def test_settings_path_uses_windows_profile() -> None:
+ path = claude_user_settings_path(
+ {"HOME": "/wrong", "USERPROFILE": r"C:\\Users\\claude"}, platform="win32"
+ )
+ assert path == Path(r"C:\\Users\\claude") / ".claude" / "settings.json"
+
+
+def test_proxy_url_is_project_scoped() -> None:
+ assert vscode_claude_proxy_url(8787, "my project").endswith("/p/my%20project")
+
+
+def test_configure_and_remove_preserve_unrelated_and_previous_values(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ path.write_text(
+ json.dumps(
+ {
+ "permissions": {"allow": ["Read"]},
+ "env": {
+ "KEEP": "yes",
+ "ANTHROPIC_BASE_URL": "https://gateway.example",
+ "ENABLE_TOOL_SEARCH": "false",
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ assert configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo") == "added"
+ configured = json.loads(path.read_text(encoding="utf-8"))
+ assert configured["env"] == {
+ "KEEP": "yes",
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/p/demo",
+ "ENABLE_TOOL_SEARCH": "true",
+ }
+ assert configured["permissions"] == {"allow": ["Read"]}
+
+ assert remove_vscode_claude_settings(path)
+ restored = json.loads(path.read_text(encoding="utf-8"))
+ assert restored["env"] == {
+ "KEEP": "yes",
+ "ANTHROPIC_BASE_URL": "https://gateway.example",
+ "ENABLE_TOOL_SEARCH": "false",
+ }
+ assert restored["permissions"] == {"allow": ["Read"]}
+ assert not (tmp_path / ".headroom-vscode-claude.json").exists()
+
+
+def test_reconfigure_updates_port_without_losing_original_values(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ path.write_text('{"env":{"ANTHROPIC_BASE_URL":"https://original.example"}}', encoding="utf-8")
+
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo")
+ assert configure_vscode_claude_settings(path, "http://127.0.0.1:9999/p/demo") == "updated"
+ assert remove_vscode_claude_settings(path)
+ assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
+ "ANTHROPIC_BASE_URL": "https://original.example"
+ }
+
+
+def test_remove_deletes_settings_created_only_for_headroom(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo")
+ assert path.exists()
+ assert remove_vscode_claude_settings(path)
+ assert not path.exists()
+
+
+def test_configure_refuses_malformed_settings(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ path.write_text("{broken", encoding="utf-8")
+ with pytest.raises(click.ClickException, match="not valid JSON"):
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787")
+ assert path.read_text(encoding="utf-8") == "{broken"
+
+
+@pytest.mark.parametrize("contents", ["[]", '{"env": []}'])
+def test_configure_refuses_unsafe_settings_shapes(tmp_path: Path, contents: str) -> None:
+ path = tmp_path / "settings.json"
+ path.write_text(contents, encoding="utf-8")
+ with pytest.raises(click.ClickException, match="refusing to overwrite"):
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787")
+ assert path.read_text(encoding="utf-8") == contents
+
+
+def test_configure_refuses_unreadable_settings(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ path.write_text("{}", encoding="utf-8")
+ with (
+ patch("headroom.providers.claude.vscode.fsutil.read_text", side_effect=OSError("denied")),
+ pytest.raises(click.ClickException, match="Could not read Claude settings"),
+ ):
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787")
+
+
+def test_empty_existing_settings_is_restored_as_existing_file(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ path.write_text("", encoding="utf-8")
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787")
+ assert remove_vscode_claude_settings(path)
+ assert json.loads(path.read_text(encoding="utf-8")) == {}
+
+
+def test_remove_without_headroom_state_is_noop(tmp_path: Path) -> None:
+ assert not remove_vscode_claude_settings(tmp_path / "settings.json")
+
+
+@pytest.mark.parametrize(
+ ("state_update", "message"),
+ [
+ ({"version": 2}, "unsupported or incomplete"),
+ ({"managed": None}, "has no managed values"),
+ ({"previous": {"ANTHROPIC_BASE_URL": None}}, "is incomplete"),
+ ],
+)
+def test_remove_refuses_incomplete_state(
+ tmp_path: Path, state_update: dict[str, object], message: str
+) -> None:
+ path = tmp_path / "settings.json"
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787")
+ state_path = tmp_path / ".headroom-vscode-claude.json"
+ state = json.loads(state_path.read_text(encoding="utf-8"))
+ state.update(state_update)
+ state_path.write_text(json.dumps(state), encoding="utf-8")
+ with pytest.raises(click.ClickException, match=message):
+ remove_vscode_claude_settings(path)
+
+
+def test_reconfigure_refuses_incomplete_or_conflicting_state(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ proxy_url = "http://127.0.0.1:8787"
+ configure_vscode_claude_settings(path, proxy_url)
+ state_path = tmp_path / ".headroom-vscode-claude.json"
+ state_path.write_text("{}", encoding="utf-8")
+ with pytest.raises(click.ClickException, match="unsupported or incomplete"):
+ configure_vscode_claude_settings(path, proxy_url)
+
+ state_path.unlink()
+ configure_vscode_claude_settings(path, proxy_url)
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ payload["env"]["ENABLE_TOOL_SEARCH"] = "false"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ with pytest.raises(click.ClickException, match="managed values"):
+ configure_vscode_claude_settings(path, proxy_url)
+
+
+def test_remove_refuses_to_overwrite_changed_managed_value(tmp_path: Path) -> None:
+ path = tmp_path / "settings.json"
+ configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo")
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ payload["env"]["ANTHROPIC_BASE_URL"] = "https://user-change.example"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+ with pytest.raises(click.ClickException, match="refusing to overwrite"):
+ remove_vscode_claude_settings(path)
+ assert json.loads(path.read_text(encoding="utf-8"))["env"]["ANTHROPIC_BASE_URL"] == (
+ "https://user-change.example"
+ )