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" + )