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
22 changes: 21 additions & 1 deletion .github/workflows/validate-bug-report-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
if (doctor.length) {
if (doctor.length !== 1) return { valid: false };
let text = doctor[0].replace(/^FCC doctor output\r?\n/, "").trim();
const details = text.match(/^<details>\s*<summary>[^<>\r\n]*<\/summary>\s*([\s\S]*?)\s*<\/details>$/);
if (details) text = details[1].trim();
const fence = text.match(/^```(?:json)?\r?\n([\s\S]*)\r?\n```$/);
if (fence) text = fence[1];
let report;
Expand All @@ -58,7 +60,25 @@ jobs:
const reportedVersion = versionMatches.length === 1 ? versionMatches[0][1] : undefined;
return { valid: reportedVersion !== undefined || fieldValue === "None", reportedVersion };
};
const { reportedVersion, valid } = parseReport(issue.body || "");
const collapseDoctorReport = (body) => {
const sections = body.split(/(?=^### )/m);
const index = sections.findIndex((section) => /^### FCC doctor output\r?\n/.test(section));
if (index === -1 || !parseReport(body).valid) return body;
const section = sections[index];
const text = section.replace(/^### FCC doctor output\r?\n/, "").trim();
if (text.startsWith("<details>")) return body;
const newline = section.includes("\r\n") ? "\r\n" : "\n";
const content = text.startsWith("```") ? text : `\`\`\`json${newline}${text}${newline}\`\`\``;
const wrapped = ["<details>", "<summary>FCC doctor output</summary>", "", content, "", "</details>"].join(newline);
const start = section.indexOf(text);
sections[index] = section.slice(0, start) + wrapped + section.slice(start + text.length);
return sections.join("");
};
const formattedBody = collapseDoctorReport(issue.body || "");
if (formattedBody !== (issue.body || "")) {
await github.rest.issues.update({ ...repo, issue_number, body: formattedBody });
}
const { reportedVersion, valid } = parseReport(formattedBody);
const labelName = "needs-fcc-version";
const invalidMarker = "<!-- fcc-version-validator -->";
const outdatedMarker = "<!-- fcc-version-outdated -->";
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ Stop all running FCC commands, then run:
fcc-update
```

If FCC is already up to date, the command stops without running the installer. Otherwise, it shows the installed and available versions before updating. If the version check fails, retry the command.

To reinstall FCC or change optional components while already up to date, run the [installer](#install) directly.

For local voice support, include `--voice-local` (macOS/Linux) or `-VoiceLocal` (Windows), plus your `--torch-backend` or `-TorchBackend` option if used.

If your installation does not have `fcc-update` yet, run the [installer](#install) once to add it.
Expand Down
18 changes: 14 additions & 4 deletions e2e/test_code_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,14 +597,18 @@ def test_settings_apply_preserves_open_creation_and_picker(
page, admin_base_url, tmp_path, code_control
):
pending = []
page.route("**/admin/api/config/apply", lambda route: pending.append(route))
page.expose_function("applyRequestIntercepted", lambda: bool(pending))

def capture_apply(route):
pending.append(route)
page.evaluate("window.applyRequestIntercepted = true")

page.route("**/admin/api/config/apply", capture_apply)
page.goto(f"{admin_base_url}/admin")
expect(page.locator("#messageArea")).to_have_text("")
page.locator("#field-PORT").fill("8081")
page.get_by_role("button", name="Apply", exact=True).click()
expect(page.locator("#messageArea")).to_have_text("Applying…")
page.wait_for_function("window.applyRequestIntercepted()")
page.wait_for_function("window.applyRequestIntercepted === true")
assert len(pending) == 1
page.get_by_role("button", name="Code sessions", exact=True).click()
page.get_by_role("button", name="New code session", exact=True).click()
Expand Down Expand Up @@ -1722,7 +1726,13 @@ def test_off_clears_effort_when_reasoning_becomes_unavailable(
):
url = create_session(page, admin_base_url, tmp_path)
effort = page.locator("#codeReasoning")
effort.select_option("high")
with page.expect_response(
lambda response: (
response.request.method == "PATCH" and "/api/code/sessions/" in response.url
)
) as changed:
effort.select_option("high")
assert changed.value.json()["reasoning_effort"] == "high"
expect(effort).to_be_enabled()
page.locator("#codeComposer").fill("Preserve this draft")
code_control.harness.efforts = ("off",)
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ license = "AGPL-3.0-only"
license-files = ["LICENSE"]
requires-python = "==3.14.7"
dependencies = [
"packaging>=26.3",
"anyio>=4.15.1",
"fastapi[standard]>=0.141.1",
"uvicorn>=0.53.0",
Expand Down Expand Up @@ -41,6 +42,7 @@ dependencies = [
]

[project.scripts]
_fcc-update-check = "free_claude_code.updater.check:main"
fcc-server = "free_claude_code.cli.entrypoints:serve"
fcc-doctor = "free_claude_code.cli.entrypoints:doctor"
fcc-claude = "free_claude_code.cli.launchers.claude:launch"
Expand Down Expand Up @@ -89,7 +91,6 @@ explicit = true

[dependency-groups]
dev = [
"packaging>=26.3",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
Expand Down
7 changes: 7 additions & 0 deletions scripts/update/fcc-update
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
#!/bin/sh
set -eu
status=0
"$(dirname "$0")/_fcc-update-check" || status=$?
case "$status" in
0) exit 0 ;;
10) ;;
*) exit "$status" ;;
esac
installer=$(curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh")
exec sh -c "$installer" sh "$@"
3 changes: 3 additions & 0 deletions scripts/update/fcc-update.cmd
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
@echo off
"%~dp0_fcc-update-check.exe"
if %errorlevel%==0 exit /b 0
if not %errorlevel%==10 exit /b %errorlevel%
(
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; & ([scriptblock]::Create((Invoke-RestMethod 'https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1')))" %*
call exit /b %%errorlevel%%
Expand Down
6 changes: 4 additions & 2 deletions smoke/lib/e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from free_claude_code.messaging.workflow import MessagingWorkflow
from smoke.lib.child_process import run_captured_text
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.http import conversation_headers
from smoke.lib.server import RunningServer, start_server
from smoke.lib.skips import fail_missing_env, skip_if_upstream_unavailable_events

Expand Down Expand Up @@ -88,6 +89,7 @@ def __init__(self, server: RunningServer, config: SmokeConfig) -> None:
self.config = config
self.messages: list[dict[str, Any]] = []
self.turns: list[ConversationTurn] = []
self._session_id = str(uuid.uuid4())

def ask(
self,
Expand Down Expand Up @@ -120,7 +122,7 @@ def stream(
*,
headers: dict[str, str] | None = None,
) -> ConversationTurn:
request_headers = headers or auth_headers()
request_headers = conversation_headers(headers, session_id=self._session_id)
stream_payload = {**payload, "stream": True}
with httpx.stream(
"POST",
Expand Down Expand Up @@ -148,7 +150,7 @@ def stream_expect_http_error(
) -> dict[str, Any]:
response = httpx.post(
f"{self.server.base_url}/v1/messages",
headers=auth_headers(),
headers=conversation_headers(session_id=self._session_id),
json=payload,
timeout=self.config.timeout_s,
)
Expand Down
15 changes: 13 additions & 2 deletions smoke/lib/http.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""HTTP helpers for live smoke requests."""

import uuid
from typing import Any

import httpx
Expand All @@ -10,6 +11,16 @@
from .server import RunningServer


def conversation_headers(
headers: dict[str, str] | None = None, *, session_id: str | None = None
) -> dict[str, str]:
"""Identify a smoke conversation while preserving explicit caller headers."""
result = httpx.Headers(auth_headers() if headers is None else headers)
result.setdefault("user-agent", "fcc-smoke/1.0")
result.setdefault("x-opencode-session", session_id or str(uuid.uuid4()))
return dict(result)


def message_payload(
text: str,
*,
Expand All @@ -35,7 +46,7 @@ def post_json(
*,
headers: dict[str, str] | None = None,
) -> httpx.Response:
request_headers = headers or auth_headers()
request_headers = conversation_headers(headers)
response = httpx.post(
f"{server.base_url}{path}",
headers=request_headers,
Expand All @@ -52,7 +63,7 @@ def collect_message_stream(
*,
headers: dict[str, str] | None = None,
) -> list[SSEEvent]:
request_headers = headers or auth_headers()
request_headers = conversation_headers(headers)
stream_payload = {**payload, "stream": True}
with httpx.stream(
"POST",
Expand Down
6 changes: 3 additions & 3 deletions smoke/prereq/test_provider_prereq_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
text_content,
thinking_content,
)
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.config import ProviderModel, SmokeConfig
from smoke.lib.e2e import ProviderMatrixDriver
from smoke.lib.http import collect_message_stream, message_payload
from smoke.lib.http import collect_message_stream, conversation_headers, message_payload
from smoke.lib.server import start_server
from smoke.lib.skips import (
skip_if_upstream_unavailable_events,
Expand Down Expand Up @@ -97,7 +97,7 @@ def test_client_disconnect_mid_stream_does_not_crash_server(
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
headers=auth_headers(),
headers=conversation_headers(),
json=message_payload(smoke_config.prompt, model="fcc-smoke-default"),
timeout=smoke_config.timeout_s,
) as response:
Expand Down
7 changes: 6 additions & 1 deletion smoke/product/test_codex_modes_product_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
atomic_write_managed_config,
settings_env_keys,
)
from free_claude_code.config.paths import FCC_DATABASE_FILENAME
from free_claude_code.config.provider_catalog import PROVIDER_CATALOG
from smoke.lib.child_process import run_captured_text
from smoke.lib.config import SmokeConfig
Expand Down Expand Up @@ -554,7 +555,11 @@ def test_codex_child_review_local_e2e(
)
assert marker.read_text().strip() == "smoke"
with closing(
sqlite3.connect(tmp_path / "home" / ".fcc" / "code" / "code.db")
sqlite3.connect(
(tmp_path / "home" / ".fcc" / FCC_DATABASE_FILENAME).as_uri()
+ "?mode=ro",
uri=True,
)
) as database:
root = database.execute(
"SELECT native_thread_id FROM code_sessions WHERE id = ?",
Expand Down
13 changes: 7 additions & 6 deletions smoke/product/test_provider_product_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
SSEEvent,
parse_sse_lines,
)
from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers
from smoke.lib.config import ProviderModel, SmokeConfig
from smoke.lib.e2e import (
ConversationDriver,
ProviderMatrixDriver,
Expand All @@ -19,7 +19,7 @@
echo_tool_schema,
tool_use_blocks,
)
from smoke.lib.http import collect_message_stream
from smoke.lib.http import collect_message_stream, conversation_headers
from smoke.lib.skips import (
skip_if_upstream_unavailable_events,
skip_if_upstream_unavailable_exception,
Expand Down Expand Up @@ -166,7 +166,7 @@ def test_provider_error_e2e(smoke_config: SmokeConfig) -> None:
):
response = client.post(
f"{server.base_url}/v1/messages",
headers=auth_headers(),
headers=conversation_headers(),
json={
"model": "fcc-smoke-default",
"max_tokens": 32,
Expand Down Expand Up @@ -605,11 +605,12 @@ def _scenario_reasoning_tool_continuation(
def _scenario_disconnect(
smoke_config: SmokeConfig, provider_model: ProviderModel
) -> None:
headers = conversation_headers()
with _server_for_provider(smoke_config, provider_model, "disconnect") as server:
with httpx.stream(
"POST",
f"{server.base_url}/v1/messages",
headers=auth_headers(),
headers=headers,
json={
"model": "fcc-smoke-default",
"max_tokens": 512,
Expand All @@ -623,7 +624,7 @@ def _scenario_disconnect(
health = httpx.get(f"{server.base_url}/health", timeout=5)
assert health.status_code == 200
followup = ConversationDriver(server, smoke_config).ask(
"Reply with one short sentence."
"Reply with one short sentence.", headers=headers
)
_assert_provider_product_stream(followup.events)

Expand All @@ -646,4 +647,4 @@ def _openai_auth_headers(smoke_config: SmokeConfig) -> dict[str, str]:
token = smoke_config.settings.proxy_auth_token
if token:
headers["authorization"] = f"Bearer {token}"
return headers
return conversation_headers(headers)
20 changes: 19 additions & 1 deletion src/free_claude_code/cli/launchers/cline.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Installed Cline launcher with private native provider settings."""
"""Installed Cline launcher with private native provider settings.

Sessions run with an isolated Hub discovery path so a stale or user-run Cline
Hub can never capture an fcc-cline session: the child resolves Hub state only
inside FCC's ephemeral configuration directory, which is deleted on exit. A
Hub started with ordinary cline keeps using its own discovery file.
"""

import re
from collections.abc import Sequence
Expand Down Expand Up @@ -29,6 +35,17 @@ def _configure(
)
providers_path = files.write_json("settings/providers.json", config.providers)
files.write_json("settings/models.json", config.models)
# Interactive Cline passes backendMode:"auto" to core, which overrides
# CLINE_SESSION_BACKEND_MODE and may attach to (or start) a detached Hub
# that resolves provider settings from the user's global store instead of
# FCC's ephemeral files. Redirecting Hub discovery into the ephemeral tree
# makes that attachment impossible without touching user-run Hub state.
# (Verified: shipped Cline 3.0.57 honors CLINE_HUB_DISCOVERY_PATH as the
# discovery-path override in its hub resolver.)
hub_discovery_path = files.directory / "hub" / "production.json"
# Pre-create the directory, not the file: the child records Hub discovery
# state here on startup, and the Hub itself must own the file.
hub_discovery_path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
return PreparedLaunch(
[ctx.binary_path, "--provider", CLINE_PROVIDER_ID, *args],
client_environment(
Expand All @@ -37,6 +54,7 @@ def _configure(
updates={
"CLINE_PROVIDER_SETTINGS_PATH": str(providers_path),
"CLINE_SESSION_BACKEND_MODE": "local",
"CLINE_HUB_DISCOVERY_PATH": str(hub_discovery_path),
},
),
)
Expand Down
10 changes: 8 additions & 2 deletions src/free_claude_code/config/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
FCC_TEMP_DIRNAME = "tmp"
LAUNCHER_TEMP_DIRNAME = "launchers"
CODE_STATE_DIRNAME = "code"
FCC_DATABASE_FILENAME = "fcc.db"
CODE_DATABASE_FILENAME = "code.db"
CODE_LOCK_FILENAME = "code.lock"

Expand Down Expand Up @@ -45,8 +46,13 @@ def launcher_temp_dir_path() -> Path:
return config_dir_path() / FCC_TEMP_DIRNAME / LAUNCHER_TEMP_DIRNAME


def code_database_path() -> Path:
"""Return the managed Code sessions database path."""
def fcc_database_path() -> Path:
"""Return the shared FCC database path."""
return config_dir_path() / FCC_DATABASE_FILENAME


def legacy_code_database_path() -> Path:
"""Return the former Code database path for startup relocation."""
return config_dir_path() / CODE_STATE_DIRNAME / CODE_DATABASE_FILENAME


Expand Down
Loading
Loading