diff --git a/README.md b/README.md index 2ed0dd09..b633a164 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ## What You Get - **50 ToS-friendly providers. 1.3B+ free tokens every month.** Use free, paid, subscription, and local models from one searchable UI without putting your account at risk. FCC follows provider terms and removes integrations if they stop being allowed. -- **9 coding agents. One model catalog.** Run [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://github.com/openai/codex), [Pi](https://github.com/earendil-works/pi), [OpenCode](https://github.com/anomalyco/opencode), [Cline](https://github.com/cline/cline), [Hermes](https://github.com/NousResearch/hermes-agent), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Grok Build](https://github.com/xai-org/grok-build), or [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2/) with your FCC models. +- **10 coding agents. One model catalog.** Run [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://github.com/openai/codex), [Pi](https://github.com/earendil-works/pi), [OpenCode](https://github.com/anomalyco/opencode), [Cline](https://github.com/cline/cline), [Hermes](https://github.com/NousResearch/hermes-agent), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [Grok Build](https://github.com/xai-org/grok-build), [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2/), or [Aider](https://aider.chat/) with your FCC models. - **Keep coding through provider outages.** After retries are exhausted, FCC automatically tries your next configured model without making you restart the turn—across every client. - **Up to 90% fewer terminal-output tokens.** Optional [RTK](https://github.com/rtk-ai/rtk) filters common command output, while five FCC optimizations handle quota probes, command-prefix detection, titles, suggestions, and filepaths without calling a provider. - **Terminal, desktop, IDE, or phone.** Work through native launchers, [VS Code](https://code.visualstudio.com/), [Codex App](https://learn.chatgpt.com/docs/app), [JetBrains](https://www.jetbrains.com/), [Discord](https://discord.com/), or [Telegram](https://telegram.org/). @@ -155,6 +155,15 @@ Muse Code: fcc-muse ``` +Aider: + +```bash +fcc-aider +``` + +`fcc-aider` exposes FCC models as `anthropic//`. Use ordinary +`aider` when you want Aider's native providers instead. +
@@ -305,7 +314,7 @@ Open **Admin UI → Model Config → Reasoning** and select the behavior you wan | Selection | Behavior | | --- | --- | -| **From client** (default) | Use the effort sent by Claude Code, Codex, Pi, OpenCode, Cline, Hermes, DeepSeek Harness, Grok Build, or Muse Code. If none is sent, keep the provider default. | +| **From client** (default) | Use the effort sent by Claude Code, Codex, Pi, OpenCode, Cline, Hermes, DeepSeek Harness, Grok Build, Muse Code, or Aider. If none is sent, keep the provider default. | | **Off** | Request reasoning to be disabled. | | **Low**, **Medium**, **High**, **X-High**, or **Max** | Override the client with the selected reasoning level. | | **Inherit** (Fable, Opus, Sonnet, and Haiku only) | Use the root Reasoning selection. | @@ -319,8 +328,8 @@ Providers that do not support a selected control retain their own behavior. ## Connect Your Client For terminal use, start `fcc-server`, then run `fcc-claude`, `fcc-codex`, -`fcc-pi`, `fcc-opencode`, `fcc-cline`, `fcc-hermes`, `fcc-dsh`, `fcc-grok`, or -`fcc-muse`. +`fcc-pi`, `fcc-opencode`, `fcc-cline`, `fcc-hermes`, `fcc-dsh`, `fcc-grok`, +`fcc-muse`, or `fcc-aider`. Use the guides below for editor integrations.
@@ -588,7 +597,7 @@ Stop every running FCC command before uninstalling. **Keeps** - uv and Python -- Claude Code, Codex, Pi, OpenCode, Cline, Hermes, DeepSeek Harness, Grok Build, Muse Code, and RTK +- Claude Code, Codex, Pi, OpenCode, Cline, Hermes, DeepSeek Harness, Grok Build, Muse Code, Aider, and RTK - Shared PATH entries macOS/Linux: diff --git a/pyproject.toml b/pyproject.toml index 488a07ef..c0008ebe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "5.14.10" +version = "5.15.0" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" @@ -40,6 +40,7 @@ fcc-hermes = "free_claude_code.cli.launchers.hermes:launch" fcc-dsh = "free_claude_code.cli.launchers.dsh:launch" fcc-grok = "free_claude_code.cli.launchers.grok:launch" fcc-muse = "free_claude_code.cli.launchers.muse:launch" +fcc-aider = "free_claude_code.cli.launchers.aider:launch" [project.gui-scripts] fcc-desktop = "free_claude_code.cli.desktop_entrypoint:launch" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 845c02ae..2d0bc90c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -26,6 +26,7 @@ $HermesInstallUrl = "https://hermes-agent.nousresearch.com/install.ps1" $DshVersion = "0.1.0-rc.8" $DshPackage = "@deepseek-ai/dsh@$DshVersion" $GrokInstallUrl = "https://x.ai/cli/install.ps1" +$AiderInstallUrl = "https://aider.chat/install.ps1" $RtkVersion = "0.44.2" $RtkReleaseBaseUrl = "https://github.com/rtk-ai/rtk/releases/download/v$RtkVersion" $RtkWindowsAssetName = "rtk-x86_64-pc-windows-msvc.zip" @@ -40,6 +41,7 @@ $script:InstallHermes = $true $script:InstallDsh = $true $script:InstallGrok = $true $script:InstallMuse = $true +$script:InstallAider = $true $script:PiAvailable = $false $script:MuseAvailable = $false $script:EnableRtk = $Rtk.IsPresent @@ -56,6 +58,7 @@ $FccCommands = @( "fcc-dsh", "fcc-grok", "fcc-muse", + "fcc-aider", "fcc-init", "free-claude-code" ) @@ -131,8 +134,11 @@ function Select-CodingAgents { $script:InstallMuse = Read-YesNo ` -Prompt "Install or verify Muse Code for fcc-muse?" ` -DefaultYes $script:InstallMuse + $script:InstallAider = Read-YesNo ` + -Prompt "Install or verify Aider for fcc-aider?" ` + -DefaultYes $script:InstallAider - if ($script:InstallClaudeCode -or $script:InstallCodex -or $script:InstallPi -or $script:InstallOpenCode -or $script:InstallCline -or $script:InstallHermes -or $script:InstallDsh -or $script:InstallGrok -or $script:InstallMuse) { + if ($script:InstallClaudeCode -or $script:InstallCodex -or $script:InstallPi -or $script:InstallOpenCode -or $script:InstallCline -or $script:InstallHermes -or $script:InstallDsh -or $script:InstallGrok -or $script:InstallMuse -or $script:InstallAider) { break } Write-Host "Select at least one coding agent." @@ -825,6 +831,23 @@ function Ensure-Grok { Confirm-Application -CommandName "grok" -DisplayName "Grok Build" } +function Install-Aider { + Invoke-DownloadedPowerShellInstaller -Url $AiderInstallUrl -Name "Aider" + Add-KnownBinDirectories +} + +function Ensure-Aider { + $command = Get-ApplicationCommand "aider" + if ($command) { + Write-Host "Aider already found on PATH; verifying it." + } + else { + Install-Aider + } + + Confirm-Application -CommandName "aider" -DisplayName "Aider" +} + function Ensure-Muse { $script:MuseAvailable = $false $command = Get-ApplicationCommand "muse" @@ -1010,7 +1033,12 @@ function Ensure-SelectedCodingAgents { Ensure-Muse } - if ((-not $script:InstallClaudeCode) -and (-not $script:InstallCodex) -and (-not $script:PiAvailable) -and (-not $script:InstallOpenCode) -and (-not $script:InstallCline) -and (-not $script:InstallHermes) -and (-not $script:InstallDsh) -and (-not $script:InstallGrok) -and (-not $script:MuseAvailable)) { + if ($script:InstallAider) { + Write-Step "Ensuring Aider is installed" + Ensure-Aider + } + + if ((-not $script:InstallClaudeCode) -and (-not $script:InstallCodex) -and (-not $script:PiAvailable) -and (-not $script:InstallOpenCode) -and (-not $script:InstallCline) -and (-not $script:InstallHermes) -and (-not $script:InstallDsh) -and (-not $script:InstallGrok) -and (-not $script:MuseAvailable) -and (-not $script:InstallAider)) { throw "No selected coding agent was installed. Re-run the installer and choose at least one." } } @@ -1165,7 +1193,7 @@ function Configure-AndConfirmFreeClaudeCode { if ($DryRun) { Write-Host "+ uv tool update-shell" Write-Host "+ uv tool dir --bin" - Write-Host "+ verify fcc-desktop, fcc-server, fcc-claude, fcc-codex, fcc-pi, fcc-opencode, fcc-cline, fcc-hermes, fcc-dsh, fcc-grok, and fcc-muse in the uv tool bin directory" + Write-Host "+ verify fcc-desktop, fcc-server, fcc-claude, fcc-codex, fcc-pi, fcc-opencode, fcc-cline, fcc-hermes, fcc-dsh, fcc-grok, fcc-muse, and fcc-aider in the uv tool bin directory" Write-Host "+ fcc-server --version" Export-FccDesktopIcon ` -DesktopCommand "\fcc-desktop.exe" ` @@ -1192,7 +1220,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")) { + 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")) { $command = Get-ApplicationCommand $commandName if (-not $command) { throw "Free Claude Code installation did not create '$commandName'." @@ -1375,4 +1403,10 @@ else { else { Write-Host "The fcc-muse wrapper is ready after you install Muse Code." } + if ($script:InstallAider) { + Write-Host "Run Aider with: fcc-aider" + } + else { + Write-Host "The fcc-aider wrapper is ready after you install Aider." + } } diff --git a/scripts/install.sh b/scripts/install.sh index 53dc2017..cb6bd3f2 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,13 +13,14 @@ DSH_VERSION="0.1.0-rc.8" DSH_PACKAGE="@deepseek-ai/dsh@$DSH_VERSION" GROK_INSTALL_URL="https://x.ai/cli/install.sh" MUSE_INSTALL_URL="https://dev.meta.ai/install.sh" +AIDER_INSTALL_URL="https://aider.chat/install.sh" RTK_VERSION="0.44.2" RTK_RELEASE_BASE_URL="https://github.com/rtk-ai/rtk/releases/download/v$RTK_VERSION" 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-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-init free-claude-code" dry_run=0 voice_nim=0 @@ -34,6 +35,7 @@ install_hermes=1 install_dsh=1 install_grok=1 install_muse=1 +install_aider=1 enable_rtk=0 torch_backend="" temporary_file="" @@ -179,7 +181,18 @@ choose_coding_agents() { install_muse=0 fi - if [ "$install_claude" -eq 1 ] || [ "$install_codex" -eq 1 ] || [ "$install_pi" -eq 1 ] || [ "$install_opencode" -eq 1 ] || [ "$install_cline" -eq 1 ] || [ "$install_hermes" -eq 1 ] || [ "$install_dsh" -eq 1 ] || [ "$install_grok" -eq 1 ] || [ "$install_muse" -eq 1 ]; then + if [ "$install_aider" -eq 1 ]; then + aider_default=yes + else + aider_default=no + fi + if prompt_yes_no "Install or verify Aider for fcc-aider?" "$aider_default"; then + install_aider=1 + else + install_aider=0 + fi + + if [ "$install_claude" -eq 1 ] || [ "$install_codex" -eq 1 ] || [ "$install_pi" -eq 1 ] || [ "$install_opencode" -eq 1 ] || [ "$install_cline" -eq 1 ] || [ "$install_hermes" -eq 1 ] || [ "$install_dsh" -eq 1 ] || [ "$install_grok" -eq 1 ] || [ "$install_muse" -eq 1 ] || [ "$install_aider" -eq 1 ]; then break fi printf 'Select at least one coding agent.\n\n' >&4 @@ -875,6 +888,21 @@ ensure_muse() { verify_command muse "Muse Code" } +install_aider_cli() { + download_and_run "$AIDER_INSTALL_URL" bash "Aider" + add_known_bin_directories +} + +ensure_aider() { + if command -v aider >/dev/null 2>&1; then + printf 'Aider already found on PATH; verifying it.\n' + else + install_aider_cli + fi + + verify_command aider "Aider" +} + ensure_selected_coding_agents() { if [ "$install_claude" -eq 1 ]; then step "Ensuring Claude Code is installed" @@ -921,7 +949,12 @@ ensure_selected_coding_agents() { ensure_muse fi - if [ "$install_claude" -eq 0 ] && [ "$install_codex" -eq 0 ] && [ "$pi_available" -eq 0 ] && [ "$install_opencode" -eq 0 ] && [ "$install_cline" -eq 0 ] && [ "$install_hermes" -eq 0 ] && [ "$install_dsh" -eq 0 ] && [ "$install_grok" -eq 0 ] && [ "$install_muse" -eq 0 ]; then + if [ "$install_aider" -eq 1 ]; then + step "Ensuring Aider is installed" + ensure_aider + fi + + if [ "$install_claude" -eq 0 ] && [ "$install_codex" -eq 0 ] && [ "$pi_available" -eq 0 ] && [ "$install_opencode" -eq 0 ] && [ "$install_cline" -eq 0 ] && [ "$install_hermes" -eq 0 ] && [ "$install_dsh" -eq 0 ] && [ "$install_grok" -eq 0 ] && [ "$install_muse" -eq 0 ] && [ "$install_aider" -eq 0 ]; then fail "No selected coding agent was installed. Re-run the installer and choose at least one." fi } @@ -1108,7 +1141,7 @@ configure_and_verify_free_claude_code() { if [ "$dry_run" -eq 1 ]; then print_command uv tool dir --bin - printf '+ verify fcc-desktop, fcc-server, fcc-claude, fcc-codex, fcc-pi, fcc-opencode, fcc-cline, fcc-hermes, fcc-dsh, fcc-grok, and fcc-muse in the uv tool bin directory\n' + printf '+ verify fcc-desktop, fcc-server, fcc-claude, fcc-codex, fcc-pi, fcc-opencode, fcc-cline, fcc-hermes, fcc-dsh, fcc-grok, fcc-muse, and fcc-aider in the uv tool bin directory\n' print_command fcc-server --version return 0 fi @@ -1126,7 +1159,7 @@ configure_and_verify_free_claude_code() { export PATH hash -r 2>/dev/null || true - 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; 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; do [ -x "$tool_bin/$command_name" ] || fail "Free Claude Code installation did not create $tool_bin/$command_name." done @@ -1249,7 +1282,7 @@ fi step "Checking installation prerequisites" require_command curl -if [ "$install_claude" -eq 1 ] || [ "$install_opencode" -eq 1 ] || [ "$install_hermes" -eq 1 ] || [ "$install_grok" -eq 1 ] || [ "$install_muse" -eq 1 ]; then +if [ "$install_claude" -eq 1 ] || [ "$install_opencode" -eq 1 ] || [ "$install_hermes" -eq 1 ] || [ "$install_grok" -eq 1 ] || [ "$install_muse" -eq 1 ] || [ "$install_aider" -eq 1 ]; then require_command bash fi require_command sh @@ -1326,4 +1359,9 @@ else else printf 'The fcc-muse wrapper is ready after you install Muse Code.\n' fi + if [ "$install_aider" -eq 1 ]; then + printf 'Run Aider with: fcc-aider\n' + else + printf 'The fcc-aider wrapper is ready after you install Aider.\n' + fi fi diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 026f2b35..672d63f1 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -23,6 +23,7 @@ $FccCommands = @( "fcc-dsh", "fcc-grok", "fcc-muse", + "fcc-aider", "fcc-init", "free-claude-code" ) @@ -34,7 +35,7 @@ function Show-Usage { Usage: uninstall.ps1 [options] Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified. -Does not remove uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, the uv-managed Python runtime, or shared PATH entries. +Does not remove uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, Muse Code, Aider, the uv-managed Python runtime, or shared PATH entries. Options: -DryRun Print commands without running them. @@ -339,5 +340,5 @@ if ($DryRun) { } else { Write-Host "Free Claude Code has been removed and verified." - Write-Host "uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, the uv-managed Python runtime, and shared PATH entries were left installed." + Write-Host "uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, Muse Code, Aider, the uv-managed Python runtime, and shared PATH entries were left installed." } diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index de58a8d7..5ff4ec17 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-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-init free-claude-code" dry_run=0 uv_tool_bin="" @@ -16,7 +16,7 @@ show_usage() { Usage: uninstall.sh [options] Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified. -Does not remove uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, the uv-managed Python runtime, or shared PATH entries. +Does not remove uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, Muse Code, Aider, the uv-managed Python runtime, or shared PATH entries. Options: --dry-run Print commands without running them. @@ -298,5 +298,5 @@ if [ "$dry_run" -eq 1 ]; then printf '\nDry run complete. No changes were made.\n' else printf '\nFree Claude Code has been removed and verified.\n' - printf 'uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, the uv-managed Python runtime, and shared PATH entries were left installed.\n' + printf 'uv, Claude Code, Codex, Pi, OpenCode, Cline, Hermes Agent, DeepSeek Harness, Grok Build, Muse Code, Aider, the uv-managed Python runtime, and shared PATH entries were left installed.\n' fi diff --git a/smoke/README.md b/smoke/README.md index aa28070a..9639c914 100644 --- a/smoke/README.md +++ b/smoke/README.md @@ -56,7 +56,7 @@ Default targets do not send real bot messages or load voice backends: | `api` | messages, count_tokens full payload, errors, `/stop`, optimizations | configured provider only for streaming messages | | `auth` | canonical bearer auth, conflicting legacy headers, invalid/missing auth | none; test sets an isolated token | | `cli` | server entrypoint, Claude CLI adaptive thinking, automatic WebSearch, Auto-mode classifier, session cleanup | Claude CLI binary and provider only for real CLI; connected OpenAI account for Auto mode; `FCC_SMOKE_RUN_WEB_TOOLS=1` for WebSearch | -| `clients` | VS Code and JetBrains protocol payloads; Pi, OpenCode, Cline, Hermes, DeepSeek Harness, Grok Build, and Muse Code CLI prompts | configured provider; installed Pi/OpenCode/Cline binaries; Hermes, DSH, Grok, and Muse use a local fake upstream | +| `clients` | VS Code and JetBrains protocol payloads; Pi, OpenCode, Aider, Cline, Hermes, DeepSeek Harness, Grok Build, and Muse Code CLI prompts | configured provider; installed Pi/OpenCode/Aider/Cline binaries; Hermes, DSH, Grok, and Muse use a local fake upstream | | `config` | env precedence, removed-env migration, proxy/timeouts | none | | `extensibility` | provider runtime and platform factory construction | none | | `messaging` | fake Discord/Telegram full flow, literal clear scopes, trees, persistence, voice cancel | none | diff --git a/smoke/capabilities.py b/smoke/capabilities.py index 263d0f08..90983004 100644 --- a/smoke/capabilities.py +++ b/smoke/capabilities.py @@ -554,6 +554,21 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = ( ), ("test_opencode_cli_prompt_e2e",), ), + CapabilityContract( + "cli", + "aider_cli_integration", + "aider_cli_integration", + "free_claude_code.cli.launchers.aider", + "Aider 0.86.2+, live FCC Messages catalog, and private process files", + "Anthropic Messages route scoped to FCC for native Aider sessions", + "binary, proxy, route conflict, catalog, or private-file failure exits before inference", + ( + "tests/cli/test_aider_config.py", + "tests/cli/test_aider_launcher.py", + "tests/cli/test_model_catalog.py", + ), + ("test_aider_cli_prompt_e2e",), + ), CapabilityContract( "cli", "cline_cli_integration", diff --git a/smoke/features.py b/smoke/features.py index 529b7626..26d00f1a 100644 --- a/smoke/features.py +++ b/smoke/features.py @@ -122,6 +122,23 @@ FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = ( ), "skip only when OpenCode is absent; configured providers must pass", ), + FeatureCoverage( + "aider_cli_integration", + "Aider discovers FCC models and sends Anthropic Messages through the proxy", + ( + "tests/cli/test_aider_config.py", + "tests/cli/test_aider_launcher.py", + "tests/cli/test_model_catalog.py", + ), + ("test_probe_and_models_routes",), + ("test_aider_cli_prompt_e2e",), + ("clients",), + ( + "Aider 0.86.2+", + "configured provider credentials or local provider endpoint", + ), + "skip only when Aider is absent; configured providers must pass", + ), FeatureCoverage( "cline_cli_integration", "Cline discovers FCC models and sends Responses through the proxy", diff --git a/smoke/product/test_client_product_live.py b/smoke/product/test_client_product_live.py index 3e7f0e4a..5a2d523e 100644 --- a/smoke/product/test_client_product_live.py +++ b/smoke/product/test_client_product_live.py @@ -211,6 +211,75 @@ def test_opencode_cli_prompt_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> N assert "POST /v1/chat/completions" not in server_log +@pytest.mark.smoke_target("clients") +def test_aider_cli_prompt_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not shutil.which("aider"): + pytest.skip("missing_env: Aider CLI not found") + uv_bin = shutil.which("uv") + if not uv_bin: + pytest.skip("missing_env: uv not found") + provider_model = ProviderMatrixDriver(smoke_config).first_model() + auth_token = smoke_config.settings.proxy_auth_token + isolated_home = tmp_path / "aider-home" + isolated_home.mkdir() + + with SmokeServerDriver( + smoke_config, + name="product-aider-cli", + env_overrides={ + "MODEL": provider_model.full_model, + "ANTHROPIC_AUTH_TOKEN": auth_token, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + env = os.environ.copy() + env.update( + { + "HOST": "127.0.0.1", + "PORT": str(server.port), + "FCC_OPEN_BROWSER": "0", + "ANTHROPIC_AUTH_TOKEN": auth_token, + "HOME": str(isolated_home), + "USERPROFILE": str(isolated_home), + "PYTHONUTF8": "1", + } + ) + env.pop("AIDER_CONFIG_FILE", None) + result = subprocess.run( + [ + uv_bin, + "run", + "--project", + str(smoke_config.root), + "--no-sync", + "fcc-aider", + "--no-git", + "--no-auto-commits", + "--no-stream", + "--no-check-update", + "--no-analytics", + "--yes-always", + "--model", + provider_model.full_model, + "--message", + "Reply with exactly FCC_SMOKE_AIDER", + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + timeout=smoke_config.timeout_s + 15, + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + assert result.returncode == 0, result.stderr or result.stdout + assert "FCC_SMOKE_AIDER" in result.stdout + assert "POST /v1/messages" in server_log + assert "POST /v1/responses" not in server_log + assert not any((isolated_home / ".fcc" / "tmp" / "aider").iterdir()) + + @pytest.mark.smoke_target("clients") def test_cline_cli_prompt_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: if not shutil.which("cline"): diff --git a/src/free_claude_code/api/dependencies.py b/src/free_claude_code/api/dependencies.py index a2a3b9b1..bb3dda52 100644 --- a/src/free_claude_code/api/dependencies.py +++ b/src/free_claude_code/api/dependencies.py @@ -59,19 +59,70 @@ def require_proxy_auth( detail="Missing proxy authentication token", ) - parts = authorization.strip().split(maxsplit=1) - if len(parts) != 2 or parts[0].casefold() != "bearer": - raise HTTPException( - status_code=401, - detail="Invalid proxy authentication token", - ) - token = parts[1].strip() - - if not token or not secrets.compare_digest( - token.encode("utf-8"), - settings.proxy_auth_token.encode("utf-8"), + if not _proxy_token_matches( + authorization, + settings.proxy_auth_token, + require_bearer=True, ): raise HTTPException( status_code=401, detail="Invalid proxy authentication token", ) + + +def require_anthropic_proxy_auth( + request: Request, + settings: Settings = Depends(get_settings), +) -> None: + """Require Bearer or Anthropic ``x-api-key`` proxy authentication.""" + if not settings.proxy_auth_enabled: + return + + authorization = request.headers.get("authorization") + if authorization is not None: + if _proxy_token_matches( + authorization, + settings.proxy_auth_token, + require_bearer=True, + ): + return + raise HTTPException( + status_code=401, + detail="Invalid proxy authentication token", + ) + + x_api_key = request.headers.get("x-api-key") + if x_api_key is None: + raise HTTPException( + status_code=401, + detail="Missing proxy authentication token", + ) + + if not _proxy_token_matches( + x_api_key, + settings.proxy_auth_token, + require_bearer=False, + ): + raise HTTPException( + status_code=401, + detail="Invalid proxy authentication token", + ) + + +def _proxy_token_matches( + credential: str, + configured_token: str, + *, + require_bearer: bool, +) -> bool: + token = credential.strip() + if require_bearer: + parts = token.split(maxsplit=1) + if len(parts) != 2 or parts[0].casefold() != "bearer": + return False + token = parts[1].strip() + + return bool(token) and secrets.compare_digest( + token.encode("utf-8"), + configured_token.encode("utf-8"), + ) diff --git a/src/free_claude_code/api/routes.py b/src/free_claude_code/api/routes.py index e1db53bb..3fc87d9c 100644 --- a/src/free_claude_code/api/routes.py +++ b/src/free_claude_code/api/routes.py @@ -18,6 +18,7 @@ from free_claude_code.core.trace import trace_event from .dependencies import ( get_services, get_settings, + require_anthropic_proxy_auth, require_proxy_auth, resolve_provider, ) @@ -111,7 +112,7 @@ async def create_message( request: Request, request_data: MessagesRequest, services: ApiServices = Depends(get_services), - _auth=Depends(require_proxy_auth), + _auth=Depends(require_anthropic_proxy_auth), ): """Create a message (JSON by default; stream=true returns Anthropic SSE).""" return await _create_messages_response( @@ -122,7 +123,7 @@ async def create_message( @router.api_route("/v1/messages", methods=["HEAD", "OPTIONS"]) -async def probe_messages(_auth=Depends(require_proxy_auth)): +async def probe_messages(_auth=Depends(require_anthropic_proxy_auth)): return _probe_response("POST, HEAD, OPTIONS") @@ -151,7 +152,7 @@ async def count_tokens( request: Request, request_data: TokenCountRequest, settings: Settings = Depends(get_settings), - _auth=Depends(require_proxy_auth), + _auth=Depends(require_anthropic_proxy_auth), ): """Count tokens for a request.""" handler = TokenCountHandler(settings, token_counter=get_token_count) @@ -159,7 +160,7 @@ async def count_tokens( @router.api_route("/v1/messages/count_tokens", methods=["HEAD", "OPTIONS"]) -async def probe_count_tokens(_auth=Depends(require_proxy_auth)): +async def probe_count_tokens(_auth=Depends(require_anthropic_proxy_auth)): return _probe_response("POST, HEAD, OPTIONS") diff --git a/src/free_claude_code/cli/launchers/aider.py b/src/free_claude_code/cli/launchers/aider.py new file mode 100644 index 00000000..6b575b95 --- /dev/null +++ b/src/free_claude_code/cli/launchers/aider.py @@ -0,0 +1,328 @@ +"""Installed `fcc-aider` launcher for the official Aider CLI.""" + +import json +import os +import secrets +import sys +import tempfile +from collections.abc import Iterator, Mapping, Sequence +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Never + +from free_claude_code.cli.local_http import with_local_proxy_bypass +from free_claude_code.config.loader import get_settings +from free_claude_code.config.paths import aider_temp_dir_path +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.core.json_types import JsonValue + +from .aider_config import ( + AIDER_API_KEY_ENV_PREFIX, + AiderConfig, + build_aider_config, +) +from .common import preflight_proxy, resolve_client_binary, run_client_process +from .model_catalog import ( + ClientModel, + client_models_from_response, + fetch_proxy_models_response, +) + +_BINARY_NAME = "aider" +_DISPLAY_NAME = "Aider" +_INSTALL_HINT = "Install Aider from: https://aider.chat/docs/install.html" +_PASSTHROUGH_FLAGS = frozenset( + { + "-h", + "--help", + "--version", + "--just-check-update", + "--upgrade", + "--update", + "--install-main-branch", + "--shell-completions", + } +) +_ROUTE_FILE_OPTIONS = ("--model-settings-file", "--model-metadata-file") +_MODEL_OPTIONS = ("--model", "--weak-model", "--editor-model") +_SETTINGS_FILENAME = "model-settings.yml" +_METADATA_FILENAME = "model-metadata.json" +_AIDER_VALIDATION_KEY = "ANTHROPIC_API_KEY=fcc-local" + + +@dataclass(frozen=True, slots=True) +class AiderConfigFiles: + """Private file paths owned for the lifetime of one Aider launch.""" + + directory: Path + settings_path: Path + metadata_path: Path + + +class _AiderConfigFilesError(RuntimeError): + """Failure while preparing launcher-owned Aider files.""" + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Aider with a process-local FCC Messages model catalog.""" + + args = list(sys.argv[1:] if argv is None else argv) + binary_path = resolve_client_binary( + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + + if is_aider_passthrough(args): + _run(binary_path, args, os.environ) + return + + reject_aider_route_file_overrides(args) + settings = get_settings() + auth_token = settings.proxy_auth_token.strip() + if not auth_token: + print("Free Claude Code proxy authentication token is empty.", file=sys.stderr) + raise SystemExit(1) + + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + try: + models = client_models_from_response( + fetch_proxy_models_response( + proxy_root_url, + auth_token, + view="messages", + ) + ) + api_key_env = new_aider_api_key_env_name() + config = build_aider_config( + models, + messages_url=f"{proxy_root_url.rstrip('/')}/v1/messages", + api_key_env=api_key_env, + ) + except Exception as exc: + print(f"Could not prepare the Aider FCC model catalog: {exc}", file=sys.stderr) + raise SystemExit(1) from None + + try: + with temporary_aider_config_files(config) as files: + child_args = normalized_aider_arguments( + args, + models=models, + settings_path=files.settings_path, + metadata_path=files.metadata_path, + ) + child_env = build_aider_launcher_env( + proxy_root_url=proxy_root_url, + api_key_env=api_key_env, + auth_token=auth_token, + base_env=os.environ, + ) + _run(binary_path, child_args, child_env) + except _AiderConfigFilesError as exc: + print(f"Could not create temporary Aider configuration: {exc}", file=sys.stderr) + raise SystemExit(1) from None + + +def is_aider_passthrough(argv: Sequence[str]) -> bool: + """Return whether Aider can run without FCC-owned process configuration.""" + + return any(argument in _PASSTHROUGH_FLAGS for argument in _before_separator(argv)) + + +def reject_aider_route_file_overrides(argv: Sequence[str]) -> None: + """Reject command-line paths that would replace FCC's process overlay.""" + + for argument in _before_separator(argv): + if argument in _ROUTE_FILE_OPTIONS or any( + argument.startswith(f"{option}=") for option in _ROUTE_FILE_OPTIONS + ): + print( + "fcc-aider owns the model settings and metadata files for its FCC " + "route. Use ordinary aider or Aider's normal home/project model " + "files for other route/catalog customization.", + file=sys.stderr, + ) + raise SystemExit(2) + + +def normalized_aider_arguments( + argv: Sequence[str], + *, + models: tuple[ClientModel, ...], + settings_path: Path, + metadata_path: Path, +) -> list[str]: + """Replace Aider's three model roles with canonical FCC model names.""" + + if not models: + raise ValueError("Aider requires at least one routable FCC model") + + before, separator_and_after = _split_separator(argv) + selected: dict[str, str] = {} + remaining: list[str] = [] + index = 0 + while index < len(before): + argument = before[index] + option = next( + ( + candidate + for candidate in _MODEL_OPTIONS + if argument == candidate or argument.startswith(f"{candidate}=") + ), + None, + ) + if option is None: + remaining.append(argument) + index += 1 + continue + if option in selected: + _model_argument_error(f"{option} may be provided only once") + + if argument == option: + value_index = index + 1 + if value_index >= len(before) or before[value_index].startswith("-"): + _model_argument_error(f"{option} requires one model value") + value = before[value_index] + index += 2 + else: + value = argument.removeprefix(f"{option}=") + index += 1 + if not value.strip(): + _model_argument_error(f"{option} requires one model value") + selected[option] = value + + canonical_by_input: dict[str, str] = {} + for model in models: + canonical = f"anthropic/{model.wire_slug}" + canonical_by_input[model.wire_slug] = canonical + canonical_by_input[canonical] = canonical + + resolved: dict[str, str] = {} + for option, value in selected.items(): + canonical = canonical_by_input.get(value) + if canonical is None: + _model_argument_error( + f"{option} model {value!r} is not in the current FCC model catalog" + ) + resolved[option] = canonical + + main_model = resolved.get("--model", f"anthropic/{models[0].wire_slug}") + weak_model = resolved.get("--weak-model", main_model) + editor_model = resolved.get("--editor-model", main_model) + owned = [ + "--model", + main_model, + "--weak-model", + weak_model, + "--editor-model", + editor_model, + "--model-settings-file", + str(settings_path), + "--model-metadata-file", + str(metadata_path), + "--set-env", + _AIDER_VALIDATION_KEY, + ] + return [*remaining, *owned, *separator_and_after] + + +def new_aider_api_key_env_name() -> str: + """Return one unguessable process-local environment variable name.""" + + return f"{AIDER_API_KEY_ENV_PREFIX}{secrets.token_hex(16).upper()}" + + +def build_aider_launcher_env( + *, + proxy_root_url: str, + api_key_env: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Build Aider's child-only environment while preserving native state.""" + + prefix = AIDER_API_KEY_ENV_PREFIX.casefold() + clean_env = { + key: value + for key, value in base_env.items() + if not key.casefold().startswith(prefix) + } + env = with_local_proxy_bypass(clean_env, proxy_root_url=proxy_root_url) + env[api_key_env] = auth_token + return env + + +@contextmanager +def temporary_aider_config_files( + config: AiderConfig, +) -> Iterator[AiderConfigFiles]: + """Write one private Aider config pair and remove its launch directory.""" + + with ExitStack() as stack: + try: + base_directory = aider_temp_dir_path() + base_directory.mkdir(parents=True, mode=0o700, exist_ok=True) + if os.name != "nt": + base_directory.chmod(0o700) + temp_directory = stack.enter_context( + tempfile.TemporaryDirectory(prefix="fcc-aider-", dir=base_directory) + ) + directory = Path(temp_directory) + if os.name != "nt": + directory.chmod(0o700) + files = AiderConfigFiles( + directory=directory, + settings_path=directory / _SETTINGS_FILENAME, + metadata_path=directory / _METADATA_FILENAME, + ) + _write_private_json(files.settings_path, config.settings) + _write_private_json(files.metadata_path, config.metadata) + except OSError as exc: + raise _AiderConfigFilesError(str(exc)) from exc + yield files + + +def _write_private_json(path: Path, payload: JsonValue) -> None: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump(payload, output, ensure_ascii=True, indent=2) + output.write("\n") + + +def _before_separator(argv: Sequence[str]) -> Sequence[str]: + try: + return argv[: argv.index("--")] + except ValueError: + return argv + + +def _split_separator(argv: Sequence[str]) -> tuple[list[str], list[str]]: + try: + index = argv.index("--") + except ValueError: + return list(argv), [] + return list(argv[:index]), list(argv[index:]) + + +def _model_argument_error(message: str) -> Never: + print(f"Invalid fcc-aider model option: {message}.", file=sys.stderr) + raise SystemExit(2) + + +def _run(binary_path: str, args: Sequence[str], env: Mapping[str, str]) -> None: + run_client_process( + command=[binary_path, *args], + env=env, + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) diff --git a/src/free_claude_code/cli/launchers/aider_config.py b/src/free_claude_code/cli/launchers/aider_config.py new file mode 100644 index 00000000..0a0212c4 --- /dev/null +++ b/src/free_claude_code/cli/launchers/aider_config.py @@ -0,0 +1,51 @@ +"""Process-local Aider configuration for FCC model routing.""" + +import re +from dataclasses import dataclass, field + +from free_claude_code.core.json_types import JsonObject + +from .model_catalog import ClientModel + +AIDER_API_KEY_ENV_PREFIX = "FCC_AIDER_PROXY_AUTH_" +_AIDER_API_KEY_ENV_PATTERN = re.compile(rf"{AIDER_API_KEY_ENV_PREFIX}[A-Z0-9]+") + + +@dataclass(frozen=True, slots=True) +class AiderConfig: + """Secret-free model settings and metadata for one Aider process.""" + + settings: list[JsonObject] = field(repr=False) + metadata: JsonObject = field(repr=False) + + +def build_aider_config( + models: tuple[ClientModel, ...], + *, + messages_url: str, + api_key_env: str, +) -> AiderConfig: + """Project a non-empty FCC Messages catalog into Aider's file contracts.""" + + if not models: + raise ValueError("Aider requires at least one routable FCC model") + if _AIDER_API_KEY_ENV_PATTERN.fullmatch(api_key_env) is None: + raise ValueError("invalid Aider proxy-auth environment variable name") + + settings: list[JsonObject] = [ + { + "name": "aider/extra_params", + "extra_params": { + "api_base": messages_url, + "api_key": f"os.environ/{api_key_env}", + }, + } + ] + metadata: JsonObject = { + f"anthropic/{model.wire_slug}": { + "litellm_provider": "anthropic", + "mode": "chat", + } + for model in models + } + return AiderConfig(settings=settings, metadata=metadata) diff --git a/src/free_claude_code/cli/launchers/model_catalog.py b/src/free_claude_code/cli/launchers/model_catalog.py index 9f1c865c..ea5741d9 100644 --- a/src/free_claude_code/cli/launchers/model_catalog.py +++ b/src/free_claude_code/cli/launchers/model_catalog.py @@ -3,6 +3,7 @@ import json from collections.abc import Mapping, Sequence from dataclasses import dataclass +from typing import Literal from urllib.request import Request from free_claude_code.cli.local_http import open_local_request @@ -58,10 +59,14 @@ def catalog_wire_slug_for_ref( return provider_model_ref -def fetch_proxy_models_response(proxy_root_url: str, auth_token: str) -> JsonObject: +def fetch_proxy_models_response( + proxy_root_url: str, + auth_token: str, + view: Literal["messages", "responses"] = "responses", +) -> JsonObject: """Fetch the authenticated FCC-local `/v1/models` response directly.""" - url = f"{proxy_root_url.rstrip('/')}/v1/models?view=responses" + url = f"{proxy_root_url.rstrip('/')}/v1/models?view={view}" request = Request( url, headers={"Authorization": f"Bearer {auth_token}"}, diff --git a/src/free_claude_code/config/paths.py b/src/free_claude_code/config/paths.py index 8e9d640e..9d385bab 100644 --- a/src/free_claude_code/config/paths.py +++ b/src/free_claude_code/config/paths.py @@ -14,6 +14,8 @@ AUTH_DIRNAME = "auth" OPENAI_AUTH_FILENAME = "openai.json" OPENAI_AUTH_LOCK_FILENAME = "openai.lock" CONFIG_LOCK_FILENAME = "config.lock" +FCC_TEMP_DIRNAME = "tmp" +AIDER_TEMP_DIRNAME = "aider" def config_dir_path() -> Path: @@ -34,6 +36,12 @@ def config_lock_path() -> Path: return config_dir_path() / CONFIG_LOCK_FILENAME +def aider_temp_dir_path() -> Path: + """Return the base directory for managed per-launch Aider files.""" + + return config_dir_path() / FCC_TEMP_DIRNAME / AIDER_TEMP_DIRNAME + + def legacy_env_paths() -> tuple[Path, ...]: """Return legacy user env paths that can be migrated to ~/.fcc/.env.""" diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index f4ebab44..39c427d8 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient @@ -9,7 +9,7 @@ from tests.api.support import create_test_app app = create_test_app() -def test_proxy_auth_requires_canonical_bearer_header(): +def test_anthropic_post_routes_accept_x_api_key(): client = TestClient(app) settings = Settings(proxy_auth_enabled=True, proxy_auth_token="s3cr3t") app.dependency_overrides[get_settings] = lambda: settings @@ -19,37 +19,61 @@ def test_proxy_auth_requires_canonical_bearer_header(): "messages": [{"role": "user", "content": "hello"}], } - with patch("free_claude_code.api.routes.get_token_count", return_value=1): - r = client.post("/v1/messages/count_tokens", json=payload) - assert r.status_code == 401 - assert r.json() == {"detail": "Missing proxy authentication token"} - assert r.headers["request-id"].startswith("req_") - assert "x-should-retry" not in r.headers - - for headers in ( - {"X-API-Key": "s3cr3t"}, - {"anthropic-auth-token": "s3cr3t"}, - ): - r = client.post( - "/v1/messages/count_tokens", - json=payload, - headers=headers, - ) - assert r.status_code == 401 - assert r.json() == {"detail": "Missing proxy authentication token"} - - r = client.post( + with ( + patch("free_claude_code.api.routes.get_token_count", return_value=1), + patch( + "free_claude_code.api.routes._create_messages_response", + new_callable=AsyncMock, + return_value={"accepted": True}, + ), + ): + count_response = client.post( "/v1/messages/count_tokens", json=payload, - headers={"Authorization": "Bearer s3cr3t"}, + headers={"X-API-Key": "s3cr3t"}, ) - assert r.status_code == 200 - assert r.json()["input_tokens"] == 1 + messages_response = client.post( + "/v1/messages", + json={**payload, "max_tokens": 16}, + headers={"X-API-Key": "s3cr3t"}, + ) + + assert count_response.status_code == 200 + assert count_response.json()["input_tokens"] == 1 + assert messages_response.status_code == 200 + assert messages_response.json() == {"accepted": True} app.dependency_overrides.clear() -def test_proxy_auth_ignores_conflicting_legacy_headers(): +def test_anthropic_probe_routes_accept_x_api_key(): + client = TestClient(app) + settings = Settings(proxy_auth_enabled=True, proxy_auth_token="probe-token") + app.dependency_overrides[get_settings] = lambda: settings + + for path in ("/v1/messages", "/v1/messages/count_tokens"): + for method in (client.head, client.options): + response = method(path, headers={"X-API-Key": "probe-token"}) + assert response.status_code == 204 + assert response.headers["Allow"] == "POST, HEAD, OPTIONS" + + app.dependency_overrides.clear() + + +def test_anthropic_routes_still_reject_anthropic_auth_token_only(): + client = TestClient(app) + settings = Settings(proxy_auth_enabled=True, proxy_auth_token="s3cr3t") + app.dependency_overrides[get_settings] = lambda: settings + + for path in ("/v1/messages", "/v1/messages/count_tokens"): + for method in (client.head, client.options): + response = method(path, headers={"anthropic-auth-token": "s3cr3t"}) + assert response.status_code == 401 + + app.dependency_overrides.clear() + + +def test_messages_auth_gives_authorization_precedence_over_x_api_key(): client = TestClient(app) settings = Settings(proxy_auth_enabled=True, proxy_auth_token="b3artoken") app.dependency_overrides[get_settings] = lambda: settings @@ -59,10 +83,14 @@ def test_proxy_auth_ignores_conflicting_legacy_headers(): "messages": [{"role": "user", "content": "hello"}], } - with patch("free_claude_code.api.routes.get_token_count", return_value=2): + with patch( + "free_claude_code.api.routes._create_messages_response", + new_callable=AsyncMock, + return_value={"accepted": True}, + ): r = client.post( - "/v1/messages/count_tokens", - json=payload, + "/v1/messages", + json={**payload, "max_tokens": 16}, headers={ "Authorization": "Bearer b3artoken", "X-API-Key": "stale-anthropic-key", @@ -70,11 +98,11 @@ def test_proxy_auth_ignores_conflicting_legacy_headers(): }, ) assert r.status_code == 200 - assert r.json()["input_tokens"] == 2 + assert r.json() == {"accepted": True} r = client.post( - "/v1/messages/count_tokens", - json=payload, + "/v1/messages", + json={**payload, "max_tokens": 16}, headers={ "Authorization": "Bearer wrong", "X-API-Key": "b3artoken", @@ -86,6 +114,22 @@ def test_proxy_auth_ignores_conflicting_legacy_headers(): app.dependency_overrides.clear() +def test_x_api_key_remains_rejected_on_non_messages_routes(): + client = TestClient(app) + settings = Settings(proxy_auth_enabled=True, proxy_auth_token="route-token") + app.dependency_overrides[get_settings] = lambda: settings + + for method, path in ( + (client.head, "/v1/responses"), + (client.get, "/v1/models"), + (client.get, "/"), + ): + response = method(path, headers={"X-API-Key": "route-token"}) + assert response.status_code == 401 + + app.dependency_overrides.clear() + + def test_proxy_auth_token_normalizes_configured_whitespace(): client = TestClient(app) settings = Settings( diff --git a/tests/api/test_dependencies.py b/tests/api/test_dependencies.py index c3b1b2cc..c4bfec22 100644 --- a/tests/api/test_dependencies.py +++ b/tests/api/test_dependencies.py @@ -6,6 +6,7 @@ from fastapi import HTTPException, Request from free_claude_code.api.dependencies import ( get_services, get_settings, + require_anthropic_proxy_auth, require_proxy_auth, resolve_provider, ) @@ -209,3 +210,108 @@ def test_require_proxy_auth_rejects_invalid_bearer_when_legacy_header_matches(): assert exc_info.value.status_code == 401 assert exc_info.value.detail == "Invalid proxy authentication token" + + +def test_require_anthropic_proxy_auth_allows_when_disabled() -> None: + request, settings = _request( + headers={ + "authorization": "Basic wrong", + "x-api-key": "wrong", + "anthropic-auth-token": "wrong", + }, + token="retained-token", + enabled=False, + ) + + require_anthropic_proxy_auth(request, settings) + + +def test_require_anthropic_proxy_auth_accepts_exact_bearer_token() -> None: + request, settings = _request( + headers={"authorization": " bEaReR secret:with:colons "}, + token="secret:with:colons", + ) + + require_anthropic_proxy_auth(request, settings) + + +def test_require_anthropic_proxy_auth_accepts_exact_x_api_key() -> None: + request, settings = _request( + headers={"x-api-key": " secret:with:colons "}, + token="secret:with:colons", + ) + + require_anthropic_proxy_auth(request, settings) + + +def test_require_anthropic_proxy_auth_rejects_missing_credentials() -> None: + request, settings = _request(headers={}, token="secret") + + with pytest.raises(HTTPException) as exc_info: + require_anthropic_proxy_auth(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Missing proxy authentication token" + + +@pytest.mark.parametrize("x_api_key", ["", " ", "wrong", "secret:model-suffix"]) +def test_require_anthropic_proxy_auth_rejects_invalid_x_api_key( + x_api_key: str, +) -> None: + request, settings = _request( + headers={"x-api-key": x_api_key}, + token="secret", + ) + + with pytest.raises(HTTPException) as exc_info: + require_anthropic_proxy_auth(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid proxy authentication token" + + +@pytest.mark.parametrize( + "authorization", + ["", " ", "secret", "Basic secret", "Bearer", "Bearer wrong"], +) +def test_require_anthropic_proxy_auth_rejects_authorization_before_x_api_key( + authorization: str, +) -> None: + request, settings = _request( + headers={ + "authorization": authorization, + "x-api-key": "secret", + }, + token="secret", + ) + + with pytest.raises(HTTPException) as exc_info: + require_anthropic_proxy_auth(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid proxy authentication token" + + +def test_require_anthropic_proxy_auth_prefers_valid_authorization() -> None: + request, settings = _request( + headers={ + "authorization": "Bearer secret", + "x-api-key": "wrong", + }, + token="secret", + ) + + require_anthropic_proxy_auth(request, settings) + + +def test_require_anthropic_proxy_auth_rejects_anthropic_auth_token_only() -> None: + request, settings = _request( + headers={"anthropic-auth-token": "secret"}, + token="secret", + ) + + with pytest.raises(HTTPException) as exc_info: + require_anthropic_proxy_auth(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Missing proxy authentication token" diff --git a/tests/cli/test_aider_config.py b/tests/cli/test_aider_config.py new file mode 100644 index 00000000..513846c6 --- /dev/null +++ b/tests/cli/test_aider_config.py @@ -0,0 +1,104 @@ +"""Contracts for Aider's process-local FCC model files.""" + +import json + +import pytest + +from free_claude_code.cli.launchers.aider_config import build_aider_config +from free_claude_code.cli.launchers.model_catalog import ClientModel + + +def _models() -> tuple[ClientModel, ...]: + return ( + ClientModel( + wire_slug="nvidia_nim/vendor/model", + provider_model_ref="nvidia_nim/vendor/model", + display_name="Nested model", + allows_reasoning=True, + ), + ClientModel( + wire_slug="ollama_cloud/qwen3-coder:480b", + provider_model_ref="ollama_cloud/qwen3-coder:480b", + display_name="Colon model", + allows_reasoning=False, + ), + ) + + +def test_aider_config_projects_messages_route_and_canonical_catalog() -> None: + config = build_aider_config( + _models(), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env="FCC_AIDER_PROXY_AUTH_A1B2C3", + ) + + assert config.settings == [ + { + "name": "aider/extra_params", + "extra_params": { + "api_base": "http://127.0.0.1:9191/v1/messages", + "api_key": "os.environ/FCC_AIDER_PROXY_AUTH_A1B2C3", + }, + } + ] + assert list(config.metadata) == [ + "anthropic/nvidia_nim/vendor/model", + "anthropic/ollama_cloud/qwen3-coder:480b", + ] + assert config.metadata == { + "anthropic/nvidia_nim/vendor/model": { + "litellm_provider": "anthropic", + "mode": "chat", + }, + "anthropic/ollama_cloud/qwen3-coder:480b": { + "litellm_provider": "anthropic", + "mode": "chat", + }, + } + + serialized = json.dumps({"settings": config.settings, "metadata": config.metadata}) + assert json.loads(serialized) == { + "settings": config.settings, + "metadata": config.metadata, + } + assert "proxy-token" not in serialized + for fabricated_key in ( + "context_window", + "max_tokens", + "input_cost_per_token", + "output_cost_per_token", + "reasoning", + "edit_format", + ): + assert fabricated_key not in serialized + + +def test_aider_config_rejects_empty_catalog() -> None: + with pytest.raises(ValueError, match="at least one"): + build_aider_config( + (), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env="FCC_AIDER_PROXY_AUTH_A1B2C3", + ) + + +@pytest.mark.parametrize( + "api_key_env", + [ + "", + "FCC_AIDER_PROXY_AUTH_", + "ANTHROPIC_API_KEY", + "FCC_AIDER_PROXY_AUTH_lowercase", + "FCC_AIDER_PROXY_AUTH_BAD-NAME", + "FCC_AIDER_PROXY_AUTH_BAD/NAME", + ], +) +def test_aider_config_rejects_invalid_api_key_environment_name( + api_key_env: str, +) -> None: + with pytest.raises(ValueError, match="environment"): + build_aider_config( + _models(), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env=api_key_env, + ) diff --git a/tests/cli/test_aider_launcher.py b/tests/cli/test_aider_launcher.py new file mode 100644 index 00000000..60229664 --- /dev/null +++ b/tests/cli/test_aider_launcher.py @@ -0,0 +1,656 @@ +"""Contracts for the installed `fcc-aider` launcher.""" + +import json +import os +from collections.abc import Mapping +from pathlib import Path +from unittest.mock import patch + +import pytest + +from free_claude_code.cli.launchers.aider_config import build_aider_config +from free_claude_code.cli.launchers.model_catalog import ClientModel +from free_claude_code.config.settings import Settings +from free_claude_code.core.json_types import JsonObject + + +def _settings(*, token: str = "proxy-token") -> Settings: + return Settings.model_construct( + host="0.0.0.0", + port=9191, + proxy_auth_enabled=False, + proxy_auth_token=token, + model="nvidia_nim/test-model", + ) + + +def _models() -> tuple[ClientModel, ...]: + return ( + ClientModel( + wire_slug="nvidia_nim/vendor/main-model", + provider_model_ref="nvidia_nim/vendor/main-model", + display_name="Main model", + allows_reasoning=True, + ), + ClientModel( + wire_slug="ollama_cloud/qwen3-coder:480b", + provider_model_ref="ollama_cloud/qwen3-coder:480b", + display_name="Colon model", + allows_reasoning=False, + ), + ) + + +def _models_payload() -> JsonObject: + return { + "data": [ + { + "id": model.wire_slug, + "provider_model_ref": model.provider_model_ref, + "display_name": model.display_name, + } + for model in _models() + ] + } + + +@pytest.mark.parametrize( + "argv", + [ + ["-h"], + ["--help"], + ["--version"], + ["--just-check-update"], + ["--upgrade"], + ["--update"], + ["--install-main-branch"], + ["--shell-completions", "bash"], + ], +) +def test_aider_maintenance_surfaces_are_native_passthrough( + argv: list[str], +) -> None: + from free_claude_code.cli.launchers import aider + + with ( + patch.object(aider, "resolve_client_binary", return_value="resolved-aider"), + patch.object(aider, "get_settings") as get_settings, + patch.object(aider, "preflight_proxy") as preflight_proxy, + patch.object(aider, "fetch_proxy_models_response") as fetch_models, + patch.object(aider, "run_client_process") as run_client_process, + ): + aider.launch(argv) + + assert run_client_process.call_args.kwargs["command"] == [ + "resolved-aider", + *argv, + ] + assert run_client_process.call_args.kwargs["env"] is os.environ + get_settings.assert_not_called() + preflight_proxy.assert_not_called() + fetch_models.assert_not_called() + + +@pytest.mark.parametrize( + "argv", + [ + [], + ["--list-models", "qwen"], + ["--gui"], + ["--browser"], + ["--copy-paste"], + ["--apply", "changes.diff"], + ["-m", "one-shot prompt"], + ["src/app.py"], + ["--", "--version"], + ], +) +def test_aider_attached_surfaces_are_not_passthrough(argv: list[str]) -> None: + from free_claude_code.cli.launchers.aider import is_aider_passthrough + + assert not is_aider_passthrough(argv) + + +@pytest.mark.parametrize( + "argv", + [ + ["--model-settings-file", "custom.yml"], + ["--model-settings-file=custom.yml"], + ["--model-metadata-file", "custom.json"], + ["--model-metadata-file=custom.json"], + ], +) +def test_aider_rejects_caller_owned_route_files( + argv: list[str], capsys: pytest.CaptureFixture[str] +) -> None: + from free_claude_code.cli.launchers import aider + + with pytest.raises(SystemExit) as exc_info: + aider.reject_aider_route_file_overrides(argv) + + assert exc_info.value.code == 2 + assert "ordinary aider" in capsys.readouterr().err.lower() + + +def test_aider_route_file_flags_after_separator_remain_positional() -> None: + from free_claude_code.cli.launchers.aider import reject_aider_route_file_overrides + + reject_aider_route_file_overrides( + ["src/app.py", "--", "--model-settings-file", "fixture.yml"] + ) + + +def test_aider_model_options_normalize_and_preserve_other_argument_order( + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.aider import normalized_aider_arguments + + result = normalized_aider_arguments( + [ + "--verbose", + "--weak-model=ollama_cloud/qwen3-coder:480b", + "src/app.py", + "--model", + "anthropic/ollama_cloud/qwen3-coder:480b", + "--editor-model", + "nvidia_nim/vendor/main-model", + "--", + "--model", + "after-separator", + ], + models=_models(), + settings_path=tmp_path / "settings.yml", + metadata_path=tmp_path / "metadata.json", + ) + + assert result == [ + "--verbose", + "src/app.py", + "--model", + "anthropic/ollama_cloud/qwen3-coder:480b", + "--weak-model", + "anthropic/ollama_cloud/qwen3-coder:480b", + "--editor-model", + "anthropic/nvidia_nim/vendor/main-model", + "--model-settings-file", + str(tmp_path / "settings.yml"), + "--model-metadata-file", + str(tmp_path / "metadata.json"), + "--set-env", + "ANTHROPIC_API_KEY=fcc-local", + "--", + "--model", + "after-separator", + ] + + +def test_aider_default_weak_and_editor_follow_resolved_main(tmp_path: Path) -> None: + from free_claude_code.cli.launchers.aider import normalized_aider_arguments + + result = normalized_aider_arguments( + ["-m", "keep this one-shot", "--model", _models()[1].wire_slug], + models=_models(), + settings_path=tmp_path / "settings.yml", + metadata_path=tmp_path / "metadata.json", + ) + + canonical = "anthropic/ollama_cloud/qwen3-coder:480b" + assert result[:2] == ["-m", "keep this one-shot"] + assert result[2:8] == [ + "--model", + canonical, + "--weak-model", + canonical, + "--editor-model", + canonical, + ] + + +def test_aider_default_main_uses_first_catalog_entry(tmp_path: Path) -> None: + from free_claude_code.cli.launchers.aider import normalized_aider_arguments + + result = normalized_aider_arguments( + [], + models=_models(), + settings_path=tmp_path / "settings.yml", + metadata_path=tmp_path / "metadata.json", + ) + + assert result[:6] == [ + "--model", + "anthropic/nvidia_nim/vendor/main-model", + "--weak-model", + "anthropic/nvidia_nim/vendor/main-model", + "--editor-model", + "anthropic/nvidia_nim/vendor/main-model", + ] + + +@pytest.mark.parametrize( + "argv", + [ + ["--model"], + ["--weak-model="], + ["--editor-model", "--verbose"], + ], +) +def test_aider_rejects_missing_model_values( + argv: list[str], tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + from free_claude_code.cli.launchers.aider import normalized_aider_arguments + + with pytest.raises(SystemExit) as exc_info: + normalized_aider_arguments( + argv, + models=_models(), + settings_path=tmp_path / "settings.yml", + metadata_path=tmp_path / "metadata.json", + ) + + assert exc_info.value.code == 2 + assert "requires one model" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "argv", + [ + ["--model", _models()[0].wire_slug, "--model=" + _models()[1].wire_slug], + [ + "--weak-model", + _models()[0].wire_slug, + "--weak-model", + _models()[1].wire_slug, + ], + [ + "--editor-model=" + _models()[0].wire_slug, + "--editor-model=" + _models()[1].wire_slug, + ], + ], +) +def test_aider_rejects_duplicate_model_options( + argv: list[str], tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + from free_claude_code.cli.launchers.aider import normalized_aider_arguments + + with pytest.raises(SystemExit) as exc_info: + normalized_aider_arguments( + argv, + models=_models(), + settings_path=tmp_path / "settings.yml", + metadata_path=tmp_path / "metadata.json", + ) + + assert exc_info.value.code == 2 + assert "only once" in capsys.readouterr().err + + +def test_aider_rejects_unknown_initial_model( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + from free_claude_code.cli.launchers.aider import normalized_aider_arguments + + with pytest.raises(SystemExit) as exc_info: + normalized_aider_arguments( + ["--model", "anthropic/not/in/catalog"], + models=_models(), + settings_path=tmp_path / "settings.yml", + metadata_path=tmp_path / "metadata.json", + ) + + assert exc_info.value.code == 2 + assert "not in the current FCC model catalog" in capsys.readouterr().err + + +def test_aider_child_env_scrubs_stale_keys_and_preserves_native_state() -> None: + from free_claude_code.cli.launchers.aider import build_aider_launcher_env + + env = build_aider_launcher_env( + proxy_root_url="http://127.0.0.1:9191", + api_key_env="FCC_AIDER_PROXY_AUTH_FRESH123", + auth_token="real-secret", + base_env={ + "PATH": "keep", + "AIDER_CONFIG_FILE": "keep-user-config", + "HTTP_PROXY": "http://proxy.example", + "NO_PROXY": "example.com", + "fcc_aider_proxy_auth_old": "remove", + "FCC_AIDER_PROXY_AUTH_OTHER": "remove", + }, + ) + + assert env["PATH"] == "keep" + assert env["AIDER_CONFIG_FILE"] == "keep-user-config" + assert env["HTTP_PROXY"] == "http://proxy.example" + assert env["NO_PROXY"] == "example.com,127.0.0.1,localhost,::1" + assert env["no_proxy"] == env["NO_PROXY"] + assert env["FCC_AIDER_PROXY_AUTH_FRESH123"] == "real-secret" + assert "fcc_aider_proxy_auth_old" not in env + assert "FCC_AIDER_PROXY_AUTH_OTHER" not in env + + +def test_aider_launch_uses_messages_catalog_and_private_ephemeral_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers import aider + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("AIDER_CONFIG_FILE", "keep-native-state") + observed_directory: Path | None = None + observed_env_name: str | None = None + + def observe_process( + *, + command: list[str], + env: Mapping[str, str], + binary_name: str, + display_name: str, + install_hint: str, + ) -> None: + nonlocal observed_directory, observed_env_name + del binary_name, display_name, install_hint + settings_index = command.index("--model-settings-file") + 1 + metadata_index = command.index("--model-metadata-file") + 1 + settings_path = Path(command[settings_index]) + metadata_path = Path(command[metadata_index]) + observed_directory = settings_path.parent + assert metadata_path.parent == observed_directory + assert settings_path.name == "model-settings.yml" + assert metadata_path.name == "model-metadata.json" + settings_payload = json.loads(settings_path.read_text(encoding="utf-8")) + metadata_payload = json.loads(metadata_path.read_text(encoding="utf-8")) + env_reference = settings_payload[0]["extra_params"]["api_key"] + assert isinstance(env_reference, str) + observed_env_name = env_reference.removeprefix("os.environ/") + assert observed_env_name.startswith("FCC_AIDER_PROXY_AUTH_") + assert env[observed_env_name] == "proxy-token" + assert env["AIDER_CONFIG_FILE"] == "keep-native-state" + rendered = ( + " ".join(command) + + json.dumps(settings_payload) + + json.dumps(metadata_payload) + ) + assert "proxy-token" not in rendered + assert command[:3] == ["resolved-aider", "--verbose", "src/app.py"] + assert command[-2:] == ["--set-env", "ANTHROPIC_API_KEY=fcc-local"] + + with ( + patch.object(aider, "resolve_client_binary", return_value="resolved-aider"), + patch.object(aider, "get_settings", return_value=_settings()), + patch.object(aider, "preflight_proxy", return_value=None), + patch.object( + aider, + "fetch_proxy_models_response", + return_value=_models_payload(), + ) as fetch_models, + patch.object(aider, "run_client_process", side_effect=observe_process), + ): + aider.launch(["--verbose", "src/app.py"]) + + fetch_models.assert_called_once_with( + "http://127.0.0.1:9191", + "proxy-token", + view="messages", + ) + assert observed_directory is not None + assert observed_env_name is not None + assert observed_directory.parent == tmp_path / ".fcc" / "tmp" / "aider" + assert not observed_directory.exists() + + +@pytest.mark.parametrize("error", [SystemExit(7), KeyboardInterrupt()]) +def test_aider_temp_files_cleanup_when_child_ends_abnormally( + error: BaseException, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers import aider + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + observed_directory: Path | None = None + + def fail_process(**kwargs: object) -> None: + nonlocal observed_directory + command = kwargs["command"] + assert isinstance(command, list) + settings_path = Path(command[command.index("--model-settings-file") + 1]) + observed_directory = settings_path.parent + assert observed_directory.is_dir() + raise error + + with ( + patch.object(aider, "resolve_client_binary", return_value="resolved-aider"), + patch.object(aider, "get_settings", return_value=_settings()), + patch.object(aider, "preflight_proxy", return_value=None), + patch.object( + aider, + "fetch_proxy_models_response", + return_value=_models_payload(), + ), + patch.object(aider, "run_client_process", side_effect=fail_process), + pytest.raises(type(error)), + ): + aider.launch([]) + + assert observed_directory is not None + assert not observed_directory.exists() + + +def test_aider_temporary_preparations_are_unique( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.aider import temporary_aider_config_files + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + first_config = build_aider_config( + _models(), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env="FCC_AIDER_PROXY_AUTH_FIRST", + ) + second_config = build_aider_config( + _models(), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env="FCC_AIDER_PROXY_AUTH_SECOND", + ) + + with ( + temporary_aider_config_files(first_config) as first, + temporary_aider_config_files(second_config) as second, + ): + first_directory = first.directory + second_directory = second.directory + assert first_directory != second_directory + assert first_directory.is_dir() + assert second_directory.is_dir() + + assert not first_directory.exists() + assert not second_directory.exists() + + +@pytest.mark.skipif( + os.name == "nt", reason="POSIX file modes are not enforced on Windows" +) +def test_aider_managed_directories_and_files_are_owner_only( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.aider import temporary_aider_config_files + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + base = tmp_path / ".fcc" / "tmp" / "aider" + base.mkdir(parents=True, mode=0o755) + base.chmod(0o755) + config = build_aider_config( + _models(), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env="FCC_AIDER_PROXY_AUTH_MODES", + ) + + with temporary_aider_config_files(config) as files: + assert base.stat().st_mode & 0o777 == 0o700 + assert files.directory.stat().st_mode & 0o777 == 0o700 + assert files.settings_path.stat().st_mode & 0o777 == 0o600 + assert files.metadata_path.stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.parametrize( + ("token", "preflight_error", "catalog", "expected"), + [ + (" ", None, _models_payload(), "token is empty"), + ("proxy-token", "connection refused", _models_payload(), "fcc-server"), + ("proxy-token", None, {"data": []}, "at least one routable"), + ], +) +def test_aider_launch_fails_before_child_for_invalid_preparation( + token: str, + preflight_error: str | None, + catalog: JsonObject, + expected: str, + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers import aider + + with ( + patch.object(aider, "resolve_client_binary", return_value="resolved-aider"), + patch.object(aider, "get_settings", return_value=_settings(token=token)), + patch.object(aider, "preflight_proxy", return_value=preflight_error), + patch.object( + aider, + "fetch_proxy_models_response", + return_value=catalog, + ), + patch.object(aider, "run_client_process") as run_client_process, + pytest.raises(SystemExit) as exc_info, + ): + aider.launch([]) + + assert exc_info.value.code == 1 + assert expected in capsys.readouterr().err + run_client_process.assert_not_called() + + +def test_aider_catalog_failure_is_reported_once_without_secret( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers import aider + + with ( + patch.object(aider, "resolve_client_binary", return_value="resolved-aider"), + patch.object(aider, "get_settings", return_value=_settings()), + patch.object(aider, "preflight_proxy", return_value=None), + patch.object( + aider, + "fetch_proxy_models_response", + side_effect=ValueError("bad catalog"), + ), + patch.object(aider, "run_client_process") as run_client_process, + pytest.raises(SystemExit) as exc_info, + ): + aider.launch([]) + + assert exc_info.value.code == 1 + error = capsys.readouterr().err + assert error.count("bad catalog") == 1 + assert "proxy-token" not in error + run_client_process.assert_not_called() + + +def test_aider_file_creation_error_does_not_launch_or_disclose_token( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers import aider + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + with ( + patch.object(aider, "resolve_client_binary", return_value="resolved-aider"), + patch.object(aider, "get_settings", return_value=_settings()), + patch.object(aider, "preflight_proxy", return_value=None), + patch.object( + aider, + "fetch_proxy_models_response", + return_value=_models_payload(), + ), + patch.object( + aider.tempfile, + "TemporaryDirectory", + side_effect=OSError("storage unavailable"), + ), + patch.object(aider, "run_client_process") as run_client_process, + pytest.raises(SystemExit) as exc_info, + ): + aider.launch([]) + + assert exc_info.value.code == 1 + error = capsys.readouterr().err + assert "storage unavailable" in error + assert "proxy-token" not in error + run_client_process.assert_not_called() + + +def test_aider_missing_binary_uses_shared_resolution_contract() -> None: + from free_claude_code.cli.launchers import aider + + with ( + patch.object( + aider, + "resolve_client_binary", + side_effect=SystemExit(127), + ) as resolve_client_binary, + patch.object(aider, "get_settings") as get_settings, + pytest.raises(SystemExit) as exc_info, + ): + aider.launch([]) + + assert exc_info.value.code == 127 + resolve_client_binary.assert_called_once_with( + binary_name="aider", + display_name="Aider", + install_hint="Install Aider from: https://aider.chat/docs/install.html", + ) + get_settings.assert_not_called() + + +def test_aider_config_repr_never_contains_the_proxy_token() -> None: + config = build_aider_config( + _models(), + messages_url="http://127.0.0.1:9191/v1/messages", + api_key_env="FCC_AIDER_PROXY_AUTH_SAFE", + ) + + assert "proxy-token" not in repr(config) + assert "127.0.0.1" not in repr(config) + + +def test_aider_random_credentials_are_uppercase_and_distinct() -> None: + from free_claude_code.cli.launchers.aider import new_aider_api_key_env_name + + with patch( + "free_claude_code.cli.launchers.aider.secrets.token_hex", + side_effect=["a1b2", "c3d4"], + ): + first = new_aider_api_key_env_name() + second = new_aider_api_key_env_name() + + assert first == "FCC_AIDER_PROXY_AUTH_A1B2" + assert second == "FCC_AIDER_PROXY_AUTH_C3D4" + assert first != second + + +def test_aider_temp_path_is_owned_by_fcc_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.config.paths import aider_temp_dir_path + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + assert aider_temp_dir_path() == tmp_path / ".fcc" / "tmp" / "aider" diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index 6bb336db..3ca8c757 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -70,6 +70,7 @@ def test_cli_scripts_are_registered() -> None: "fcc-dsh": "free_claude_code.cli.launchers.dsh:launch", "fcc-grok": "free_claude_code.cli.launchers.grok:launch", "fcc-muse": "free_claude_code.cli.launchers.muse:launch", + "fcc-aider": "free_claude_code.cli.launchers.aider:launch", } assert pyproject["project"]["gui-scripts"] == { "fcc-desktop": "free_claude_code.cli.desktop_entrypoint:launch", diff --git a/tests/cli/test_model_catalog.py b/tests/cli/test_model_catalog.py index 65defbdf..ecd1178c 100644 --- a/tests/cli/test_model_catalog.py +++ b/tests/cli/test_model_catalog.py @@ -161,6 +161,24 @@ def test_fetch_proxy_models_uses_canonical_bearer_request() -> None: assert request.get_header("Authorization") == "Bearer proxy-token" +def test_fetch_proxy_models_can_request_messages_view() -> None: + with patch( + "free_claude_code.cli.launchers.model_catalog.open_local_request", + return_value=_ModelsResponse(b'{"data": []}'), + ) as open_local_request: + response = fetch_proxy_models_response( + "http://127.0.0.1:9191/", + "proxy-token", + view="messages", + ) + + assert response == {"data": []} + request = open_local_request.call_args.args[0] + assert request.full_url == "http://127.0.0.1:9191/v1/models?view=messages" + assert request.get_method() == "GET" + assert request.get_header("Authorization") == "Bearer proxy-token" + + def test_fetch_proxy_models_rejects_non_object_json() -> None: with ( patch( diff --git a/tests/scripts/test_installers.py b/tests/scripts/test_installers.py index 83716fea..591b9f0a 100644 --- a/tests/scripts/test_installers.py +++ b/tests/scripts/test_installers.py @@ -23,6 +23,7 @@ FCC_COMMANDS = ( "fcc-dsh", "fcc-grok", "fcc-muse", + "fcc-aider", "fcc-init", "free-claude-code", ) @@ -149,6 +150,7 @@ if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "install" ]; then cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-dsh" cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-grok" cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-muse" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-aider" if [ "$FAIL_STEP" != "fcc-missing" ]; then cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-codex" fi @@ -338,7 +340,7 @@ while [ "$#" -gt 0 ]; do done echo "download:$url" >> "$CALL_LOG" case "$url:$FAIL_STEP" in - *claude.ai*:claude-download|*chatgpt.com*:codex-download|*pi.dev*:pi-download|*opencode.ai*:opencode-download|*hermes-agent.nousresearch.com*:hermes-download|*x.ai*:grok-download|*dev.meta.ai*:muse-download|*rtk-ai*:rtk-download|*astral.sh*:uv-download) + *claude.ai*:claude-download|*chatgpt.com*:codex-download|*pi.dev*:pi-download|*opencode.ai*:opencode-download|*hermes-agent.nousresearch.com*:hermes-download|*x.ai*:grok-download|*dev.meta.ai*:muse-download|*aider.chat*:aider-download|*rtk-ai*:rtk-download|*astral.sh*:uv-download) exit 41 ;; esac @@ -350,6 +352,7 @@ case "$url" in *hermes-agent.nousresearch.com*) source="$FAKE_FIXTURES/hermes-installer.sh" ;; *x.ai*) source="$FAKE_FIXTURES/grok-installer.sh" ;; *dev.meta.ai*) source="$FAKE_FIXTURES/muse-installer.sh" ;; + *aider.chat*) source="$FAKE_FIXTURES/aider-installer.sh" ;; *rtk-ai*) if [ "$FAIL_STEP" = "rtk-install" ]; then printf 'invalid archive\n' > "$output" @@ -438,6 +441,16 @@ echo "muse-install" >> "$CALL_LOG" mkdir -p "$HOME/.local/bin" cp "$FAKE_FIXTURES/muse-command.sh" "$HOME/.local/bin/muse" chmod +x "$HOME/.local/bin/muse" +""", + ) + _write_executable( + fixtures / "aider-installer.sh", + """#!/bin/sh +echo "aider-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "aider-install" ] && exit 29 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/aider-command.sh" "$HOME/.local/bin/aider" +chmod +x "$HOME/.local/bin/aider" """, ) _write_executable( @@ -459,6 +472,7 @@ chmod +x "$HOME/.local/bin/uv" _write_executable(fixtures / "dsh-command.sh", _posix_command("dsh")) _write_executable(fixtures / "grok-command.sh", _posix_command("grok")) _write_executable(fixtures / "muse-command.sh", _posix_command("muse")) + _write_executable(fixtures / "aider-command.sh", _posix_command("aider")) rtk_command = _posix_rtk_command().encode() with tarfile.open( fixtures / "rtk-x86_64-unknown-linux-musl.tar.gz", "w:gz" @@ -555,6 +569,7 @@ def test_install_sh_fresh_install_is_verified(posix_harness: PosixHarness) -> No ) assert calls.index("grok-install") < calls.index("grok:--version") assert calls.index("muse-install") < calls.index("muse:--version") + assert calls.index("aider-install") < calls.index("aider:--version") assert calls.index("uv-install") < calls.index("uv:--version") assert any( call.startswith( @@ -594,7 +609,7 @@ def test_install_sh_discovers_grok_in_custom_bin_directory( def test_install_sh_installs_selected_hermes_without_setup( posix_harness: PosixHarness, ) -> None: - result = posix_harness.run_interactive("n\nn\nn\nn\nn\ny\nn\nn\nn\nn\n") + result = posix_harness.run_interactive("n\nn\nn\nn\nn\ny\nn\nn\nn\nn\nn\n") assert result.returncode == 0, result.stdout calls = posix_harness.calls() @@ -614,7 +629,7 @@ def test_install_sh_stops_when_selected_hermes_install_fails( failure: str, ) -> None: result = posix_harness.run_interactive( - "n\nn\nn\nn\nn\ny\nn\nn\nn\nn\n", fail_step=failure + "n\nn\nn\nn\nn\ny\nn\nn\nn\nn\nn\n", fail_step=failure ) assert result.returncode != 0 @@ -628,7 +643,7 @@ def test_install_sh_rejects_unsupported_hermes_platform_before_download( posix_harness.env["FAKE_UNAME"] = "Darwin" posix_harness.env["FAKE_UNAME_MACHINE"] = "x86_64" - result = posix_harness.run_interactive("n\nn\nn\nn\nn\ny\nn\nn\nn\nn\n") + result = posix_harness.run_interactive("n\nn\nn\nn\nn\ny\nn\nn\nn\nn\nn\n") assert result.returncode != 0 assert "does not provide a supported release for Darwin x86_64" in result.stdout @@ -645,6 +660,7 @@ def test_install_sh_rejects_unsupported_hermes_platform_before_download( ("hermes", "hermes-install:--non-interactive --skip-setup"), ("grok", "grok-install"), ("muse", "muse-install"), + ("aider", "aider-install"), ], ) def test_install_sh_preserves_upstream_managed_harness_without_parsing_version( @@ -671,7 +687,7 @@ def test_install_sh_stops_when_grok_install_fails( failure: str, ) -> None: result = posix_harness.run_interactive( - "n\nn\nn\nn\nn\nn\nn\ny\nn\nn\n", fail_step=failure + "n\nn\nn\nn\nn\nn\nn\ny\nn\nn\nn\n", fail_step=failure ) assert result.returncode != 0 @@ -685,7 +701,7 @@ def test_install_sh_stops_when_muse_install_fails( failure: str, ) -> None: result = posix_harness.run_interactive( - "n\nn\nn\nn\nn\nn\nn\nn\ny\nn\n", fail_step=failure + "n\nn\nn\nn\nn\nn\nn\nn\ny\nn\nn\n", fail_step=failure ) assert result.returncode != 0 @@ -693,10 +709,51 @@ def test_install_sh_stops_when_muse_install_fails( assert not any(call.startswith("uv:") for call in posix_harness.calls()) +@pytest.mark.parametrize("failure", ["aider-download", "aider-install"]) +def test_install_sh_stops_when_aider_install_fails( + posix_harness: PosixHarness, + failure: str, +) -> None: + result = posix_harness.run_interactive( + "n\nn\nn\nn\nn\nn\nn\nn\nn\ny\nn\n", fail_step=failure + ) + + assert result.returncode != 0 + assert "Free Claude Code is installed and verified." not in result.stdout + assert not any(call.startswith("uv:") for call in posix_harness.calls()) + + +def test_install_sh_accepts_aider_as_the_only_selected_agent( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run_interactive("n\nn\nn\nn\nn\nn\nn\nn\nn\ny\nn\n") + + assert result.returncode == 0, result.stdout + calls = posix_harness.calls() + assert "download:https://aider.chat/install.sh" in calls + assert calls.index("aider-install") < calls.index("aider:--version") + assert "Run Aider with: fcc-aider" in result.stdout + assert "Select at least one coding agent." not in result.stdout + + +def test_install_sh_rejects_broken_existing_aider_without_replacing_it( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("aider") + + result = posix_harness.run(fail_step="aider-verify") + + assert result.returncode != 0 + calls = posix_harness.calls() + assert "aider:--version" in calls + assert "aider-install" not in calls + assert not any("aider.chat" in call for call in calls) + + def test_install_sh_installs_selected_dsh_at_exact_preview( posix_harness: PosixHarness, ) -> None: - result = posix_harness.run_interactive("n\nn\nn\nn\nn\nn\ny\nn\nn\nn\n") + result = posix_harness.run_interactive("n\nn\nn\nn\nn\nn\ny\nn\nn\nn\nn\n") assert result.returncode == 0, result.stdout calls = posix_harness.calls() @@ -749,7 +806,7 @@ def test_install_sh_rejects_incompatible_node_for_selected_dsh( _posix_command("node").replace("node 22.19.0", f"node {node_version}"), ) - result = posix_harness.run_interactive("n\nn\nn\nn\nn\nn\ny\nn\nn\nn\n") + result = posix_harness.run_interactive("n\nn\nn\nn\nn\nn\ny\nn\nn\nn\nn\n") assert result.returncode != 0 assert "Free Claude Code is installed and verified." not in result.stdout @@ -775,7 +832,7 @@ def test_install_sh_stops_when_selected_dsh_install_fails( posix_harness: PosixHarness, ) -> None: result = posix_harness.run_interactive( - "n\nn\nn\nn\nn\nn\ny\nn\nn\nn\n", fail_step="dsh-install" + "n\nn\nn\nn\nn\nn\ny\nn\nn\nn\nn\n", fail_step="dsh-install" ) assert result.returncode != 0 @@ -870,7 +927,7 @@ def test_install_sh_preserves_existing_rtk_and_configures_only_selected_agent( ) -> None: posix_harness.add_rtk() - result = posix_harness.run_interactive("n\ny\nn\nn\nn\nn\nn\nn\nn\ny\n") + result = posix_harness.run_interactive("n\ny\nn\nn\nn\nn\nn\nn\nn\nn\ny\n") assert result.returncode == 0, result.stdout assert "verifying it without updating it" in result.stdout @@ -917,9 +974,7 @@ def test_install_sh_stops_when_rtk_setup_fails( def test_install_sh_reprompts_then_installs_only_selected_agent( posix_harness: PosixHarness, ) -> None: - result = posix_harness.run_interactive( - "n\nn\nn\nn\nn\nn\nn\nn\nn\nn\ny\nn\nn\nn\nn\nn\nn\nn\nn\n" - ) + result = posix_harness.run_interactive("n\n" * 11 + "y\n" + "n\n" * 9) assert result.returncode == 0, result.stdout assert "Select at least one coding agent." in result.stdout @@ -939,7 +994,7 @@ def test_install_sh_rejects_uninstalled_only_selection( posix_harness: PosixHarness, ) -> None: result = posix_harness.run_interactive( - "n\nn\ny\nn\nn\nn\nn\nn\nn\nn\n", fail_step="pi-skip" + "n\nn\ny\nn\nn\nn\nn\nn\nn\nn\nn\n", fail_step="pi-skip" ) assert result.returncode != 0 @@ -1044,6 +1099,7 @@ def test_install_sh_preserves_valid_existing_tools( posix_harness.add_client("hermes") posix_harness.add_client("grok") posix_harness.add_client("muse") + posix_harness.add_client("aider") posix_harness.add_uv(uv_version) result = posix_harness.run() @@ -1577,6 +1633,7 @@ copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-hermes.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-dsh.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-grok.cmd" >nul copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-muse.cmd" >nul +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-aider.cmd" >nul if not "%FAIL_STEP%"=="fcc-missing" copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-codex.cmd" >nul exit /b 0 :update_shell @@ -1710,6 +1767,9 @@ def powershell_harness( (fixtures / "dsh-command.cmd").write_text(_batch_client("dsh"), encoding="utf-8") (fixtures / "grok-command.cmd").write_text(_batch_client("grok"), encoding="utf-8") (fixtures / "muse-command.cmd").write_text(_batch_client("muse"), encoding="utf-8") + (fixtures / "aider-command.cmd").write_text( + _batch_client("aider"), encoding="utf-8" + ) (fixtures / "rtk-command.cmd").write_text(_batch_rtk(), encoding="utf-8") (fixtures / "uv-command.cmd").write_text(_batch_uv("0.11.28"), encoding="utf-8") (fixtures / "fcc-command.cmd").write_text( @@ -1777,6 +1837,15 @@ $bin = if ($env:GROK_BIN_DIR) { $env:GROK_BIN_DIR } else { Join-Path $env:USERPR New-Item -ItemType Directory -Force -Path $bin | Out-Null Copy-Item (Join-Path $env:FAKE_FIXTURES "grok-command.cmd") (Join-Path $bin "grok.cmd") -Force Add-Content -LiteralPath $env:CALL_LOG -Value "grok-install" +""", + encoding="utf-8", + ) + (fixtures / "aider-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "aider-install") { exit 67 } +$bin = Join-Path $env:USERPROFILE ".local\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "aider-command.cmd") (Join-Path $bin "aider.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "aider-install" """, encoding="utf-8", ) @@ -1825,6 +1894,7 @@ function Invoke-RestMethod { ($env:FAIL_STEP -eq "opencode-download" -and $Uri.Contains("anomalyco/opencode")) -or ($env:FAIL_STEP -eq "hermes-download" -and $Uri.Contains("hermes-agent.nousresearch.com")) -or ($env:FAIL_STEP -eq "grok-download" -and $Uri.Contains("x.ai/cli")) -or + ($env:FAIL_STEP -eq "aider-download" -and $Uri.Contains("aider.chat")) -or ($env:FAIL_STEP -eq "rtk-download" -and $Uri.Contains("rtk-ai/rtk")) -or ($env:FAIL_STEP -eq "uv-download" -and $Uri.Contains("astral.sh")) ) { @@ -1845,6 +1915,9 @@ function Invoke-RestMethod { elseif ($Uri.Contains("x.ai/cli")) { $source = Join-Path $env:FAKE_FIXTURES "grok-installer.ps1" } + elseif ($Uri.Contains("aider.chat")) { + $source = Join-Path $env:FAKE_FIXTURES "aider-installer.ps1" + } elseif ($Uri.Contains("opencode-windows-")) { if ($env:FAIL_STEP -eq "opencode-archive") { Set-Content -LiteralPath $OutFile -Value "not a zip" @@ -1950,6 +2023,7 @@ def test_install_ps1_fresh_install_is_verified( "dsh:--version" ) assert calls.index("grok-install") < calls.index("grok:--version") + assert calls.index("aider-install") < calls.index("aider:--version") assert "Muse Code is not installed" in result.stdout assert not any(call.startswith("muse:") for call in calls) assert not any("hermes:setup" in call for call in calls) @@ -2021,6 +2095,7 @@ def test_install_ps1_discovers_grok_in_custom_bin_directory( ("hermes", "hermes-install:True:True"), ("grok", "grok-install"), ("muse", "meta.ai"), + ("aider", "aider-install"), ], ) def test_install_ps1_preserves_upstream_managed_harness_without_parsing_version( @@ -2053,6 +2128,32 @@ def test_install_ps1_stops_when_grok_install_fails( assert not any(call.startswith("uv:") for call in powershell_harness.calls()) +@pytest.mark.parametrize("failure", ["aider-download", "aider-install"]) +def test_install_ps1_stops_when_aider_install_fails( + powershell_harness: PowerShellHarness, + failure: str, +) -> None: + result = powershell_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert "Free Claude Code is installed and verified." not in result.stdout + assert not any(call.startswith("uv:") for call in powershell_harness.calls()) + + +def test_install_ps1_rejects_broken_existing_aider_without_replacing_it( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("aider") + + result = powershell_harness.run(fail_step="aider-verify") + + assert result.returncode != 0 + calls = powershell_harness.calls() + assert "aider:--version" in calls + assert "aider-install" not in calls + assert not any("aider.chat" in call for call in calls) + + def test_install_ps1_preserves_exact_dsh_preview( powershell_harness: PowerShellHarness, ) -> None: @@ -2359,6 +2460,7 @@ def test_install_ps1_preserves_valid_existing_tools( powershell_harness.add_client("cline") powershell_harness.add_client("hermes") powershell_harness.add_client("grok") + powershell_harness.add_client("aider") powershell_harness.add_uv(uv_version) result = powershell_harness.run() @@ -2666,6 +2768,8 @@ def test_installers_use_native_clients_and_single_python_selection() -> None: assert "https://x.ai/cli/install.sh" in shell assert "https://x.ai/cli/install.ps1" in powershell assert "https://dev.meta.ai/install.sh" in shell + assert "https://aider.chat/install.sh" in shell + assert "https://aider.chat/install.ps1" in powershell assert "dev.meta.ai" not in powershell assert "muse-code/channels" not in powershell @@ -2719,8 +2823,8 @@ Invoke-DownloadedPowerShellInstaller ` ("answers", "expected", "expected_messages"), [ ( - ("", "", "", "", "", "", "", "", "", ""), - "True,True,True,True,False,True,True,True,True,False", + ("", "", "", "", "", "", "", "", "", "", ""), + "True,True,True,True,False,True,True,True,True,True,False", (), ), ( @@ -2736,6 +2840,7 @@ Invoke-DownloadedPowerShellInstaller ` "n", "n", "n", + "n", "y", "n", "n", @@ -2744,9 +2849,10 @@ Invoke-DownloadedPowerShellInstaller ` "n", "n", "n", + "n", "y", ), - "False,True,False,False,False,False,False,False,False,True", + "False,True,False,False,False,False,False,False,False,False,True", ("Please answer Y or N.", "Select at least one coding agent."), ), ], @@ -2774,6 +2880,7 @@ $script:InstallHermes = $true $script:InstallDsh = $true $script:InstallGrok = $true $script:InstallMuse = $true +$script:InstallAider = $true $script:EnableRtk = $false function Read-Host {{ param([string] $Prompt) @@ -2784,7 +2891,7 @@ function Read-Host {{ function Read-YesNo {{{read_yes_no}}} function Select-CodingAgents {{{select_agents}}} Select-CodingAgents -Write-Output "selection:$($script:InstallClaudeCode),$($script:InstallCodex),$($script:InstallPi),$($script:InstallOpenCode),$($script:InstallCline),$($script:InstallHermes),$($script:InstallDsh),$($script:InstallGrok),$($script:InstallMuse),$($script:EnableRtk)" +Write-Output "selection:$($script:InstallClaudeCode),$($script:InstallCodex),$($script:InstallPi),$($script:InstallOpenCode),$($script:InstallCline),$($script:InstallHermes),$($script:InstallDsh),$($script:InstallGrok),$($script:InstallMuse),$($script:InstallAider),$($script:EnableRtk)" """ result = subprocess.run( @@ -2815,6 +2922,7 @@ $script:InstallHermes = $false $script:InstallDsh = $false $script:InstallGrok = $false $script:InstallMuse = $false +$script:InstallAider = $false $script:PiAvailable = $false $script:MuseAvailable = $false $script:Calls = @() @@ -2828,6 +2936,7 @@ function Ensure-Hermes {{ $script:Calls += "hermes" }} function Ensure-Dsh {{ $script:Calls += "dsh" }} function Ensure-Grok {{ $script:Calls += "grok" }} function Ensure-Muse {{ $script:Calls += "muse"; $script:MuseAvailable = $true }} +function Ensure-Aider {{ $script:Calls += "aider" }} function Ensure-SelectedCodingAgents {{{body}}} Ensure-SelectedCodingAgents Write-Output "calls:$($script:Calls -join ',')" @@ -2862,6 +2971,7 @@ $script:InstallHermes = $false $script:InstallDsh = $false $script:InstallGrok = $false $script:InstallMuse = $false +$script:InstallAider = $false $script:PiAvailable = $false $script:MuseAvailable = $false $script:Calls = @() @@ -2902,6 +3012,7 @@ $script:InstallHermes = $false $script:InstallDsh = $false $script:InstallGrok = $false $script:InstallMuse = $false +$script:InstallAider = $false $script:PiAvailable = $false $script:MuseAvailable = $false function Write-Step {{ param([string] $Message) }} @@ -2914,6 +3025,7 @@ function Ensure-Hermes {{ }} function Ensure-Dsh {{ }} function Ensure-Grok {{ }} function Ensure-Muse {{ }} +function Ensure-Aider {{ }} function Ensure-SelectedCodingAgents {{{body}}} Ensure-SelectedCodingAgents """ diff --git a/tests/scripts/test_uninstallers.py b/tests/scripts/test_uninstallers.py index 1de70aa6..b8a65e7a 100644 --- a/tests/scripts/test_uninstallers.py +++ b/tests/scripts/test_uninstallers.py @@ -18,6 +18,7 @@ FCC_COMMANDS = ( "fcc-dsh", "fcc-grok", "fcc-muse", + "fcc-aider", "fcc-init", "free-claude-code", ) @@ -144,6 +145,7 @@ def posix_uninstall_harness(tmp_path: Path) -> PosixUninstallHarness: _write_executable(bin_dir / "dsh", "#!/bin/sh\nexit 0\n") _write_executable(bin_dir / "grok", "#!/bin/sh\nexit 0\n") _write_executable(bin_dir / "muse", "#!/bin/sh\nexit 0\n") + _write_executable(bin_dir / "aider", "#!/bin/sh\nexit 0\n") hermes_state = home / ".hermes" / "sessions" / "state.json" hermes_state.parent.mkdir(parents=True) hermes_state.write_text('{"native": true}\n', encoding="utf-8") @@ -156,6 +158,8 @@ def posix_uninstall_harness(tmp_path: Path) -> PosixUninstallHarness: muse_state = home / ".local" / "share" / "muse" / "sessions" / "state.json" muse_state.parent.mkdir(parents=True) muse_state.write_text('{"native": true}\n', encoding="utf-8") + aider_state = home / ".aider.conf.yml" + aider_state.write_text("model: native\n", encoding="utf-8") _write_executable( bin_dir / "pgrep", """#!/bin/sh @@ -187,7 +191,7 @@ if [ "${1:-}" = "tool" ] && [ "${2:-}" = "uninstall" ]; then 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-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-init free-claude-code; do /bin/rm -f "$FAKE_TOOL_BIN/$name" done echo "Uninstalled free-claude-code" @@ -262,9 +266,13 @@ def test_uninstall_sh_removes_and_verifies_only_fcc( assert (posix_uninstall_harness.bin_dir / "dsh").exists() assert (posix_uninstall_harness.bin_dir / "grok").exists() assert (posix_uninstall_harness.bin_dir / "muse").exists() + assert (posix_uninstall_harness.bin_dir / "aider").exists() assert ( posix_uninstall_harness.home / ".hermes" / "sessions" / "state.json" ).read_text(encoding="utf-8") == '{"native": true}\n' + assert (posix_uninstall_harness.home / ".aider.conf.yml").read_text( + encoding="utf-8" + ) == "model: native\n" assert ( posix_uninstall_harness.home / ".dsh" / "sessions" / "state.json" ).read_text(encoding="utf-8") == '{"native": true}\n' @@ -535,6 +543,7 @@ def powershell_uninstall_harness( "dsh", "grok", "muse", + "aider", ): (bin_dir / f"{name}.cmd").write_text("@echo off\nexit /b 0\n", encoding="utf-8") hermes_state = local_app_data / "hermes" / "state.json" @@ -549,6 +558,8 @@ def powershell_uninstall_harness( muse_state = local_app_data / "Muse Code" / "sessions" / "state.json" muse_state.parent.mkdir(parents=True) muse_state.write_text('{"native": true}\n', encoding="utf-8") + aider_state = home / ".aider.conf.yml" + aider_state.write_text("model: native\n", encoding="utf-8") uv_commands = " ".join(FCC_COMMANDS) (bin_dir / "uv.cmd").write_text( @@ -679,9 +690,13 @@ def test_uninstall_ps1_removes_and_verifies_only_fcc( assert (powershell_uninstall_harness.bin_dir / "dsh.cmd").exists() assert (powershell_uninstall_harness.bin_dir / "grok.cmd").exists() assert (powershell_uninstall_harness.bin_dir / "muse.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "aider.cmd").exists() assert ( Path(powershell_uninstall_harness.env["LOCALAPPDATA"]) / "hermes" / "state.json" ).read_text(encoding="utf-8") == '{"native": true}\n' + assert (powershell_uninstall_harness.home / ".aider.conf.yml").read_text( + encoding="utf-8" + ) == "model: native\n" assert ( powershell_uninstall_harness.home / ".dsh" / "sessions" / "state.json" ).read_text(encoding="utf-8") == '{"native": true}\n' diff --git a/uv.lock b/uv.lock index 19779d03..f2c5c0f3 100644 --- a/uv.lock +++ b/uv.lock @@ -607,7 +607,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "5.14.10" +version = "5.15.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },