Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
)
Expand Down
116 changes: 11 additions & 105 deletions e2e/test_admin_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
("claude-vscode", "openClaudeIntegration"),
("codex", "openCodexIntegration"),
("claude-desktop", "openClaudeDesktopIntegration"),
("jetbrains-acp", "openJetBrainsIntegration"),
],
)
@pytest.mark.parametrize("connected", [False, True])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down
77 changes: 77 additions & 0 deletions e2e/test_jetbrains_acp_integration.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions src/free_claude_code/api/admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading