mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-08-31 09:54:34 +00:00
Add Aider as the tenth coding harness (#1583)
Some checks are pending
Dependency Cache / Warm dependency cache (push) Waiting to run
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:
parent
aa5f9bc7fb
commit
1963c4e486
25 changed files with 1775 additions and 91 deletions
19
README.md
19
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/<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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
328
src/free_claude_code/cli/launchers/aider.py
Normal file
328
src/free_claude_code/cli/launchers/aider.py
Normal 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,
|
||||
)
|
||||
51
src/free_claude_code/cli/launchers/aider_config.py
Normal file
51
src/free_claude_code/cli/launchers/aider_config.py
Normal 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)
|
||||
|
|
@ -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}"},
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
104
tests/cli/test_aider_config.py
Normal file
104
tests/cli/test_aider_config.py
Normal 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,
|
||||
)
|
||||
656
tests/cli/test_aider_launcher.py
Normal file
656
tests/cli/test_aider_launcher.py
Normal 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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -607,7 +607,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "free-claude-code"
|
||||
version = "5.14.10"
|
||||
version = "5.15.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue