From 0abb0ecadd5ba73d4884a6a885e0615a8289ec48 Mon Sep 17 00:00:00 2001 From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:00:15 -0700 Subject: [PATCH 1/2] patch: Add fcc-doctor and simplify bug reports (#1906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Bug reports need FCC configuration and environment details without making users collect settings by hand. Installation failures need a simpler form because FCC commands may not be available yet. ## How Add `fcc-doctor` to print indented JSON with the FCC version first and copy it to the clipboard. Collect saved settings with current terminal overrides, provider configuration, routing, reasoning, web tools, integration state, system information, and bounded native harness version probes. Explicit projections exclude credentials, account identities, file paths, logs, and code sessions. The command works without a running server, leaves configuration unchanged, and preserves printed output when clipboard access is unavailable. Add an installation form with one required description and a post-install form with exactly two required fields: description and doctor JSON. Read the version reminder from that JSON, exempt installation reports, and retain support for edits to existing issue forms. Register the command in the installers and uninstallers and add its README usage command.

RetriggerConfidence Score: 5/5

Safe to merge; the remaining diagnostic inaccuracy is non-blocking.

Fix All in CodexFindings

1. P2 **Unrelated version reported for harness** ▶
Summary The PR adds a shareable `fcc-doctor` report, separates installation and post-installation issue forms, and updates version validation, installers, and tests. One previously reported diagnostic inaccuracy remains.
Reviews (3) · Last reviewed commit: ["Synchronize API key rejection test with ..."](https://github.com/alishahryar1/free-claude-code/commit/82b4315625581d86efd9a7e30274f219b143b3d2) --- .github/ISSUE_TEMPLATE/bug-report.yml | 57 +-- .../ISSUE_TEMPLATE/installation-problem.yml | 12 + .../workflows/validate-bug-report-version.yml | 37 +- README.md | 6 + e2e/test_admin_apply.py | 54 +-- pyproject.toml | 2 + scripts/install.ps1 | 3 +- scripts/install.sh | 4 +- scripts/uninstall.ps1 | 1 + scripts/uninstall.sh | 2 +- src/free_claude_code/cli/clipboard.py | 14 + src/free_claude_code/cli/doctor.py | 25 ++ src/free_claude_code/cli/entrypoints.py | 9 + .../providers/openai_codex/auth.py | 34 +- src/free_claude_code/runtime/diagnostics.py | 337 ++++++++++++++++++ tests/cli/test_doctor.py | 267 ++++++++++++++ tests/cli/test_entrypoints.py | 1 + tests/contracts/test_import_boundaries.py | 7 + .../test_issue_form_version_validation.py | 82 ++++- tests/installers/test_installers.py | 3 + tests/installers/test_uninstallers.py | 3 +- tests/providers/test_openai_codex_auth.py | 20 ++ uv.lock | 11 + 23 files changed, 885 insertions(+), 106 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/installation-problem.yml create mode 100644 src/free_claude_code/cli/clipboard.py create mode 100644 src/free_claude_code/cli/doctor.py create mode 100644 src/free_claude_code/runtime/diagnostics.py create mode 100644 tests/cli/test_doctor.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 6b4d965637..b9653ca72f 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,57 +1,20 @@ -name: Bug report -description: Report something that is not working in Free Claude Code. +name: Problem after installation +description: Report a problem using Free Claude Code after installing it. labels: - bug body: - - type: input - id: fcc-version + - type: textarea + id: description attributes: - label: FCC version - description: Run `fcc-server --version` and include one version exactly as printed, including any development suffix. If FCC did not install, enter `None`. - placeholder: "The version is 1.22.333, or None" + label: Describe the issue + description: Describe what happened, what you expected, and how to reproduce it. Include the provider, model, and harness you were using when relevant. Reports missing relevant details may be closed. You can attach screenshots here. validations: required: true - - - type: dropdown - id: cli - attributes: - label: CLI - description: If the problem occurred while using a coding CLI, select it. - options: - - Claude Code (fcc-claude) - - Codex (fcc-codex) - - Pi (fcc-pi) - - - type: input - id: selected-models - attributes: - label: Selected model(s) - description: If relevant, enter each model exactly as shown in the selector or configuration. - placeholder: nvidia_nim/nvidia/nemotron-3-super-120b-a12b - - - type: textarea - id: model-mappings - attributes: - label: Model mappings - description: If relevant, enter the model mappings used when the problem occurred. - placeholder: | - MODEL_FABLE= - MODEL_OPUS= - MODEL_SONNET= - MODEL_HAIKU= - - - type: input - id: providers - attributes: - label: Provider(s) - description: If relevant, enter the provider or providers involved. - placeholder: NVIDIA NIM, OpenRouter - - type: textarea - id: issue + id: doctor attributes: - label: Issue - description: Describe what happened and include any useful reproduction details. Remove API keys and tokens from logs or screenshots. - placeholder: Tell us what went wrong. + label: FCC doctor output + description: Run `fcc-doctor` and paste the JSON it copies to your clipboard. If copying is unavailable, copy the printed JSON from your terminal. + render: json validations: required: true diff --git a/.github/ISSUE_TEMPLATE/installation-problem.yml b/.github/ISSUE_TEMPLATE/installation-problem.yml new file mode 100644 index 0000000000..5396c38787 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/installation-problem.yml @@ -0,0 +1,12 @@ +name: Installation problem +description: Report a problem before FCC has been installed successfully. +labels: + - bug +body: + - type: textarea + id: description + attributes: + label: Installation issue + description: Describe what happened and include the error you received. You can attach screenshots here if helpful. + validations: + required: true diff --git a/.github/workflows/validate-bug-report-version.yml b/.github/workflows/validate-bug-report-version.yml index 27d180d98d..7d1c6641b0 100644 --- a/.github/workflows/validate-bug-report-version.yml +++ b/.github/workflows/validate-bug-report-version.yml @@ -31,11 +31,34 @@ jobs: const fieldPattern = "^### FCC version\\r?\\n+\\s*([^\\r\\n]+)"; const releaseVersionPattern = "^v((?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*))$"; const versionPattern = "(?:^|[^0-9A-Za-z_.])v?([0-9]+\\.[0-9]+\\.[0-9]+(?:\\.dev[0-9]+(?:\\+g[0-9a-f]+(?:\\.d[0-9]{8})?)?|\\+d[0-9]{8})?)(?![0-9A-Za-z_+-]|\\.[0-9A-Za-z_])"; - const match = (issue.body || "").match(new RegExp(fieldPattern, "m")); - const fieldValue = match?.[1]?.trim() || ""; - const versionMatches = [...fieldValue.matchAll(new RegExp(versionPattern, "g"))]; - const reportedVersion = versionMatches.length === 1 ? versionMatches[0][1] : undefined; - const valid = reportedVersion !== undefined || fieldValue === "None"; + const parseReport = (body) => { + const sections = body.split(/^### /m); + const doctor = sections.filter((section) => /^FCC doctor output\r?\n/.test(section)); + if (doctor.length) { + if (doctor.length !== 1) return { valid: false }; + let text = doctor[0].replace(/^FCC doctor output\r?\n/, "").trim(); + const fence = text.match(/^```(?:json)?\r?\n([\s\S]*)\r?\n```$/); + if (fence) text = fence[1]; + let report; + try { report = JSON.parse(text); } + catch { return { valid: false }; } + const version = report?.version; + if (version === "0+unknown") return { valid: true }; + if (typeof version !== "string") return { valid: false }; + const matches = [...version.matchAll(new RegExp(versionPattern, "g"))]; + if (matches.length !== 1 || matches[0][0] !== version) return { valid: false }; + return { valid: true, reportedVersion: matches[0][1] }; + } + const match = body.match(new RegExp(fieldPattern, "m")); + if (!match && sections.some((section) => /^Installation issue\r?\n/.test(section))) { + return { valid: true }; + } + const fieldValue = match?.[1]?.trim() || ""; + const versionMatches = [...fieldValue.matchAll(new RegExp(versionPattern, "g"))]; + const reportedVersion = versionMatches.length === 1 ? versionMatches[0][1] : undefined; + return { valid: reportedVersion !== undefined || fieldValue === "None", reportedVersion }; + }; + const { reportedVersion, valid } = parseReport(issue.body || ""); const labelName = "needs-fcc-version"; const invalidMarker = ""; const outdatedMarker = ""; @@ -82,7 +105,7 @@ jobs: ...repo, name: labelName, color: "d4c5f9", - description: "FCC version must contain one installed version or be None", + description: "Bug report needs valid FCC version information", }); } catch (createError) { if (createError.status !== 422) throw createError; @@ -98,7 +121,7 @@ jobs: await github.rest.issues.createComment({ ...repo, issue_number, - body: `${invalidMarker}\nPlease edit **FCC version** so it contains one version copied from \`fcc-server --version\`, including any development suffix, or is exactly \`None\`.`, + body: `${invalidMarker}\nPlease run \`fcc-doctor\` and paste its complete JSON into **FCC doctor output**. For an older issue with an **FCC version** field, enter one version from \`fcc-server --version\` or exactly \`None\`. If FCC has not installed successfully, use the Installation problem form.`, }); } return; diff --git a/README.md b/README.md index cd41bed3ed..d599643393 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,12 @@ Windows PowerShell: ## Project Links +For a problem after installation, run the command below and paste its output into your bug report. It also copies the JSON to your clipboard when available. + +```bash +fcc-doctor +``` + - [Report bugs or request features](https://github.com/Alishahryar1/free-claude-code/issues) - [Contributing guide](CONTRIBUTING.md) diff --git a/e2e/test_admin_apply.py b/e2e/test_admin_apply.py index c418190829..cd8fb0c388 100644 --- a/e2e/test_admin_apply.py +++ b/e2e/test_admin_apply.py @@ -47,8 +47,32 @@ def record_config_mutation(method: str, url: str) -> None: def test_key_rejection_retains_edits_and_restores_focus( page: Page, admin_base_url: str ): - pending: list[Route] = [] - page.route("**/admin/api/config/apply", lambda route: pending.append(route)) + request_count = 0 + + def reject_key(route: Route) -> None: + nonlocal request_count + request_count += 1 + expect(page.locator("#messageArea")).to_have_text("Checking API keys…") + expect(page.locator("#view-providers")).to_have_attribute("inert", "") + expect(page.locator("#applyButton")).to_be_disabled() + submitted = route.request.post_data_json + assert isinstance(submitted, dict) + assert submitted["values"] == {"MISTRAL_API_KEY": "bad-key"} + route.fulfill( + json={ + "applied": False, + "errors": ["Rejected key"], + "credential_checks": [ + { + "key": "MISTRAL_API_KEY", + "status": "rejected", + "message": "Check this API key.", + } + ], + } + ) + + page.route("**/admin/api/config/apply", reject_key) page.goto(f"{admin_base_url}/admin") expect(page.locator("#messageArea")).to_have_text("") page.get_by_role("button", name="Model Config", exact=True).click() @@ -58,34 +82,14 @@ def test_key_rejection_retains_edits_and_restores_focus( open_provider(page, "mistral") key = page.locator("#field-MISTRAL_API_KEY") key.fill("bad-key") - page.get_by_role("button", name="Save", exact=True).click() - expect(page.locator("#messageArea")).to_have_text("Checking API keys…") - expect(page.locator("#view-providers")).to_have_attribute("inert", "") - expect(page.locator("#applyButton")).to_be_disabled() - assert len(pending) == 1 - submitted = pending[0].request.post_data_json - assert isinstance(submitted, dict) - assert submitted["values"] == { - "MISTRAL_API_KEY": "bad-key", - } - pending.pop().fulfill( - json={ - "applied": False, - "errors": ["Rejected key"], - "credential_checks": [ - { - "key": "MISTRAL_API_KEY", - "status": "rejected", - "message": "Check this API key.", - } - ], - } - ) + with page.expect_response("**/admin/api/config/apply"): + page.get_by_role("button", name="Save", exact=True).click() expect(key).to_be_focused() expect(key).to_have_attribute("aria-invalid", "true") expect(page.locator("#field-MISTRAL_API_KEY-error")).to_have_text( "Check this API key." ) + assert request_count == 1 expect(key).to_have_value("bad-key") expect(other).to_have_value("open_router/other-edit") expect(page.locator("#dirtyState")).to_have_text("1 unsaved change") diff --git a/pyproject.toml b/pyproject.toml index 16f7131e7d..095dd2e90a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,12 @@ dependencies = [ "grpcio>=1.84.0", "grpcio-tools>=1.81.1", "nvidia-riva-client>=2.26.0", + "pyperclip>=1.11.0", ] [project.scripts] 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" fcc-codex = "free_claude_code.cli.launchers.codex:launch" fcc-pi = "free_claude_code.cli.launchers.pi:launch" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f141b5ab1a..f580b08c70 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -56,6 +56,7 @@ $FccCommands = @( "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-doctor", "fcc-update", "fcc-init", "free-claude-code" @@ -1488,7 +1489,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", "fcc-update.cmd")) { + 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-doctor", "fcc-update.cmd")) { $command = Get-ApplicationCommand $commandName if (-not $command) { throw "Free Claude Code installation did not create '$commandName'." diff --git a/scripts/install.sh b/scripts/install.sh index 61418c2876..437562a1df 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -18,7 +18,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-update 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-doctor fcc-update fcc-init free-claude-code" dry_run=0 voice_local=0 @@ -1325,7 +1325,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 fcc-update; 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-doctor 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 bd5a9a8ad5..e9a28297fb 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -24,6 +24,7 @@ $FccCommands = @( "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-doctor", "fcc-update", "fcc-init", "free-claude-code" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index fba40e3880..b36535dbda 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-update 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-doctor fcc-update fcc-init free-claude-code" dry_run=0 uv_tool_bin="" diff --git a/src/free_claude_code/cli/clipboard.py b/src/free_claude_code/cli/clipboard.py new file mode 100644 index 0000000000..c08f44e971 --- /dev/null +++ b/src/free_claude_code/cli/clipboard.py @@ -0,0 +1,14 @@ +"""Best-effort text copy for the diagnostic command.""" + +import pyperclip + + +class ClipboardUnavailable(Exception): + """The current desktop cannot accept clipboard text.""" + + +def copy_text(text: str) -> None: + try: + pyperclip.copy(text) + except (pyperclip.PyperclipException, OSError) as error: + raise ClipboardUnavailable from error diff --git a/src/free_claude_code/cli/doctor.py b/src/free_claude_code/cli/doctor.py new file mode 100644 index 0000000000..6c09943683 --- /dev/null +++ b/src/free_claude_code/cli/doctor.py @@ -0,0 +1,25 @@ +"""Print and copy a shareable local diagnostic report.""" + +import argparse +import json +import sys +from collections.abc import Sequence + +from free_claude_code.runtime.diagnostics import collect_report + +from .clipboard import ClipboardUnavailable, copy_text + + +def main(argv: Sequence[str] | None = None) -> None: + argparse.ArgumentParser( + prog="fcc-doctor", + description="Print and copy FCC diagnostics for a bug report.", + ).parse_args(argv) + text = json.dumps(collect_report(), indent=2, ensure_ascii=False) + "\n" + sys.stdout.write(text) + try: + copy_text(text) + except ClipboardUnavailable: + print("Clipboard unavailable. Copy the JSON above manually.", file=sys.stderr) + else: + print("FCC diagnostics copied to the clipboard.", file=sys.stderr) diff --git a/src/free_claude_code/cli/entrypoints.py b/src/free_claude_code/cli/entrypoints.py index 5c6567e63a..17854b9dcc 100644 --- a/src/free_claude_code/cli/entrypoints.py +++ b/src/free_claude_code/cli/entrypoints.py @@ -23,3 +23,12 @@ def _print_version_if_requested(argv: Sequence[str] | None) -> bool: return False print(f"free-claude-code {package_version()}") return True + + +def doctor(argv: Sequence[str] | None = None) -> None: + """Print and copy a local diagnostic report (registered as ``fcc-doctor``).""" + if _print_version_if_requested(argv): + return + from free_claude_code.cli.doctor import main + + main(argv) diff --git a/src/free_claude_code/providers/openai_codex/auth.py b/src/free_claude_code/providers/openai_codex/auth.py index bf46148ed9..8173287958 100644 --- a/src/free_claude_code/providers/openai_codex/auth.py +++ b/src/free_claude_code/providers/openai_codex/auth.py @@ -130,6 +130,30 @@ def as_json(self) -> dict[str, Any]: } +def _read_saved_credentials(path: Path) -> _Credentials | None: + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or payload.get("version") != 1: + raise ValueError("credential document has an unsupported schema") + return _Credentials.from_json(payload.get("credentials")) + except (OSError, json.JSONDecodeError, OpenAILoginError, ValueError) as exc: + raise ValueError("OpenAI credential file is invalid") from exc + + +def saved_connection_state() -> str: + """Inspect saved credentials without creating a client or refreshing tokens.""" + try: + return ( + "connected" + if _read_saved_credentials(openai_auth_path()) + else "disconnected" + ) + except OSError, ValueError: + return "unavailable" + + class OpenAIAuthManager: """Own credentials, interactive login, refresh, and revocation.""" @@ -515,15 +539,7 @@ def _delete_credentials_unlocked(self) -> None: self._credential_path.unlink(missing_ok=True) def _read_credentials(self) -> _Credentials | None: - if not self._credential_path.is_file(): - return None - try: - payload = json.loads(self._credential_path.read_text(encoding="utf-8")) - if not isinstance(payload, dict) or payload.get("version") != 1: - raise ValueError("credential document has an unsupported schema") - return _Credentials.from_json(payload.get("credentials")) - except (OSError, json.JSONDecodeError, OpenAILoginError, ValueError) as exc: - raise ValueError("OpenAI credential file is invalid") from exc + return _read_saved_credentials(self._credential_path) def _detach_login_locked( self, diff --git a/src/free_claude_code/runtime/diagnostics.py b/src/free_claude_code/runtime/diagnostics.py new file mode 100644 index 0000000000..623078e25e --- /dev/null +++ b/src/free_claude_code/runtime/diagnostics.py @@ -0,0 +1,337 @@ +"""Shareable local diagnostics, without starting or changing FCC.""" + +import importlib +import json +import platform +import re +import shutil +import subprocess +from typing import Any +from urllib.parse import urlsplit + +from free_claude_code.config.loader import ManagedConfigStore +from free_claude_code.config.paths import ( + claude_desktop_disconnect_path, + codex_model_catalog_path, +) +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG, ProviderAuthKind +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import Settings +from free_claude_code.core.version import package_version +from free_claude_code.harnesses import ( + claude_desktop_integration, + claude_integration, + codex_integration, + jetbrains_acp_integration, +) +from free_claude_code.providers.github_copilot.auth import CopilotAuthManager +from free_claude_code.providers.openai_codex.auth import saved_connection_state +from free_claude_code.providers.runtime.config import has_provider_configuration +from free_claude_code.providers.runtime.discovery import referenced_provider_ids + +RUNTIME_FIELDS = ( + "host", + "port", + "open_admin_browser", + "proxy_auth_enabled", + "provider_rate_limit", + "provider_rate_window", + "provider_max_concurrency", + "provider_progress_timeout", + "http_read_timeout", + "http_write_timeout", + "http_connect_timeout", + "fast_prefix_detection", + "enable_network_probe_mock", + "enable_title_generation_skip", + "enable_suggestion_mode_skip", + "enable_filepath_extraction_mock", + "log_level", + "log_raw_api_payloads", + "log_raw_sse_events", + "log_api_error_tracebacks", + "log_raw_messaging_content", + "log_raw_cli_diagnostics", + "log_messaging_error_details", + "debug_platform_edits", + "debug_subagent_stack", + "voice_note_enabled", + "whisper_device", + "whisper_model", + "messaging_platform", + "messaging_rate_limit", + "messaging_rate_window", + "max_message_log_entries_per_chat", +) +ROUTING_FIELDS = ( + "model", + "model_fable", + "model_opus", + "model_sonnet", + "model_haiku", + "model_fallbacks", +) +REASONING_FIELDS = ( + "reasoning_policy", + "reasoning_fable", + "reasoning_opus", + "reasoning_sonnet", + "reasoning_haiku", +) +WEB_FIELDS = ( + "enable_web_server_tools", + "web_fetch_allowed_schemes", + "web_fetch_allow_private_networks", +) +NIM_FIELDS = ( + "temperature", + "top_p", + "top_k", + "max_tokens", + "presence_penalty", + "frequency_penalty", + "min_p", + "repetition_penalty", + "seed", + "parallel_tool_calls", + "ignore_eos", + "min_tokens", +) +EXCLUDED_FIELDS = { + "proxy_auth_token", + "telegram_bot_token", + "allowed_telegram_user_id", + "discord_bot_token", + "allowed_discord_channels", + "allowed_dir", + "cloudflare_account_id", + "vertex_project_id", +} | {d.credential_attr for d in PROVIDER_CATALOG.values() if d.credential_attr} +URL_FIELDS = { + attr + for d in PROVIDER_CATALOG.values() + for attr in (d.base_url_attr, d.proxy_attr) + if attr +} | {"telegram_proxy_url"} +HARNESS_NAMES = ( + "claude", + "codex", + "pi", + "opencode", + "cline", + "hermes", + "dsh", + "grok", + "muse", + "aider", +) +_VERSION = re.compile( + r"(? set[str]: + return ( + set(RUNTIME_FIELDS + ROUTING_FIELDS + REASONING_FIELDS + WEB_FIELDS) + | EXCLUDED_FIELDS + | URL_FIELDS + | {"nim", "vertex_location"} + ) + + +def _url_summary(value: str | None) -> dict[str, Any]: + origin = None + if value: + try: + parsed = urlsplit(value) + if ( + parsed.scheme in {"http", "https", "socks5", "socks5h", "socks4"} + and parsed.hostname + ): + host = parsed.hostname + if ":" in host: + host = f"[{host}]" + origin = f"{parsed.scheme}://{host}" + if parsed.port is not None: + origin += f":{parsed.port}" + except ValueError: + pass + return {"configured": bool(value), "origin": origin} + + +def settings_report(settings: Settings, accounts: dict[str, str]) -> dict[str, Any]: + referenced = referenced_provider_ids(settings) + providers = {} + for name, descriptor in sorted(PROVIDER_CATALOG.items()): + account = accounts.get(name, "unavailable") + configured = has_provider_configuration(descriptor, settings) + if descriptor.auth_kind is ProviderAuthKind.CONNECTED_ACCOUNT: + configured = account == "connected" if account != "unavailable" else None + row = {"configured": configured, "referenced": name in referenced} + if descriptor.auth_kind is ProviderAuthKind.CONNECTED_ACCOUNT: + row["saved_connection"] = account or "unavailable" + for key, attr in ( + ("base_url", descriptor.base_url_attr), + ("proxy", descriptor.proxy_attr), + ): + if attr: + row[key] = _url_summary(getattr(settings, attr)) + if name == "vertex": + row["location"] = settings.vertex_location + providers[name] = row + runtime = {name: getattr(settings, name) for name in RUNTIME_FIELDS} + runtime["telegram_proxy"] = _url_summary(settings.telegram_proxy_url) + runtime["nim"] = {name: getattr(settings.nim, name) for name in NIM_FIELDS} + return { + "providers": providers, + "runtime": runtime, + "model_routing": {name: getattr(settings, name) for name in ROUTING_FIELDS}, + "reasoning": {name: getattr(settings, name) for name in REASONING_FIELDS}, + "web_tools": {name: getattr(settings, name) for name in WEB_FIELDS}, + } + + +def integration_report(settings: Settings) -> dict[str, Any]: + url, token = local_proxy_root_url(settings), settings.proxy_auth_token + readers = { + "claude-vscode": lambda: claude_integration.configure( + claude_integration.settings_path(), + claude_integration.claude_state_path(), + url, + token, + ), + "codex": lambda: codex_integration.configure( + codex_integration.config_path(), codex_model_catalog_path(), url + ), + "claude-desktop": lambda: claude_desktop_integration.configure( + claude_desktop_integration.config_root(), + url, + token, + disconnect_path=claude_desktop_disconnect_path(), + ), + "jetbrains-acp": lambda: jetbrains_acp_integration.configure( + jetbrains_acp_integration.config_path(), url, token + ), + } + result: dict[str, Any] = {} + for name, read in readers.items(): + try: + status = read() + result[name] = {"status": "available", "connected": status["connected"]} + if "disconnect_pending" in status: + result[name]["disconnect_pending"] = status["disconnect_pending"] + except OSError, ValueError: + result[name] = {"status": "unavailable", "connected": None} + return result + + +def probe_harness(binary: str, args: tuple[str, ...]) -> dict[str, Any]: + path = shutil.which(binary) + result = {"installed": path is not None, "version": None, "status": "not_installed"} + if path is None: + return result + try: + probe = subprocess.run( + [path, *args], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + check=False, + ) + if probe.returncode != 0: + result["status"] = "failed" + return result + output = probe.stdout.strip() + if binary == "grok": + payload = json.loads(output) + output = ( + payload.get("currentVersion", "") if isinstance(payload, dict) else "" + ) + else: + output = f"{output}\n{probe.stderr}" + match = _VERSION.search(output) if isinstance(output, str) else None + result["version"] = match[1] if match else None + result["status"] = "available" if match else "unrecognized_version" + except subprocess.TimeoutExpired: + result["status"] = "timeout" + except OSError: + result["status"] = "unavailable" + except ValueError: + result["status"] = "unrecognized_version" + return result + + +def harness_report() -> dict[str, Any]: + result = {} + for name in HARNESS_NAMES: + spec = importlib.import_module(f"free_claude_code.cli.launchers.{name}").SPEC + args = ( + tuple(spec.compatibility_check.args) + if spec.compatibility_check + else ("--version",) + ) + result[name] = probe_harness(spec.binary_name, args) + return result + + +def collect_report() -> dict[str, Any]: + report = { + "version": package_version(), + "system": { + "os": platform.system(), + "release": platform.release(), + "architecture": platform.machine(), + "python_version": platform.python_version(), + "python_implementation": platform.python_implementation(), + }, + "configuration": { + "source": "saved_config_with_process_overrides", + "status": "available", + }, + "providers": None, + "runtime": None, + "model_routing": None, + "reasoning": None, + "web_tools": None, + "integrations": None, + "harnesses": {}, + "errors": [], + } + store = ManagedConfigStore() + try: + if not store.path.is_file(): + raise FileNotFoundError + settings = store.read().settings + except FileNotFoundError: + report["configuration"]["status"] = "missing" + except ValueError: + report["configuration"]["status"] = "invalid" + except OSError: + report["configuration"]["status"] = "unavailable" + else: + accounts = {"openai": saved_connection_state()} + copilot = CopilotAuthManager().status().state.value + accounts["github_copilot"] = ( + copilot if copilot in {"connected", "disconnected"} else "unavailable" + ) + report.update(settings_report(settings, accounts)) + report["integrations"] = integration_report(settings) + for name, state in accounts.items(): + if state not in {"connected", "disconnected"}: + report["errors"].append( + {"section": f"providers.{name}", "code": "unavailable"} + ) + for name, status in report["integrations"].items(): + if status["status"] != "available": + report["errors"].append( + {"section": f"integrations.{name}", "code": "unavailable"} + ) + if report["configuration"]["status"] != "available": + report["errors"].append( + {"section": "configuration", "code": report["configuration"]["status"]} + ) + report["harnesses"] = harness_report() + return report diff --git a/tests/cli/test_doctor.py b/tests/cli/test_doctor.py new file mode 100644 index 0000000000..b9ac54c5f9 --- /dev/null +++ b/tests/cli/test_doctor.py @@ -0,0 +1,267 @@ +import json +import os +import subprocess +import sys + +import pytest + +from free_claude_code.cli import doctor +from free_claude_code.config import paths +from free_claude_code.config.loader import ManagedConfigStore +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import Settings +from free_claude_code.providers.openai_codex import auth as openai_auth +from free_claude_code.runtime import diagnostics + + +def test_missing_configuration_still_produces_report(monkeypatch): + monkeypatch.setattr(diagnostics, "harness_report", lambda: {}) + report = diagnostics.collect_report() + assert next(iter(report)) == "version" + assert report["configuration"]["status"] == "missing" + assert report["runtime"] is None + assert report["providers"] is None + assert report["system"]["python_version"] + + +def test_settings_projection_excludes_secrets_and_url_credentials(): + settings = Settings( + OPENAI_API_KEY="secret-api-key", + ANTHROPIC_AUTH_TOKEN="secret-proxy-token", + TELEGRAM_BOT_TOKEN="secret-bot-token", + ALLOWED_TELEGRAM_USER_ID="secret-user-id", + LM_STUDIO_BASE_URL="http://user:secret-password@localhost:1234/private?key=secret-query", + MODEL="openai_api/gpt-example", + ) + result = diagnostics.settings_report(settings, {}) + encoded = json.dumps(result) + assert "secret-" not in encoded + assert "/private" not in encoded + assert result["providers"]["openai_api"]["configured"] is True + assert result["providers"]["openai_api"]["referenced"] is True + assert ( + result["providers"]["lmstudio"]["base_url"]["origin"] == "http://localhost:1234" + ) + assert result["model_routing"]["model"] == "openai_api/gpt-example" + + +def test_all_settings_have_an_explicit_reporting_policy(): + assert diagnostics.classified_settings() == set(Settings.model_fields) + + +def test_collection_does_not_initialize_or_expose_validation_input(monkeypatch): + store = ManagedConfigStore() + store.path.parent.mkdir(parents=True) + store.path.write_text("FCC_CONFIG_SCHEMA=invalid\nOPENAI_API_KEY=secret-value\n") + + def forbidden(*args, **kwargs): + pytest.fail("doctor must not initialize configuration") + + monkeypatch.setattr(ManagedConfigStore, "initialize", forbidden) + monkeypatch.setattr(diagnostics, "harness_report", lambda: {}) + report = diagnostics.collect_report() + assert report["configuration"]["status"] == "invalid" + assert "secret-value" not in json.dumps(report) + + +def test_complete_report_reads_real_files_without_mutation( + monkeypatch, tmp_path, capsys +): + store = ManagedConfigStore() + store.initialize({}) + store.commit( + dict(store.read({}).managed) + | {"MODEL": "openai_api/saved", "ANTHROPIC_AUTH_TOKEN": "managed-secret"} + ) + monkeypatch.setenv("MODEL", "openai_api/process") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "ignored-secret") + settings = store.read().settings + assert settings.proxy_auth_token == "managed-secret" + claude = diagnostics.claude_integration + claude.configure( + claude.settings_path(), + claude.claude_state_path(), + local_proxy_root_url(settings), + settings.proxy_auth_token, + connected=True, + ) + copilot_path = paths.github_copilot_auth_path() + copilot_path.parent.mkdir(parents=True) + copilot_path.write_text( + json.dumps( + { + "schema_version": 1, + "enabled": True, + "revision": 1, + "identity": {"host": "github.com", "login": "private-account"}, + } + ) + ) + before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + + def forbidden(*args, **kwargs): + pytest.fail("diagnostics must not initialize config or construct HTTP clients") + + monkeypatch.setattr(ManagedConfigStore, "initialize", forbidden) + monkeypatch.setattr(openai_auth.httpx, "AsyncClient", forbidden) + monkeypatch.setattr(diagnostics.shutil, "which", lambda binary: None) + copied = [] + monkeypatch.setattr(doctor, "copy_text", copied.append) + doctor.main([]) + stdout = capsys.readouterr().out + report = json.loads(stdout) + assert copied == [stdout] + assert report["model_routing"]["model"] == "openai_api/process" + assert report["integrations"]["claude-vscode"]["connected"] is True + assert report["providers"]["github_copilot"]["saved_connection"] == "connected" + assert report["errors"] == [] + assert all(not item["installed"] for item in report["harnesses"].values()) + for private in ( + "managed-secret", + "ignored-secret", + "private-account", + str(tmp_path), + ): + assert private not in stdout + assert before == {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + + +def test_every_credential_and_private_identifier_is_excluded(): + values = dict.fromkeys(diagnostics.EXCLUDED_FIELDS, "private-canary") + encoded = json.dumps( + diagnostics.settings_report(Settings.model_validate(values), {}) + ) + assert "private-canary" not in encoded + + +def test_one_unreadable_integration_does_not_hide_other_sections(monkeypatch): + store = ManagedConfigStore() + store.initialize({}) + codex_path = diagnostics.codex_integration.config_path() + codex_path.parent.mkdir(parents=True) + codex_path.write_text("not valid toml private-content") + monkeypatch.setattr(diagnostics.shutil, "which", lambda binary: None) + report = diagnostics.collect_report() + assert report["runtime"] is not None + assert report["integrations"]["codex"]["connected"] is None + assert report["integrations"]["claude-vscode"]["status"] == "available" + assert "private-content" not in json.dumps(report) + + +@pytest.mark.parametrize( + "banner, redirect", [("", ""), ("echo private-banner\n", ""), ("", " >&2")] +) +def test_native_version_probe_works_with_platform_command_shim( + monkeypatch, tmp_path, banner, redirect +): + suffix = ".cmd" if sys.platform == "win32" else "" + binary = tmp_path / ("doctor-test-harness" + suffix) + binary.write_text( + ("@echo off\n" if suffix else "#!/bin/sh\n") + + banner + + f"echo 1.2.3{redirect}\n" + ) + binary.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + assert diagnostics.probe_harness("doctor-test-harness", ("--version",)) == { + "installed": True, + "version": "1.2.3", + "status": "available", + } + + +def test_command_prints_and_copies_identical_json(monkeypatch, capsys): + copied = [] + monkeypatch.setattr( + doctor, "collect_report", lambda: {"version": "1.2.3", "errors": []} + ) + monkeypatch.setattr(doctor, "copy_text", copied.append) + doctor.main([]) + captured = capsys.readouterr() + assert copied == [captured.out] + assert captured.out.startswith('{\n "version": "1.2.3"') + assert json.loads(captured.out)["errors"] == [] + + +def test_clipboard_failure_leaves_valid_stdout(monkeypatch, capsys): + monkeypatch.setattr(doctor, "collect_report", lambda: {"version": "1.2.3"}) + + def unavailable(text): + raise doctor.ClipboardUnavailable + + monkeypatch.setattr(doctor, "copy_text", unavailable) + doctor.main([]) + captured = capsys.readouterr() + assert json.loads(captured.out) == {"version": "1.2.3"} + assert "manually" in captured.err + + +@pytest.mark.parametrize( + "failure, status", + [ + (subprocess.TimeoutExpired("stub", 5), "timeout"), + (OSError("secret-path"), "unavailable"), + ], +) +def test_version_probe_failure_is_bounded_and_sanitized(monkeypatch, failure, status): + monkeypatch.setattr(diagnostics.shutil, "which", lambda binary: "stub") + + def run(args, **kwargs): + assert kwargs["timeout"] == 5 + assert kwargs["stdin"] == subprocess.DEVNULL + raise failure + + monkeypatch.setattr(diagnostics.subprocess, "run", run) + result = diagnostics.probe_harness("claude", ("--version",)) + assert result == {"installed": True, "version": None, "status": status} + + +@pytest.mark.parametrize( + "name, output, stderr, expected", + [ + ("claude", "2.1.7 (Claude Code)", "secret-error", "2.1.7"), + ("dsh", "0.1.0-rc.8", "secret-error", "0.1.0-rc.8"), + ("dsh", "0.1.0-rc.8+build.1", "secret-error", "0.1.0-rc.8+build.1"), + ( + "grok", + '{"currentVersion":"1.0.5","path":"secret-path"}', + "secret-warning 9.8.7", + "1.0.5", + ), + ("grok", '{"path":"secret-path"}', "secret-warning 9.8.7", None), + ("claude", "unknown secret-output", "secret-error", None), + ], +) +def test_probe_returns_only_version(monkeypatch, name, output, stderr, expected): + monkeypatch.setattr(diagnostics.shutil, "which", lambda binary: "stub") + monkeypatch.setattr( + diagnostics.subprocess, + "run", + lambda args, **kwargs: subprocess.CompletedProcess(args, 0, output, stderr), + ) + result = diagnostics.probe_harness(name, ("--version",)) + assert result["version"] == expected + assert "secret-" not in json.dumps(result) + + +def test_clipboard_adapter_maps_backend_failure(monkeypatch): + from free_claude_code.cli import clipboard + + def unavailable(text): + raise clipboard.pyperclip.PyperclipException("no desktop") + + monkeypatch.setattr(clipboard.pyperclip, "copy", unavailable) + with pytest.raises(clipboard.ClipboardUnavailable): + clipboard.copy_text("diagnostic text") + + +def test_doctor_version_does_not_collect_or_copy(monkeypatch, capsys): + from free_claude_code.cli import entrypoints + + def forbidden(): + pytest.fail("version must not collect or copy a report") + + monkeypatch.setattr(doctor, "collect_report", forbidden) + monkeypatch.setattr(entrypoints, "package_version", lambda: "1.2.3") + entrypoints.doctor(["--version"]) + assert capsys.readouterr().out == "free-claude-code 1.2.3\n" diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index 1b38fe8495..20d7ab4354 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -39,6 +39,7 @@ def test_cli_scripts_are_registered() -> None: assert pyproject["project"]["scripts"] == { "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", "fcc-codex": "free_claude_code.cli.launchers.codex:launch", "fcc-pi": "free_claude_code.cli.launchers.pi:launch", diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py index 341a2c8108..a701352a3e 100644 --- a/tests/contracts/test_import_boundaries.py +++ b/tests/contracts/test_import_boundaries.py @@ -32,6 +32,13 @@ } IMPORT_EXCEPTIONS: dict[tuple[str, str], str] = { + ( + "free_claude_code.cli.doctor", + "free_claude_code.runtime.diagnostics", + ): ( + "Owner: installed diagnostic command. " + "Reason: read-only report composition owns provider and harness inspection." + ), ( "free_claude_code.cli.commands", "free_claude_code.runtime.bootstrap", diff --git a/tests/contracts/test_issue_form_version_validation.py b/tests/contracts/test_issue_form_version_validation.py index 2e797653cf..d0160e8de2 100644 --- a/tests/contracts/test_issue_form_version_validation.py +++ b/tests/contracts/test_issue_form_version_validation.py @@ -7,6 +7,7 @@ from typing import Any import pytest +import yaml BUG_FORM = Path(".github/ISSUE_TEMPLATE/bug-report.yml") WORKFLOW = Path(".github/workflows/validate-bug-report-version.yml") @@ -56,14 +57,61 @@ def _run_javascript(script: str) -> Any: return json.loads(completed.stdout) -def test_bug_form_requests_a_contained_version_or_none() -> None: - form = BUG_FORM.read_text(encoding="utf-8") +def test_bug_forms_require_description_and_post_install_diagnostics() -> None: + post = yaml.safe_load(BUG_FORM.read_text(encoding="utf-8")) + pre = yaml.safe_load( + BUG_FORM.with_name("installation-problem.yml").read_text(encoding="utf-8") + ) + assert len(post["body"]) == 2 + assert all(field["validations"]["required"] for field in post["body"]) + assert all(field["type"] == "textarea" for field in post["body"]) + assert post["body"][1]["attributes"]["render"] == "json" + assert "fcc-doctor" in post["body"][1]["attributes"]["description"] + assert len(pre["body"]) == 1 + assert pre["body"][0]["validations"]["required"] + assert "render" not in pre["body"][0]["attributes"] - assert "Run `fcc-server --version`" in form - assert "include one version" in form - assert "enter `None`" in form - assert 'placeholder: "The version is 1.22.333, or None"' in form - assert "not installed" not in form + +@pytest.mark.parametrize( + "body, valid, version", + [ + ('### FCC doctor output\n\n```json\n{"version":"1.2.3"}\n```', True, "1.2.3"), + ( + '### FCC doctor output\r\n\r\n```json\r\n{"version":"1.2.3"}\r\n```', + True, + "1.2.3", + ), + ('### FCC doctor output\n\n{"version":"0+unknown"}', True, None), + ( + '### FCC doctor output\n\n{"version":"6.2.67.dev1+g123"}', + True, + "6.2.67.dev1+g123", + ), + ( + '### Describe the issue\n\nversion 9.9.9\n\n### FCC doctor output\n\n{"version":"1.2.3"}', + True, + "1.2.3", + ), + ("### FCC doctor output\n\n{invalid}", False, None), + ('### FCC doctor output\n\n{"version":123}', False, None), + ('### FCC doctor output\n\n{"version":"hello 1.2.3"}', False, None), + ("### FCC doctor output\n\n{}\n\n### FCC version\n\n1.2.3", False, None), + ("### Installation issue\n\nCannot install", True, None), + ("unstructured issue 1.2.3", False, None), + ], +) +def test_report_parser_uses_the_correct_form_section(body, valid, version): + patterns = "\n".join( + f"const {name} = {json.dumps(_workflow_pattern(name))};" + for name in ("fieldPattern", "versionPattern") + ) + script = patterns + "\n" + _javascript_function("parseReport") + script += ( + f"\nprocess.stdout.write(JSON.stringify(parseReport({json.dumps(body)})));" + ) + result = _run_javascript(script) + assert result["valid"] is valid + assert result.get("reportedVersion") == version @pytest.mark.parametrize( @@ -180,7 +228,8 @@ def test_release_tags_are_exact_stable_versions(tag, expected): assert (match[1] if match else None) == expected -def test_outdated_version_comment_is_reconciled_across_edits() -> None: +@pytest.mark.parametrize("form", ["legacy", "doctor"]) +def test_outdated_version_comment_is_reconciled_across_edits(form) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") latest = "17.23.456" source = f"return (async () => {{\n{_workflow_script()}\n}})();" @@ -245,14 +294,14 @@ def test_outdated_version_comment_is_reconciled_across_edits() -> None: }, }, }; -const bodyFor = (value) => `### FCC version\n\n${value}\n\n### CLI\n\nClaude Code`; +const bodyFor = __BODY_FOR__; liveIssue.body = bodyFor("latest"); await run(github, context); -liveIssue.body = bodyFor("The version is 17.23.454"); +liveIssue.body = bodyFor("17.23.454"); await run(github, context); await run(github, context); -liveIssue.body = bodyFor("free-claude-code 17.23.455"); +liveIssue.body = bodyFor("17.23.455"); await run(github, context); liveIssue.body = bodyFor(latestVersion); await run(github, context); @@ -268,8 +317,15 @@ def test_outdated_version_comment_is_reconciled_across_edits() -> None: process.stdout.write(JSON.stringify({ calls, comments })); """ result = _run_javascript( - harness.replace("__SOURCE__", json.dumps(source)).replace( - "__LATEST__", json.dumps(latest) + harness.replace("__SOURCE__", json.dumps(source)) + .replace("__LATEST__", json.dumps(latest)) + .replace( + "__BODY_FOR__", + ( + "(value) => `### FCC version\\n\\n${value}\\n\\n### CLI\\n\\nClaude Code`" + if form == "legacy" + else '(value) => value === "None" ? "### Installation issue\\n\\nCannot install" : `### FCC doctor output\\n\\n${JSON.stringify({version: value})}`' + ), ) ) calls = result["calls"] diff --git a/tests/installers/test_installers.py b/tests/installers/test_installers.py index 9f0938df2b..042daa5eb9 100644 --- a/tests/installers/test_installers.py +++ b/tests/installers/test_installers.py @@ -26,6 +26,7 @@ "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-doctor", "fcc-update", "fcc-init", "free-claude-code", @@ -191,6 +192,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-doctor" 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" @@ -2107,6 +2109,7 @@ 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-doctor.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-update.cmd" >nul if not "%FAIL_STEP%"=="fcc-missing" copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%UV_BIN_DIR%\fcc-codex.cmd" >nul exit /b 0 diff --git a/tests/installers/test_uninstallers.py b/tests/installers/test_uninstallers.py index 8de3b0c892..81a1432a09 100644 --- a/tests/installers/test_uninstallers.py +++ b/tests/installers/test_uninstallers.py @@ -19,6 +19,7 @@ "fcc-grok", "fcc-muse", "fcc-aider", + "fcc-doctor", "fcc-update", "fcc-init", "free-claude-code", @@ -192,7 +193,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-update 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-doctor fcc-update fcc-init free-claude-code; do /bin/rm -f "$FAKE_TOOL_BIN/$name" done echo "Uninstalled free-claude-code" diff --git a/tests/providers/test_openai_codex_auth.py b/tests/providers/test_openai_codex_auth.py index 63d26a4850..2d31e20839 100644 --- a/tests/providers/test_openai_codex_auth.py +++ b/tests/providers/test_openai_codex_auth.py @@ -53,6 +53,26 @@ def _credential_document(*, expires_at: int) -> dict[str, object]: } +def test_saved_connection_inspection_has_no_client_or_write_side_effects( + monkeypatch, tmp_path +): + path = tmp_path / "openai.json" + monkeypatch.setattr(openai_auth, "openai_auth_path", lambda: path) + + def forbidden(*args, **kwargs): + pytest.fail("saved inspection must not construct an HTTP client") + + monkeypatch.setattr(openai_auth.httpx, "AsyncClient", forbidden) + assert openai_auth.saved_connection_state() == "disconnected" + assert not path.exists() + path.write_text(json.dumps(_credential_document(expires_at=1)), encoding="utf-8") + before = path.read_bytes() + assert openai_auth.saved_connection_state() == "connected" + assert path.read_bytes() == before + path.write_text('{"private":"not credentials"}', encoding="utf-8") + assert openai_auth.saved_connection_state() == "unavailable" + + @pytest.mark.asyncio async def test_auth_manager_refreshes_atomically_without_exposing_tokens( tmp_path: Path, diff --git a/uv.lock b/uv.lock index 56789f0e54..57d1cd296b 100644 --- a/uv.lock +++ b/uv.lock @@ -619,6 +619,7 @@ dependencies = [ { name = "openai" }, { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'win32'" }, { name = "pydantic" }, + { name = "pyperclip" }, { name = "pystray", marker = "sys_platform == 'darwin' or sys_platform == 'win32'" }, { name = "python-dotenv" }, { name = "python-telegram-bot" }, @@ -672,6 +673,7 @@ requires-dist = [ { name = "openai", specifier = ">=3.18.0" }, { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'win32'", specifier = ">=12.0.0" }, { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pyperclip", specifier = ">=1.11.0" }, { name = "pystray", marker = "sys_platform == 'darwin' or sys_platform == 'win32'", specifier = ">=0.19.5" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-telegram-bot", specifier = ">=22.8" }, @@ -1873,6 +1875,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pysocks" version = "1.7.1" From ba4b934147855025df30bbca3ad6d436158fe671 Mon Sep 17 00:00:00 2001 From: Ali Khokhar <20476625+Alishahryar1@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:08:30 -0700 Subject: [PATCH 2/2] Move installer checks to the opt-in smoke suite (#1908) ## Why Installer subprocess checks take substantial time in regular CI. Make them an opt-in local suite. ## How Move the complete installer test folder, including fixtures, helpers, and rules, from `tests/installers/` to `smoke/installers/`. Document the explicit command to run it and update its rules for local-only execution. Regular pytest continues to collect `tests/`, so these checks no longer run in CI. --- smoke/README.md | 13 +++++++++++-- {tests => smoke}/installers/AGENTS.md | 6 +++--- {tests => smoke}/installers/_pty_runner.py | 0 {tests => smoke}/installers/conftest.py | 0 {tests => smoke}/installers/fixtures/.gitattributes | 0 {tests => smoke}/installers/fixtures/README.md | 0 {tests => smoke}/installers/fixtures/rtk-LICENSE | 0 .../installers/fixtures/rtk-opencode-v1.ts | 0 {tests => smoke}/installers/test_installers.py | 0 .../installers/test_muse_windows_lifecycle.py | 0 .../installers/test_powershell_cache.py | 0 {tests => smoke}/installers/test_uninstallers.py | 0 12 files changed, 14 insertions(+), 5 deletions(-) rename {tests => smoke}/installers/AGENTS.md (88%) rename {tests => smoke}/installers/_pty_runner.py (100%) rename {tests => smoke}/installers/conftest.py (100%) rename {tests => smoke}/installers/fixtures/.gitattributes (100%) rename {tests => smoke}/installers/fixtures/README.md (100%) rename {tests => smoke}/installers/fixtures/rtk-LICENSE (100%) rename {tests => smoke}/installers/fixtures/rtk-opencode-v1.ts (100%) rename {tests => smoke}/installers/test_installers.py (100%) rename {tests => smoke}/installers/test_muse_windows_lifecycle.py (100%) rename {tests => smoke}/installers/test_powershell_cache.py (100%) rename {tests => smoke}/installers/test_uninstallers.py (100%) diff --git a/smoke/README.md b/smoke/README.md index d6c7c28579..ceb243127b 100644 --- a/smoke/README.md +++ b/smoke/README.md @@ -1,11 +1,13 @@ # Product E2E Smoke Tests `smoke/` is local-only. It can launch subprocesses, call real providers, touch -local model servers, and optionally send/delete bot messages. Hermetic contracts -belong under `tests/` and must stay green with plain `uv run pytest`. +local model servers, and optionally send/delete bot messages. Installer checks +also live here as an opt-in suite. Regular CI collects only `tests/` and `e2e/`. ## Taxonomy +- `smoke/installers/`: installer, uninstaller, and harness lifecycle checks with + isolated files and simulated downloads. These do not install real tools. - `smoke/prereq/`: liveness checks that prove the server, routes, auth, CLI scripts, provider pings, local `/models`, and bot permissions are reachable. These are prerequisites only. @@ -24,6 +26,13 @@ uv run pytest smoke -n 0 -s --tb=short The second command skips everything unless `FCC_LIVE_SMOKE=1` is set, but still writes skip entries to `.smoke-results/`. +Run only the installer checks: + +```powershell +$env:FCC_LIVE_SMOKE = "1" +uv run pytest smoke/installers +``` + ## Product Smoke Run ```powershell diff --git a/tests/installers/AGENTS.md b/smoke/installers/AGENTS.md similarity index 88% rename from tests/installers/AGENTS.md rename to smoke/installers/AGENTS.md index 55bf8e7f76..61c0f8e127 100644 --- a/tests/installers/AGENTS.md +++ b/smoke/installers/AGENTS.md @@ -9,7 +9,7 @@ These rules apply to installer, uninstaller, and harness lifecycle tests here. and abort ordering between installation phases. Preserve those assertions when making tests faster. Prepare unrelated prerequisites as already installed. - Keep coverage for both Windows PowerShell and PowerShell 7, plus the applicable - POSIX paths. Do not skip coverage or move it out of CI to hide slow tests. + POSIX paths. This suite is opt-in and does not run in regular CI. - Stub downloads and external installers. Never install real harnesses, use live providers, or modify the user's installed tools and configuration in these tests. - Give every scenario private files, configuration, PATH, and cache storage. Use @@ -22,7 +22,7 @@ These rules apply to installer, uninstaller, and harness lifecycle tests here. or user configuration to save startup time. Clean up processes you launch. - Diagnose slowness with per-test durations and timings inside subprocesses. Do not increase timeouts or add sleeps without identifying the operation being delayed. -- Use `scripts/ci.ps1` for local verification. `PYTEST_ADDOPTS` can select focused - cases and enable duration reporting. Run the full relevant suite before pushing. +- Run these checks explicitly with `FCC_LIVE_SMOKE=1` and + `uv run pytest smoke/installers`. Select focused cases when checking a component. - Reuse pytest's capture handler for logs. Do not create a logging cycle between Python logging and Loguru while adding diagnostics. diff --git a/tests/installers/_pty_runner.py b/smoke/installers/_pty_runner.py similarity index 100% rename from tests/installers/_pty_runner.py rename to smoke/installers/_pty_runner.py diff --git a/tests/installers/conftest.py b/smoke/installers/conftest.py similarity index 100% rename from tests/installers/conftest.py rename to smoke/installers/conftest.py diff --git a/tests/installers/fixtures/.gitattributes b/smoke/installers/fixtures/.gitattributes similarity index 100% rename from tests/installers/fixtures/.gitattributes rename to smoke/installers/fixtures/.gitattributes diff --git a/tests/installers/fixtures/README.md b/smoke/installers/fixtures/README.md similarity index 100% rename from tests/installers/fixtures/README.md rename to smoke/installers/fixtures/README.md diff --git a/tests/installers/fixtures/rtk-LICENSE b/smoke/installers/fixtures/rtk-LICENSE similarity index 100% rename from tests/installers/fixtures/rtk-LICENSE rename to smoke/installers/fixtures/rtk-LICENSE diff --git a/tests/installers/fixtures/rtk-opencode-v1.ts b/smoke/installers/fixtures/rtk-opencode-v1.ts similarity index 100% rename from tests/installers/fixtures/rtk-opencode-v1.ts rename to smoke/installers/fixtures/rtk-opencode-v1.ts diff --git a/tests/installers/test_installers.py b/smoke/installers/test_installers.py similarity index 100% rename from tests/installers/test_installers.py rename to smoke/installers/test_installers.py diff --git a/tests/installers/test_muse_windows_lifecycle.py b/smoke/installers/test_muse_windows_lifecycle.py similarity index 100% rename from tests/installers/test_muse_windows_lifecycle.py rename to smoke/installers/test_muse_windows_lifecycle.py diff --git a/tests/installers/test_powershell_cache.py b/smoke/installers/test_powershell_cache.py similarity index 100% rename from tests/installers/test_powershell_cache.py rename to smoke/installers/test_powershell_cache.py diff --git a/tests/installers/test_uninstallers.py b/smoke/installers/test_uninstallers.py similarity index 100% rename from tests/installers/test_uninstallers.py rename to smoke/installers/test_uninstallers.py