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" },