Add Aider as the tenth coding harness (#1583)
Some checks are pending
Dependency Cache / Warm dependency cache (push) Waiting to run

## Problem

Aider users cannot currently select FCC's provider-backed model catalog
or launch the native client through the supported installer flow.

## Changes

Add a process-local fcc-aider launcher backed by FCC's Anthropic
Messages endpoint, including authenticated model discovery, private
ephemeral routing files, explicit model-role normalization, and native
Aider state preservation. Add default-yes Windows and macOS/Linux
installer support, wrapper-only uninstall coverage, focused launcher and
installer contracts, Aider 0.86.2 acceptance coverage, concise README
guidance, and the single 5.15.0 feature bump.



<!-- greptile_comment -->

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

This change adds the `fcc-aider` launcher and integrates Aider with
FCC’s authenticated Anthropic Messages model catalog. It includes
process-local Aider configuration, installer support, documentation, and
product smoke coverage.
</details>


<h3>Confidence Score: 5/5</h3>

No blocking failure remains.

No accepted blocking findings remain after exercising the native Aider
configuration against the Anthropic Messages endpoint.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Installed transient Aider version 0.86.2 to prepare the test
environment.
- Ran a real Aider 0.86.2 process with FCC-shaped settings against a
local Anthropic Messages endpoint; it resolved the environment-backed
API key, requested /v1/messages, returned the expected response marker,
and exited successfully.
- Committed the native Aider smoke test to the repository to enable
end-to-end FCC testing.
- Ran the native Aider live test using the generated configuration, and
it completed an Anthropic Messages request to validate the end-to-end
workflow.
- Repository test collection was blocked by Python 3.11 constraints
related to forward annotations.

<a
href="https://app.greptile.com/trex/runs/20966695/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<sub>Reviews (2): Last reviewed commit: ["test: add native Aider smoke
coverage"](d2a78ec1b2)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=57388574)</sub>

<!-- /greptile_comment -->
This commit is contained in:
Ali Khokhar 2026-08-26 23:01:01 -07:00 committed by GitHub
parent aa5f9bc7fb
commit 1963c4e486
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1775 additions and 91 deletions

View file

@ -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/<provider>/<model>`. Use ordinary
`aider` when you want Aider's native providers instead.
<a id="model-picker"></a>
<div align="center">
@ -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.
<details>
@ -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:

View file

@ -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"

View file

@ -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 "<uv-tool-bin>\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."
}
}

View file

@ -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

View file

@ -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."
}

View file

@ -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

View file

@ -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 |

View file

@ -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",

View file

@ -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",

View file

@ -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"):

View file

@ -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"),
)

View file

@ -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")

View file

@ -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,
)

View file

@ -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)

View file

@ -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}"},

View file

@ -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."""

View file

@ -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(

View file

@ -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"

View file

@ -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,
)

View file

@ -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"

View file

@ -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",

View file

@ -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(

View file

@ -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
"""

View file

@ -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'

2
uv.lock generated
View file

@ -607,7 +607,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "5.14.10"
version = "5.15.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },