mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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>
This commit is contained in:
parent
08fce29b47
commit
13a310a00d
12 changed files with 810 additions and 7 deletions
28
README.md
28
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…**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"litellm",
|
||||
"claude-code-vertex",
|
||||
"claude-code-azure-foundry",
|
||||
"vscode-claude-code",
|
||||
"vscode-copilot",
|
||||
"opencode",
|
||||
"grok-build",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -203,6 +203,13 @@ Transforms: ['smart_crusher', 'cache_aligner']
|
|||
|
||||
## Alternative: proxy mode (zero code changes)
|
||||
|
||||
<Callout type="info" title="Using Claude Code inside VS Code?">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
If you do not want to change any code, run Headroom as a proxy and point your existing client at it:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
127
docs/content/docs/vscode-claude-code.mdx
Normal file
127
docs/content/docs/vscode-claude-code.mdx
Normal file
|
|
@ -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.
|
||||
|
||||
<Callout type="info" title="Using Claude Code in a terminal instead?">
|
||||
Use `headroom wrap claude`. This page is specifically for Anthropic's official
|
||||
Claude Code extension inside VS Code.
|
||||
</Callout>
|
||||
|
||||
## 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
170
headroom/providers/claude/vscode.py
Normal file
170
headroom/providers/claude/vscode.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
62
tests/test_cli/test_wrap_vscode_claude.py
Normal file
62
tests/test_cli/test_wrap_vscode_claude.py
Normal file
|
|
@ -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
|
||||
182
tests/test_provider_claude_vscode_config.py
Normal file
182
tests/test_provider_claude_vscode_config.py
Normal file
|
|
@ -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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue