From 56ded247dfd1d055a13ec2109968845f93e9be8e Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Sun, 20 Sep 2026 19:18:57 -0700 Subject: [PATCH] Connect Claude Code in JetBrains ACP (#1848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The JetBrains ACP card currently opens a no-op modal. Users need Connect and Disconnect to configure Claude Code through FCC from that card. ## How - Connect adds a dedicated **Claude Code (FCC)** agent to `~/.jetbrains/acp.json`, using JetBrains' installed Claude ACP adapter and a compatible existing Node runtime. The modal shows the affected file and setup instructions. - Recognize the FCC entry by its ownership marker. Refresh its token, server address, shared Claude settings, and installed adapter paths when FCC starts. Preserve other agents and settings; reject same-name entries without the marker. - Disconnect removes the dedicated entry, including customizations inside it. Keep Disconnect available when adapter discovery or startup refresh fails. - Use the existing serialized configuration operations and atomic file writer. Support JSON5 input with duplicate-key rejection; writes use ordinary JSON and may discard comments and formatting. - Support standard local JetBrains locations on Windows, macOS, and Linux. Users install and start Claude Agent in JetBrains first, and restart FCC after agent updates to refresh versioned paths. - Update the README setup steps and bump the patch version to **6.2.52**.

RetriggerConfidence Score: 4/5

Not safe to merge until the Linux-breaking Windows simulation in the JetBrains ACP harness is corrected.

Fix All in CodexFindings

1. P1 **Windows simulation breaks Linux CI** ▶
Summary The JetBrains ACP integration adds runtime configuration, administrative controls, and test coverage. The simulated Windows harness path fails on Linux before it can validate the Windows configuration layout, so this must be fixed before merging.
Reviews (1) · Last reviewed commit: ["Connect Claude Code through JetBrains AC..."](https://github.com/alishahryar1/free-claude-code/commit/5c44d43c60ee5ae74a2ab9e3db75f94c9eb89c0a) --- README.md | 2 +- e2e/conftest.py | 14 + e2e/test_admin_integrations.py | 116 +------- e2e/test_jetbrains_acp_integration.py | 77 +++++ pyproject.toml | 2 +- src/free_claude_code/api/admin_routes.py | 32 ++ .../api/admin_static/admin.js | 90 +++++- .../api/admin_static/index.html | 8 +- src/free_claude_code/api/ports.py | 8 + .../harnesses/jetbrains_acp_integration.py | 242 ++++++++++++++++ src/free_claude_code/runtime/application.py | 57 ++++ tests/api/test_jetbrains_acp_integration.py | 70 +++++ tests/conftest.py | 14 + .../test_jetbrains_acp_integration.py | 274 ++++++++++++++++++ tests/runtime/test_integration_startup.py | 48 +++ uv.lock | 2 +- 16 files changed, 946 insertions(+), 110 deletions(-) create mode 100644 e2e/test_jetbrains_acp_integration.py create mode 100644 src/free_claude_code/harnesses/jetbrains_acp_integration.py create mode 100644 tests/api/test_jetbrains_acp_integration.py create mode 100644 tests/harnesses/test_jetbrains_acp_integration.py diff --git a/README.md b/README.md index cf4a2fcbde..b9617eb2fa 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ For editor and app integrations, install the client, start FCC, then open - **Claude Code in VS Code** — install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code). - **Claude Desktop** — install [Claude Desktop](https://claude.ai/download). Fully quit it before connecting or disconnecting, then reopen it. Disconnect returns to normal Claude sign-in. - **Codex in VS Code and App** — install the [Codex extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt) or Codex App. -- **Claude Code in JetBrains ACP** — install the Claude Code agent in your JetBrains IDE. +- **Claude Code in JetBrains ACP** — install Claude Agent in JetBrains AI Assistant and start it once, then click **Connect** in FCC. Reopen the IDE, select **Claude Code (FCC)**, and start a new chat. After JetBrains updates the agent, restart FCC before starting a new chat. Requires a local IDE in its standard installation locations. Reload VS Code or restart the app/IDE after connecting. In Codex and Claude Desktop, select an FCC model from the model picker. FCC keeps connected integrations up to date when it diff --git a/e2e/conftest.py b/e2e/conftest.py index 902191f61a..0eb1e22df8 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -30,6 +30,7 @@ claude_desktop_integration, claude_integration, codex_integration, + jetbrains_acp_integration, ) from free_claude_code.providers.base import BaseProvider, ProviderConfig from free_claude_code.providers.runtime import ProviderRuntime @@ -140,6 +141,19 @@ def admin_base_url( """Serve one fully isolated Admin application on an OS-assigned port.""" config_dir = tmp_path / ".fcc" + monkeypatch.setattr( + jetbrains_acp_integration, + "config_path", + lambda: tmp_path / ".jetbrains/acp.json", + ) + monkeypatch.setattr( + jetbrains_acp_integration, + "registry_path", + lambda: tmp_path / "jetbrains/installed.json", + ) + monkeypatch.setattr( + jetbrains_acp_integration, "system_root", lambda: tmp_path / "jetbrains/systems" + ) monkeypatch.setattr( claude_desktop_integration, "config_root", lambda: tmp_path / "Claude-3p" ) diff --git a/e2e/test_admin_integrations.py b/e2e/test_admin_integrations.py index 2fc8da9725..1617e2bd70 100644 --- a/e2e/test_admin_integrations.py +++ b/e2e/test_admin_integrations.py @@ -14,6 +14,7 @@ ("claude-vscode", "openClaudeIntegration"), ("codex", "openCodexIntegration"), ("claude-desktop", "openClaudeDesktopIntegration"), + ("jetbrains-acp", "openJetBrainsIntegration"), ], ) @pytest.mark.parametrize("connected", [False, True]) @@ -148,101 +149,6 @@ def test_codex_connect_disconnect_and_modal_paths( assert not (tmp_path / ".claude.json").exists() -@pytest.mark.parametrize( - "integration,title,description,modal_description,card_index", - [ - ( - "JetBrains", - "Claude Code in JetBrains ACP", - "Use FCC's models in Claude Code through JetBrains ACP.", - "Route Claude Code in JetBrains through your FCC server.", - 2, - ), - ], - ids=["jetbrains"], -) -@pytest.mark.parametrize("width", [1200, 1440, 390]) -def test_preview_connect_is_noop_and_modal_dismisses( - page, - admin_base_url, - tmp_path, - width, - integration, - title, - description, - modal_description, - card_index, -): - page.set_viewport_size({"width": width, "height": 900}) - page.goto(f"{admin_base_url}/admin/integrations") - expect(page.locator("#messageArea")).to_have_text("") - expect(page.locator("#openClaudeIntegration")).to_be_enabled() - expect(page.locator("#openCodexIntegration")).to_be_enabled() - opener = page.locator(f"#open{integration}Integration") - expect(opener).to_be_enabled() - expect(opener).to_have_text("Connect") - card = page.locator("#view-integrations > article").nth(card_index) - expect(card).to_contain_text(description) - expect(card.get_by_role("status")).to_have_count(0) - page.screenshot( - path=str(tmp_path / f"{integration}-card-{width}.png"), full_page=True - ) - paths = [ - tmp_path / ".fcc" / ".env", - tmp_path / "vscode" / "settings.json", - tmp_path / ".claude.json", - tmp_path / ".codex" / "config.toml", - ] - before = {path: path.read_bytes() if path.exists() else None for path in paths} - page.wait_for_function("!state.startupRequest && !state.startupTimer") - requests = [] - - def record_request(request): - requests.append((request.method, request.url)) - - page.on("request", record_request) - dialog = page.get_by_role("dialog", name=title, exact=True) - for dismiss in ("close", "escape", "outside"): - opener.click() - expect(dialog).to_be_visible() - close = dialog.get_by_role("button", name="Close", exact=True) - expect(close).to_be_focused() - expect(dialog).to_have_accessible_description(modal_description) - assert dialog.evaluate("element => element.scrollWidth <= element.clientWidth") - dialog.locator("p").click() - dialog.click(position={"x": 10, "y": 80}) - expect(dialog).to_be_visible() - action = dialog.get_by_role("button", name="Connect", exact=True) - for _ in range(2): - expect(action).to_be_enabled() - action.click() - expect(dialog).to_be_visible() - expect(action).to_have_text("Connect") - if dismiss == "close": - page.screenshot(path=str(tmp_path / f"{integration}-modal-{width}.png")) - close.click() - elif dismiss == "escape": - page.keyboard.press("Escape") - else: - page.mouse.click(1, 1) - expect(dialog).not_to_be_visible() - expect(opener).to_be_focused() - expect(opener).to_have_text("Connect") - assert requests == [] - expect(page.locator("#dirtyState")).to_have_text("No changes") - expect(page.locator("#applyButton")).to_be_disabled() - assert { - path: path.read_bytes() if path.exists() else None for path in paths - } == before - page.remove_listener("request", record_request) - page.reload() - expect(opener).to_be_enabled() - expect(opener).to_have_text("Connect") - expect(dialog).not_to_be_visible() - opener.click() - expect(dialog).to_be_visible() - - @pytest.mark.parametrize("width", [1280, 390]) def test_modal_shows_files_for_the_selected_action( page, admin_base_url, tmp_path, width @@ -402,21 +308,21 @@ def test_codex_existing_setup_revisit_and_invalid_config_retry( def test_codex_save_pending_and_failure_stay_in_modal(page, admin_base_url, tmp_path): page.goto(f"{admin_base_url}/admin/integrations") page.locator("#openCodexIntegration").click() - requests = [] - page.route( - "**/admin/api/integrations/codex/connect", lambda route: requests.append(route) - ) action = page.locator("#confirmCodexIntegration") + + def reject_save(route): + expect(action).to_be_disabled() + expect(action).to_have_text("Saving…") + expect(page.locator("#openCodexIntegration")).to_be_disabled() + route.fulfill(status=503, json={"detail": "Could not save settings."}) + + page.route("**/admin/api/integrations/codex/connect", reject_save) action.click() - expect(action).to_be_disabled() - expect(action).to_have_text("Saving…") - expect(page.locator("#openCodexIntegration")).to_be_disabled() - requests[0].fulfill(status=503, json={"detail": "Could not save settings."}) - expect(action).to_be_enabled() - expect(page.locator("#codexIntegrationDialog")).to_be_visible() expect(page.locator("#codexIntegrationDialogMessage")).to_have_text( "Could not save settings." ) + expect(action).to_be_enabled() + expect(page.locator("#codexIntegrationDialog")).to_be_visible() assert not (tmp_path / ".codex" / "config.toml").exists() diff --git a/e2e/test_jetbrains_acp_integration.py b/e2e/test_jetbrains_acp_integration.py new file mode 100644 index 0000000000..15b2f03b84 --- /dev/null +++ b/e2e/test_jetbrains_acp_integration.py @@ -0,0 +1,77 @@ +import json +import subprocess + +from playwright.sync_api import expect + +from free_claude_code.harnesses import jetbrains_acp_integration as jb +from tests.harnesses.test_jetbrains_acp_integration import install + + +def test_connect_retry_disconnect_and_modal_dismissal( + page, admin_base_url, monkeypatch +): + page.goto(f"{admin_base_url}/admin/integrations") + opener = page.locator("#openJetBrainsIntegration") + dialog = page.locator("#jetBrainsIntegrationDialog") + action = page.locator("#confirmJetBrainsIntegration") + expect(opener).to_have_text("Connect") + for dismiss in ("close", "escape", "outside"): + opener.click() + expect(dialog).to_be_visible() + expect(page.locator("#jetBrainsIntegrationFiles li")).to_have_text( + [str(jb.config_path().resolve())] + ) + page.locator("#jetBrainsIntegrationDescription").click() + expect(dialog).to_be_visible() + if dismiss == "close": + page.locator("#closeJetBrainsIntegration").click() + elif dismiss == "escape": + page.keyboard.press("Escape") + else: + page.mouse.click(1, 1) + expect(dialog).not_to_be_visible() + opener.click() + action.click() + expect(page.locator("#jetBrainsIntegrationDialogMessage")).to_be_visible() + expect(action).to_be_enabled() + assert not jb.config_path().exists() + install(jb.registry_path(), jb.system_root()) + monkeypatch.setattr( + jb.subprocess, + "run", + lambda args, **kw: subprocess.CompletedProcess(args, 0, "v24.19.0", ""), + ) + action.click() + expect(dialog).not_to_be_visible() + expect(opener).to_have_text("Disconnect") + assert ( + "Claude Code (FCC)" in json.loads(jb.config_path().read_text())["agent_servers"] + ) + page.reload() + expect(opener).to_have_text("Disconnect") + expect(opener).to_have_css("color", "rgb(239, 68, 68)") + jb.registry_path().unlink() + assert page.request.post( + f"{admin_base_url}/admin/api/integrations/jetbrains-acp/refresh" + ).ok + page.reload() + expect(page.locator("#jetBrainsIntegrationMessage")).to_be_visible() + expect(opener).to_have_text("Disconnect") + expect(opener).to_be_enabled() + opener.click() + action.click() + expect(opener).to_have_text("Connect") + assert json.loads(jb.config_path().read_text()) == {"agent_servers": {}} + + +def test_unreadable_config_can_be_retried(page, admin_base_url): + path = jb.config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{") + page.goto(f"{admin_base_url}/admin/integrations") + opener = page.locator("#openJetBrainsIntegration") + expect(opener).to_have_text("Retry") + expect(page.locator("#jetBrainsIntegrationMessage")).to_be_visible() + path.write_text("{}") + opener.click() + expect(opener).to_have_text("Connect") diff --git a/pyproject.toml b/pyproject.toml index ab4078f908..eb825ca613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.51" +version = "6.2.52" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/free_claude_code/api/admin_routes.py b/src/free_claude_code/api/admin_routes.py index a579468bdd..533abe6a07 100644 --- a/src/free_claude_code/api/admin_routes.py +++ b/src/free_claude_code/api/admin_routes.py @@ -319,6 +319,38 @@ async def refresh_claude_desktop( return await _integration_response(services.admin.refresh_claude_desktop) +@router.get("/admin/api/integrations/jetbrains-acp") +async def jetbrains_acp_status( + request: Request, services: ApiServices = Depends(get_services) +): + require_loopback_admin(request) + return await _integration_response(services.admin.jetbrains_acp_status) + + +@router.post("/admin/api/integrations/jetbrains-acp/connect") +async def connect_jetbrains_acp( + request: Request, services: ApiServices = Depends(get_services) +): + require_loopback_admin(request) + return await _integration_response(services.admin.connect_jetbrains_acp) + + +@router.post("/admin/api/integrations/jetbrains-acp/disconnect") +async def disconnect_jetbrains_acp( + request: Request, services: ApiServices = Depends(get_services) +): + require_loopback_admin(request) + return await _integration_response(services.admin.disconnect_jetbrains_acp) + + +@router.post("/admin/api/integrations/jetbrains-acp/refresh") +async def refresh_jetbrains_acp( + request: Request, services: ApiServices = Depends(get_services) +): + require_loopback_admin(request) + return await _integration_response(services.admin.refresh_jetbrains_acp) + + @router.get("/admin/api/integrations/codex") async def codex_integration_status( request: Request, diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js index d0b4a53566..7acf3d4ee7 100644 --- a/src/free_claude_code/api/admin_static/admin.js +++ b/src/free_claude_code/api/admin_static/admin.js @@ -139,6 +139,7 @@ function renderStartup() { renderProviderCheckResult(provider.provider_id); }); renderClaudeIntegration(); + renderJetBrainsIntegration(); renderCodexIntegration(); renderClaudeDesktopIntegration(); } @@ -282,6 +283,7 @@ function setActiveView(viewId, { scroll = false } = {}) { else window.CodeSessions.deactivate(); if (activeView.id === "integrations") { refreshClaudeIntegration(); + refreshJetBrainsIntegration(); refreshCodexIntegration(); refreshClaudeDesktopIntegration(); } @@ -1498,6 +1500,7 @@ function refreshIntegrationUpdates(previous, current) { if (state.activeView !== "integrations") return; for (const [id, integration, refresh] of [ ["claude-vscode", claudeIntegration, refreshClaudeIntegration], + ["jetbrains-acp", jetBrainsIntegration, refreshJetBrainsIntegration], ["codex", codexIntegration, refreshCodexIntegration], ["claude-desktop", claudeDesktopIntegration, refreshClaudeDesktopIntegration], ]) { @@ -1703,7 +1706,92 @@ codexIntegrationDialog.addEventListener("click", (event) => { }); const jetBrainsIntegrationDialog = byId("jetBrainsIntegrationDialog"); -byId("openJetBrainsIntegration").addEventListener("click", () => jetBrainsIntegrationDialog.showModal()); +const jetBrainsIntegration = { connected: null, busy: false, paths: null, update: null }; +const jetBrainsIntegrationPath = "/admin/api/integrations/jetbrains-acp"; + +function renderJetBrainsIntegration() { + const { connected, paths } = jetBrainsIntegration; + const busy = jetBrainsIntegration.busy || integrationUpdating(jetBrainsIntegration, "jetbrains-acp"); + const action = connected ? "Disconnect" : "Connect"; + byId("openJetBrainsIntegration").textContent = busy ? "Loading…" : connected === null ? "Retry" : action; + byId("openJetBrainsIntegration").disabled = busy; + byId("openJetBrainsIntegration").setAttribute("aria-busy", String(busy)); + byId("confirmJetBrainsIntegration").textContent = busy ? "Saving…" : action; + byId("confirmJetBrainsIntegration").disabled = busy || connected === null; + byId("openJetBrainsIntegration").className = connected && !busy ? "danger-button" : "primary-button"; + byId("confirmJetBrainsIntegration").className = connected ? "danger-button" : "primary-button"; + byId("jetBrainsIntegrationDescription").textContent = connected + ? "Remove the Claude Code (FCC) agent, including its custom settings. Finish any configuration edits first." + : "Add Claude Code (FCC) using JetBrains' installed Claude Agent. Start that agent once before connecting, and finish any configuration edits."; + const files = byId("jetBrainsIntegrationFiles"); + files.replaceChildren(); + if (paths) { + const targets = [paths.acp_config]; + targets.forEach((path) => { + const item = document.createElement("li"); + const code = document.createElement("code"); + code.textContent = path; + item.appendChild(code); + files.appendChild(item); + }); + } +} + +async function refreshJetBrainsIntegration(retry = false) { + if (jetBrainsIntegration.busy) return; + jetBrainsIntegration.busy = true; + renderJetBrainsIntegration(); + integrationMessage("jetBrainsIntegrationMessage", ""); + try { + if (retry) await api(`${jetBrainsIntegrationPath}/refresh`, { method: "POST" }); + const result = await api(jetBrainsIntegrationPath); + jetBrainsIntegration.connected = result.connected; + jetBrainsIntegration.paths = result.paths; + jetBrainsIntegration.update = result.update; + if (result.update?.state === "failed") integrationMessage("jetBrainsIntegrationMessage", result.update.message, true); + else if (result.update?.changed) integrationMessage("jetBrainsIntegrationMessage", "Settings updated. Reopen JetBrains and start a new chat."); + } catch (error) { + jetBrainsIntegration.connected = null; + jetBrainsIntegration.update = null; + integrationMessage("jetBrainsIntegrationMessage", error.message, true); + } finally { + jetBrainsIntegration.busy = false; + renderJetBrainsIntegration(); + if (jetBrainsIntegration.update?.state === "starting") void refreshStartup(); + } +} + +byId("openJetBrainsIntegration").addEventListener("click", () => { + if (jetBrainsIntegration.connected === null) { + refreshJetBrainsIntegration(jetBrainsIntegration.update?.state === "failed"); + return; + } + integrationMessage("jetBrainsIntegrationDialogMessage", ""); + jetBrainsIntegrationDialog.showModal(); +}); +byId("confirmJetBrainsIntegration").addEventListener("click", async () => { + if (byId("confirmJetBrainsIntegration").disabled) return; + const disconnect = jetBrainsIntegration.connected; + jetBrainsIntegration.busy = true; + renderJetBrainsIntegration(); + integrationMessage("jetBrainsIntegrationDialogMessage", ""); + integrationMessage("jetBrainsIntegrationMessage", ""); + try { + const result = await api(`${jetBrainsIntegrationPath}/${disconnect ? "disconnect" : "connect"}`, { method: "POST" }); + jetBrainsIntegration.connected = result.connected; + jetBrainsIntegration.update = null; + jetBrainsIntegrationDialog.close(); + integrationMessage("jetBrainsIntegrationMessage", disconnect + ? "Agent removed. Reopen JetBrains to disconnect." + : "Settings saved. Reopen JetBrains, select Claude Code (FCC), and start a new chat."); + } catch (error) { + integrationMessage("jetBrainsIntegrationDialogMessage", error.message, true); + integrationMessage("jetBrainsIntegrationMessage", error.message, true); + } finally { + jetBrainsIntegration.busy = false; + renderJetBrainsIntegration(); + } +}); byId("closeJetBrainsIntegration").addEventListener("click", () => jetBrainsIntegrationDialog.close()); jetBrainsIntegrationDialog.addEventListener("click", (event) => { if (event.target !== jetBrainsIntegrationDialog) return; diff --git a/src/free_claude_code/api/admin_static/index.html b/src/free_claude_code/api/admin_static/index.html index 2826b7939a..21bf4e76f9 100644 --- a/src/free_claude_code/api/admin_static/index.html +++ b/src/free_claude_code/api/admin_static/index.html @@ -118,7 +118,8 @@

Claude Code in JetBrains ACP

Use FCC's models in Claude Code through JetBrains ACP.

- + +
@@ -126,7 +127,12 @@

Claude Code in JetBrains ACP

Route Claude Code in JetBrains through your FCC server.

+
+ Files to modify +
    +
    +
    diff --git a/src/free_claude_code/api/ports.py b/src/free_claude_code/api/ports.py index c19855e763..983c30c5a1 100644 --- a/src/free_claude_code/api/ports.py +++ b/src/free_claude_code/api/ports.py @@ -39,6 +39,14 @@ async def refresh_claude_vscode(self) -> JsonObject: ... async def claude_desktop_status(self) -> JsonObject: ... + async def jetbrains_acp_status(self) -> JsonObject: ... + + async def connect_jetbrains_acp(self) -> JsonObject: ... + + async def disconnect_jetbrains_acp(self) -> JsonObject: ... + + async def refresh_jetbrains_acp(self) -> JsonObject: ... + async def connect_claude_desktop(self) -> JsonObject: ... async def disconnect_claude_desktop(self) -> JsonObject: ... diff --git a/src/free_claude_code/harnesses/jetbrains_acp_integration.py b/src/free_claude_code/harnesses/jetbrains_acp_integration.py new file mode 100644 index 0000000000..56a331e1ef --- /dev/null +++ b/src/free_claude_code/harnesses/jetbrains_acp_integration.py @@ -0,0 +1,242 @@ +"""Configure a custom FCC agent using JetBrains' installed Claude ACP adapter.""" + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import cast + +import json5 + +from free_claude_code.core.json_types import JsonObject +from free_claude_code.harnesses.claude import claude_proxy_values +from free_claude_code.harnesses.config_file import atomic_write_text + +_NAME = "Claude Code (FCC)" +_MARKER = "FCC_JETBRAINS_ACP" +_PACKAGE = "@agentclientprotocol/claude-agent-acp" +_MISSING = ( + "Could not find a complete Claude Agent installation and compatible Node runtime. " + "Install Claude Agent in JetBrains and start it once, then retry. " + "FCC supports standard local JetBrains installation locations." +) + + +class SetupError(ValueError): + """Actionable configuration or prerequisite problem, without secret details.""" + + +def config_path() -> Path: + return Path.home() / ".jetbrains/acp.json" + + +def _env_root(name: str, fallback: Path) -> Path: + value = Path(os.environ.get(name) or fallback) + return value if value.is_absolute() else fallback + + +def registry_path() -> Path: + home = Path.home() + if sys.platform == "win32": + parent = _env_root("APPDATA", home / "AppData/Roaming") + elif sys.platform == "darwin": + parent = home / "Library/Application Support" + else: + parent = _env_root("XDG_DATA_HOME", home / ".local/share") + return parent / "JetBrains/acp-agents/installed.json" + + +def system_root() -> Path: + home = Path.home() + if sys.platform == "win32": + parent = _env_root("LOCALAPPDATA", home / "AppData/Local") + elif sys.platform == "darwin": + parent = home / "Library/Caches" + else: + parent = _env_root("XDG_CACHE_HOME", home / ".cache") + return parent / "JetBrains" + + +def _object(value: object) -> JsonObject: + if not isinstance(value, dict): + raise ValueError("Expected a JSON object") + return cast(JsonObject, value) + + +def _read(path: Path) -> JsonObject: + try: + source = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + return {} + value = _object(json5.loads(source, allow_duplicate_keys=False)) + json.dumps(value, allow_nan=False) + return value + + +def _entry(document: JsonObject) -> tuple[JsonObject, JsonObject | None]: + agents = _object(document.get("agent_servers", {})) + if _NAME not in agents: + return agents, None + entry = _object(agents[_NAME]) + env = _object(entry.get("env", {})) + if any(not isinstance(value, str) for value in env.values()): + raise ValueError("Environment values must be strings") + if env.get(_MARKER) != "1": + raise SetupError( + "An unrelated agent already uses the name Claude Code (FCC). " + "Rename that entry in acp.json before connecting." + ) + return agents, entry + + +def _version(value: str) -> tuple[int, int, int] | None: + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value) + return (int(match[1]), int(match[2]), int(match[3])) if match else None + + +def _node( + root: Path, minimum: tuple[int, int, int], previous: JsonObject +) -> Path | None: + executable = "node.exe" if sys.platform == "win32" else "bin/node" + runtimes = root / ".runtimes/node" + versions = sorted( + ( + (version, directory) + for directory in runtimes.glob("*") + if (version := _version(directory.name)) is not None + ), + reverse=True, + ) + candidates = [(directory / executable).resolve() for _, directory in versions] + if system_node := shutil.which("node"): + candidates.append(Path(system_node).resolve()) + candidates = list(dict.fromkeys(candidates)) + candidates.sort(key=lambda path: str(path) != previous.get("command")) + for path in candidates: + if not path.is_file(): + continue + try: + result = subprocess.run( + [str(path), "--version"], + capture_output=True, + text=True, + timeout=3, + creationflags=subprocess.CREATE_NO_WINDOW + if sys.platform == "win32" + else 0, + ) + except OSError, subprocess.TimeoutExpired: + continue + actual = _version(result.stdout.strip()) + if result.returncode == 0 and actual is not None and actual >= minimum: + return path + return None + + +def _launch(previous: JsonObject) -> tuple[str, list[str]]: + registry = _read(registry_path()) + try: + installed = _object( + _object(_object(registry["agents"])["acp.registry.claude-acp"])["agent"] + ) + version = installed["version"] + package_spec = _object(_object(installed["distribution"])["npx"])["package"] + if not isinstance(version, str) or not re.fullmatch( + r"[0-9][A-Za-z0-9._-]*", version + ): + raise SetupError(_MISSING) + if package_spec != f"{_PACKAGE}@{version}": + raise SetupError(_MISSING) + except KeyError, ValueError: + raise SetupError(_MISSING) from None + previous_args = previous.get("args") + old_bin = ( + previous_args[0] if isinstance(previous_args, list) and previous_args else None + ) + candidates = sorted( + system_root().glob("*/acp-agents"), key=lambda path: str(path.resolve()) + ) + candidates.sort( + key=lambda root: ( + not ( + isinstance(old_bin, str) + and Path(old_bin).is_relative_to(root.resolve()) + ) + ) + ) + for root in candidates: + directory = root / "claude-acp" / version + if not (directory / ".installed").is_file(): + continue + package = (directory / "node_modules" / _PACKAGE).resolve() + try: + metadata = _read(package / "package.json") + if metadata.get("name") != _PACKAGE or metadata.get("version") != version: + continue + bin_path = _object(metadata.get("bin"))["claude-agent-acp"] + engine = _object(metadata.get("engines"))["node"] + if not isinstance(bin_path, str) or not isinstance(engine, str): + continue + minimum = re.fullmatch(r">=(\d+)(?:\.(\d+))?(?:\.(\d+))?", engine.strip()) + if minimum is None: + continue + script = (package / bin_path).resolve() + if not script.is_relative_to(package) or not script.is_file(): + continue + required = (int(minimum[1]), int(minimum[2] or 0), int(minimum[3] or 0)) + node = _node(root, required, previous) + if node is not None: + return str(node), [str(script), "--hide-claude-auth"] + except KeyError, ValueError, OSError: + continue + raise SetupError(_MISSING) + + +def _connect( + path: Path, + document: JsonObject, + agents: JsonObject, + previous: JsonObject, + url: str, + token: str, +) -> bool: + command, args = _launch(previous) + before = json.dumps(document, allow_nan=False) + entry = dict(previous) + entry.update(command=command, args=args) + entry["env"] = { + **_object(previous.get("env", {})), + **claude_proxy_values(url, token), + _MARKER: "1", + } + agents[_NAME] = entry + document["agent_servers"] = agents + if json.dumps(document, allow_nan=False) == before: + return False + atomic_write_text(path, json.dumps(document, indent=2, allow_nan=False) + "\n") + return True + + +def refresh_connected(path: Path, url: str, token: str) -> bool: + path = path.resolve() + document = _read(path) + agents, entry = _entry(document) + return entry is not None and _connect(path, document, agents, entry, url, token) + + +def configure( + path: Path, url: str, token: str, connected: bool | None = None +) -> JsonObject: + path = path.resolve() + document = _read(path) + agents, entry = _entry(document) + if connected is True: + _connect(path, document, agents, entry or {}, url, token) + elif connected is False and entry is not None: + del agents[_NAME] + atomic_write_text(path, json.dumps(document, indent=2, allow_nan=False) + "\n") + _, entry = _entry(_read(path)) if connected is not None else (agents, entry) + return {"connected": entry is not None, "paths": {"acp_config": str(path)}} diff --git a/src/free_claude_code/runtime/application.py b/src/free_claude_code/runtime/application.py index 252d1a3352..627556a116 100644 --- a/src/free_claude_code/runtime/application.py +++ b/src/free_claude_code/runtime/application.py @@ -46,6 +46,7 @@ claude_desktop_integration, claude_integration, codex_integration, + jetbrains_acp_integration, ) from free_claude_code.messaging.platforms import factory as messaging_platform_factory from free_claude_code.messaging.platforms.factory import MessagingPlatformOptions @@ -215,6 +216,7 @@ def __init__( self._claude_update = _IntegrationUpdate() self._desktop_update = _IntegrationUpdate() self._codex_update = _IntegrationUpdate() + self._jetbrains_update = _IntegrationUpdate() self._http_ready = asyncio.Event() self._messaging_state = ( "disabled" if self.settings.messaging_platform == "none" else "starting" @@ -251,6 +253,7 @@ async def start(self) -> None: await self.refresh_claude_vscode() await self.refresh_codex_integration() await self.refresh_claude_desktop() + await self.refresh_jetbrains_acp() self._startup_tasks.append( asyncio.create_task( run_sync_owned(remove_retired_chat_history), @@ -477,6 +480,59 @@ async def refresh_codex_integration(self) -> JsonObject: self._codex_update, partial(self._codex_integration, "refresh") ) + async def jetbrains_acp_status(self) -> JsonObject: + return await self._jetbrains_acp("status") + + async def connect_jetbrains_acp(self) -> JsonObject: + return await self._jetbrains_acp("connect") + + async def disconnect_jetbrains_acp(self) -> JsonObject: + return await self._jetbrains_acp("disconnect") + + async def refresh_jetbrains_acp(self) -> JsonObject: + return self._start_integration_update( + self._jetbrains_update, partial(self._jetbrains_acp, "refresh") + ) + + async def _jetbrains_acp(self, action: IntegrationAction) -> JsonObject: + async with self._config_lock: + self._check_integration_available() + settings = self.settings + + def operate() -> JsonObject: + path = jetbrains_acp_integration.config_path() + url = local_proxy_root_url(settings) + if action == "refresh": + return { + "changed": jetbrains_acp_integration.refresh_connected( + path, url, settings.proxy_auth_token + ) + } + return jetbrains_acp_integration.configure( + path, + url, + settings.proxy_auth_token, + None if action == "status" else action == "connect", + ) + + try: + result = await run_sync_owned(operate) + except jetbrains_acp_integration.SetupError as exc: + raise InvalidRequestError(str(exc)) from None + except ValueError, UnicodeError: + raise InvalidRequestError( + "Could not read JetBrains ACP configuration. Check the JSON in acp.json and the installed Claude Agent metadata." + ) from None + except OSError: + raise ApplicationUnavailableError( + "Could not access JetBrains ACP files. Finish any configuration edits, check file permissions, and retry." + ) from None + if action in {"connect", "disconnect"}: + self._jetbrains_update.complete() + if action == "status": + result["update"] = self._jetbrains_update.snapshot() + return result + async def claude_desktop_status(self) -> JsonObject: return await self._claude_desktop("status") @@ -687,6 +743,7 @@ async def admin_status(self) -> JsonObject: "claude-vscode": self._claude_update.snapshot(), "claude-desktop": self._desktop_update.snapshot(), "codex": self._codex_update.snapshot(), + "jetbrains-acp": self._jetbrains_update.snapshot(), }, }, "host": settings.host, diff --git a/tests/api/test_jetbrains_acp_integration.py b/tests/api/test_jetbrains_acp_integration.py new file mode 100644 index 0000000000..f8bba2de91 --- /dev/null +++ b/tests/api/test_jetbrains_acp_integration.py @@ -0,0 +1,70 @@ +import json +import subprocess +import time + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.config.settings import Settings +from free_claude_code.harnesses import jetbrains_acp_integration as jb +from tests.api.support import create_test_app +from tests.harnesses.test_jetbrains_acp_integration import install + +ROOT = "/admin/api/integrations/jetbrains-acp" + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setattr(jb, "config_path", lambda: tmp_path / "acp.json") + monkeypatch.setattr(jb, "registry_path", lambda: tmp_path / "installed.json") + monkeypatch.setattr(jb, "system_root", lambda: tmp_path / "systems") + monkeypatch.setattr( + jb.subprocess, + "run", + lambda args, **kw: subprocess.CompletedProcess(args, 0, "v24.19.0", ""), + ) + with TestClient( + create_test_app(Settings(proxy_auth_token="secret")), + base_url="http://127.0.0.1", + client=("127.0.0.1", 50000), + ) as client: + yield client + + +def test_api_connect_failure_retry_disconnect_and_safe_status(client): + status = client.get(ROOT) + assert status.status_code == 200 + assert status.json()["connected"] is False + assert status.headers["cache-control"] == "no-store" + assert client.post(ROOT + "/connect").status_code == 400 + install(jb.registry_path(), jb.system_root()) + response = client.post(ROOT + "/connect") + assert response.status_code == 200 + assert response.json()["connected"] is True + assert "secret" not in response.text + before = jb.config_path().read_bytes() + assert client.get(ROOT).json()["connected"] is True + assert jb.config_path().read_bytes() == before + jb.registry_path().unlink() + assert client.post(ROOT + "/refresh").status_code == 200 + # Wait for the background result; a failure must not block Disconnect. + for _ in range(100): + state = client.get(ROOT).json() + if state["update"]["state"] != "starting": + break + time.sleep(0.01) + assert state["update"]["state"] == "failed" + assert state["connected"] is True + assert client.post(ROOT + "/disconnect").json()["connected"] is False + assert json.loads(jb.config_path().read_text()) == {"agent_servers": {}} + + +@pytest.mark.parametrize("action", ["", "/connect", "/disconnect", "/refresh"]) +def test_routes_reject_other_origins(client, action): + response = client.request( + "POST" if action else "GET", + ROOT + action, + headers={"Origin": "https://evil.test"}, + ) + assert response.status_code == 403 + assert not jb.config_path().exists() diff --git a/tests/conftest.py b/tests/conftest.py index 4488d96029..24dd9bb049 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ claude_desktop_integration, claude_integration, codex_integration, + jetbrains_acp_integration, ) from tests.providers.support import ( immediate_admission, @@ -32,6 +33,19 @@ def _isolate_managed_config(monkeypatch, tmp_path): """Keep every test away from real home, checkout, and running-server config.""" config_dir = tmp_path / ".fcc" + monkeypatch.setattr( + jetbrains_acp_integration, + "config_path", + lambda: tmp_path / ".jetbrains/acp.json", + ) + monkeypatch.setattr( + jetbrains_acp_integration, + "registry_path", + lambda: tmp_path / "jetbrains/installed.json", + ) + monkeypatch.setattr( + jetbrains_acp_integration, "system_root", lambda: tmp_path / "jetbrains/systems" + ) monkeypatch.setattr( claude_desktop_integration, "config_root", lambda: tmp_path / "Claude-3p" ) diff --git a/tests/harnesses/test_jetbrains_acp_integration.py b/tests/harnesses/test_jetbrains_acp_integration.py new file mode 100644 index 0000000000..1908801355 --- /dev/null +++ b/tests/harnesses/test_jetbrains_acp_integration.py @@ -0,0 +1,274 @@ +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from free_claude_code.harnesses import jetbrains_acp_integration as jb +from free_claude_code.harnesses.jetbrains_acp_integration import ( + registry_path, + system_root, +) + + +def write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def install(registry, systems, version="0.79.0", ide="IDE 2026.2"): + write( + registry, + { + "agents": { + "acp.registry.claude-acp": { + "agent": { + "version": version, + "distribution": { + "npx": { + "package": f"@agentclientprotocol/claude-agent-acp@{version}" + } + }, + } + } + } + }, + ) + root = systems / ide / "acp-agents" + package = ( + root + / "claude-acp" + / version + / "node_modules/@agentclientprotocol/claude-agent-acp" + ) + write( + package / "package.json", + { + "name": "@agentclientprotocol/claude-agent-acp", + "version": version, + "bin": {"claude-agent-acp": "dist/index.js"}, + "engines": {"node": ">=22"}, + }, + ) + script = package / "dist/index.js" + script.parent.mkdir() + script.write_text("adapter", encoding="utf-8") + (root / "claude-acp" / version / ".installed").touch() + node = ( + root + / ".runtimes/node/24.19.0" + / ("node.exe" if jb.sys.platform == "win32" else "bin/node") + ) + node.parent.mkdir(parents=True, exist_ok=True) + node.touch() + return node.resolve(), script.resolve() + + +@pytest.fixture +def files(tmp_path, monkeypatch): + config, registry, systems = ( + tmp_path / "acp.json", + tmp_path / "installed.json", + tmp_path / "systems", + ) + monkeypatch.setattr(jb, "registry_path", lambda: registry) + monkeypatch.setattr(jb, "system_root", lambda: systems) + monkeypatch.setattr(jb.shutil, "which", lambda _: None) + monkeypatch.setattr( + jb.subprocess, + "run", + lambda args, **kw: subprocess.CompletedProcess(args, 0, "v24.19.0\n", ""), + ) + return config, registry, systems + + +def test_lifecycle_preserves_other_agents_and_refreshes_owned_settings(files): + config, registry, systems = files + node, script = install(registry, systems) + original = registry.read_bytes() + other = {"command": "other", "env": {"SECRET": "private"}} + write( + config, + { + "agent_servers": {"Other": other}, + "default_mcp_settings": {"use_idea_mcp": True}, + }, + ) + assert not jb.configure(config, "http://localhost:8082", "token")["connected"] + assert jb.configure(config, "http://localhost:8082", "token", True)["connected"] + assert registry.read_bytes() == original + saved = json.loads(config.read_text()) + entry = saved["agent_servers"]["Claude Code (FCC)"] + assert entry["command"] == str(node) + assert entry["args"] == [str(script), "--hide-claude-auth"] + assert entry["env"]["ANTHROPIC_AUTH_TOKEN"] == "token" + assert entry["env"]["CLAUDE_CODE_AUTO_MODE_SERVER"] == "0" + entry["env"]["KEEP"] = "yes" + entry["use_idea_mcp"] = False + write(config, saved) + _, updated = install(registry, systems, version="0.80.0") + assert jb.refresh_connected(config, "http://localhost:9090", "rotated") + refreshed = json.loads(config.read_text())["agent_servers"]["Claude Code (FCC)"] + assert refreshed["args"][0] == str(updated) + assert refreshed["env"]["ANTHROPIC_AUTH_TOKEN"] == "rotated" + assert refreshed["env"]["KEEP"] == "yes" + assert refreshed["use_idea_mcp"] is False + before = config.stat().st_mtime_ns + assert not jb.refresh_connected(config, "http://localhost:9090", "rotated") + assert config.stat().st_mtime_ns == before + registry.unlink() + node.unlink() + assert not jb.configure(config, "http://localhost:9090", "rotated", False)[ + "connected" + ] + assert json.loads(config.read_text()) == { + "agent_servers": {"Other": other}, + "default_mcp_settings": {"use_idea_mcp": True}, + } + assert not jb.configure(config, "http://localhost:9090", "rotated", False)[ + "connected" + ] + + +def test_missing_installation_never_creates_config(files): + config, _, _ = files + assert not jb.refresh_connected(config, "http://localhost:8082", "token") + with pytest.raises(jb.SetupError): + jb.configure(config, "http://localhost:8082", "token", True) + assert not config.exists() + + +@pytest.mark.parametrize( + "source", + [ + "{", + "[]", + '{"agent_servers":[]}', + '{"agent_servers":{"Claude Code (FCC)":{"command":"other"}}}', + '{"agent_servers":{},"agent_servers":{}}', + ], +) +def test_invalid_or_unowned_configuration_is_never_overwritten(files, source): + config, _, _ = files + config.write_text(source) + for action in [None, True, False]: + with pytest.raises(ValueError): + jb.configure(config, "http://localhost:8082", "token", action) + assert config.read_text() == source + + +def test_status_and_disconnect_do_not_discover_or_run_programs(files, monkeypatch): + config, registry, systems = files + install(registry, systems) + jb.configure(config, "http://localhost:8082", "token", True) + monkeypatch.setattr(jb, "registry_path", lambda: pytest.fail("registry access")) + monkeypatch.setattr(jb.subprocess, "run", lambda *a, **k: pytest.fail("subprocess")) + assert jb.configure(config, "http://localhost:8082", "token")["connected"] + assert not jb.configure(config, "http://localhost:8082", "token", False)[ + "connected" + ] + + +@pytest.mark.parametrize("fault", ["marker", "version", "escape", "engine", "node"]) +def test_unusable_installation_preserves_connected_entry(files, monkeypatch, fault): + config, registry, systems = files + _, script = install(registry, systems) + jb.configure(config, "http://localhost:8082", "token", True) + before = config.read_bytes() + package = script.parent.parent / "package.json" + metadata = json.loads(package.read_text()) + if fault == "marker": + next(systems.rglob(".installed")).unlink() + elif fault == "version": + metadata["version"] = "0.1.0" + elif fault == "escape": + metadata["bin"]["claude-agent-acp"] = str(config) + elif fault == "engine": + metadata["engines"]["node"] = "^22" + else: + monkeypatch.setattr( + jb.subprocess, + "run", + lambda args, **kw: subprocess.CompletedProcess(args, 0, "v20.0.0", ""), + ) + write(package, metadata) + with pytest.raises(jb.SetupError): + jb.refresh_connected(config, "http://localhost:8082", "new") + assert config.read_bytes() == before + assert jb.configure(config, "http://localhost:8082", "new")["connected"] + + +@pytest.mark.parametrize( + "platform,data,cache", + [ + ("win32", "AppData/Roaming", "AppData/Local"), + ("darwin", "Library/Application Support", "Library/Caches"), + ("linux", ".local/share", ".cache"), + ], +) +def test_platform_layout_and_launch( + files, monkeypatch, tmp_path, platform, data, cache +): + monkeypatch.setattr(jb, "sys", SimpleNamespace(platform=platform)) + monkeypatch.setattr(jb.subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + for key in ("APPDATA", "LOCALAPPDATA", "XDG_DATA_HOME", "XDG_CACHE_HOME"): + monkeypatch.delenv(key, raising=False) + assert registry_path() == tmp_path / data / "JetBrains/acp-agents/installed.json" + assert system_root() == tmp_path / cache / "JetBrains" + config, registry, systems = files + node, script = install(registry, systems) + jb.configure(config, "http://localhost:8082", "token", True) + entry = json.loads(config.read_text())["agent_servers"]["Claude Code (FCC)"] + assert entry["command"] == str(node) + assert entry["args"][0] == str(script) + + +def test_previous_ide_is_preferred_and_system_node_is_fallback(files, monkeypatch): + config, registry, systems = files + node, script = install(registry, systems, ide="Z IDE") + jb.configure(config, "http://localhost:8082", "token", True) + install(registry, systems, ide="A IDE") + assert not jb.refresh_connected(config, "http://localhost:8082", "token") + system = config.parent / "system node.exe" + system.touch() + node.unlink() + monkeypatch.setattr(jb.shutil, "which", lambda _: str(system)) + jb.refresh_connected(config, "http://localhost:8082", "token") + entry = json.loads(config.read_text())["agent_servers"]["Claude Code (FCC)"] + assert entry["command"] == str(system.resolve()) + assert entry["args"][0] == str(script) + + +def test_timed_out_node_leaves_config_unchanged(files, monkeypatch): + config, registry, systems = files + install(registry, systems) + write(config, {"agent_servers": {"Other": {"command": "other"}}}) + before = config.read_bytes() + + def timeout(args, **kwargs): + assert kwargs["timeout"] == 3 + raise subprocess.TimeoutExpired(args, 3) + + monkeypatch.setattr(jb.subprocess, "run", timeout) + with pytest.raises(jb.SetupError): + jb.configure(config, "http://localhost:8082", "token", True) + assert config.read_bytes() == before + + +def test_failed_atomic_replace_preserves_existing_config(files, monkeypatch): + from free_claude_code.harnesses import config_file + + config, registry, systems = files + install(registry, systems) + write(config, {"agent_servers": {"Other": {"command": "other"}}}) + before = config.read_bytes() + + def fail(*args): + raise PermissionError("busy") + + monkeypatch.setattr(config_file.os, "replace", fail) + with pytest.raises(PermissionError): + jb.configure(config, "http://localhost:8082", "token", True) + assert config.read_bytes() == before diff --git a/tests/runtime/test_integration_startup.py b/tests/runtime/test_integration_startup.py index 0d56f05ec3..d0ce318f3e 100644 --- a/tests/runtime/test_integration_startup.py +++ b/tests/runtime/test_integration_startup.py @@ -1,5 +1,6 @@ import asyncio import json +import subprocess import threading from unittest.mock import AsyncMock, MagicMock @@ -20,12 +21,16 @@ claude_integration, codex_integration, ) +from free_claude_code.harnesses import ( + jetbrains_acp_integration as jb, +) from free_claude_code.providers.base import BaseProvider from free_claude_code.providers.runtime.runtime import ProviderRuntime from free_claude_code.runtime.application import ApplicationRuntime from free_claude_code.runtime.configuration import ConfigurationService from free_claude_code.runtime.provider_manager import ProviderRuntimeManager from tests.harnesses.test_integration_refresh import OLD_CLAUDE, OLD_CODEX, TOKEN +from tests.harnesses.test_jetbrains_acp_integration import install, write from tests.web_tools_support import StubWebToolsClient @@ -70,6 +75,49 @@ def old_connections(): return claude, codex +@pytest.mark.asyncio +async def test_jetbrains_startup_refresh_and_failed_discovery_allow_disconnect( + runtime, monkeypatch +): + install(jb.registry_path(), jb.system_root()) + monkeypatch.setattr( + jb.subprocess, + "run", + lambda args, **kw: subprocess.CompletedProcess(args, 0, "v24.19.0", ""), + ) + write( + jb.config_path(), + { + "agent_servers": { + "Claude Code (FCC)": { + "command": "old-node", + "env": {"FCC_JETBRAINS_ACP": "1", "ANTHROPIC_AUTH_TOKEN": "old"}, + } + } + }, + ) + try: + await runtime.start() + await asyncio.wait_for(runtime._jetbrains_update.task, 5) + status = await runtime.jetbrains_acp_status() + assert status["connected"] is True + assert status["update"]["changed"] is True + entry = json.loads(jb.config_path().read_text())["agent_servers"][ + "Claude Code (FCC)" + ] + assert entry["env"]["ANTHROPIC_AUTH_TOKEN"] == TOKEN + assert entry["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8000" + jb.registry_path().unlink() + await runtime.refresh_jetbrains_acp() + await asyncio.wait_for(runtime._jetbrains_update.task, 5) + status = await runtime.jetbrains_acp_status() + assert status["update"]["state"] == "failed" + assert status["connected"] is True + assert (await runtime.disconnect_jetbrains_acp())["connected"] is False + finally: + await runtime.close() + + async def finish(runtime): await asyncio.wait_for( asyncio.gather(runtime._claude_update.task, runtime._codex_update.task), 5 diff --git a/uv.lock b/uv.lock index 2ffe4f5198..ead56c5a25 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.51" +version = "6.2.52" source = { editable = "." } dependencies = [ { name = "aiohttp" },