param( [switch] $VoiceNim, [switch] $VoiceLocal, [switch] $VoiceAll, [string] $TorchBackend = "", [switch] $DryRun, [switch] $Help, [Parameter(ValueFromRemainingArguments = $true)] [object[]] $RemainingArgs = @() ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $RepoArchiveUrl = "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" $PythonVersion = "3.14.0" $MinUvVersion = "0.11.0" $ClaudeInstallUrl = "https://claude.ai/install.ps1" $CodexInstallUrl = "https://chatgpt.com/codex/install.ps1" $PiInstallUrl = "https://pi.dev/install.ps1" $UvInstallUrl = "https://astral.sh/uv/install.ps1" function Show-Usage { @" Usage: install.ps1 [options] Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code. Options: -VoiceNim Install NVIDIA NIM voice transcription support. -VoiceLocal Install local Whisper voice transcription support. -VoiceAll Install all voice transcription backends. -TorchBackend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice. -DryRun Print commands without running them. -Help Show this help text. "@ } function Write-Step { param([string] $Message) Write-Host "" Write-Host "==> $Message" } function Format-Argument { param([string] $Value) if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') { return $Value } return "'" + ($Value -replace "'", "''") + "'" } function Format-Command { param( [string] $FilePath, [string[]] $Arguments = @() ) $parts = @($FilePath) + $Arguments return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " } function Invoke-NativeCommand { param( [string] $FilePath, [string[]] $Arguments = @() ) $commandText = Format-Command -FilePath $FilePath -Arguments $Arguments Write-Host "+ $commandText" if ($DryRun) { return } $global:LASTEXITCODE = 0 & $FilePath @Arguments $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "Command failed with exit code ${exitCode}: $commandText" } } function Invoke-NativeCapture { param( [string] $FilePath, [string[]] $Arguments = @() ) $commandText = Format-Command -FilePath $FilePath -Arguments $Arguments Write-Host "+ $commandText" $global:LASTEXITCODE = 0 $output = & $FilePath @Arguments $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { throw "Command failed with exit code ${exitCode}: $commandText" } return ($output | Out-String).Trim() } function Get-ApplicationCommand { param([string] $Name) $commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue) if ($commands.Count -eq 0) { return $null } return $commands[0] } function Get-PowerShellExecutable { param([string] $PowerShellHome = $PSHOME) $executableName = if ($PSVersionTable.PSEdition -eq "Core") { "pwsh.exe" } else { "powershell.exe" } $bundledExecutable = Join-Path $PowerShellHome $executableName if (Test-Path -LiteralPath $bundledExecutable -PathType Leaf) { return $bundledExecutable } $pathCommand = Get-ApplicationCommand ([IO.Path]::GetFileNameWithoutExtension($executableName)) if ($pathCommand) { return $pathCommand.Source } throw "Unable to locate a PowerShell executable for the downloaded installer." } function Add-PathEntry { param([string] $PathEntry) if ([string]::IsNullOrWhiteSpace($PathEntry)) { return } $separator = [IO.Path]::PathSeparator $entries = @() if (-not [string]::IsNullOrEmpty($env:Path)) { $entries = $env:Path -split [regex]::Escape([string] $separator) } if ($entries -notcontains $PathEntry) { $env:Path = "$PathEntry$separator$env:Path" } } function Add-KnownBinDirectories { if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin") } if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { Add-PathEntry (Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin") Add-PathEntry (Join-Path $env:LOCALAPPDATA "pi-node\current") } if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { Add-PathEntry (Join-Path $env:APPDATA "npm") } } function Add-PiBinDirectories { if ($DryRun) { return } Add-KnownBinDirectories $npm = Get-ApplicationCommand "npm" if (-not $npm) { return } $prefix = (& $npm.Source prefix -g 2>$null | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) { $prefix = (& $npm.Source config get prefix 2>$null | Out-String).Trim() } if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($prefix)) { Add-PathEntry $prefix } } function Invoke-DownloadedPowerShellInstaller { param( [string] $Url, [string] $Name, [switch] $NonInteractive ) if ($DryRun) { Write-Host "+ irm $Url -OutFile " $prefix = if ($NonInteractive) { "CODEX_NON_INTERACTIVE=1 " } else { "" } Write-Host "+ ${prefix}powershell -NoProfile -ExecutionPolicy Bypass -File " return } $temporaryScript = Join-Path ([IO.Path]::GetTempPath()) ("fcc-install-" + [guid]::NewGuid().ToString("N") + ".ps1") try { Write-Host "+ irm $Url -OutFile $(Format-Argument $temporaryScript)" Invoke-RestMethod -Uri $Url -OutFile $temporaryScript -ErrorAction Stop if ((-not (Test-Path -LiteralPath $temporaryScript)) -or ((Get-Item -LiteralPath $temporaryScript).Length -eq 0)) { throw "The downloaded $Name installer was empty." } $powerShellPath = Get-PowerShellExecutable $hadNonInteractive = Test-Path Env:CODEX_NON_INTERACTIVE $previousNonInteractive = $env:CODEX_NON_INTERACTIVE try { if ($NonInteractive) { $env:CODEX_NON_INTERACTIVE = "1" } Invoke-NativeCommand -FilePath $powerShellPath -Arguments @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $temporaryScript ) } finally { if ($hadNonInteractive) { $env:CODEX_NON_INTERACTIVE = $previousNonInteractive } else { Remove-Item Env:CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue } } } finally { Remove-Item -LiteralPath $temporaryScript -Force -ErrorAction SilentlyContinue } } function Confirm-Application { param( [string] $CommandName, [string] $DisplayName ) if ($DryRun) { Write-Host "+ $CommandName --version" return } $command = Get-ApplicationCommand $CommandName if (-not $command) { throw "$DisplayName was installed, but '$CommandName' is not available on PATH." } Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version") } function Test-PiApplication { param($Command) try { $helpOutput = (& $Command.Source --help 2>$null | Out-String) } catch { return $false } return ( $LASTEXITCODE -eq 0 -and $helpOutput.Contains("--extension") -and $helpOutput.Contains("--models") ) } function Confirm-PiApplication { if ($DryRun) { Write-Host "+ pi --help (verify --extension and --models support)" Write-Host "+ pi --version" return } $command = Get-ApplicationCommand "pi" if (-not $command) { throw "Pi was installed, but 'pi' is not available on PATH." } if (-not (Test-PiApplication $command)) { throw "The 'pi' command at '$($command.Source)' is not a compatible Pi Coding Agent." } Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version") } function Ensure-ClaudeCode { if (Get-ApplicationCommand "claude") { Write-Host "Claude Code already found on PATH; verifying it." } else { Invoke-DownloadedPowerShellInstaller -Url $ClaudeInstallUrl -Name "Claude Code" Add-KnownBinDirectories } Confirm-Application -CommandName "claude" -DisplayName "Claude Code" } function Ensure-Codex { if (Get-ApplicationCommand "codex") { Write-Host "Codex already found on PATH; verifying it." } else { Invoke-DownloadedPowerShellInstaller -Url $CodexInstallUrl -Name "Codex" -NonInteractive Add-KnownBinDirectories } Confirm-Application -CommandName "codex" -DisplayName "Codex" } function Ensure-Pi { $existingPi = Get-ApplicationCommand "pi" if ($existingPi -and ($DryRun -or (Test-PiApplication $existingPi))) { Write-Host "Pi already found on PATH; verifying it." } else { if ($existingPi) { Write-Host "The existing 'pi' command at '$($existingPi.Source)' is not Pi Coding Agent; installing Pi." } Invoke-DownloadedPowerShellInstaller -Url $PiInstallUrl -Name "Pi" Add-PiBinDirectories } Confirm-PiApplication } function Convert-UvVersionOutput { param([string] $Output) if ([string]::IsNullOrWhiteSpace($Output)) { return "" } if ($Output -match '(?m)(?:^|\s)(?:uv\s+)?(?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?)\b') { return $Matches["version"] } return "" } function Get-UvVersion { param([string] $UvPath) $output = Invoke-NativeCapture -FilePath $UvPath -Arguments @("--version") $version = Convert-UvVersionOutput $output if ([string]::IsNullOrWhiteSpace($version)) { throw "uv is present, but 'uv --version' did not return a valid version." } return $version } function Test-UvVersionAtLeast { param( [string] $Version, [string] $Minimum ) $normalizedVersion = (Convert-UvVersionOutput $Version) -replace '[-+].*$', '' $normalizedMinimum = (Convert-UvVersionOutput $Minimum) -replace '[-+].*$', '' if ([string]::IsNullOrWhiteSpace($normalizedVersion) -or [string]::IsNullOrWhiteSpace($normalizedMinimum)) { throw "Unable to compare uv versions." } return ([version] $normalizedVersion) -ge ([version] $normalizedMinimum) } function Confirm-Uv { if ($DryRun) { Write-Host "+ uv --version" return } $uvCommand = Get-ApplicationCommand "uv" if (-not $uvCommand) { throw "uv was installed, but it is not available on PATH." } $version = Get-UvVersion $uvCommand.Source if (-not (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion)) { throw "uv $MinUvVersion or newer is required; found uv $version after installation." } Write-Host "Verified uv $version." } function Ensure-Uv { if ($DryRun) { if (Get-ApplicationCommand "uv") { Write-Host "+ uv --version" Write-Host "A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer." } else { Write-Host "uv is not installed; the current standalone uv would be installed." Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv" Confirm-Uv } return } $uvCommand = Get-ApplicationCommand "uv" if ($uvCommand) { $version = Get-UvVersion $uvCommand.Source if (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion) { Write-Host "uv $version already satisfies >=$MinUvVersion; leaving it unchanged." return } Write-Host "uv $version is below $MinUvVersion; installing the current standalone uv." } else { Write-Host "uv is not installed; installing the current standalone uv." } Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv" Add-KnownBinDirectories Confirm-Uv } function Get-PackageSpec { $includeNim = $VoiceNim $includeLocal = $VoiceLocal if ($VoiceAll) { $includeNim = $true $includeLocal = $true } if ($includeNim -and $includeLocal) { return "free-claude-code[voice,voice_local] @ $RepoArchiveUrl" } if ($includeNim) { return "free-claude-code[voice] @ $RepoArchiveUrl" } if ($includeLocal) { return "free-claude-code[voice_local] @ $RepoArchiveUrl" } return "free-claude-code @ $RepoArchiveUrl" } function Install-FreeClaudeCode { $packageSpec = Get-PackageSpec $arguments = @( "tool", "install", "--force", "--refresh-package", "free-claude-code", "--python", $PythonVersion ) if (-not [string]::IsNullOrWhiteSpace($TorchBackend)) { $arguments += @("--torch-backend", $TorchBackend) } $arguments += $packageSpec $uvPath = "uv" if (-not $DryRun) { $uvCommand = Get-ApplicationCommand "uv" if (-not $uvCommand) { throw "uv is not available for the Free Claude Code installation." } $uvPath = $uvCommand.Source } Invoke-NativeCommand -FilePath $uvPath -Arguments $arguments } function Configure-AndConfirmFreeClaudeCode { if ($DryRun) { Write-Host "+ uv tool update-shell" Write-Host "+ uv tool dir --bin" Write-Host "+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory" Write-Host "+ fcc-server --version" return } $uvCommand = Get-ApplicationCommand "uv" if (-not $uvCommand) { throw "uv is not available for PATH configuration." } Invoke-NativeCommand -FilePath $uvCommand.Source -Arguments @("tool", "update-shell") $toolBin = Invoke-NativeCapture -FilePath $uvCommand.Source -Arguments @("tool", "dir", "--bin") if ([string]::IsNullOrWhiteSpace($toolBin)) { throw "uv returned an empty tool bin directory." } Add-PathEntry $toolBin $toolBinPath = ([IO.Path]::GetFullPath($toolBin)).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) $installedCommands = @{} foreach ($commandName in @("fcc-server", "fcc-claude", "fcc-codex", "fcc-pi")) { $command = Get-ApplicationCommand $commandName if (-not $command) { throw "Free Claude Code installation did not create '$commandName'." } $commandDirectory = ([IO.Path]::GetFullPath((Split-Path -Parent $command.Source))).TrimEnd( [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar ) if (-not $commandDirectory.Equals($toolBinPath, [StringComparison]::OrdinalIgnoreCase)) { throw "'$commandName' resolved outside the uv tool bin directory: $($command.Source)" } $installedCommands[$commandName] = $command.Source } Invoke-NativeCommand -FilePath $installedCommands["fcc-server"] -Arguments @("--version") } if ($Help) { Show-Usage return } if ($RemainingArgs.Count -gt 0) { Show-Usage throw "Unknown option: $($RemainingArgs -join ' ')" } if ((-not [string]::IsNullOrWhiteSpace($TorchBackend)) -and (-not ($VoiceLocal -or $VoiceAll))) { throw "-TorchBackend requires -VoiceLocal or -VoiceAll." } Add-KnownBinDirectories Write-Step "Ensuring Claude Code is installed" Ensure-ClaudeCode Write-Step "Ensuring Codex is installed" Ensure-Codex Write-Step "Ensuring Pi is installed" Ensure-Pi Write-Step "Ensuring uv $MinUvVersion or newer is installed" Ensure-Uv Write-Step "Installing or updating Free Claude Code" Install-FreeClaudeCode Write-Step "Configuring PATH and verifying Free Claude Code" Configure-AndConfirmFreeClaudeCode Write-Host "" if ($DryRun) { Write-Host "Dry run complete. No changes were made." } else { Write-Host "Free Claude Code is installed and verified. Start the proxy with: fcc-server" Write-Host "Run Claude Code with: fcc-claude" Write-Host "Run Codex with: fcc-codex" Write-Host "Run Pi with: fcc-pi" }