From 8d2a431fef6e8fe42bbddde9295c55439ac96f95 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Mon, 21 Sep 2026 19:38:51 -0700 Subject: [PATCH 1/3] Fix misleading OpenRouter image-input errors (#1855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Fixes #1853. OpenRouter returns HTTP 404 when no endpoint supports an attached image. Claude interprets that status as a missing or inaccessible model and hides the actual image-support explanation. ## How Recognize OpenRouter's structured image-routing rejection in its provider behavior and translate it into a non-retryable HTTP 400. Tell the user to remove the image or choose an image-capable model, retaining the original upstream diagnostic and request ID. Add regression coverage for classification boundaries and image-bearing Messages/Responses requests. Bump the patch version to 6.2.55.

RetriggerConfidence Score: 5/5

Safe to merge: the public Messages and Responses flows correctly translate the targeted image-routing rejection, while unrelated OpenRouter 404 responses remain unchanged. What we checked: - Ran a FastAPI/TestClient probe through the public Messages and Responses endpoints with mocked OpenRouter responses; verified that a normal OpenRouter 404 remains a 404 api_error with diagnostics, request IDs, and forwarded image input, and that the image-routing 404 becomes a non-retryable 400 invalid_request_error on both endpoints; 41 tests passed. T-Rex - Executed the runtime and test commands: run the image-routing runtime script with and without the image-routing flag, and run the focused pytest suite for OpenRouter image errors and API tests; the authored executable source is uploaded as openrouter-image-routing-runtime.py, and the captured outputs show the commands, working directory, and exit codes. T-Rex
Summary OpenRouter image-support routing rejections now return a clear, non-retryable invalid-request response while preserving upstream diagnostics, request IDs, and image forwarding. Ordinary OpenRouter 404 routing responses retain their existing behavior. The package version is updated to 6.2.55. Merge safety: safe to merge.
Reviews (1) · Last reviewed commit: ["Explain OpenRouter image-routing rejecti..."](https://github.com/alishahryar1/free-claude-code/commit/e1e5fcc478611d0a872178c79a28dcbf70b21d42) --- pyproject.toml | 2 +- .../providers/open_router/client.py | 31 ++++++ tests/api/test_openrouter_image_errors.py | 81 ++++++++++++++ tests/providers/test_open_router.py | 103 ++++++++++++++++++ uv.lock | 2 +- 5 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_openrouter_image_errors.py diff --git a/pyproject.toml b/pyproject.toml index 503eac4e6d..9e3e7d11f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.54" +version = "6.2.55" 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/providers/open_router/client.py b/src/free_claude_code/providers/open_router/client.py index 27666398a7..1372a7985e 100644 --- a/src/free_claude_code/providers/open_router/client.py +++ b/src/free_claude_code/providers/open_router/client.py @@ -1,11 +1,13 @@ """OpenRouter provider implementation.""" import json +from collections.abc import Mapping from free_claude_code.application.model_metadata import ProviderModelInfo from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS from free_claude_code.core.anthropic import ReasoningReplayMode from free_claude_code.core.diagnostics import extract_upstream_error_detail +from free_claude_code.core.failures import ExecutionFailure, FailureKind from free_claude_code.core.reasoning import ReasoningEffort from free_claude_code.providers.admission import ProviderAdmissionController from free_claude_code.providers.base import ProviderConfig @@ -34,6 +36,35 @@ class OpenRouterChatBehavior(OpenAIChatBehavior): """OpenRouter Chat adaptation without HTTP ownership.""" + def failure_override(self, error: Exception) -> ExecutionFailure | None: + if getattr(error, "status_code", None) != 404: + return None + body = getattr(error, "body", None) + if isinstance(body, Mapping) and "error" in body: + if any(field in body for field in ("code", "message", "metadata")): + return None + body = body["error"] + if not isinstance(body, Mapping): + return None + code = body.get("code") + if not isinstance(code, int) or code != 404: + return None + if not isinstance(body.get("message"), str): + return None + metadata = body.get("metadata") + if ( + not isinstance(metadata, Mapping) + or metadata.get("failed_routing_step") != "Filter by Image Support" + ): + return None + return ExecutionFailure( + FailureKind.INVALID_REQUEST, + 400, + "No OpenRouter endpoint for this request supports image input. " + "Remove the image or choose an image-capable model.", + False, + ) + def reasoning_disable_rejected(self, error: Exception) -> bool: detail = extract_upstream_error_detail(error) if detail.status_code not in {None, 200}: diff --git a/tests/api/test_openrouter_image_errors.py b/tests/api/test_openrouter_image_errors.py new file mode 100644 index 0000000000..b92bd084ce --- /dev/null +++ b/tests/api/test_openrouter_image_errors.py @@ -0,0 +1,81 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.providers.open_router import OpenRouterProvider +from tests.api.support import create_test_app +from tests.providers.support import immediate_admission, make_provider_config +from tests.providers.test_open_router import image_routing_error_body, routing_error + + +@pytest.mark.parametrize( + ("wire", "stream"), [("messages", False), ("messages", True), ("responses", True)] +) +@pytest.mark.parametrize("image_rejected", [True, False]) +def test_openrouter_image_error_reaches_client_without_losing_image( + wire, stream, image_rejected +): + provider = OpenRouterProvider( + make_provider_config("test-key", "https://openrouter.ai/api/v1"), + admission=immediate_admission(), + ) + body = ( + image_routing_error_body() + if image_rejected + else {"code": 404, "message": "Model not found"} + ) + create = AsyncMock(side_effect=routing_error(body)) + image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nGQAAAAASUVORK5CYII=" + url = f"data:image/png;base64,{image_data}" + payload = {"model": "open_router/test-model", "stream": stream} + if wire == "messages": + content = [ + {"type": "text", "text": "Read this image"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": image_data, + }, + }, + ] + payload.update(messages=[{"role": "user", "content": content}], max_tokens=100) + else: + content = [ + {"type": "input_text", "text": "Read this image"}, + {"type": "input_image", "image_url": url}, + ] + payload.update( + input=[{"role": "user", "content": content}], max_output_tokens=100 + ) + try: + with ( + patch( + "free_claude_code.api.routes.resolve_provider", return_value=provider + ), + patch.object(provider._client.chat.completions, "create", create), + TestClient(create_test_app()) as client, + ): + response = client.post(f"/v1/{wire}", json=payload) + assert response.status_code == (400 if image_rejected else 404) + assert response.headers["x-should-retry"] == "false" + error = response.json()["error"] + assert error["type"] == ( + "invalid_request_error" if image_rejected else "api_error" + ) + assert body["message"] in error["message"] + assert response.headers["request-id"] in error["message"] + if image_rejected: + assert "Remove the image" in error["message"] + assert create.await_count == 1 + assert any( + part.get("image_url", {}).get("url") == url + for message in create.await_args_list[0].kwargs["messages"] + if isinstance(message["content"], list) + for part in message["content"] + ) + finally: + asyncio.run(provider.cleanup()) diff --git a/tests/providers/test_open_router.py b/tests/providers/test_open_router.py index 0be5ae8366..e6ad157367 100644 --- a/tests/providers/test_open_router.py +++ b/tests/providers/test_open_router.py @@ -4,6 +4,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +import httpx2 +import openai import pytest from free_claude_code.application.errors import InvalidRequestError @@ -15,8 +17,10 @@ text_content, thinking_content, ) +from free_claude_code.core.failures import FailureKind from free_claude_code.core.history_replay import decode_replay from free_claude_code.core.model_capabilities import ModelInputModality +from free_claude_code.providers.failure_policy import classify_provider_failure from free_claude_code.providers.open_router import OpenRouterProvider from free_claude_code.providers.openai_chat import OpenAIChatProvider from tests.providers.request_factory import make_messages_request @@ -29,6 +33,105 @@ ) +def image_routing_error_body(): + return { + "message": "No endpoints found that support image input", + "code": 404, + "metadata": {"failed_routing_step": "Filter by Image Support"}, + } + + +def routing_error(body, status=404): + return openai.APIStatusError( + "OpenRouter routing failed", + response=httpx2.Response( + status, + request=httpx2.Request( + "POST", "https://openrouter.ai/api/v1/chat/completions" + ), + ), + body=body, + ) + + +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize( + "message", ["No endpoints found that support image input", "Image routing rejected"] +) +def test_image_routing_failure_explains_unsupported_input( + open_router_provider, wrapped, message +): + body = {**image_routing_error_body(), "message": message} + error = routing_error({"error": body} if wrapped else body) + failure = classify_provider_failure( + error, + provider_name="OPENROUTER", + read_timeout_s=30, + request_id="req_image", + provider_failure_override=open_router_provider._chat._behavior.failure_override, + ) + assert failure.kind == FailureKind.INVALID_REQUEST + assert failure.status_code == 400 + assert failure.retryable is False + assert "Remove the image" in failure.message + assert "image-capable" in failure.message + assert message in failure.message + assert "HTTP 404" in failure.message + assert "req_image" in failure.message + assert ( + classify_provider_failure( + error, provider_name="OTHER", read_timeout_s=30, request_id=None + ).status_code + == 404 + ) + + +@pytest.mark.parametrize( + "body", + [ + {"message": "Model not found", "code": 404}, + {**image_routing_error_body(), "metadata": {}}, + { + **image_routing_error_body(), + "metadata": {"failed_routing_step": "Filter by Data Policy"}, + }, + {**image_routing_error_body(), "metadata": None}, + {**image_routing_error_body(), "code": "404"}, + {**image_routing_error_body(), "code": 404.0}, + {**image_routing_error_body(), "code": 400}, + {**image_routing_error_body(), "message": None}, + {**image_routing_error_body(), "error": image_routing_error_body()}, + {"error": "malformed"}, + "No endpoints found that support image input", + None, + ], +) +def test_unrelated_routing_errors_keep_shared_policy(open_router_provider, body): + error = routing_error(body) + override = open_router_provider._chat._behavior.failure_override + assert override(error) is None + failure = classify_provider_failure( + error, + provider_name="OPENROUTER", + read_timeout_s=30, + request_id=None, + provider_failure_override=override, + ) + assert failure.status_code == 404 + + +@pytest.mark.parametrize("status", [400, 401, 403, 429, 500, 200]) +def test_image_metadata_does_not_override_other_http_status( + open_router_provider, status +): + assert ( + open_router_provider._chat._behavior.failure_override( + routing_error(image_routing_error_body(), status) + ) + is None + ) + + class AsyncStream(SDKStreamDouble): def __init__(self, chunks): self._chunks = chunks diff --git a/uv.lock b/uv.lock index 60b9b381e5..eac11b710f 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.54" +version = "6.2.55" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From b7a9722df34f8e0be3a448cbd12791c98bb854b9 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Mon, 21 Sep 2026 21:22:19 -0700 Subject: [PATCH 2/3] Add fcc-update and separate README update instructions (#1856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Updating FCC currently requires rerunning the installer. A dedicated command should refresh FCC, stay in the foreground, and report the actual result without locking the files it replaces. ## How Add `fcc-update` as uv-owned native shell launchers. A short-lived preparation process verifies the installation and stopped FCC processes, preserves installed voice extras and identifiable backends, then exits before the launcher runs the existing `uv tool install --force --refresh-package free-claude-code` operation. The launcher verifies the resulting version and returns failures to the terminal. Keep updates scoped to FCC. Unknown/custom local-voice backends stop with instructions to use the matching installer. Add installer/uninstaller command verification and separate README Install and Update instructions, with a bootstrap note for older installations. Bump the patch version to 6.2.56.

RetriggerConfidence Score: 5/5

Safe to merge.

Fix All in CodexFindings

1. P2 **Verify PowerShell companion** ▶
Summary Adds and verifies the dedicated `fcc-update` updater flow for uv-managed installations. The Windows installer now confirms that the PowerShell companion is installed next to its command launcher, preventing a successful installation from leaving an unusable update command.
Reviews (3) · Last reviewed commit: ["Decode uv updater output as UTF-8"](https://github.com/alishahryar1/free-claude-code/commit/ef558e85724d8c09e607b237fbfaf9e9862ce99c) --- .gitattributes | 2 + README.md | 12 +- pyproject.toml | 7 +- scripts/install.ps1 | 8 +- scripts/install.sh | 4 +- scripts/uninstall.ps1 | 3 +- scripts/uninstall.sh | 4 +- scripts/update/fcc-update | 12 ++ scripts/update/fcc-update-windows.ps1 | 27 +++ scripts/update/fcc-update.cmd | 5 + src/free_claude_code/cli/update.py | 261 ++++++++++++++++++++++++++ tests/cli/test_update.py | 179 ++++++++++++++++++ tests/cli/test_update_lifecycle.py | 222 ++++++++++++++++++++++ tests/scripts/test_installers.py | 24 +++ tests/scripts/test_uninstallers.py | 3 +- uv.lock | 2 +- 16 files changed, 763 insertions(+), 12 deletions(-) create mode 100644 scripts/update/fcc-update create mode 100644 scripts/update/fcc-update-windows.ps1 create mode 100644 scripts/update/fcc-update.cmd create mode 100644 src/free_claude_code/cli/update.py create mode 100644 tests/cli/test_update.py create mode 100644 tests/cli/test_update_lifecycle.py diff --git a/.gitattributes b/.gitattributes index a5486f9210..0ee20a9b0b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ src/free_claude_code/core/data/cl100k_base.tiktoken -text linguist-vendored=true +scripts/update/fcc-update text eol=lf +scripts/update/*.cmd text eol=crlf diff --git a/README.md b/README.md index b9617eb2fa..8148dbc6d9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Free-tier availability and limits are controlled by each provider and may change -### 1. Install Or Update +### 1. Install macOS/Linux: @@ -64,7 +64,7 @@ Windows PowerShell: & ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) ``` -Re-run the same command to update. When prompted, choose at least one coding agent and optionally RTK. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1). +When prompted, choose at least one coding agent and optionally RTK. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1). ### 2. Start FCC @@ -483,7 +483,13 @@ Run `fcc-server --version` to check the installed version without starting FCC. ### Update -Re-run the matching command from [Install Or Update](#install). +Stop all running FCC commands, then run: + +```sh +fcc-update +``` + +If your installation does not have `fcc-update` yet, run the [installer](#install) once to add it. ### Muse Code on native Windows diff --git a/pyproject.toml b/pyproject.toml index 9e3e7d11f7..29439584a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.55" +version = "6.2.56" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" @@ -66,6 +66,11 @@ voice_local = [ [tool.hatch.build.targets.wheel] packages = ["src/free_claude_code"] +[tool.hatch.build.targets.wheel.shared-scripts] +"scripts/update/fcc-update" = "fcc-update" +"scripts/update/fcc-update.cmd" = "fcc-update.cmd" +"scripts/update/fcc-update-windows.ps1" = "fcc-update-windows.ps1" + [tool.uv] required-version = ">=0.12.13" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b65b22574e..2c534fad15 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -59,6 +59,7 @@ $FccCommands = @( "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-update", "fcc-init", "free-claude-code" ) @@ -1506,7 +1507,7 @@ function Configure-AndConfirmFreeClaudeCode { [IO.Path]::AltDirectorySeparatorChar ) $installedCommands = @{} - foreach ($commandName in @("fcc-desktop", "fcc-server", "fcc-claude", "fcc-codex", "fcc-pi", "fcc-opencode", "fcc-cline", "fcc-hermes", "fcc-dsh", "fcc-grok", "fcc-muse", "fcc-aider")) { + foreach ($commandName in @("fcc-desktop", "fcc-server", "fcc-claude", "fcc-codex", "fcc-pi", "fcc-opencode", "fcc-cline", "fcc-hermes", "fcc-dsh", "fcc-grok", "fcc-muse", "fcc-aider", "fcc-update.cmd")) { $command = Get-ApplicationCommand $commandName if (-not $command) { throw "Free Claude Code installation did not create '$commandName'." @@ -1521,6 +1522,11 @@ function Configure-AndConfirmFreeClaudeCode { $installedCommands[$commandName] = $command.Source } + $updateCompanion = Join-Path $toolBinPath "fcc-update-windows.ps1" + if (-not (Test-Path -LiteralPath $updateCompanion -PathType Leaf)) { + throw "Free Claude Code installation did not create '$updateCompanion', required alongside fcc-update.cmd." + } + Invoke-NativeCommand -FilePath $installedCommands["fcc-server"] -Arguments @("--version") Export-FccDesktopIcon ` -DesktopCommand $installedCommands["fcc-desktop"] ` diff --git a/scripts/install.sh b/scripts/install.sh index 58be25685b..1feb508787 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -19,7 +19,7 @@ UV_INSTALL_URL="https://astral.sh/uv/install.sh" FCC_MACOS_BUNDLE_ID="io.github.alishahryar1.free-claude-code" FCC_MACOS_OWNER_FILE=".free-claude-code-owner" # Include retired entry points so updates reject older FCC processes before replacement. -FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-init free-claude-code" +FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update fcc-init free-claude-code" dry_run=0 voice_nim=0 @@ -1353,7 +1353,7 @@ configure_and_verify_free_claude_code() { add_uv_tool_bin_directory - for command_name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider; do + for command_name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update; do [ -x "$tool_bin/$command_name" ] || fail "Free Claude Code installation did not create $tool_bin/$command_name." done diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 672d63f1fb..773f423fe9 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -24,6 +24,7 @@ $FccCommands = @( "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-update", "fcc-init", "free-claude-code" ) @@ -205,7 +206,7 @@ function Confirm-FccCommandsRemoved { $remaining = @() $extensions = @("", ".exe", ".cmd", ".bat", ".ps1") - foreach ($commandName in $FccCommands) { + foreach ($commandName in ($FccCommands + "fcc-update-windows")) { foreach ($extension in $extensions) { $commandPath = Join-Path $script:UvToolBin "$commandName$extension" if (Test-Path -LiteralPath $commandPath) { diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 5ff4ec170b..1b0e43ec9f 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -6,7 +6,7 @@ FCC_HOME_DIRNAME=".fcc" FCC_MACOS_BUNDLE_ID="io.github.alishahryar1.free-claude-code" FCC_MACOS_OWNER_FILE=".free-claude-code-owner" # Include retired entry points so older installations are fully stopped and removed. -FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-init free-claude-code" +FCC_COMMANDS="fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update fcc-init free-claude-code" dry_run=0 uv_tool_bin="" @@ -191,7 +191,7 @@ verify_fcc_commands_removed() { fi remaining="" - for command_name in $FCC_COMMANDS; do + for command_name in $FCC_COMMANDS fcc-update.cmd fcc-update-windows.ps1; do command_path="$uv_tool_bin/$command_name" if [ -e "$command_path" ] || [ -L "$command_path" ]; then remaining="${remaining} ${command_path}" diff --git a/scripts/update/fcc-update b/scripts/update/fcc-update new file mode 100644 index 0000000000..9b58870ed5 --- /dev/null +++ b/scripts/update/fcc-update @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +if [ "$#" -ne 0 ]; then + printf 'Usage: fcc-update (stop other FCC commands first)\n' >&2 + exit 2 +fi +uv_path=$(command -v uv) || { printf 'uv is missing; rerun the FCC installer.\n' >&2; exit 1; } +tool_dir=$("$uv_path" tool dir) +worker=$("$tool_dir/free-claude-code/bin/python" -I -m free_claude_code.cli.update --uv "$uv_path" --launcher "$0" --shell) +trap 'rm -f -- "$worker"' EXIT +# The metadata-reading Python has exited before uv can replace the environment. +exec /bin/sh "$worker" diff --git a/scripts/update/fcc-update-windows.ps1 b/scripts/update/fcc-update-windows.ps1 new file mode 100644 index 0000000000..be3f42f86e --- /dev/null +++ b/scripts/update/fcc-update-windows.ps1 @@ -0,0 +1,27 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +try { + if ($args.Count -ne 0) { throw "Usage: fcc-update (stop other FCC commands first)" } + $uvPath = (Get-Command uv -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + $toolDir = (& $uvPath tool dir | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $pythonPath = Join-Path $toolDir "free-claude-code\Scripts\python.exe" + $launcher = Join-Path $PSScriptRoot "fcc-update.cmd" + $json = & $pythonPath -I -m free_claude_code.cli.update --uv $uvPath --launcher $launcher + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $plan = ($json | Out-String) | ConvertFrom-Json + Write-Host "Updating FCC $($plan.version)..." + # The installed Python process is gone before replacement starts. + $uvArguments = @($plan.arguments) + & $plan.uv @uvArguments + $updateExit = $LASTEXITCODE + if ($updateExit -ne 0) { exit $updateExit } + $verify = @($plan.verify) + $verifyArguments = @($verify | Select-Object -Skip 1) + & $verify[0] @verifyArguments + exit $LASTEXITCODE +} +catch { + [Console]::Error.WriteLine("fcc-update: $($_.Exception.Message)") + exit 1 +} diff --git a/scripts/update/fcc-update.cmd b/scripts/update/fcc-update.cmd new file mode 100644 index 0000000000..a8d59c861b --- /dev/null +++ b/scripts/update/fcc-update.cmd @@ -0,0 +1,5 @@ +@echo off +( + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0fcc-update-windows.ps1" %* + call exit /b %%errorlevel%% +) diff --git a/src/free_claude_code/cli/update.py b/src/free_claude_code/cli/update.py new file mode 100644 index 0000000000..5843ebf334 --- /dev/null +++ b/src/free_claude_code/cli/update.py @@ -0,0 +1,261 @@ +"""Prepare a uv update, then exit before the native host replaces FCC.""" + +import argparse +import ast +import csv +import importlib.metadata +import json +import os +import re +import shlex +import subprocess +import sys +import tempfile +import tomllib +from pathlib import Path + +ARCHIVE_URL = ( + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" +) +PACKAGE = "free-claude-code" +FCC_COMMANDS = frozenset( + [ + "fcc-desktop", + "fcc-server", + "fcc-claude", + "fcc-codex", + "fcc-pi", + "fcc-opencode", + "fcc-cline", + "fcc-hermes", + "fcc-dsh", + "fcc-grok", + "fcc-muse", + "fcc-aider", + "fcc-init", + "fcc-update", + "free-claude-code", + ] +) + + +def capture(args: list[str], *, encoding: str | None = None) -> str: + result = subprocess.run( + args, capture_output=True, text=True, encoding=encoding, timeout=30 + ) + if result.returncode: + raise ValueError(result.stderr.strip() or f"Command failed: {args[0]}") + return result.stdout.strip() + + +def installed_version() -> str: + return importlib.metadata.version(PACKAGE) + + +def running_fcc() -> list[str]: + if os.name == "nt": + rows = csv.reader(capture(["tasklist.exe", "/FO", "CSV", "/NH"]).splitlines()) + return [ + f"{row[0]} (PID {row[1]})" + for row in rows + if len(row) >= 2 and row[0].lower().removesuffix(".exe") in FCC_COMMANDS + ] + pattern = re.compile(r"(?:^|/)(" + "|".join(sorted(FCC_COMMANDS)) + r")(?:\s|$)") + found = [] + for line in capture(["ps", "-A", "-o", "pid=", "-o", "args="]).splitlines(): + pid, _, args = line.strip().partition(" ") + if ( + pid.isdecimal() + and int(pid) not in {os.getpid(), os.getppid()} + and (match := pattern.search(args.strip())) + ): + found.append(f"{match[1]} (PID {pid})") + return found + + +def torch_build_version() -> str: + distribution = importlib.metadata.distribution("torch") + version = distribution.version + if "+" in version: + return version + # PyPI metadata can omit the backend that the installed build records. + # Inspect literal build metadata without importing torch or loading its DLLs. + source = Path(str(distribution.locate_file("torch/version.py"))).read_text( + encoding="utf-8" + ) + for node in ast.parse(source).body: + if isinstance(node, ast.Assign): + targets, value = node.targets, node.value + elif isinstance(node, ast.AnnAssign): + targets, value = [node.target], node.value + else: + continue + if ( + any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in targets + ) + and isinstance(value, ast.Constant) + and isinstance(value.value, str) + ): + return value.value + return version + + +def torch_backend(uv: Path, options: dict) -> str | None: + backend = options.get("torch-backend") + if backend is None: + try: + build = torch_build_version() + except (OSError, SyntaxError, importlib.metadata.PackageNotFoundError) as exc: + raise ValueError( + "Cannot identify local voice build; rerun the matching voice installer." + ) from exc + _, separator, backend = build.partition("+") + if ( + not separator + and sys.platform == "darwin" + and re.fullmatch(r"\d+\.\d+\.\d+", build) + ): + return None + if not separator or not re.fullmatch( + r"cpu|cu\d+|rocm\d+(?:\.\d+)+|xpu", backend + ): + raise ValueError( + "Cannot identify local voice backend; rerun the matching voice installer with --torch-backend." + ) + if not isinstance(backend, str) or not backend: + raise ValueError( + "Invalid recorded voice backend; rerun the matching voice installer." + ) + capture( + [str(uv), "tool", "install", "--torch-backend", backend, "--help"], + encoding="utf-8", + ) + return backend + + +def prepare(uv: Path, launcher: Path) -> dict: + version = capture([str(uv), "--version"], encoding="utf-8") + match = re.match(r"uv (\d+)\.(\d+)\.(\d+)(?:\s|$)", version) + if not match or tuple(map(int, match.groups())) < (0, 12, 13): + raise ValueError("uv 0.12.13 or newer is required. Rerun the FCC installer.") + root = Path(capture([str(uv), "tool", "dir"], encoding="utf-8")) / PACKAGE + bin_dir = Path(capture([str(uv), "tool", "dir", "--bin"], encoding="utf-8")) + if not root.samefile(sys.prefix) or not Path(__file__).resolve().is_relative_to( + root.resolve() + ): + raise ValueError( + "This is not the uv-managed FCC installation. Use its original installation method." + ) + receipt = tomllib.loads((root / "uv-receipt.toml").read_text(encoding="utf-8"))[ + "tool" + ] + requirements = receipt.get("requirements", []) + if ( + len(requirements) != 1 + or requirements[0].get("name") != PACKAGE + or requirements[0].get("url") != ARCHIVE_URL + ): + raise ValueError( + "fcc-update supports the official FCC installer source. Use this installation's original method." + ) + entry_name = "fcc-update.cmd" if os.name == "nt" else "fcc-update" + entries = [ + entry + for entry in receipt.get("entrypoints", []) + if entry.get("name") == entry_name and entry.get("from") == PACKAGE + ] + if ( + len(entries) != 1 + or not launcher.samefile(bin_dir / entry_name) + or not launcher.samefile(entries[0]["install-path"]) + ): + raise ValueError("The updater launcher belongs to a different installation.") + extras = sorted( + {extra.replace("_", "-") for extra in requirements[0].get("extras", [])} + ) + if set(extras) - {"voice", "voice-local"}: + raise ValueError("Unsupported FCC extras. Use the original installer.") + python_request = receipt.get("python") + if not isinstance(python_request, str) or not python_request: + raise ValueError( + "The installed Python request is missing. Rerun the FCC installer." + ) + if processes := running_fcc(): + raise ValueError( + f"Stop running FCC processes before updating: {', '.join(processes)}" + ) + arguments = [ + "tool", + "install", + "--force", + "--refresh-package", + PACKAGE, + "--python", + python_request, + ] + if "voice-local" in extras and ( + backend := torch_backend(uv, receipt.get("options", {})) + ): + arguments += ["--torch-backend", backend] + spec = PACKAGE + (f"[{','.join(extras)}]" if extras else "") + arguments.append(f"{spec} @ {ARCHIVE_URL}") + python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + return { + "uv": str(uv), + "arguments": arguments, + "version": installed_version(), + "verify": [ + str(python), + "-I", + "-c", + "from importlib.metadata import version; print('FCC refreshed (version ' + version('free-claude-code') + ')')", + ], + } + + +def shell_worker(plan: dict) -> str: + fd, name = tempfile.mkstemp(prefix="fcc-update-", suffix=".sh") + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: + stream.write( + "#!/bin/sh\nset -eu\ntrap 'rm -f -- \"$0\"' EXIT\n" + "trap 'exit 130' INT\ntrap 'exit 143' TERM HUP\n" + + "printf '%s\\n' " + + shlex.quote(f"Updating FCC {plan['version']}...") + + "\n" + + shlex.join([plan["uv"], *plan["arguments"]]) + + "\n" + + shlex.join(plan["verify"]) + + "\n" + ) + except BaseException: + Path(name).unlink(missing_ok=True) + raise + return name + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--uv", type=Path, required=True) + parser.add_argument("--launcher", type=Path, required=True) + parser.add_argument("--shell", action="store_true") + args = parser.parse_args() + try: + plan = prepare(args.uv, args.launcher) + print(shell_worker(plan) if args.shell else json.dumps(plan)) + except ( + OSError, + ValueError, + KeyError, + TypeError, + subprocess.SubprocessError, + importlib.metadata.PackageNotFoundError, + ) as exc: + print(f"fcc-update: {exc}", file=sys.stderr) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/tests/cli/test_update.py b/tests/cli/test_update.py new file mode 100644 index 0000000000..411ef6469a --- /dev/null +++ b/tests/cli/test_update.py @@ -0,0 +1,179 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from free_claude_code.cli import update + + +@pytest.fixture +def installation(tmp_path, monkeypatch): + root = tmp_path / "tools" / "free-claude-code" + root.mkdir(parents=True) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + launcher = bin_dir / ("fcc-update.cmd" if update.os.name == "nt" else "fcc-update") + launcher.touch() + module = root / "lib" / "update.py" + module.parent.mkdir() + module.touch() + monkeypatch.setattr(update.sys, "prefix", str(root)) + monkeypatch.setattr(update, "__file__", str(module)) + monkeypatch.setattr(update, "running_fcc", lambda: []) + monkeypatch.setattr(update, "installed_version", lambda: "1.0.0") + + def capture(args, **kwargs): + if args[1:] == ["--version"]: + return "uv 0.12.13" + if args[1:] == ["tool", "dir"]: + return str(root.parent) + if args[1:] == ["tool", "dir", "--bin"]: + return str(bin_dir) + raise AssertionError(args) + + monkeypatch.setattr(update, "capture", capture) + + def receipt(extras=(), source=None): + (root / "uv-receipt.toml").write_text( + '[tool]\npython = "3.14.0"\nrequirements = [{name = "free-claude-code", url = ' + + json.dumps(source or update.ARCHIVE_URL) + + ", extras = " + + json.dumps(list(extras)) + + "}]\nentrypoints = [{ name = " + + json.dumps(launcher.name) + + ', from = "free-claude-code", install-path = ' + + json.dumps(str(launcher)) + + "}]\n", + encoding="utf-8", + ) + + receipt() + return root, launcher, receipt + + +@pytest.mark.parametrize( + "extras", [(), ("voice",), ("voice-local",), ("voice", "voice-local")] +) +def test_prepare_preserves_install_choices(installation, monkeypatch, extras): + _, launcher, receipt = installation + receipt(extras) + monkeypatch.setattr(update, "torch_backend", lambda uv, options: "cu130") + result = update.prepare(Path("uv"), launcher) + args = result["arguments"] + assert args[:4] == ["tool", "install", "--force", "--refresh-package"] + assert args[4:7] == ["free-claude-code", "--python", "3.14.0"] + assert ("--torch-backend" in args) == ("voice-local" in extras) + assert ( + args[-1] + == "free-claude-code" + + ("[" + ",".join(sorted(extras)) + "]" if extras else "") + + " @ " + + update.ARCHIVE_URL + ) + + +def test_foreign_source_refused_before_update(installation): + _, launcher, receipt = installation + receipt(source="https://example.com/foreign.zip") + with pytest.raises(ValueError, match="official"): + update.prepare(Path("uv"), launcher) + + +def test_checkout_cannot_update_installed_tool(installation, monkeypatch, tmp_path): + _, launcher, _ = installation + monkeypatch.setattr(update, "__file__", str(tmp_path / "checkout" / "update.py")) + with pytest.raises(ValueError, match="installation"): + update.prepare(Path("uv"), launcher) + + +def test_wrong_launcher_refused(installation, tmp_path): + other = tmp_path / "other.cmd" + other.touch() + with pytest.raises(ValueError, match="launcher"): + update.prepare(Path("uv"), other) + + +def test_running_fcc_refused(installation, monkeypatch): + _, launcher, _ = installation + monkeypatch.setattr(update, "running_fcc", lambda: ["fcc-server (PID 123)"]) + with pytest.raises(ValueError, match=r"Stop.*fcc-server"): + update.prepare(Path("uv"), launcher) + + +@pytest.mark.parametrize( + "build,expected", + [ + ("2.14.0+cpu", "cpu"), + ("2.14.0+cu130", "cu130"), + ("2.14.0+rocm6.2.4", "rocm6.2.4"), + ("2.14.0+xpu", "xpu"), + ], +) +def test_torch_backend_uses_build_not_hardware(monkeypatch, build, expected): + monkeypatch.setattr(update, "torch_build_version", lambda: build) + calls = [] + monkeypatch.setattr( + update, "capture", lambda args, **kwargs: calls.append(args) or "" + ) + assert update.torch_backend(Path("uv"), {}) == expected + assert calls == [["uv", "tool", "install", "--torch-backend", expected, "--help"]] + + +def test_unknown_torch_backend_is_not_silently_changed(monkeypatch): + monkeypatch.setattr(update, "torch_build_version", lambda: "2.14.0+custom") + with pytest.raises(ValueError, match="voice installer"): + update.torch_backend(Path("uv"), {}) + + +def test_recorded_backend_is_preserved_without_inspecting_build(monkeypatch): + calls = [] + monkeypatch.setattr( + update, "capture", lambda args, **kwargs: calls.append(args) or "" + ) + monkeypatch.setattr( + update, "torch_build_version", lambda: pytest.fail("must use recorded backend") + ) + assert update.torch_backend(Path("uv"), {"torch-backend": "auto"}) == "auto" + + +def test_torch_build_metadata_is_read_without_loading_code(tmp_path, monkeypatch): + version_file = tmp_path / "version.py" + version_file.write_text( + "raise RuntimeError('must not execute')\n__version__: str = '2.14.0+cu130'\n" + ) + monkeypatch.setattr( + update.importlib.metadata, + "distribution", + lambda _: SimpleNamespace(version="2.14.0", locate_file=lambda _: version_file), + ) + assert update.torch_build_version() == "2.14.0+cu130" + + +def test_process_guard_finds_fcc_without_matching_unrelated_apps(monkeypatch): + if update.os.name == "nt": + output = ( + '"fcc-server.exe","123","Console"\n"not-fcc-server.exe","124","Console"' + ) + else: + output = "123 /some/bin/fcc-server\n124 /some/bin/not-fcc-server\n" + monkeypatch.setattr(update, "capture", lambda _: output) + found = update.running_fcc() + assert len(found) == 1 + assert "123" in found[0] + + +def test_no_server_imports_in_isolated_preparation_module(): + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-c", + "from free_claude_code.cli import update; import sys; assert not any(k.startswith(('free_claude_code.config', 'free_claude_code.runtime', 'free_claude_code.providers')) for k in sys.modules)", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/cli/test_update_lifecycle.py b/tests/cli/test_update_lifecycle.py new file mode 100644 index 0000000000..12ea196b43 --- /dev/null +++ b/tests/cli/test_update_lifecycle.py @@ -0,0 +1,222 @@ +"""Exercise real uv script ownership and self-replacement in disposable tools.""" + +import base64 +import contextlib +import http.server +import io +import os +import shutil +import subprocess +import sys +import threading +import tomllib +import zipfile +from pathlib import Path + +import pytest + +from free_claude_code.cli import update + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ("fcc-update", "fcc-update.cmd", "fcc-update-windows.ps1") + + +def fixture_archive(url, version, marker, *, verify_exit=None): + """Build offline fixtures; production has no alternate update-source switch.""" + buffer = io.BytesIO() + dist = f"free_claude_code-{version}" + source = Path(update.__file__).read_text(encoding="utf-8") + source = source.replace(update.ARCHIVE_URL, url) + # Match only the fixture's processes; never stop or interact with the real FCC. + source = source.replace( + 'if __name__ == "__main__":', + 'FCC_COMMANDS = frozenset({"fcc-fixture-busy"})\nif __name__ == "__main__":', + ) + if verify_exit is not None: + source = source.replace( + 'if __name__ == "__main__":', + f"original_prepare = prepare\ndef prepare(*args):\n" + f" plan = original_prepare(*args)\n" + f" plan['verify'] = [sys.executable, '-c', 'raise SystemExit({verify_exit})']\n" + f" return plan\nif __name__ == '__main__':", + ) + with zipfile.ZipFile(buffer, "w") as wheel: + wheel.writestr("free_claude_code/__init__.py", "") + wheel.writestr("free_claude_code/cli/__init__.py", "") + wheel.writestr("free_claude_code/cli/update.py", source) + wheel.writestr("free_claude_code/marker.txt", marker) + wheel.writestr( + f"{dist}.dist-info/METADATA", + f"Metadata-Version: 2.1\nName: free-claude-code\nVersion: {version}\nProvides-Extra: voice\n", + ) + wheel.writestr( + f"{dist}.dist-info/WHEEL", + "Wheel-Version: 1.0\nGenerator: fixture\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + for name in SCRIPTS: + info = zipfile.ZipInfo(f"{dist}.data/scripts/{name}") + # Match Hatch's wheel built on Windows; uv must make shell scripts executable. + info.external_attr = (0o100755 if name.endswith(".cmd") else 0o100644) << 16 + wheel.writestr(info, (ROOT / "scripts/update" / name).read_bytes()) + wheel.writestr( + f"{dist}.dist-info/RECORD", + "".join(f"{name},,\n" for name in wheel.namelist()) + + f"{dist}.dist-info/RECORD,,\n", + ) + # A dependency-free build backend serves the prepared wheel as a source zip. + backend = f"import base64\nfrom pathlib import Path\ndef build_wheel(wheel_directory, config_settings=None, metadata_directory=None):\n name = '{dist}-py3-none-any.whl'\n Path(wheel_directory, name).write_bytes(base64.b64decode({base64.b64encode(buffer.getvalue())!r}))\n return name\n" + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as source_zip: + source_zip.writestr( + "fcc/pyproject.toml", + '[build-system]\nrequires = []\nbuild-backend = "backend"\nbackend-path = ["."]\n', + ) + source_zip.writestr("fcc/backend.py", backend) + return archive.getvalue() + + +@contextlib.contextmanager +def archive_server(): + payload = {"body": b""} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(len(payload["body"]))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(payload["body"]) + + def log_message(self, format: str, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}/main.zip", payload + finally: + server.shutdown() + server.server_close() + thread.join() + + +def run(args, env, cwd): + return subprocess.run( + args, env=env, cwd=cwd, capture_output=True, text=True, timeout=90 + ) + + +def test_installed_updater_replaces_itself_and_uv_removes_scripts(tmp_path): + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for real package-manager lifecycle coverage") + area = tmp_path / "José 测试 with spaces & (brackets) ! apostrophe'" + area.mkdir() + env = dict(os.environ) + env.update( + UV_TOOL_DIR=str(area / "tools"), + UV_TOOL_BIN_DIR=str(area / "bin"), + UV_CACHE_DIR=str(area / "cache"), + UV_NO_CONFIG="1", + UV_HTTP_RETRIES="0", + UV_PYTHON_DOWNLOADS="never", + ) + env["PATH"] = str(Path(uv).parent) + os.pathsep + env["PATH"] + sentinel = area / "settings.json" + sentinel.write_text("keep me") + with archive_server() as (url, payload): + payload["body"] = fixture_archive(url, "1.0.0", "first") + result = run( + [ + uv, + "tool", + "install", + "--python", + getattr(sys, "_base_executable", sys.executable), + f"free-claude-code[voice] @ {url}", + ], + env, + area, + ) + assert result.returncode == 0, result.stdout + result.stderr + bin_dir = area / "bin" + assert all((bin_dir / name).is_file() for name in SCRIPTS) + root = area / "tools/free-claude-code" + python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + launcher = bin_dir / ("fcc-update.cmd" if os.name == "nt" else "fcc-update") + command = ( + f'"{os.environ.get("COMSPEC", "cmd.exe")}" /d /s /c ""{launcher}""' + if os.name == "nt" + else [str(launcher)] + ) + for version, marker in [ + ("1.1.0", "second"), + ("1.1.0", "same-version-new-code"), + ]: + payload["body"] = fixture_archive(url, version, marker) + result = run(command, env, area) + assert result.returncode == 0, result.stdout + result.stderr + assert f"FCC refreshed (version {version})" in result.stdout + installed = run( + [ + str(python), + "-I", + "-c", + "import free_claude_code; from pathlib import Path; print(Path(free_claude_code.__file__).with_name('marker.txt').read_text())", + ], + env, + area, + ) + assert installed.stdout.strip() == marker + receipt = tomllib.loads( + (root / "uv-receipt.toml").read_text(encoding="utf-8") + ) + assert receipt["tool"]["requirements"][0]["extras"] == ["voice"] + payload["body"] = b"invalid archive" + result = run(command, env, area) + direct_failure = run( + [ + uv, + "tool", + "install", + "--force", + "--refresh-package", + "free-claude-code", + f"free-claude-code[voice] @ {url}", + ], + env, + area, + ) + assert result.returncode == direct_failure.returncode != 0 + assert "FCC refreshed" not in result.stdout + payload["body"] = fixture_archive( + url, "1.2.0", "verification-error", verify_exit=23 + ) + result = run( + [ + uv, + "tool", + "install", + "--force", + "--refresh-package", + "free-claude-code", + "--python", + getattr(sys, "_base_executable", sys.executable), + f"free-claude-code[voice] @ {url}", + ], + env, + area, + ) + assert result.returncode == 0, result.stderr + payload["body"] = fixture_archive( + url, "1.3.0", "installed-despite-verification-error" + ) + result = run(command, env, area) + assert result.returncode == 23, result.stdout + result.stderr + assert "FCC refreshed" not in result.stdout + assert sentinel.read_text() == "keep me" + result = run([uv, "tool", "uninstall", "free-claude-code"], env, area) + assert result.returncode == 0, result.stderr + assert not any((bin_dir / name).exists() for name in SCRIPTS) diff --git a/tests/scripts/test_installers.py b/tests/scripts/test_installers.py index 55aefdc79b..9c6a43b0d8 100644 --- a/tests/scripts/test_installers.py +++ b/tests/scripts/test_installers.py @@ -26,6 +26,7 @@ "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-update", "fcc-init", "free-claude-code", ) @@ -190,6 +191,7 @@ def _posix_uv_command(version: str) -> str: cp "$FAKE_FIXTURES/fcc-command.sh" "$tool_bin/fcc-grok" cp "$FAKE_FIXTURES/fcc-command.sh" "$tool_bin/fcc-muse" cp "$FAKE_FIXTURES/fcc-command.sh" "$tool_bin/fcc-aider" + cp "$FAKE_FIXTURES/fcc-command.sh" "$tool_bin/fcc-update" if [ "$FAIL_STEP" != "fcc-missing" ]; then cp "$FAKE_FIXTURES/fcc-command.sh" "$tool_bin/fcc-codex" fi @@ -2080,6 +2082,10 @@ def _batch_uv(version: str) -> str: copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-grok.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-muse.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-aider.cmd" >nul +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-update.cmd" >nul +if not "%FAIL_STEP%"=="fcc-update-companion-missing" ( + echo exit 0 > "%UV_BIN_DIR%\fcc-update-windows.ps1" +) if not "%FAIL_STEP%"=="fcc-missing" copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-codex.cmd" >nul exit /b 0 :install_aider @@ -3073,6 +3079,24 @@ def test_install_ps1_does_not_use_uv_install_directory_for_aider( assert "aider:--version" not in powershell_harness.calls() +@pytest.mark.parametrize("companion_elsewhere", [False, True]) +def test_install_ps1_rejects_missing_update_companion( + powershell_harness: PowerShellHarness, companion_elsewhere: bool +) -> None: + if companion_elsewhere: + (powershell_harness.bin_dir / "fcc-update-windows.ps1").write_text( + "throw 'Must not use a companion from another directory'\n", + encoding="utf-8", + ) + + result = powershell_harness.run(fail_step="fcc-update-companion-missing") + + assert result.returncode != 0 + assert "fcc-update-windows.ps1" in result.stderr + assert "Free Claude Code is installed and verified." not in result.stdout + assert "fcc-server:--version" not in powershell_harness.calls() + + def test_install_ps1_fresh_install_is_verified( powershell_harness: PowerShellHarness, ) -> None: diff --git a/tests/scripts/test_uninstallers.py b/tests/scripts/test_uninstallers.py index b8a65e7a10..d3bc455b40 100644 --- a/tests/scripts/test_uninstallers.py +++ b/tests/scripts/test_uninstallers.py @@ -19,6 +19,7 @@ "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-update", "fcc-init", "free-claude-code", ) @@ -191,7 +192,7 @@ def posix_uninstall_harness(tmp_path: Path) -> PosixUninstallHarness: echo 'Tool `free-claude-code` is not installed' >&2 exit 2 fi - for name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-init free-claude-code; do + for name in fcc-desktop fcc-server fcc-claude fcc-codex fcc-pi fcc-opencode fcc-cline fcc-hermes fcc-dsh fcc-grok fcc-muse fcc-aider fcc-update fcc-init free-claude-code; do /bin/rm -f "$FAKE_TOOL_BIN/$name" done echo "Uninstalled free-claude-code" diff --git a/uv.lock b/uv.lock index eac11b710f..60d0a03c6d 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.55" +version = "6.2.56" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 1f8b1fd7d05473652416376cb7acbb8dbf466ea9 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Mon, 21 Sep 2026 22:44:09 -0700 Subject: [PATCH 3/3] Make fcc-update run the existing installer (#1857) ## Why `fcc-update` should run the same installer as the README command. The separate updater introduced in #1856 bypasses coding-agent prompts, checks, and setup. ## How Replace the separate updater with native shell and Windows command wrappers that fetch and run the official installer in the foreground. Forward installer options and preserve failure exit codes while allowing the launcher to replace itself. Remove the Python update preparation code and PowerShell companion. Keep package-manager ownership of the launchers, explain installer and voice-option behavior in the README, and bump FCC to 6.2.57. --- README.md | 2 + pyproject.toml | 3 +- scripts/install.ps1 | 5 - scripts/uninstall.ps1 | 2 +- scripts/uninstall.sh | 2 +- scripts/update/fcc-update | 12 +- scripts/update/fcc-update-windows.ps1 | 27 --- scripts/update/fcc-update.cmd | 2 +- src/free_claude_code/cli/update.py | 261 -------------------------- tests/cli/test_update.py | 179 ------------------ tests/cli/test_update_lifecycle.py | 222 ---------------------- tests/cli/test_update_wrapper.py | 168 +++++++++++++++++ tests/scripts/test_installers.py | 21 --- uv.lock | 2 +- 14 files changed, 177 insertions(+), 731 deletions(-) delete mode 100644 scripts/update/fcc-update-windows.ps1 delete mode 100644 src/free_claude_code/cli/update.py delete mode 100644 tests/cli/test_update.py delete mode 100644 tests/cli/test_update_lifecycle.py create mode 100644 tests/cli/test_update_wrapper.py diff --git a/README.md b/README.md index 8148dbc6d9..d779e2da31 100644 --- a/README.md +++ b/README.md @@ -489,6 +489,8 @@ Stop all running FCC commands, then run: fcc-update ``` +This runs the same installer as above, including its coding-agent prompts and checks. If you use voice support, pass the same voice options used during installation. + If your installation does not have `fcc-update` yet, run the [installer](#install) once to add it. ### Muse Code on native Windows diff --git a/pyproject.toml b/pyproject.toml index 29439584a8..823cd38e0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.56" +version = "6.2.57" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" @@ -69,7 +69,6 @@ packages = ["src/free_claude_code"] [tool.hatch.build.targets.wheel.shared-scripts] "scripts/update/fcc-update" = "fcc-update" "scripts/update/fcc-update.cmd" = "fcc-update.cmd" -"scripts/update/fcc-update-windows.ps1" = "fcc-update-windows.ps1" [tool.uv] required-version = ">=0.12.13" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 2c534fad15..1d536ed23e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1522,11 +1522,6 @@ function Configure-AndConfirmFreeClaudeCode { $installedCommands[$commandName] = $command.Source } - $updateCompanion = Join-Path $toolBinPath "fcc-update-windows.ps1" - if (-not (Test-Path -LiteralPath $updateCompanion -PathType Leaf)) { - throw "Free Claude Code installation did not create '$updateCompanion', required alongside fcc-update.cmd." - } - Invoke-NativeCommand -FilePath $installedCommands["fcc-server"] -Arguments @("--version") Export-FccDesktopIcon ` -DesktopCommand $installedCommands["fcc-desktop"] ` diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 773f423fe9..bd5a9a8ad5 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -206,7 +206,7 @@ function Confirm-FccCommandsRemoved { $remaining = @() $extensions = @("", ".exe", ".cmd", ".bat", ".ps1") - foreach ($commandName in ($FccCommands + "fcc-update-windows")) { + foreach ($commandName in $FccCommands) { foreach ($extension in $extensions) { $commandPath = Join-Path $script:UvToolBin "$commandName$extension" if (Test-Path -LiteralPath $commandPath) { diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 1b0e43ec9f..fba40e3880 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -191,7 +191,7 @@ verify_fcc_commands_removed() { fi remaining="" - for command_name in $FCC_COMMANDS fcc-update.cmd fcc-update-windows.ps1; do + for command_name in $FCC_COMMANDS fcc-update.cmd; do command_path="$uv_tool_bin/$command_name" if [ -e "$command_path" ] || [ -L "$command_path" ]; then remaining="${remaining} ${command_path}" diff --git a/scripts/update/fcc-update b/scripts/update/fcc-update index 9b58870ed5..8fb65aa740 100644 --- a/scripts/update/fcc-update +++ b/scripts/update/fcc-update @@ -1,12 +1,4 @@ #!/bin/sh set -eu -if [ "$#" -ne 0 ]; then - printf 'Usage: fcc-update (stop other FCC commands first)\n' >&2 - exit 2 -fi -uv_path=$(command -v uv) || { printf 'uv is missing; rerun the FCC installer.\n' >&2; exit 1; } -tool_dir=$("$uv_path" tool dir) -worker=$("$tool_dir/free-claude-code/bin/python" -I -m free_claude_code.cli.update --uv "$uv_path" --launcher "$0" --shell) -trap 'rm -f -- "$worker"' EXIT -# The metadata-reading Python has exited before uv can replace the environment. -exec /bin/sh "$worker" +installer=$(curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh") +exec sh -c "$installer" sh "$@" diff --git a/scripts/update/fcc-update-windows.ps1 b/scripts/update/fcc-update-windows.ps1 deleted file mode 100644 index be3f42f86e..0000000000 --- a/scripts/update/fcc-update-windows.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" -try { - if ($args.Count -ne 0) { throw "Usage: fcc-update (stop other FCC commands first)" } - $uvPath = (Get-Command uv -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source - $toolDir = (& $uvPath tool dir | Out-String).Trim() - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $pythonPath = Join-Path $toolDir "free-claude-code\Scripts\python.exe" - $launcher = Join-Path $PSScriptRoot "fcc-update.cmd" - $json = & $pythonPath -I -m free_claude_code.cli.update --uv $uvPath --launcher $launcher - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $plan = ($json | Out-String) | ConvertFrom-Json - Write-Host "Updating FCC $($plan.version)..." - # The installed Python process is gone before replacement starts. - $uvArguments = @($plan.arguments) - & $plan.uv @uvArguments - $updateExit = $LASTEXITCODE - if ($updateExit -ne 0) { exit $updateExit } - $verify = @($plan.verify) - $verifyArguments = @($verify | Select-Object -Skip 1) - & $verify[0] @verifyArguments - exit $LASTEXITCODE -} -catch { - [Console]::Error.WriteLine("fcc-update: $($_.Exception.Message)") - exit 1 -} diff --git a/scripts/update/fcc-update.cmd b/scripts/update/fcc-update.cmd index a8d59c861b..f2f6c8d527 100644 --- a/scripts/update/fcc-update.cmd +++ b/scripts/update/fcc-update.cmd @@ -1,5 +1,5 @@ @echo off ( - powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0fcc-update-windows.ps1" %* + 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%% ) diff --git a/src/free_claude_code/cli/update.py b/src/free_claude_code/cli/update.py deleted file mode 100644 index 5843ebf334..0000000000 --- a/src/free_claude_code/cli/update.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Prepare a uv update, then exit before the native host replaces FCC.""" - -import argparse -import ast -import csv -import importlib.metadata -import json -import os -import re -import shlex -import subprocess -import sys -import tempfile -import tomllib -from pathlib import Path - -ARCHIVE_URL = ( - "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" -) -PACKAGE = "free-claude-code" -FCC_COMMANDS = frozenset( - [ - "fcc-desktop", - "fcc-server", - "fcc-claude", - "fcc-codex", - "fcc-pi", - "fcc-opencode", - "fcc-cline", - "fcc-hermes", - "fcc-dsh", - "fcc-grok", - "fcc-muse", - "fcc-aider", - "fcc-init", - "fcc-update", - "free-claude-code", - ] -) - - -def capture(args: list[str], *, encoding: str | None = None) -> str: - result = subprocess.run( - args, capture_output=True, text=True, encoding=encoding, timeout=30 - ) - if result.returncode: - raise ValueError(result.stderr.strip() or f"Command failed: {args[0]}") - return result.stdout.strip() - - -def installed_version() -> str: - return importlib.metadata.version(PACKAGE) - - -def running_fcc() -> list[str]: - if os.name == "nt": - rows = csv.reader(capture(["tasklist.exe", "/FO", "CSV", "/NH"]).splitlines()) - return [ - f"{row[0]} (PID {row[1]})" - for row in rows - if len(row) >= 2 and row[0].lower().removesuffix(".exe") in FCC_COMMANDS - ] - pattern = re.compile(r"(?:^|/)(" + "|".join(sorted(FCC_COMMANDS)) + r")(?:\s|$)") - found = [] - for line in capture(["ps", "-A", "-o", "pid=", "-o", "args="]).splitlines(): - pid, _, args = line.strip().partition(" ") - if ( - pid.isdecimal() - and int(pid) not in {os.getpid(), os.getppid()} - and (match := pattern.search(args.strip())) - ): - found.append(f"{match[1]} (PID {pid})") - return found - - -def torch_build_version() -> str: - distribution = importlib.metadata.distribution("torch") - version = distribution.version - if "+" in version: - return version - # PyPI metadata can omit the backend that the installed build records. - # Inspect literal build metadata without importing torch or loading its DLLs. - source = Path(str(distribution.locate_file("torch/version.py"))).read_text( - encoding="utf-8" - ) - for node in ast.parse(source).body: - if isinstance(node, ast.Assign): - targets, value = node.targets, node.value - elif isinstance(node, ast.AnnAssign): - targets, value = [node.target], node.value - else: - continue - if ( - any( - isinstance(target, ast.Name) and target.id == "__version__" - for target in targets - ) - and isinstance(value, ast.Constant) - and isinstance(value.value, str) - ): - return value.value - return version - - -def torch_backend(uv: Path, options: dict) -> str | None: - backend = options.get("torch-backend") - if backend is None: - try: - build = torch_build_version() - except (OSError, SyntaxError, importlib.metadata.PackageNotFoundError) as exc: - raise ValueError( - "Cannot identify local voice build; rerun the matching voice installer." - ) from exc - _, separator, backend = build.partition("+") - if ( - not separator - and sys.platform == "darwin" - and re.fullmatch(r"\d+\.\d+\.\d+", build) - ): - return None - if not separator or not re.fullmatch( - r"cpu|cu\d+|rocm\d+(?:\.\d+)+|xpu", backend - ): - raise ValueError( - "Cannot identify local voice backend; rerun the matching voice installer with --torch-backend." - ) - if not isinstance(backend, str) or not backend: - raise ValueError( - "Invalid recorded voice backend; rerun the matching voice installer." - ) - capture( - [str(uv), "tool", "install", "--torch-backend", backend, "--help"], - encoding="utf-8", - ) - return backend - - -def prepare(uv: Path, launcher: Path) -> dict: - version = capture([str(uv), "--version"], encoding="utf-8") - match = re.match(r"uv (\d+)\.(\d+)\.(\d+)(?:\s|$)", version) - if not match or tuple(map(int, match.groups())) < (0, 12, 13): - raise ValueError("uv 0.12.13 or newer is required. Rerun the FCC installer.") - root = Path(capture([str(uv), "tool", "dir"], encoding="utf-8")) / PACKAGE - bin_dir = Path(capture([str(uv), "tool", "dir", "--bin"], encoding="utf-8")) - if not root.samefile(sys.prefix) or not Path(__file__).resolve().is_relative_to( - root.resolve() - ): - raise ValueError( - "This is not the uv-managed FCC installation. Use its original installation method." - ) - receipt = tomllib.loads((root / "uv-receipt.toml").read_text(encoding="utf-8"))[ - "tool" - ] - requirements = receipt.get("requirements", []) - if ( - len(requirements) != 1 - or requirements[0].get("name") != PACKAGE - or requirements[0].get("url") != ARCHIVE_URL - ): - raise ValueError( - "fcc-update supports the official FCC installer source. Use this installation's original method." - ) - entry_name = "fcc-update.cmd" if os.name == "nt" else "fcc-update" - entries = [ - entry - for entry in receipt.get("entrypoints", []) - if entry.get("name") == entry_name and entry.get("from") == PACKAGE - ] - if ( - len(entries) != 1 - or not launcher.samefile(bin_dir / entry_name) - or not launcher.samefile(entries[0]["install-path"]) - ): - raise ValueError("The updater launcher belongs to a different installation.") - extras = sorted( - {extra.replace("_", "-") for extra in requirements[0].get("extras", [])} - ) - if set(extras) - {"voice", "voice-local"}: - raise ValueError("Unsupported FCC extras. Use the original installer.") - python_request = receipt.get("python") - if not isinstance(python_request, str) or not python_request: - raise ValueError( - "The installed Python request is missing. Rerun the FCC installer." - ) - if processes := running_fcc(): - raise ValueError( - f"Stop running FCC processes before updating: {', '.join(processes)}" - ) - arguments = [ - "tool", - "install", - "--force", - "--refresh-package", - PACKAGE, - "--python", - python_request, - ] - if "voice-local" in extras and ( - backend := torch_backend(uv, receipt.get("options", {})) - ): - arguments += ["--torch-backend", backend] - spec = PACKAGE + (f"[{','.join(extras)}]" if extras else "") - arguments.append(f"{spec} @ {ARCHIVE_URL}") - python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - return { - "uv": str(uv), - "arguments": arguments, - "version": installed_version(), - "verify": [ - str(python), - "-I", - "-c", - "from importlib.metadata import version; print('FCC refreshed (version ' + version('free-claude-code') + ')')", - ], - } - - -def shell_worker(plan: dict) -> str: - fd, name = tempfile.mkstemp(prefix="fcc-update-", suffix=".sh") - try: - with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: - stream.write( - "#!/bin/sh\nset -eu\ntrap 'rm -f -- \"$0\"' EXIT\n" - "trap 'exit 130' INT\ntrap 'exit 143' TERM HUP\n" - + "printf '%s\\n' " - + shlex.quote(f"Updating FCC {plan['version']}...") - + "\n" - + shlex.join([plan["uv"], *plan["arguments"]]) - + "\n" - + shlex.join(plan["verify"]) - + "\n" - ) - except BaseException: - Path(name).unlink(missing_ok=True) - raise - return name - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--uv", type=Path, required=True) - parser.add_argument("--launcher", type=Path, required=True) - parser.add_argument("--shell", action="store_true") - args = parser.parse_args() - try: - plan = prepare(args.uv, args.launcher) - print(shell_worker(plan) if args.shell else json.dumps(plan)) - except ( - OSError, - ValueError, - KeyError, - TypeError, - subprocess.SubprocessError, - importlib.metadata.PackageNotFoundError, - ) as exc: - print(f"fcc-update: {exc}", file=sys.stderr) - raise SystemExit(1) from None - - -if __name__ == "__main__": - main() diff --git a/tests/cli/test_update.py b/tests/cli/test_update.py deleted file mode 100644 index 411ef6469a..0000000000 --- a/tests/cli/test_update.py +++ /dev/null @@ -1,179 +0,0 @@ -import json -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from free_claude_code.cli import update - - -@pytest.fixture -def installation(tmp_path, monkeypatch): - root = tmp_path / "tools" / "free-claude-code" - root.mkdir(parents=True) - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - launcher = bin_dir / ("fcc-update.cmd" if update.os.name == "nt" else "fcc-update") - launcher.touch() - module = root / "lib" / "update.py" - module.parent.mkdir() - module.touch() - monkeypatch.setattr(update.sys, "prefix", str(root)) - monkeypatch.setattr(update, "__file__", str(module)) - monkeypatch.setattr(update, "running_fcc", lambda: []) - monkeypatch.setattr(update, "installed_version", lambda: "1.0.0") - - def capture(args, **kwargs): - if args[1:] == ["--version"]: - return "uv 0.12.13" - if args[1:] == ["tool", "dir"]: - return str(root.parent) - if args[1:] == ["tool", "dir", "--bin"]: - return str(bin_dir) - raise AssertionError(args) - - monkeypatch.setattr(update, "capture", capture) - - def receipt(extras=(), source=None): - (root / "uv-receipt.toml").write_text( - '[tool]\npython = "3.14.0"\nrequirements = [{name = "free-claude-code", url = ' - + json.dumps(source or update.ARCHIVE_URL) - + ", extras = " - + json.dumps(list(extras)) - + "}]\nentrypoints = [{ name = " - + json.dumps(launcher.name) - + ', from = "free-claude-code", install-path = ' - + json.dumps(str(launcher)) - + "}]\n", - encoding="utf-8", - ) - - receipt() - return root, launcher, receipt - - -@pytest.mark.parametrize( - "extras", [(), ("voice",), ("voice-local",), ("voice", "voice-local")] -) -def test_prepare_preserves_install_choices(installation, monkeypatch, extras): - _, launcher, receipt = installation - receipt(extras) - monkeypatch.setattr(update, "torch_backend", lambda uv, options: "cu130") - result = update.prepare(Path("uv"), launcher) - args = result["arguments"] - assert args[:4] == ["tool", "install", "--force", "--refresh-package"] - assert args[4:7] == ["free-claude-code", "--python", "3.14.0"] - assert ("--torch-backend" in args) == ("voice-local" in extras) - assert ( - args[-1] - == "free-claude-code" - + ("[" + ",".join(sorted(extras)) + "]" if extras else "") - + " @ " - + update.ARCHIVE_URL - ) - - -def test_foreign_source_refused_before_update(installation): - _, launcher, receipt = installation - receipt(source="https://example.com/foreign.zip") - with pytest.raises(ValueError, match="official"): - update.prepare(Path("uv"), launcher) - - -def test_checkout_cannot_update_installed_tool(installation, monkeypatch, tmp_path): - _, launcher, _ = installation - monkeypatch.setattr(update, "__file__", str(tmp_path / "checkout" / "update.py")) - with pytest.raises(ValueError, match="installation"): - update.prepare(Path("uv"), launcher) - - -def test_wrong_launcher_refused(installation, tmp_path): - other = tmp_path / "other.cmd" - other.touch() - with pytest.raises(ValueError, match="launcher"): - update.prepare(Path("uv"), other) - - -def test_running_fcc_refused(installation, monkeypatch): - _, launcher, _ = installation - monkeypatch.setattr(update, "running_fcc", lambda: ["fcc-server (PID 123)"]) - with pytest.raises(ValueError, match=r"Stop.*fcc-server"): - update.prepare(Path("uv"), launcher) - - -@pytest.mark.parametrize( - "build,expected", - [ - ("2.14.0+cpu", "cpu"), - ("2.14.0+cu130", "cu130"), - ("2.14.0+rocm6.2.4", "rocm6.2.4"), - ("2.14.0+xpu", "xpu"), - ], -) -def test_torch_backend_uses_build_not_hardware(monkeypatch, build, expected): - monkeypatch.setattr(update, "torch_build_version", lambda: build) - calls = [] - monkeypatch.setattr( - update, "capture", lambda args, **kwargs: calls.append(args) or "" - ) - assert update.torch_backend(Path("uv"), {}) == expected - assert calls == [["uv", "tool", "install", "--torch-backend", expected, "--help"]] - - -def test_unknown_torch_backend_is_not_silently_changed(monkeypatch): - monkeypatch.setattr(update, "torch_build_version", lambda: "2.14.0+custom") - with pytest.raises(ValueError, match="voice installer"): - update.torch_backend(Path("uv"), {}) - - -def test_recorded_backend_is_preserved_without_inspecting_build(monkeypatch): - calls = [] - monkeypatch.setattr( - update, "capture", lambda args, **kwargs: calls.append(args) or "" - ) - monkeypatch.setattr( - update, "torch_build_version", lambda: pytest.fail("must use recorded backend") - ) - assert update.torch_backend(Path("uv"), {"torch-backend": "auto"}) == "auto" - - -def test_torch_build_metadata_is_read_without_loading_code(tmp_path, monkeypatch): - version_file = tmp_path / "version.py" - version_file.write_text( - "raise RuntimeError('must not execute')\n__version__: str = '2.14.0+cu130'\n" - ) - monkeypatch.setattr( - update.importlib.metadata, - "distribution", - lambda _: SimpleNamespace(version="2.14.0", locate_file=lambda _: version_file), - ) - assert update.torch_build_version() == "2.14.0+cu130" - - -def test_process_guard_finds_fcc_without_matching_unrelated_apps(monkeypatch): - if update.os.name == "nt": - output = ( - '"fcc-server.exe","123","Console"\n"not-fcc-server.exe","124","Console"' - ) - else: - output = "123 /some/bin/fcc-server\n124 /some/bin/not-fcc-server\n" - monkeypatch.setattr(update, "capture", lambda _: output) - found = update.running_fcc() - assert len(found) == 1 - assert "123" in found[0] - - -def test_no_server_imports_in_isolated_preparation_module(): - import subprocess - import sys - - result = subprocess.run( - [ - sys.executable, - "-c", - "from free_claude_code.cli import update; import sys; assert not any(k.startswith(('free_claude_code.config', 'free_claude_code.runtime', 'free_claude_code.providers')) for k in sys.modules)", - ], - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr diff --git a/tests/cli/test_update_lifecycle.py b/tests/cli/test_update_lifecycle.py deleted file mode 100644 index 12ea196b43..0000000000 --- a/tests/cli/test_update_lifecycle.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Exercise real uv script ownership and self-replacement in disposable tools.""" - -import base64 -import contextlib -import http.server -import io -import os -import shutil -import subprocess -import sys -import threading -import tomllib -import zipfile -from pathlib import Path - -import pytest - -from free_claude_code.cli import update - -ROOT = Path(__file__).resolve().parents[2] -SCRIPTS = ("fcc-update", "fcc-update.cmd", "fcc-update-windows.ps1") - - -def fixture_archive(url, version, marker, *, verify_exit=None): - """Build offline fixtures; production has no alternate update-source switch.""" - buffer = io.BytesIO() - dist = f"free_claude_code-{version}" - source = Path(update.__file__).read_text(encoding="utf-8") - source = source.replace(update.ARCHIVE_URL, url) - # Match only the fixture's processes; never stop or interact with the real FCC. - source = source.replace( - 'if __name__ == "__main__":', - 'FCC_COMMANDS = frozenset({"fcc-fixture-busy"})\nif __name__ == "__main__":', - ) - if verify_exit is not None: - source = source.replace( - 'if __name__ == "__main__":', - f"original_prepare = prepare\ndef prepare(*args):\n" - f" plan = original_prepare(*args)\n" - f" plan['verify'] = [sys.executable, '-c', 'raise SystemExit({verify_exit})']\n" - f" return plan\nif __name__ == '__main__':", - ) - with zipfile.ZipFile(buffer, "w") as wheel: - wheel.writestr("free_claude_code/__init__.py", "") - wheel.writestr("free_claude_code/cli/__init__.py", "") - wheel.writestr("free_claude_code/cli/update.py", source) - wheel.writestr("free_claude_code/marker.txt", marker) - wheel.writestr( - f"{dist}.dist-info/METADATA", - f"Metadata-Version: 2.1\nName: free-claude-code\nVersion: {version}\nProvides-Extra: voice\n", - ) - wheel.writestr( - f"{dist}.dist-info/WHEEL", - "Wheel-Version: 1.0\nGenerator: fixture\nRoot-Is-Purelib: true\nTag: py3-none-any\n", - ) - for name in SCRIPTS: - info = zipfile.ZipInfo(f"{dist}.data/scripts/{name}") - # Match Hatch's wheel built on Windows; uv must make shell scripts executable. - info.external_attr = (0o100755 if name.endswith(".cmd") else 0o100644) << 16 - wheel.writestr(info, (ROOT / "scripts/update" / name).read_bytes()) - wheel.writestr( - f"{dist}.dist-info/RECORD", - "".join(f"{name},,\n" for name in wheel.namelist()) - + f"{dist}.dist-info/RECORD,,\n", - ) - # A dependency-free build backend serves the prepared wheel as a source zip. - backend = f"import base64\nfrom pathlib import Path\ndef build_wheel(wheel_directory, config_settings=None, metadata_directory=None):\n name = '{dist}-py3-none-any.whl'\n Path(wheel_directory, name).write_bytes(base64.b64decode({base64.b64encode(buffer.getvalue())!r}))\n return name\n" - archive = io.BytesIO() - with zipfile.ZipFile(archive, "w") as source_zip: - source_zip.writestr( - "fcc/pyproject.toml", - '[build-system]\nrequires = []\nbuild-backend = "backend"\nbackend-path = ["."]\n', - ) - source_zip.writestr("fcc/backend.py", backend) - return archive.getvalue() - - -@contextlib.contextmanager -def archive_server(): - payload = {"body": b""} - - class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header("Content-Type", "application/zip") - self.send_header("Content-Length", str(len(payload["body"]))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(payload["body"]) - - def log_message(self, format: str, *args): - pass - - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = threading.Thread(target=server.serve_forever) - thread.start() - try: - yield f"http://127.0.0.1:{server.server_port}/main.zip", payload - finally: - server.shutdown() - server.server_close() - thread.join() - - -def run(args, env, cwd): - return subprocess.run( - args, env=env, cwd=cwd, capture_output=True, text=True, timeout=90 - ) - - -def test_installed_updater_replaces_itself_and_uv_removes_scripts(tmp_path): - uv = shutil.which("uv") - if uv is None: - pytest.skip("uv is required for real package-manager lifecycle coverage") - area = tmp_path / "José 测试 with spaces & (brackets) ! apostrophe'" - area.mkdir() - env = dict(os.environ) - env.update( - UV_TOOL_DIR=str(area / "tools"), - UV_TOOL_BIN_DIR=str(area / "bin"), - UV_CACHE_DIR=str(area / "cache"), - UV_NO_CONFIG="1", - UV_HTTP_RETRIES="0", - UV_PYTHON_DOWNLOADS="never", - ) - env["PATH"] = str(Path(uv).parent) + os.pathsep + env["PATH"] - sentinel = area / "settings.json" - sentinel.write_text("keep me") - with archive_server() as (url, payload): - payload["body"] = fixture_archive(url, "1.0.0", "first") - result = run( - [ - uv, - "tool", - "install", - "--python", - getattr(sys, "_base_executable", sys.executable), - f"free-claude-code[voice] @ {url}", - ], - env, - area, - ) - assert result.returncode == 0, result.stdout + result.stderr - bin_dir = area / "bin" - assert all((bin_dir / name).is_file() for name in SCRIPTS) - root = area / "tools/free-claude-code" - python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - launcher = bin_dir / ("fcc-update.cmd" if os.name == "nt" else "fcc-update") - command = ( - f'"{os.environ.get("COMSPEC", "cmd.exe")}" /d /s /c ""{launcher}""' - if os.name == "nt" - else [str(launcher)] - ) - for version, marker in [ - ("1.1.0", "second"), - ("1.1.0", "same-version-new-code"), - ]: - payload["body"] = fixture_archive(url, version, marker) - result = run(command, env, area) - assert result.returncode == 0, result.stdout + result.stderr - assert f"FCC refreshed (version {version})" in result.stdout - installed = run( - [ - str(python), - "-I", - "-c", - "import free_claude_code; from pathlib import Path; print(Path(free_claude_code.__file__).with_name('marker.txt').read_text())", - ], - env, - area, - ) - assert installed.stdout.strip() == marker - receipt = tomllib.loads( - (root / "uv-receipt.toml").read_text(encoding="utf-8") - ) - assert receipt["tool"]["requirements"][0]["extras"] == ["voice"] - payload["body"] = b"invalid archive" - result = run(command, env, area) - direct_failure = run( - [ - uv, - "tool", - "install", - "--force", - "--refresh-package", - "free-claude-code", - f"free-claude-code[voice] @ {url}", - ], - env, - area, - ) - assert result.returncode == direct_failure.returncode != 0 - assert "FCC refreshed" not in result.stdout - payload["body"] = fixture_archive( - url, "1.2.0", "verification-error", verify_exit=23 - ) - result = run( - [ - uv, - "tool", - "install", - "--force", - "--refresh-package", - "free-claude-code", - "--python", - getattr(sys, "_base_executable", sys.executable), - f"free-claude-code[voice] @ {url}", - ], - env, - area, - ) - assert result.returncode == 0, result.stderr - payload["body"] = fixture_archive( - url, "1.3.0", "installed-despite-verification-error" - ) - result = run(command, env, area) - assert result.returncode == 23, result.stdout + result.stderr - assert "FCC refreshed" not in result.stdout - assert sentinel.read_text() == "keep me" - result = run([uv, "tool", "uninstall", "free-claude-code"], env, area) - assert result.returncode == 0, result.stderr - assert not any((bin_dir / name).exists() for name in SCRIPTS) diff --git a/tests/cli/test_update_wrapper.py b/tests/cli/test_update_wrapper.py new file mode 100644 index 0000000000..288de6ac55 --- /dev/null +++ b/tests/cli/test_update_wrapper.py @@ -0,0 +1,168 @@ +"""Native update commands delegate to the installer and survive replacement.""" + +import contextlib +import http.server +import os +import shutil +import subprocess +import sys +import threading +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +INSTALL_URL = "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install" +SCRIPTS = ("fcc-update", "fcc-update.cmd") + + +@contextlib.contextmanager +def installer_server(body, status): + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + payload = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}/install", requests + finally: + server.shutdown() + server.server_close() + thread.join() + + +def fixture_wheel(area, url): + wheel = area / "free_claude_code-1.0.0-py3-none-any.whl" + dist = "free_claude_code-1.0.0" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + f"{dist}.dist-info/METADATA", + "Metadata-Version: 2.1\nName: free-claude-code\nVersion: 1.0.0\n", + ) + archive.writestr( + f"{dist}.dist-info/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + for name in SCRIPTS: + source = (ROOT / "scripts/update" / name).read_text(encoding="utf-8") + archive.writestr( + f"{dist}.data/scripts/{name}", source.replace(INSTALL_URL, url) + ) + archive.writestr( + f"{dist}.dist-info/RECORD", + "".join(f"{name},,\n" for name in archive.namelist()) + + f"{dist}.dist-info/RECORD,,\n", + ) + return wheel + + +@pytest.mark.parametrize("outcome", ["success", "exit", "error", "download-error"]) +def test_installed_update_delegates_and_survives_replacement(tmp_path, outcome): + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for native script lifecycle coverage") + area = tmp_path / "José 测试 & (spaces) ! apostrophe'" + area.mkdir() + env = dict(os.environ) + env.update( + UV_TOOL_DIR=str(area / "tools"), + UV_TOOL_BIN_DIR=str(area / "bin"), + UV_CACHE_DIR=str(area / "cache"), + UV_NO_CONFIG="1", + UV_PYTHON_DOWNLOADS="never", + FCC_TEST_LAUNCHER=str( + area / "bin" / ("fcc-update.cmd" if os.name == "nt" else "fcc-update") + ), + ) + if os.name == "nt": + body = """param([switch] $VoiceLocal, [string] $TorchBackend) +$answer = Read-Host 'Continue' +Write-Output "installer:$VoiceLocal|$TorchBackend|$answer" +[IO.File]::WriteAllText($env:FCC_TEST_LAUNCHER, 'exit /b 99') +""" + arguments = "-VoiceLocal -TorchBackend cu130" + expected = "installer:True|cu130|yes" + body += { + "success": "", + "exit": "exit 23", + "error": "throw 'installer failed'", + "download-error": "", + }[outcome] + else: + body = """read -r answer +printf 'installer:%s|%s|%s|%s\\n' "$1" "$2" "$3" "$answer" +printf 'exit 99\\n' > "$FCC_TEST_LAUNCHER" +""" + arguments = ["--voice-local", "--torch-backend", "cu130"] + expected = "installer:--voice-local|--torch-backend|cu130|yes" + body += { + "success": "", + "exit": "exit 23", + "error": "exit 1", + "download-error": "", + }[outcome] + with installer_server(body, 503 if outcome == "download-error" else 200) as ( + url, + requests, + ): + wheel = fixture_wheel(area, url) + result = subprocess.run( + [ + uv, + "tool", + "install", + "--python", + getattr(sys, "_base_executable", sys.executable), + str(wheel), + ], + env=env, + capture_output=True, + timeout=90, + ) + assert result.returncode == 0, result.stderr + launcher = env["FCC_TEST_LAUNCHER"] + command = ( + f'"{os.environ.get("COMSPEC", "cmd.exe")}" /d /s /c ""{launcher}" {arguments}"' + if os.name == "nt" + else [launcher, *arguments] + ) + result = subprocess.run( + command, + env=env, + cwd=area, + input="yes\n", + capture_output=True, + text=True, + timeout=30, + ) + assert requests == ["/install.ps1" if os.name == "nt" else "/install.sh"] + if outcome == "download-error": + assert result.returncode != 0 + assert "installer:" not in result.stdout + else: + assert expected in result.stdout, result.stdout + result.stderr + assert ( + result.returncode == {"success": 0, "exit": 23, "error": 1}[outcome] + ), result.stderr + result = subprocess.run( + [uv, "tool", "uninstall", "free-claude-code"], + env=env, + capture_output=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert not any((area / "bin" / name).exists() for name in SCRIPTS) diff --git a/tests/scripts/test_installers.py b/tests/scripts/test_installers.py index 9c6a43b0d8..66771e8ed6 100644 --- a/tests/scripts/test_installers.py +++ b/tests/scripts/test_installers.py @@ -2083,9 +2083,6 @@ def _batch_uv(version: str) -> str: copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-muse.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-aider.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-update.cmd" >nul -if not "%FAIL_STEP%"=="fcc-update-companion-missing" ( - echo exit 0 > "%UV_BIN_DIR%\fcc-update-windows.ps1" -) if not "%FAIL_STEP%"=="fcc-missing" copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-codex.cmd" >nul exit /b 0 :install_aider @@ -3079,24 +3076,6 @@ def test_install_ps1_does_not_use_uv_install_directory_for_aider( assert "aider:--version" not in powershell_harness.calls() -@pytest.mark.parametrize("companion_elsewhere", [False, True]) -def test_install_ps1_rejects_missing_update_companion( - powershell_harness: PowerShellHarness, companion_elsewhere: bool -) -> None: - if companion_elsewhere: - (powershell_harness.bin_dir / "fcc-update-windows.ps1").write_text( - "throw 'Must not use a companion from another directory'\n", - encoding="utf-8", - ) - - result = powershell_harness.run(fail_step="fcc-update-companion-missing") - - assert result.returncode != 0 - assert "fcc-update-windows.ps1" in result.stderr - assert "Free Claude Code is installed and verified." not in result.stdout - assert "fcc-server:--version" not in powershell_harness.calls() - - def test_install_ps1_fresh_install_is_verified( powershell_harness: PowerShellHarness, ) -> None: diff --git a/uv.lock b/uv.lock index 60d0a03c6d..4cecec0075 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.56" +version = "6.2.57" source = { editable = "." } dependencies = [ { name = "aiohttp" },