patch: add independent Claude onboarding (#2052)
Some checks are pending
Post merge / Warm dependency cache (Linux) (push) Waiting to run
Post merge / Warm dependency cache (Windows) (push) Waiting to run
Post merge / Warm dependency cache (macOS) (push) Waiting to run
Post merge / Publish to PyPI (push) Waiting to run

## Why

Claude onboarding was tied to the VS Code connection. A missing or
invalid `.claude.json` could make a configured editor appear
disconnected, and background refresh completed onboarding. Users also
had no way to complete onboarding independently.

A pending standalone save could leave the onboarding card incomplete
after successful VS Code Connect.

## How

Add a Claude Code onboarding card and local Admin status/completion
endpoints. A dedicated helper reads status passively and completes
onboarding with an idempotent atomic update that preserves unrelated
values. Keep VS Code status, refresh, and diagnostics scoped to its
editor settings.

Explicit VS Code Connect validates editor settings, completes
onboarding, then saves the connection under the existing owned locks. If
the final save fails, onboarding remains complete and Connect can retry.
Queue refreshes requested during an onboarding save, reconcile once the
save settles, and discard stale status replies.




<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=78830265"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"
align="right"></picture></a>Confidence Score: 5/5</h2>

<!-- greptile-impact-verdict --> No identified issues block merging. The
previous diagnostics failure-handling concern is fully addressed by
removing the wrapper and its workflow loading.

<details open><summary><h3>Summary</h3></summary>

This PR separates Claude Code onboarding from the VS Code connection.
- Users can complete Claude onboarding from its own Admin card.
- VS Code connection status now depends on editor settings alone.

No outstanding findings remain.
</details>

<!-- greptile_confidence_score:5 -->

<sub>Reviews (3) · Last reviewed commit: ["Remove temporary Windows
profiling"](a4fcb1af63)
· [Reviewed by
Greptile](https://www.greptile.com/?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=alishahryar1/free-claude-code)</sub>

<!-- /greptile_comment -->
This commit is contained in:
Ali Khokhar 2026-10-11 15:45:11 -07:00 • committed by GitHub
parent fa6adde24f
commit 2b55662847
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 933 additions and 151 deletions

View file

@ -374,6 +374,10 @@ Reload VS Code or restart the app/IDE after connecting. In Codex and Claude Desk
model from the model picker. Reload or restart the client when FCC reports updated settings. Use
**Disconnect** on the same card to remove the integration.
To complete Claude Code onboarding separately, open **Admin UI → Integrations →
Claude Code onboarding** and click **Complete onboarding**. Connecting Claude Code
in VS Code also completes onboarding.
Run FCC on the same computer and in the same user environment as the client you
are configuring.

View file

@ -32,6 +32,7 @@ from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningP
from free_claude_code.harnesses import (
claude_desktop_integration,
claude_integration,
claude_onboarding,
codex_integration,
jetbrains_acp_integration,
vscode_chat_integration,
@ -194,7 +195,7 @@ def admin_base_url(
lambda: tmp_path / "vscode/chatLanguageModels.json",
)
monkeypatch.setattr(
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
claude_onboarding, "state_path", lambda: tmp_path / ".claude.json"
)
monkeypatch.setattr(
claude_integration,

View file

@ -5,7 +5,7 @@ from itertools import groupby, pairwise
import pytest
from playwright.sync_api import expect
from free_claude_code.harnesses import claude_integration
from free_claude_code.harnesses import claude_integration, claude_onboarding
@pytest.mark.parametrize(
@ -210,6 +210,7 @@ def test_connect_disconnect_and_modal_dismissal(page, admin_base_url, tmp_path):
assert json.loads(path.read_text())["claudeCode.disableLoginPrompt"] is True
state_path = tmp_path / ".claude.json"
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
expect(page.locator("#openClaudeOnboarding")).to_have_text("Completed")
page.reload()
expect(card_button).to_have_text("Disconnect")
card_button.click()
@ -232,19 +233,18 @@ def test_manual_setup_and_revisit_read_the_file(page, admin_base_url, tmp_path):
status = page.request.get(f"{admin_base_url}/admin/api/status").json()
claude_integration.configure(
path,
tmp_path / ".claude.json",
f"http://localhost:{status['port']}/",
"e2e-proxy-token",
True,
)
claude_onboarding.complete(tmp_path / ".claude.json")
page.goto(f"{admin_base_url}/admin/integrations")
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
(tmp_path / ".claude.json").unlink()
page.reload()
expect(page.locator("#openClaudeIntegration")).to_have_text("Connect")
page.locator("#openClaudeIntegration").click()
expect(page.locator("#claudeIntegrationDescription")).to_contain_text("onboarding")
page.locator("#confirmClaudeIntegration").click()
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
page.locator("#openClaudeOnboarding").click()
page.locator("#confirmClaudeOnboarding").click()
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
page.get_by_role("button", name="Providers", exact=True).click()
path.write_text("{}")
@ -449,10 +449,8 @@ def test_startup_updates_before_opening_integrations(
entry == {"name": "CLAUDE_CODE_DISABLE_ADVISOR_TOOL", "value": "1"}
for entry in saved["claudeCode.environmentVariables"]
)
assert (
json.loads((tmp_path / ".claude.json").read_text())["hasCompletedOnboarding"]
is True
)
assert not (tmp_path / ".claude.json").exists()
expect(page.locator("#openClaudeOnboarding")).to_have_text("Complete onboarding")
page.screenshot(path=str(tmp_path / "integration-updated.png"), full_page=True)
button.click()
page.locator("#confirmClaudeIntegration").click()
@ -460,3 +458,223 @@ def test_startup_updates_before_opening_integrations(
page.reload()
expect(button).to_have_text("Connect")
expect(page.locator("#claudeIntegrationMessage")).to_be_hidden()
def test_independent_onboarding_modal_and_completion(page, admin_base_url, tmp_path):
state = tmp_path / ".claude.json"
settings = tmp_path / "vscode" / "settings.json"
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator("#openClaudeOnboarding")
dialog = page.locator("#claudeOnboardingDialog")
expect(button).to_have_text("Complete onboarding")
for dismiss in ("close", "escape", "outside"):
button.click()
expect(dialog).to_be_visible()
expect(page.locator("#claudeOnboardingFiles li")).to_have_text(
[str(state.resolve())]
)
if dismiss == "close":
dialog.get_by_role("button", name="Close", exact=True).click()
elif dismiss == "escape":
page.keyboard.press("Escape")
else:
page.mouse.click(1, 1)
expect(dialog).not_to_be_visible()
assert not state.exists() and not settings.exists()
button.click()
page.locator("#confirmClaudeOnboarding").click()
expect(dialog).not_to_be_visible()
expect(button).to_have_text("Completed")
expect(button).to_be_disabled()
expect(page.locator("#openClaudeIntegration")).to_have_text("Connect")
assert json.loads(state.read_text())["hasCompletedOnboarding"] is True
assert not settings.exists()
state.write_text('{"hasCompletedOnboarding":false}')
page.get_by_role("button", name="Providers", exact=True).click()
page.get_by_role("button", name="Integrations", exact=True).click()
expect(button).to_have_text("Complete onboarding")
assert state.read_text() == '{"hasCompletedOnboarding":false}'
@pytest.mark.parametrize("completed", [False, True])
def test_onboarding_status_uses_loading_and_completed_button(
page, admin_base_url, tmp_path, completed
):
pending = []
def hold(route):
pending.append(route)
page.evaluate("window.onboardingStatusIntercepted = true")
page.route("**/admin/api/integrations/claude-onboarding", hold)
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator("#openClaudeOnboarding")
expect(button).to_have_text("Loading…")
expect(button).to_be_disabled()
expect(button).to_have_attribute("aria-busy", "true")
expect(button).to_have_css("background-color", "rgb(23, 27, 38)")
assert (
button.evaluate(
"element => getComputedStyle(element, '::before').animationName"
)
== "integration-spinner"
)
page.wait_for_function("window.onboardingStatusIntercepted === true")
pending.pop().fulfill(
json={
"completed": completed,
"paths": {"claude_state": str(tmp_path / ".claude.json")},
}
)
expect(button).to_have_text("Completed" if completed else "Complete onboarding")
expect(button).to_have_attribute("aria-busy", "false")
if completed:
expect(button).to_be_disabled()
expect(button).to_have_css("background-color", "rgb(23, 27, 38)")
else:
expect(button).to_be_enabled()
def test_onboarding_errors_retry_independently(page, admin_base_url, tmp_path):
assert page.request.post(
f"{admin_base_url}/admin/api/integrations/claude-vscode/connect"
).ok
state = tmp_path / ".claude.json"
state.write_text('{"secret-state":invalid}')
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator("#openClaudeOnboarding")
expect(button).to_have_text("Retry")
expect(page.locator("#claudeOnboardingMessage")).to_contain_text(".claude.json")
expect(page.locator("#claudeOnboardingMessage")).not_to_contain_text("secret-state")
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
state.write_text('{"hasCompletedOnboarding":false}')
button.click()
expect(button).to_have_text("Complete onboarding")
assert state.read_text() == '{"hasCompletedOnboarding":false}'
def reject(route):
route.fulfill(status=503, json={"detail": "Could not access .claude.json."})
endpoint = "**/admin/api/integrations/claude-onboarding/complete"
page.route(endpoint, reject)
button.click()
action = page.locator("#confirmClaudeOnboarding")
action.click()
expect(page.locator("#claudeOnboardingDialogMessage")).to_contain_text(
"Could not access"
)
expect(action).to_be_enabled()
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
page.unroute(endpoint, reject)
action.click()
expect(button).to_have_text("Completed")
def test_failed_connect_updates_independent_onboarding_card(
page, admin_base_url, tmp_path, monkeypatch
):
settings = tmp_path / "vscode" / "settings.json"
write = claude_integration.atomic_write_text
def fail_settings(path, content):
if path == settings:
raise PermissionError("denied")
write(path, content)
monkeypatch.setattr(claude_integration, "atomic_write_text", fail_settings)
page.goto(f"{admin_base_url}/admin/integrations")
expect(page.locator("#openClaudeOnboarding")).to_have_text("Complete onboarding")
page.locator("#openClaudeIntegration").click()
page.locator("#confirmClaudeIntegration").click()
expect(page.locator("#claudeIntegrationDialogMessage")).to_contain_text(
"Could not access"
)
expect(page.locator("#openClaudeOnboarding")).to_have_text("Completed")
expect(page.locator("#openClaudeIntegration")).to_have_text("Connect")
assert not settings.exists()
assert (
json.loads((tmp_path / ".claude.json").read_text())["hasCompletedOnboarding"]
is True
)
def test_late_onboarding_status_cannot_undo_connect_result(
page, admin_base_url, tmp_path
):
pending = []
checks = 0
def hold_first(route):
nonlocal checks
checks += 1
if checks == 1:
pending.append(route)
else:
route.continue_()
endpoint = "**/admin/api/integrations/claude-onboarding"
page.route(endpoint, hold_first)
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator("#openClaudeOnboarding")
expect(button).to_have_text("Loading…")
page.locator("#openClaudeIntegration").click()
page.locator("#confirmClaudeIntegration").click()
expect(button).to_have_text("Completed")
assert checks == 2
with page.expect_response(
lambda response: response.url.endswith("/claude-onboarding")
):
pending.pop().fulfill(
json={
"completed": False,
"paths": {"claude_state": str(tmp_path / ".claude.json")},
}
)
page.evaluate(
"() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"
)
expect(button).to_have_text("Completed")
expect(button).to_be_disabled()
@pytest.mark.parametrize("save_failed", [False, True])
def test_connect_reconciles_after_pending_onboarding_save(
page, admin_base_url, tmp_path, save_failed
):
pending = []
def hold(route):
pending.append(route)
endpoint = "**/admin/api/integrations/claude-onboarding/complete"
page.route(endpoint, hold)
page.goto(f"{admin_base_url}/admin/integrations")
button = page.locator("#openClaudeOnboarding")
expect(button).to_have_text("Complete onboarding")
button.click()
page.locator("#confirmClaudeOnboarding").click()
expect(button).to_have_text("Saving…")
page.keyboard.press("Escape")
expect(page.locator("#claudeOnboardingDialog")).not_to_be_visible()
page.locator("#openClaudeIntegration").click()
page.locator("#confirmClaudeIntegration").click()
expect(page.locator("#openClaudeIntegration")).to_have_text("Disconnect")
assert (
json.loads((tmp_path / ".claude.json").read_text())["hasCompletedOnboarding"]
is True
)
assert len(pending) == 1
with page.expect_response(
lambda response: response.url.endswith("/claude-onboarding")
):
pending.pop().fulfill(
status=503 if save_failed else 200,
json=(
{"detail": "Temporary completion error"}
if save_failed
else {"completed": True, "changed": False}
),
)
expect(button).to_have_text("Completed")
expect(button).to_be_disabled()
expect(page.locator("#claudeOnboardingMessage")).to_be_hidden()

View file

@ -259,6 +259,24 @@ async def models(
return _model_options(services)
@router.get("/admin/api/integrations/claude-onboarding")
async def claude_onboarding_status(
request: Request,
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
return await _integration_response(services.admin.claude_onboarding_status)
@router.post("/admin/api/integrations/claude-onboarding/complete")
async def complete_claude_onboarding(
request: Request,
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
return await _integration_response(services.admin.complete_claude_onboarding)
@router.get("/admin/api/integrations/claude-vscode")
async def claude_vscode_status(
request: Request,

View file

@ -293,6 +293,7 @@ function setActiveView(viewId, { scroll = false } = {}) {
else window.CodeSessions.deactivate();
if (activeView.id === "integrations") {
refreshClaudeIntegration();
refreshClaudeOnboarding();
refreshVSCodeChatIntegration();
refreshJetBrainsIntegration();
refreshCodexIntegration();
@ -1579,6 +1580,96 @@ try {
const claudeIntegrationDialog = byId("claudeIntegrationDialog");
const claudeIntegration = { connected: null, busy: false, paths: null, update: null };
const claudeIntegrationPath = "/admin/api/integrations/claude-vscode";
const claudeOnboardingDialog = byId("claudeOnboardingDialog");
const claudeOnboarding = { completed: null, busy: false, paths: null, refreshAfterSave: false };
const claudeOnboardingPath = "/admin/api/integrations/claude-onboarding";
function renderClaudeOnboarding() {
const { completed, busy, paths, checkRequest } = claudeOnboarding;
const button = byId("openClaudeOnboarding");
button.textContent = busy ? checkRequest ? "Loading…" : "Saving…" : completed === null ? "Retry" : completed ? "Completed" : "Complete onboarding";
button.disabled = busy || completed === true;
button.setAttribute("aria-busy", String(busy));
const action = byId("confirmClaudeOnboarding");
action.textContent = busy ? "Saving…" : "Complete onboarding";
action.disabled = busy || completed !== false;
const files = byId("claudeOnboardingFiles");
files.replaceChildren();
if (paths) {
const item = document.createElement("li");
const code = document.createElement("code");
code.textContent = paths.claude_state;
item.appendChild(code);
files.appendChild(item);
}
}
async function refreshClaudeOnboarding({ replaceCheck = false } = {}) {
if (claudeOnboarding.busy && !claudeOnboarding.checkRequest) {
claudeOnboarding.refreshAfterSave = true;
return;
}
if (replaceCheck && claudeOnboarding.checkRequest) {
claudeOnboarding.checkRequest = null;
claudeOnboarding.busy = false;
}
const request = beginIntegrationCheck(claudeOnboarding);
if (!request) return;
renderClaudeOnboarding();
try {
const result = await api(claudeOnboardingPath);
if (!currentIntegrationCheck(claudeOnboarding, request)) return;
claudeOnboarding.completed = result.completed;
claudeOnboarding.paths = result.paths;
integrationMessage("claudeOnboardingMessage", "");
} catch (error) {
if (!currentIntegrationCheck(claudeOnboarding, request)) return;
claudeOnboarding.completed = null;
integrationMessage("claudeOnboardingMessage", error.message, true);
} finally {
finishIntegrationCheck(claudeOnboarding, request, renderClaudeOnboarding);
}
}
byId("openClaudeOnboarding").addEventListener("click", () => {
if (claudeOnboarding.completed === null) {
void refreshClaudeOnboarding();
return;
}
integrationMessage("claudeOnboardingDialogMessage", "");
claudeOnboardingDialog.showModal();
});
byId("confirmClaudeOnboarding").addEventListener("click", async () => {
if (byId("confirmClaudeOnboarding").disabled) return;
claudeOnboarding.busy = true;
renderClaudeOnboarding();
integrationMessage("claudeOnboardingDialogMessage", "");
integrationMessage("claudeOnboardingMessage", "");
try {
const result = await api(`${claudeOnboardingPath}/complete`, { method: "POST" });
claudeOnboarding.completed = result.completed;
claudeOnboardingDialog.close();
integrationMessage("claudeOnboardingMessage", "Claude onboarding completed.");
} catch (error) {
integrationMessage("claudeOnboardingDialogMessage", error.message, true);
integrationMessage("claudeOnboardingMessage", error.message, true);
} finally {
claudeOnboarding.busy = false;
renderClaudeOnboarding();
if (claudeOnboarding.refreshAfterSave) {
claudeOnboarding.refreshAfterSave = false;
void refreshClaudeOnboarding();
}
}
});
byId("closeClaudeOnboarding").addEventListener("click", () => claudeOnboardingDialog.close());
claudeOnboardingDialog.addEventListener("click", (event) => {
if (event.target !== claudeOnboardingDialog) return;
const bounds = claudeOnboardingDialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) {
claudeOnboardingDialog.close();
}
});
function integrationMessage(id, message, error = false) {
const element = byId(id);
@ -1650,7 +1741,7 @@ function renderClaudeIntegration() {
byId("openClaudeIntegration").className = connected && !busy ? "danger-button" : "primary-button";
byId("confirmClaudeIntegration").className = connected ? "danger-button" : "primary-button";
byId("claudeIntegrationDescription").textContent = connected
? "Remove FCC's VS Code settings. Claude onboarding stays completed."
? "Remove FCC's VS Code settings."
: "Will set FCC's URL and token, enable model discovery, skip VS Code login, and complete Claude onboarding.";
const files = byId("claudeIntegrationFiles");
files.replaceChildren();
@ -1719,6 +1810,7 @@ byId("confirmClaudeIntegration").addEventListener("click", async () => {
} finally {
claudeIntegration.busy = false;
renderClaudeIntegration();
if (!disconnect) void refreshClaudeOnboarding({ replaceCheck: true });
}
});
byId("closeClaudeIntegration").addEventListener("click", () => claudeIntegrationDialog.close());

View file

@ -114,6 +114,29 @@
<button id="confirmClaudeIntegration" class="primary-button" type="button">Connect</button>
<p id="claudeIntegrationDialogMessage" class="message-area error" role="alert" hidden></p>
</dialog>
<article class="provider-strip integration-card">
<div class="section-heading">
<div>
<h3>Claude Code onboarding</h3>
<p>Complete Claude Code's first-time setup for this user.</p>
</div>
</div>
<p id="claudeOnboardingMessage" class="message-area" role="status" hidden></p>
<button id="openClaudeOnboarding" class="primary-button" type="button" aria-busy="true" disabled>Loading…</button>
</article>
<dialog id="claudeOnboardingDialog" class="integration-dialog" aria-labelledby="claudeOnboardingTitle" aria-describedby="claudeOnboardingDescription">
<div class="section-heading">
<h3 id="claudeOnboardingTitle">Claude Code onboarding</h3>
<button id="closeClaudeOnboarding" class="ghost-button" type="button" aria-label="Close" autofocus>×</button>
</div>
<p id="claudeOnboardingDescription">Mark Claude Code onboarding as completed for this user.</p>
<div class="integration-files">
<strong>Files to modify</strong>
<ul id="claudeOnboardingFiles"></ul>
</div>
<button id="confirmClaudeOnboarding" class="primary-button" type="button">Complete onboarding</button>
<p id="claudeOnboardingDialogMessage" class="message-area error" role="alert" hidden></p>
</dialog>
<article class="provider-strip integration-card">
<div class="section-heading">
<div>

View file

@ -34,6 +34,10 @@ class AdminRuntimePort(Protocol):
async def claude_vscode_status(self) -> JsonObject: ...
async def claude_onboarding_status(self) -> JsonObject: ...
async def complete_claude_onboarding(self) -> JsonObject: ...
async def vscode_chat_status(self) -> JsonObject: ...
async def connect_vscode_chat(self) -> JsonObject: ...

View file

@ -13,7 +13,6 @@ from free_claude_code.harnesses.config_file import atomic_write_text, decode_jso
_ENV = "claudeCode.environmentVariables"
_LOGIN = "claudeCode.disableLoginPrompt"
_ONBOARDING = "hasCompletedOnboarding"
def settings_path() -> Path:
@ -30,10 +29,6 @@ def settings_path() -> Path:
return root / "Code/User/settings.json"
def claude_state_path() -> Path:
return Path.home() / ".claude.json"
def _read_object(path: Path) -> JsonObject:
try:
source = path.read_text(encoding="utf-8-sig")
@ -81,12 +76,10 @@ def _connected(
def _connect(
path: Path,
state_path: Path,
document: JsonObject,
entries: list[JsonObject],
values: dict[str, str],
) -> bool:
onboarding = _read_object(state_path)
before = json.dumps(document, allow_nan=False)
document[_LOGIN] = True
remaining = dict(values)
@ -97,32 +90,23 @@ def _connect(
entries.extend({"name": name, "value": value} for name, value in remaining.items())
document[_ENV] = entries
settings_changed = json.dumps(document, allow_nan=False) != before
onboarding_changed = onboarding.get(_ONBOARDING) is not True
if onboarding_changed:
onboarding[_ONBOARDING] = True
atomic_write_text(
state_path, json.dumps(onboarding, indent=2, allow_nan=False) + "\n"
)
if settings_changed:
atomic_write_text(path, json.dumps(document, indent=2, allow_nan=False) + "\n")
return settings_changed or onboarding_changed
return settings_changed
def refresh_connected(
path: Path, state_path: Path, proxy_root_url: str, auth_token: str
) -> bool:
def refresh_connected(path: Path, proxy_root_url: str, auth_token: str) -> bool:
"""Apply current settings only to an existing connection, even from release one."""
path = path.resolve()
values = claude_proxy_values(proxy_root_url, auth_token)
document, entries = _read(path, set(values))
if not _connected(document, entries, values):
return False
return _connect(path, state_path.resolve(), document, entries, values)
return _connect(path, document, entries, values)
def configure(
path: Path,
state_path: Path,
proxy_root_url: str,
auth_token: str,
connected: bool | None = None,
@ -131,10 +115,8 @@ def configure(
path = path.resolve()
values = claude_proxy_values(proxy_root_url, auth_token)
document, entries = _read(path, set(values))
if connected is not False:
state_path = state_path.resolve()
if connected is True:
_connect(path, state_path, document, entries, values)
_connect(path, document, entries, values)
elif connected is False:
before = json.dumps(document, allow_nan=False)
document.pop(_LOGIN, None)
@ -150,14 +132,9 @@ def configure(
)
if connected is not None:
document, entries = _read(path, set(values))
onboarding = _read_object(state_path) if connected is not False else {}
result: JsonObject = {
"connected": _connected(document, entries, values)
and onboarding.get(_ONBOARDING) is True,
}
result: JsonObject = {"connected": _connected(document, entries, values)}
if connected is None:
result["paths"] = {
"vscode_settings": str(path),
"claude_state": str(state_path),
}
return result

View file

@ -0,0 +1,43 @@
"""Read or complete user-global Claude Code onboarding independently of editors."""
import json
from pathlib import Path
from typing import cast
from free_claude_code.core.json_types import JsonObject
from free_claude_code.harnesses.config_file import atomic_write_text, decode_json
_ONBOARDING = "hasCompletedOnboarding"
def state_path() -> Path:
return Path.home() / ".claude.json"
def _read(path: Path) -> JsonObject:
try:
source = path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
return {}
document = decode_json(source)
if not isinstance(document, dict):
raise ValueError("Claude state must be an object")
return cast(JsonObject, document)
def status(path: Path) -> JsonObject:
path = path.resolve()
return {
"completed": _read(path).get(_ONBOARDING) is True,
"paths": {"claude_state": str(path)},
}
def complete(path: Path) -> JsonObject:
path = path.resolve()
document = _read(path)
changed = document.get(_ONBOARDING) is not True
if changed:
document[_ONBOARDING] = True
atomic_write_text(path, json.dumps(document, indent=2, allow_nan=False) + "\n")
return {"completed": True, "changed": changed}

View file

@ -380,6 +380,12 @@ class ApplicationRuntime:
async def claude_vscode_status(self) -> JsonObject:
return await self._integrations.claude_vscode_status()
async def claude_onboarding_status(self) -> JsonObject:
return await self._integrations.claude_onboarding_status()
async def complete_claude_onboarding(self) -> JsonObject:
return await self._integrations.complete_claude_onboarding()
async def connect_claude_vscode(self) -> JsonObject:
return await self._integrations.connect_claude_vscode()

View file

@ -213,7 +213,6 @@ def integration_report(settings: Settings) -> dict[str, Any]:
),
"claude-vscode": lambda: claude_integration.configure(
claude_integration.settings_path(),
claude_integration.claude_state_path(),
url,
token,
),

View file

@ -27,6 +27,7 @@ from free_claude_code.core.json_types import JsonObject
from free_claude_code.harnesses import (
claude_desktop_integration,
claude_integration,
claude_onboarding,
codex_integration,
dsh_desktop_integration,
jetbrains_acp_integration,
@ -408,6 +409,38 @@ class IntegrationService:
async def disconnect_claude_vscode(self) -> JsonObject:
return await self._claude_vscode("disconnect")
async def claude_onboarding_status(self) -> JsonObject:
return await self._claude_onboarding(complete=False)
async def complete_claude_onboarding(self) -> JsonObject:
return await self._claude_onboarding(complete=True)
async def _claude_onboarding(self, *, complete: bool) -> JsonObject:
def operate() -> JsonObject:
operation = (
claude_onboarding.complete if complete else claude_onboarding.status
)
return operation(claude_onboarding.state_path())
try:
if complete:
async with self._config_lock, self._claude_update.access.write():
self._check_integration_available()
return await run_sync_owned(operate)
async with self._claude_update.access.read():
self._check_integration_available()
result = await run_sync_owned(operate)
self._check_integration_available()
return result
except ValueError, UnicodeError:
raise InvalidRequestError(
"Could not read Claude onboarding state. Check the JSON in .claude.json."
) from None
except OSError:
raise ApplicationUnavailableError(
"Could not access .claude.json. Check file permissions and try again."
) from None
def _check_integration_available(self) -> None:
if self._is_draining() or self._has_pending_restart():
raise ApplicationUnavailableError(
@ -608,40 +641,55 @@ class IntegrationService:
async def _claude_vscode(self, action: IntegrationAction) -> JsonObject:
def operate(url: str, token: str, ready: bool) -> JsonObject:
path = claude_integration.settings_path()
state_path = claude_integration.claude_state_path()
if action == "status" and not ready:
return {
"connected": None,
"paths": {
"vscode_settings": str(path.resolve()),
"claude_state": str(state_path.resolve()),
"claude_state": str(claude_onboarding.state_path().absolute()),
},
}
if action == "refresh":
return {
"changed": claude_integration.refresh_connected(
path, state_path, url, token
)
"changed": claude_integration.refresh_connected(path, url, token)
}
return claude_integration.configure(
if action == "connect":
claude_integration.configure(path, url, token)
claude_onboarding.complete(claude_onboarding.state_path())
result = claude_integration.configure(
path,
state_path,
url,
token,
None if action == "status" else action == "connect",
)
if action == "status":
result["paths"] = {
"vscode_settings": str(path.resolve()),
"claude_state": str(claude_onboarding.state_path().absolute()),
}
return result
try:
return await self._run_integration(
self._claude_update, action, operate, mask_when_unready=True
)
except ValueError, UnicodeError:
files = (
"VS Code settings.json and .claude.json"
if action == "connect"
else "VS Code settings.json"
)
raise InvalidRequestError(
"Could not read Claude integration settings. Check the JSON in VS Code settings.json and .claude.json."
f"Could not read Claude integration settings. Check the JSON in {files}."
) from None
except OSError:
files = (
"VS Code settings.json or .claude.json"
if action == "connect"
else "VS Code settings.json"
)
raise ApplicationUnavailableError(
"Could not access VS Code settings.json or .claude.json. Check file permissions and try again."
f"Could not access {files}. Check file permissions and try again."
) from None
async def codex_integration_status(self) -> JsonObject:

View file

@ -0,0 +1,120 @@
import json
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from free_claude_code.harnesses import claude_integration, claude_onboarding
from tests.api.support import create_test_app, runtime_for_app
ROOT = "/admin/api/integrations/claude-onboarding"
VSCODE = "/admin/api/integrations/claude-vscode"
@pytest.fixture
def onboarding():
app = create_test_app()
with TestClient(
app, base_url="http://127.0.0.1", client=("127.0.0.1", 50000)
) as client:
yield client, runtime_for_app(app), claude_onboarding.state_path()
def test_passive_status_and_independent_completion(onboarding):
client, runtime, path = onboarding
runtime.provider_manager.wait_for_catalog = AsyncMock(
side_effect=AssertionError("No model discovery needed")
)
runtime._integrations._claude_update.state = "failed"
runtime._integrations._claude_update.message = "VS Code unavailable"
result = client.get(ROOT)
assert result.status_code == 200
assert result.headers["cache-control"] == "no-store"
assert result.json() == {
"completed": False,
"paths": {"claude_state": str(path.resolve())},
}
assert not path.exists()
response = client.post(f"{ROOT}/complete")
assert response.status_code == 200
assert response.json() == {"completed": True, "changed": True}
assert response.headers["cache-control"] == "no-store"
assert json.loads(path.read_text())["hasCompletedOnboarding"] is True
assert not claude_integration.settings_path().exists()
assert client.get(ROOT).json()["completed"] is True
before = path.stat().st_mtime_ns
assert client.post(f"{ROOT}/complete").json() == {
"completed": True,
"changed": False,
}
assert path.stat().st_mtime_ns == before
assert runtime._integrations._claude_update.state == "failed"
assert runtime._integrations._claude_update.message == "VS Code unavailable"
runtime.provider_manager.wait_for_catalog.assert_not_awaited()
@pytest.mark.parametrize("action", ["", "/complete"])
def test_invalid_onboarding_is_scoped_and_does_not_change_connection(
onboarding, action
):
client, _, path = onboarding
assert client.post(f"{VSCODE}/connect").status_code == 200
source = '{"onboarding-secret": invalid}'
path.write_text(source)
response = client.request("POST" if action else "GET", ROOT + action)
assert response.status_code == 400
assert response.headers["cache-control"] == "no-store"
assert "onboarding-secret" not in response.text
assert ".claude.json" in response.json()["detail"]
assert path.read_text() == source
assert client.get(VSCODE).json()["connected"] is True
@pytest.mark.parametrize("action", ["", "/complete"])
@pytest.mark.parametrize(
"headers", [{"Host": "evil.test"}, {"Origin": "https://evil.test"}]
)
def test_loopback_admin_guard(onboarding, action, headers):
client, _, path = onboarding
assert (
client.request(
"POST" if action else "GET", ROOT + action, headers=headers
).status_code
== 403
)
assert not path.exists()
def test_remote_client_is_rejected(onboarding):
client, _, path = onboarding
with TestClient(
client.app, base_url="http://127.0.0.1", client=("192.0.2.1", 50000)
) as remote:
assert remote.get(ROOT).status_code == 403
assert remote.post(f"{ROOT}/complete").status_code == 403
assert not path.exists()
def test_pending_restart_and_shutdown_do_not_write(onboarding):
client, runtime, path = onboarding
runtime._pending_fields = ["PORT"]
assert client.post(f"{ROOT}/complete").status_code == 503
runtime._pending_fields = []
runtime.begin_shutdown()
assert client.post(f"{ROOT}/complete").status_code == 503
assert not path.exists()
@pytest.mark.parametrize("action", ["", "/complete"])
def test_io_failure_returns_safe_error(onboarding, monkeypatch, action):
client, _, path = onboarding
def fail(*args):
raise PermissionError("onboarding-secret")
monkeypatch.setattr(claude_onboarding, "complete" if action else "status", fail)
response = client.request("POST" if action else "GET", ROOT + action)
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert "onboarding-secret" not in response.text
assert not path.exists()

View file

@ -1,10 +1,11 @@
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from free_claude_code.config.settings import Settings
from free_claude_code.harnesses import claude_integration
from free_claude_code.harnesses import claude_integration, claude_onboarding
from tests.api.support import create_test_app, runtime_for_app
ROOT = "/admin/api/integrations/claude-vscode"
@ -15,7 +16,7 @@ def integration(tmp_path, monkeypatch):
path = tmp_path / "settings.json"
monkeypatch.setattr(claude_integration, "settings_path", lambda: path)
monkeypatch.setattr(
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
claude_onboarding, "state_path", lambda: tmp_path / ".claude.json"
)
app = create_test_app(
Settings(host="0.0.0.0", port=4321, proxy_auth_token="integration-secret")
@ -45,7 +46,7 @@ def test_routes_read_actual_file_connect_and_disconnect(integration):
state_path = path.parent / ".claude.json"
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
state_path.write_text('{"hasCompletedOnboarding":false}')
assert client.get(ROOT).json()["connected"] is False
assert client.get(ROOT).json()["connected"] is True
assert client.post(f"{ROOT}/connect").json() == {"connected": True}
path.write_text("{}")
assert client.get(ROOT).json()["connected"] is False
@ -53,6 +54,26 @@ def test_routes_read_actual_file_connect_and_disconnect(integration):
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
@pytest.mark.parametrize(
"source", [None, '{"hasCompletedOnboarding":false}', "{invalid", "[]"]
)
def test_connection_status_ignores_global_onboarding(integration, source):
client, path, _ = integration
assert client.post(f"{ROOT}/connect").status_code == 200
state_path = path.parent / ".claude.json"
if source is None:
state_path.unlink()
else:
state_path.write_text(source)
response = client.get(ROOT)
assert response.status_code == 200
assert response.json()["connected"] is True
if source is None:
assert not state_path.exists()
else:
assert state_path.read_text() == source
@pytest.mark.parametrize("field", ["vscode_settings", "claude_state"])
def test_status_reports_resolved_file_paths_without_creating_files(integration, field):
client, path, _ = integration
@ -74,7 +95,8 @@ def test_status_reports_resolved_file_paths_without_creating_files(integration,
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires Developer Mode or privilege")
raise
assert client.get(ROOT).json()["paths"][field] == str(target.resolve())
expected = target.resolve() if field == "vscode_settings" else state_path.absolute()
assert client.get(ROOT).json()["paths"][field] == str(expected)
@pytest.mark.parametrize("action", ["", "/connect", "/disconnect"])
@ -86,7 +108,7 @@ def test_invalid_file_returns_safe_uncached_error(integration, action, target):
path = path.parent / ".claude.json"
path.write_text(source)
response = client.request("GET" if not action else "POST", ROOT + action)
expected_error = target == "settings" or action != "/disconnect"
expected_error = target == "settings" or action == "/connect"
assert response.status_code == (400 if expected_error else 200)
assert response.headers["cache-control"] == "no-store"
assert "integration-secret" not in response.text
@ -129,6 +151,57 @@ def test_io_failure_returns_safe_error(integration, monkeypatch):
assert "integration-secret" not in response.text
@pytest.mark.parametrize("failed_file", ["state", "settings"])
def test_connect_partial_write_is_retryable(integration, monkeypatch, failed_file):
client, path, _ = integration
state = claude_onboarding.state_path()
path.write_text('{"keep":true}')
original_replace = Path.replace
def fail(self, target):
if target == (state if failed_file == "state" else path):
raise PermissionError("denied")
return original_replace(self, target)
with monkeypatch.context() as patch:
patch.setattr(Path, "replace", fail)
assert client.post(f"{ROOT}/connect").status_code == 503
assert path.read_text() == '{"keep":true}'
assert client.get(ROOT).json()["connected"] is False
if failed_file == "state":
assert not state.exists()
else:
assert json.loads(state.read_text())["hasCompletedOnboarding"] is True
assert client.post(f"{ROOT}/connect").json() == {"connected": True}
def test_invalid_vscode_preflight_does_not_complete_onboarding(integration):
client, path, _ = integration
path.write_text('{"claudeCode.environmentVariables":{}}')
state = claude_onboarding.state_path()
state.write_text('{"hasCompletedOnboarding":false}')
assert client.post(f"{ROOT}/connect").status_code == 400
assert state.read_text() == '{"hasCompletedOnboarding":false}'
def test_connection_status_does_not_resolve_global_state(integration, monkeypatch):
client, _, _ = integration
assert client.post(f"{ROOT}/connect").status_code == 200
state = claude_onboarding.state_path()
resolve = Path.resolve
def fail_global(self, *args, **kwargs):
if self == state:
raise OSError("global state is inaccessible")
return resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_global)
response = client.get(ROOT)
assert response.status_code == 200
assert response.json()["connected"] is True
assert response.json()["paths"]["claude_state"] == str(state.absolute())
def test_pending_restart_and_shutdown_do_not_write(integration):
client, path, runtime = integration
runtime._pending_fields = ["PORT"]

View file

@ -82,7 +82,6 @@ def test_complete_report_reads_real_files_without_mutation(
claude = diagnostics.claude_integration
claude.configure(
claude.settings_path(),
claude.claude_state_path(),
local_proxy_root_url(settings),
settings.proxy_auth_token,
connected=True,

View file

@ -13,6 +13,7 @@ from free_claude_code.config.loader import clear_settings_cache
from free_claude_code.harnesses import (
claude_desktop_integration,
claude_integration,
claude_onboarding,
codex_integration,
jetbrains_acp_integration,
vscode_chat_integration,
@ -71,7 +72,7 @@ def _isolate_managed_config(monkeypatch, tmp_path):
claude_integration, "settings_path", lambda: tmp_path / "vscode/settings.json"
)
monkeypatch.setattr(
claude_integration, "claude_state_path", lambda: tmp_path / ".claude.json"
claude_onboarding, "state_path", lambda: tmp_path / ".claude.json"
)
monkeypatch.setattr(
codex_integration, "config_path", lambda: tmp_path / ".codex/config.toml"

View file

@ -13,23 +13,18 @@ LOGIN = "claudeCode.disableLoginPrompt"
def operate(path, connected=None):
result = claude_integration.configure(
path, path.parent / ".claude.json", URL, TOKEN, connected
)
result = claude_integration.configure(path, URL, TOKEN, connected)
return {"connected": result["connected"]}
def test_connect_completes_onboarding_and_disconnect_preserves_state(tmp_path):
def test_vscode_operations_preserve_global_state(tmp_path):
path = tmp_path / "settings.json"
state_path = tmp_path / ".claude.json"
state_path.write_text('{"theme":"dark","hasCompletedOnboarding":false}')
assert operate(path, True) == {"connected": True}
assert json.loads(state_path.read_text()) == {
"theme": "dark",
"hasCompletedOnboarding": True,
}
saved = state_path.read_bytes()
modified = state_path.stat().st_mtime_ns
assert operate(path) == {"connected": True}
operate(path, True)
assert state_path.stat().st_mtime_ns == modified
assert operate(path, False) == {"connected": False}
@ -37,40 +32,38 @@ def test_connect_completes_onboarding_and_disconnect_preserves_state(tmp_path):
@pytest.mark.parametrize("flag", [None, False, 1, "true"])
def test_connected_requires_completed_onboarding(tmp_path, flag):
def test_connection_is_independent_of_onboarding(tmp_path, flag):
path = tmp_path / "settings.json"
state_path = tmp_path / ".claude.json"
operate(path, True)
state_path.write_text(json.dumps({"hasCompletedOnboarding": flag}))
assert operate(path) == {"connected": False}
assert operate(path) == {"connected": True}
operate(path, True)
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] == flag
assert operate(path) == {"connected": True}
def test_missing_onboarding_state_is_read_only_until_connect(tmp_path):
def test_vscode_operations_do_not_create_global_state(tmp_path):
path = tmp_path / "settings.json"
state_path = tmp_path / ".claude.json"
operate(path, True)
state_path.unlink()
assert operate(path) == {"connected": False}
assert operate(path) == {"connected": True}
assert not state_path.exists()
assert operate(path, False) == {"connected": False}
assert not state_path.exists()
operate(path, True)
assert json.loads(state_path.read_text()) == {"hasCompletedOnboarding": True}
assert not state_path.exists()
@pytest.mark.parametrize("source", ["{invalid", "[]", '{"a":1,"a":2}', '{"a":NaN}'])
def test_invalid_state_blocks_connect_but_not_disconnect(tmp_path, source):
def test_invalid_global_state_does_not_affect_vscode(tmp_path, source):
path = tmp_path / "settings.json"
state_path = tmp_path / ".claude.json"
operate(path, True)
settings_before = path.read_bytes()
state_path.write_text(source)
for action in (None, True):
with pytest.raises(ValueError):
operate(path, action)
assert operate(path, action) == {"connected": True}
assert path.read_bytes() == settings_before
assert state_path.read_text() == source
assert operate(path, False) == {"connected": False}
@ -88,52 +81,6 @@ def test_invalid_vscode_settings_do_not_update_onboarding(tmp_path):
assert state_path.read_text() == '{"hasCompletedOnboarding":false}'
@pytest.mark.parametrize("failed_file", ["state", "settings"])
def test_save_failure_preserves_completed_steps_and_retry_completes(
tmp_path, monkeypatch, failed_file
):
path = tmp_path / "settings.json"
state_path = tmp_path / ".claude.json"
path.write_text('{"keep":true}')
original_replace = Path.replace
def fail_replace(self, target):
if target == (state_path if failed_file == "state" else path):
raise PermissionError("test failure")
return original_replace(self, target)
with monkeypatch.context() as patch:
patch.setattr(Path, "replace", fail_replace)
with pytest.raises(OSError):
operate(path, True)
if failed_file == "state":
assert not state_path.exists()
else:
assert json.loads(state_path.read_text())["hasCompletedOnboarding"] is True
assert path.read_text() == '{"keep":true}'
assert operate(path) == {"connected": False}
assert operate(path, True) == {"connected": True}
def test_onboarding_symlink_and_escaped_unicode_are_preserved(tmp_path):
path = tmp_path / "settings.json"
state_path = tmp_path / ".claude.json"
target = tmp_path / "state.json"
target.write_text(json.dumps({"keep": "\U0001f600"}))
try:
state_path.symlink_to(Path("state.json"))
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires Developer Mode or privilege")
raise
assert operate(path, True) == {"connected": True}
assert state_path.is_symlink()
assert json.loads(target.read_text()) == {
"keep": "\U0001f600",
"hasCompletedOnboarding": True,
}
def test_connect_merges_jsonc_and_disconnect_preserves_unrelated_values(tmp_path):
path = tmp_path / "settings.json"
path.write_text(
@ -240,7 +187,6 @@ def test_settings_symlink_is_preserved(tmp_path, connected, target_exists):
@pytest.mark.parametrize("url", ["http://localhost:8000/", URL, "http://[::1]:8000"])
def test_manual_setup_only_requires_connection_fields(tmp_path, url):
path = tmp_path / "settings.json"
(tmp_path / ".claude.json").write_text('{"hasCompletedOnboarding":true}')
path.write_text(
json.dumps(
{

View file

@ -0,0 +1,91 @@
import json
from pathlib import Path
import pytest
from free_claude_code.harnesses import claude_onboarding
def test_missing_state_status_does_not_create_files(tmp_path):
path = tmp_path / "missing" / ".claude.json"
assert claude_onboarding.status(path) == {
"completed": False,
"paths": {"claude_state": str(path.resolve())},
}
assert not path.parent.exists()
assert claude_onboarding.complete(path) == {"completed": True, "changed": True}
assert json.loads(path.read_text()) == {"hasCompletedOnboarding": True}
@pytest.mark.parametrize("flag", [None, False, 1, "true", True])
def test_complete_preserves_values_and_is_idempotent(tmp_path, flag):
path = tmp_path / ".claude.json"
path.write_text(
json.dumps(
{"theme": "dark", "keep": "\U0001f600", "hasCompletedOnboarding": flag}
)
)
before = path.read_bytes(), path.stat().st_mtime_ns
assert claude_onboarding.status(path)["completed"] is (flag is True)
assert (path.read_bytes(), path.stat().st_mtime_ns) == before
assert claude_onboarding.complete(path) == {
"completed": True,
"changed": flag is not True,
}
assert json.loads(path.read_text()) == {
"theme": "dark",
"keep": "\U0001f600",
"hasCompletedOnboarding": True,
}
before = path.read_bytes(), path.stat().st_mtime_ns
assert claude_onboarding.complete(path) == {"completed": True, "changed": False}
assert (path.read_bytes(), path.stat().st_mtime_ns) == before
@pytest.mark.parametrize("source", ["{invalid", "[]", '{"a":1,"a":2}', '{"a":NaN}'])
def test_invalid_state_is_never_overwritten(tmp_path, source):
path = tmp_path / ".claude.json"
path.write_text(source)
for operation in (claude_onboarding.status, claude_onboarding.complete):
with pytest.raises(ValueError):
operation(path)
assert path.read_text() == source
def test_failed_write_preserves_original_and_removes_temporary(tmp_path, monkeypatch):
path = tmp_path / ".claude.json"
path.write_text('{"theme":"dark"}')
def fail(self, target):
raise PermissionError("denied")
monkeypatch.setattr(Path, "replace", fail)
with pytest.raises(PermissionError):
claude_onboarding.complete(path)
assert path.read_text() == '{"theme":"dark"}'
assert list(tmp_path.iterdir()) == [path]
def test_symlink_is_preserved(tmp_path):
target, link = tmp_path / "state.json", tmp_path / ".claude.json"
target.write_text('{"theme":"dark"}')
try:
link.symlink_to(target.name)
except OSError as exc:
if getattr(exc, "winerror", None) == 1314:
pytest.skip("Windows symlink creation requires Developer Mode or privilege")
raise
assert claude_onboarding.status(link)["paths"] == {
"claude_state": str(target.resolve())
}
assert claude_onboarding.complete(link) == {"completed": True, "changed": True}
assert link.is_symlink()
assert json.loads(target.read_text()) == {
"theme": "dark",
"hasCompletedOnboarding": True,
}
def test_default_state_path(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
assert claude_onboarding.state_path() == tmp_path / ".claude.json"

View file

@ -28,14 +28,27 @@ base_url = "https://example.com/v1"
"""
@pytest.mark.parametrize(
"source", [None, '{"hasCompletedOnboarding":false}', "{invalid"]
)
def test_refresh_ignores_global_onboarding(tmp_path, source):
settings, state = tmp_path / "settings.json", tmp_path / ".claude.json"
settings.write_text(json.dumps(OLD_CLAUDE))
if source is not None:
state.write_text(source)
assert claude_integration.refresh_connected(settings, URL, TOKEN) is True
if source is None:
assert not state.exists()
else:
assert state.read_text() == source
def test_refresh_upgrades_original_claude_directly_and_only_once(tmp_path):
settings, state = tmp_path / "settings.json", tmp_path / ".claude.json"
settings.write_text(json.dumps(OLD_CLAUDE))
state.write_text('{"theme":"dark"}')
assert (
claude_integration.configure(settings, state, URL, TOKEN)["connected"] is False
)
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is True
assert claude_integration.configure(settings, URL, TOKEN)["connected"] is True
assert claude_integration.refresh_connected(settings, URL, TOKEN) is True
saved = json.loads(settings.read_text())
env = {
entry["name"]: entry["value"]
@ -45,15 +58,10 @@ def test_refresh_upgrades_original_claude_directly_and_only_once(tmp_path):
assert env["CLAUDE_CODE_AUTO_MODE_SERVER"] == "0"
assert env["KEEP_ME"] == "user-value"
assert saved["editor.fontSize"] == 15
assert json.loads(state.read_text()) == {
"theme": "dark",
"hasCompletedOnboarding": True,
}
assert (
claude_integration.configure(settings, state, URL, TOKEN)["connected"] is True
)
assert state.read_text() == '{"theme":"dark"}'
assert claude_integration.configure(settings, URL, TOKEN)["connected"] is True
before = [path.stat().st_mtime_ns for path in (settings, state)]
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is False
assert claude_integration.refresh_connected(settings, URL, TOKEN) is False
assert [path.stat().st_mtime_ns for path in (settings, state)] == before
@ -72,7 +80,7 @@ def test_refresh_does_not_claim_unrecognized_claude(tmp_path, change):
before = settings.read_bytes() if settings.exists() else None
# Unrelated Claude state is not even parsed when the integration is not ours.
state.write_text("{invalid")
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is False
assert claude_integration.refresh_connected(settings, URL, TOKEN) is False
assert (settings.read_bytes() if settings.exists() else None) == before
assert state.read_text() == "{invalid"
@ -91,13 +99,11 @@ def test_refresh_claude_retry_finishes_partial_write(tmp_path, monkeypatch):
with monkeypatch.context() as patch:
patch.setattr(claude_integration, "atomic_write_text", fail_settings)
with pytest.raises(PermissionError):
claude_integration.refresh_connected(settings, state, URL, TOKEN)
claude_integration.refresh_connected(settings, URL, TOKEN)
assert settings.read_bytes() == before
assert json.loads(state.read_text())["hasCompletedOnboarding"] is True
assert claude_integration.refresh_connected(settings, state, URL, TOKEN) is True
assert (
claude_integration.configure(settings, state, URL, TOKEN)["connected"] is True
)
assert not state.exists()
assert claude_integration.refresh_connected(settings, URL, TOKEN) is True
assert claude_integration.configure(settings, URL, TOKEN)["connected"] is True
def test_refresh_codex_merges_latest_settings_without_changing_model(tmp_path):
@ -148,9 +154,7 @@ def test_refresh_rejects_malformed_input_without_writing(tmp_path, integration):
path.write_text("{invalid")
with pytest.raises(ValueError):
if integration == "claude":
claude_integration.refresh_connected(
path, tmp_path / ".claude.json", URL, TOKEN
)
claude_integration.refresh_connected(path, URL, TOKEN)
else:
codex_integration.refresh_connected(path, tmp_path / "catalog.json", URL)
assert path.read_text() == "{invalid"

View file

@ -19,6 +19,7 @@ from free_claude_code.harnesses import (
)
from free_claude_code.harnesses import (
claude_integration,
claude_onboarding,
codex_integration,
)
from free_claude_code.harnesses import (
@ -148,6 +149,7 @@ async def test_startup_refreshes_old_connections_and_status_is_read_only(runtime
await runtime.claude_vscode_status()
await runtime.codex_integration_status()
assert [p.stat().st_mtime_ns for p in (claude, codex)] == before
assert not claude_onboarding.state_path().exists()
await runtime.disconnect_claude_vscode()
assert (await runtime.claude_vscode_status())["update"]["changed"] is False
await runtime.refresh_claude_vscode()
@ -157,6 +159,21 @@ async def test_startup_refreshes_old_connections_and_status_is_read_only(runtime
await runtime.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("source", ['{"hasCompletedOnboarding":false}', "{invalid"])
async def test_startup_preserves_global_onboarding(runtime, source):
old_connections()
state = claude_onboarding.state_path()
state.write_text(source)
try:
await runtime.start()
await finish(runtime)
assert (await runtime.claude_vscode_status())["connected"] is True
assert state.read_text() == source
finally:
await runtime.close()
@pytest.mark.asyncio
async def test_codex_wait_does_not_delay_http_or_claude(runtime, monkeypatch):
old_connections()
@ -208,7 +225,7 @@ async def test_unrecognized_codex_never_waits_for_catalog(runtime, monkeypatch):
wait.assert_not_awaited()
assert (await runtime.codex_integration_status())["connected"] is False
assert not codex_integration.config_path().exists()
assert not claude_integration.claude_state_path().exists()
assert not claude_onboarding.state_path().exists()
finally:
await runtime.close()

View file

@ -1,4 +1,5 @@
import asyncio
import json
import threading
from unittest.mock import AsyncMock
@ -6,6 +7,7 @@ import pytest
from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.harnesses import claude_integration as claude
from free_claude_code.harnesses import claude_onboarding
from free_claude_code.harnesses import vscode_chat_integration as vscode
from tests.runtime.test_integration_startup import runtime as runtime
@ -98,20 +100,20 @@ async def test_cancelled_reader_drains_before_writer_enters(runtime, monkeypatch
async def test_status_cannot_observe_partial_multifile_write(runtime, monkeypatch):
entered, release = asyncio.Event(), threading.Event()
loop = asyncio.get_running_loop()
atomic = claude.atomic_write_text
atomic = claude_onboarding.atomic_write_text
def held(path, content):
atomic(path, content)
if path == claude.claude_state_path():
if path == claude_onboarding.state_path():
loop.call_soon_threadsafe(entered.set)
assert release.wait(5)
monkeypatch.setattr(claude, "atomic_write_text", held)
monkeypatch.setattr(claude_onboarding, "atomic_write_text", held)
writer = asyncio.create_task(runtime.connect_claude_vscode())
reader = None
try:
await asyncio.wait_for(entered.wait(), 5)
assert claude.claude_state_path().exists()
assert claude_onboarding.state_path().exists()
assert not claude.settings_path().exists()
reader = asyncio.create_task(runtime.claude_vscode_status())
assert (await asyncio.wait_for(runtime.jetbrains_acp_status(), 1))[
@ -129,12 +131,108 @@ async def test_status_cannot_observe_partial_multifile_write(runtime, monkeypatc
await runtime.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("first", ["connect", "complete"])
async def test_connect_and_onboarding_share_write_ownership(
runtime, monkeypatch, first
):
entered, release = asyncio.Event(), threading.Event()
loop = asyncio.get_running_loop()
complete = claude_onboarding.complete
calls = 0
path = claude_onboarding.state_path()
path.write_text('{"theme":"dark"}')
def held(path):
nonlocal calls
calls += 1
if calls == 1:
loop.call_soon_threadsafe(entered.set)
assert release.wait(5)
return complete(path)
monkeypatch.setattr(claude_onboarding, "complete", held)
operations = [runtime.connect_claude_vscode, runtime.complete_claude_onboarding]
if first == "complete":
operations.reverse()
writer = asyncio.create_task(operations[0]())
second = reader = None
try:
await asyncio.wait_for(entered.wait(), 5)
second = asyncio.create_task(operations[1]())
reader = asyncio.create_task(runtime.claude_onboarding_status())
assert (await asyncio.wait_for(runtime.jetbrains_acp_status(), 1))[
"connected"
] is False
assert not second.done() and not reader.done()
assert calls == 1
release.set()
await asyncio.wait_for(asyncio.gather(writer, second), 5)
assert (await asyncio.wait_for(reader, 5))["completed"] is True
assert calls == 2
assert json.loads(path.read_text()) == {
"theme": "dark",
"hasCompletedOnboarding": True,
}
assert (await runtime.claude_vscode_status())["connected"] is True
finally:
release.set()
await asyncio.gather(
writer,
*([second] if second else []),
*([reader] if reader else []),
return_exceptions=True,
)
await runtime.close()
@pytest.mark.asyncio
async def test_cancelled_onboarding_write_is_drained_before_shutdown(
runtime, monkeypatch
):
entered, release = asyncio.Event(), threading.Event()
loop = asyncio.get_running_loop()
complete = claude_onboarding.complete
def held(path):
loop.call_soon_threadsafe(entered.set)
assert release.wait(5)
return complete(path)
monkeypatch.setattr(claude_onboarding, "complete", held)
writer = asyncio.create_task(runtime.complete_claude_onboarding())
close = None
try:
await asyncio.wait_for(entered.wait(), 5)
writer.cancel()
close = asyncio.create_task(runtime.close())
await asyncio.sleep(0)
assert not writer.done() and not close.done()
assert runtime._config_lock.locked()
release.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(writer, 5)
assert await asyncio.wait_for(close, 5)
assert (
json.loads(claude_onboarding.state_path().read_text())[
"hasCompletedOnboarding"
]
is True
)
finally:
release.set()
await asyncio.gather(
writer, *([close] if close else []), return_exceptions=True
)
await runtime.close()
@pytest.mark.asyncio
async def test_continuously_changed_generation_has_bounded_retry(runtime, monkeypatch):
entered, release = asyncio.Queue(), threading.Semaphore(0)
loop = asyncio.get_running_loop()
def held(path, state_path, url, token, connected=None):
def held(path, url, token, connected=None):
loop.call_soon_threadsafe(entered.put_nowait, token)
assert release.acquire(timeout=5)
return {"connected": False, "paths": {}}
@ -257,7 +355,7 @@ async def test_generation_change_discards_old_status_outcome(
tokens = []
initial_token = runtime.settings.proxy_auth_token
def held(path, state_path, url, token, connected=None):
def held(path, url, token, connected=None):
tokens.append(token)
if len(tokens) == 1:
loop.call_soon_threadsafe(entered.set)