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 @@
Use FCC's models in Claude Code through JetBrains ACP.
-
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" },