shin / scripts /install.ps1
LastNoob's picture
Add coding-agent selection and validate PowerShell downloads (#1324)
beba61f unverified
Raw
History Blame Contribute Delete
24.3 kB
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"
# Windows on ARM emulates x64, whose Python package ecosystem has broader wheel support.
$PythonRequest = "cpython-3.14.0-windows-x86_64-none"
$MinUvVersion = "0.11.16"
$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"
$script:InstallClaudeCode = $true
$script:InstallCodex = $true
$script:InstallPi = $true
$script:PiAvailable = $false
$FccCommands = @(
# Include retired entry points so updates reject older FCC processes before replacement.
"fcc-desktop",
"fcc-server",
"fcc-claude",
"fcc-codex",
"fcc-pi",
"fcc-init",
"free-claude-code"
)
function Show-Usage {
@"
Usage: install.ps1 [options]
Installs or updates Free Claude Code and lets you choose which coding agents to install or verify.
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 Test-InteractiveInstaller {
return (-not [Console]::IsInputRedirected) -and (-not [Console]::IsOutputRedirected)
}
function Read-YesNo {
param([string] $Prompt)
while ($true) {
$answer = ([string] (Read-Host "$Prompt [Y/n]")).Trim().ToLowerInvariant()
if ($answer -in @("", "y", "yes")) {
return $true
}
if ($answer -in @("n", "no")) {
return $false
}
Write-Host "Please answer Y or N."
}
}
function Select-CodingAgents {
while ($true) {
$script:InstallClaudeCode = Read-YesNo "Install or verify Claude Code for fcc-claude?"
$script:InstallCodex = Read-YesNo "Install or verify Codex for fcc-codex?"
$script:InstallPi = Read-YesNo "Install or verify Pi for fcc-pi?"
if ($script:InstallClaudeCode -or $script:InstallCodex -or $script:InstallPi) {
return
}
Write-Host "Select at least one coding agent."
Write-Host ""
}
}
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-Utf8NativeCapture {
param(
[string] $FilePath,
[string[]] $Arguments = @()
)
$commandText = Format-Command -FilePath $FilePath -Arguments $Arguments
Write-Host "+ $commandText"
$originalOutputEncoding = [Console]::OutputEncoding
try {
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
$global:LASTEXITCODE = 0
$output = & $FilePath @Arguments
$exitCode = $LASTEXITCODE
}
finally {
[Console]::OutputEncoding = $originalOutputEncoding
}
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 Assert-NoFccProcessesRunning {
$running = @()
foreach ($commandName in $FccCommands) {
$processes = @(Get-Process -Name $commandName -ErrorAction SilentlyContinue)
foreach ($process in $processes) {
$running += "$commandName (PID $($process.Id))"
}
}
if ($running.Count -gt 0) {
throw "Free Claude Code is still running ($($running -join ', ')). Stop those processes, then rerun the installer."
}
}
function Invoke-DownloadedPowerShellInstaller {
param(
[string] $Url,
[string] $Name,
[switch] $NonInteractive
)
if ($DryRun) {
Write-Host "+ irm $Url -OutFile <temporary-script>"
$prefix = if ($NonInteractive) { "CODEX_NON_INTERACTIVE=1 " } else { "" }
Write-Host "+ ${prefix}powershell -NoProfile -ExecutionPolicy Bypass -File <temporary-script>"
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."
}
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$temporaryScript,
[ref] $tokens,
[ref] $parseErrors
) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "The downloaded $Name installer from '$Url' is not valid PowerShell. A network proxy or filter may have replaced it with an HTML response."
}
$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 {
$script:PiAvailable = $false
Add-PiBinDirectories
$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
if (-not $DryRun) {
$currentPi = Get-ApplicationCommand "pi"
$unchangedIncompatiblePi = (
$currentPi -and
$existingPi -and
$currentPi.Source -eq $existingPi.Source -and
-not (Test-PiApplication $currentPi)
)
if ((-not $currentPi) -or $unchangedIncompatiblePi) {
Write-Host "Pi was not installed; continuing without it."
return
}
}
}
Confirm-PiApplication
$script:PiAvailable = $true
}
function Ensure-SelectedCodingAgents {
if ($script:InstallClaudeCode) {
Write-Step "Ensuring Claude Code is installed"
Ensure-ClaudeCode
}
if ($script:InstallCodex) {
Write-Step "Ensuring Codex is installed"
Ensure-Codex
}
if ($script:InstallPi) {
Write-Step "Checking or installing Pi"
Ensure-Pi
}
if ((-not $script:InstallClaudeCode) -and (-not $script:InstallCodex) -and (-not $script:PiAvailable)) {
throw "No selected coding agent was installed. Re-run the installer and choose at least one."
}
}
function Convert-UvVersionOutput {
param([string] $Output)
if ([string]::IsNullOrWhiteSpace($Output)) {
return ""
}
if ($Output -match '(?m)(?:^|\s)(?:uv\s+)?(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?)\b') {
return $Matches["version"]
}
return ""
}
function Get-UvVersion {
param([string] $UvPath)
$output = Invoke-Utf8NativeCapture -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-SupportedUvVersion {
param(
[string] $Version,
[string] $Minimum
)
$parsedVersion = Convert-UvVersionOutput $Version
$parsedMinimum = Convert-UvVersionOutput $Minimum
if ([string]::IsNullOrWhiteSpace($parsedVersion) -or [string]::IsNullOrWhiteSpace($parsedMinimum)) {
throw "Unable to compare uv versions."
}
if ($parsedVersion.Contains("-")) {
return $false
}
$normalizedVersion = $parsedVersion -replace '\+.*$', ''
$normalizedMinimum = $parsedMinimum -replace '\+.*$', ''
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-SupportedUvVersion -Version $version -Minimum $MinUvVersion)) {
throw "Stable 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-SupportedUvVersion -Version $version -Minimum $MinUvVersion) {
Write-Host "uv $version already satisfies >=$MinUvVersion; leaving it unchanged."
return
}
Write-Host "uv $version does not satisfy stable >=$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 {
Assert-NoFccProcessesRunning
$packageSpec = Get-PackageSpec
$arguments = @(
"tool",
"install",
"--force",
"--refresh-package",
"free-claude-code",
"--python",
$PythonRequest
)
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 Export-FccDesktopIcon {
param(
[string] $DesktopCommand,
[string] $IconPath
)
$arguments = @("--export-icon", $IconPath)
$commandText = Format-Command -FilePath $DesktopCommand -Arguments $arguments
Write-Host "+ $commandText"
if ($DryRun) {
return
}
# PowerShell does not wait when directly invoking a Windows GUI executable.
$process = Start-Process `
-FilePath $DesktopCommand `
-ArgumentList @("--export-icon", ('"' + $IconPath + '"')) `
-WindowStyle Hidden `
-Wait `
-PassThru
try {
$exitCode = $process.ExitCode
}
finally {
$process.Dispose()
}
if ($exitCode -ne 0) {
throw "Command failed with exit code ${exitCode}: $commandText"
}
if (-not (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
throw "Free Claude Code did not export its Windows app icon to '$IconPath'."
}
}
function Configure-AndConfirmFreeClaudeCode {
$iconPath = Join-Path $env:USERPROFILE ".fcc\app-icon.ico"
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, and fcc-pi in the uv tool bin directory"
Write-Host "+ fcc-server --version"
Export-FccDesktopIcon `
-DesktopCommand "<uv-tool-bin>\fcc-desktop.exe" `
-IconPath $iconPath
Install-FccDesktopShortcuts `
-DesktopCommand "<uv-tool-bin>\fcc-desktop.exe" `
-IconPath $iconPath
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-Utf8NativeCapture -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-desktop", "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")
Export-FccDesktopIcon `
-DesktopCommand $installedCommands["fcc-desktop"] `
-IconPath $iconPath
Install-FccDesktopShortcuts `
-DesktopCommand $installedCommands["fcc-desktop"] `
-IconPath $iconPath
}
function Test-EquivalentPath {
param(
[string] $Left,
[string] $Right
)
if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) {
return $false
}
try {
return [string]::Equals(
[IO.Path]::GetFullPath($Left),
[IO.Path]::GetFullPath($Right),
[StringComparison]::OrdinalIgnoreCase
)
}
catch {
return $false
}
}
function Install-FccDesktopShortcuts {
param(
[string] $DesktopCommand,
[string] $IconPath
)
$shortcutPaths = @(
(Join-Path $env:USERPROFILE "Desktop\Free Claude Code.lnk"),
(Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Free Claude Code.lnk")
)
foreach ($shortcutPath in $shortcutPaths) {
Write-Host "+ create shortcut $(Format-Argument $shortcutPath) -> $(Format-Argument $DesktopCommand)"
}
if ($DryRun) {
return
}
$shell = New-Object -ComObject WScript.Shell
foreach ($shortcutPath in $shortcutPaths) {
if (Test-Path -LiteralPath $shortcutPath) {
try {
$existingShortcut = $shell.CreateShortcut($shortcutPath)
$isFccShortcut = Test-EquivalentPath -Left $existingShortcut.TargetPath -Right $DesktopCommand
}
catch {
$isFccShortcut = $false
}
if (-not $isFccShortcut) {
Write-Host "A shortcut not managed by Free Claude Code already exists at $shortcutPath; leaving it unchanged."
continue
}
}
$parent = Split-Path -Parent $shortcutPath
New-Item -ItemType Directory -Force -Path $parent | Out-Null
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $DesktopCommand
$shortcut.WorkingDirectory = $env:USERPROFILE
$shortcut.IconLocation = "$IconPath,0"
$shortcut.Description = "Run Free Claude Code in the background"
$shortcut.Save()
}
}
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 "Checking for running Free Claude Code processes"
Assert-NoFccProcessesRunning
if (Test-InteractiveInstaller) {
Write-Step "Choosing coding agents"
Select-CodingAgents
}
Ensure-SelectedCodingAgents
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. Open the Free Claude Code desktop shortcut to run it in the background."
Write-Host "For terminal use, start the proxy with: fcc-server"
if ($script:InstallClaudeCode) {
Write-Host "Run Claude Code with: fcc-claude"
}
if ($script:InstallCodex) {
Write-Host "Run Codex with: fcc-codex"
}
if ($script:PiAvailable) {
Write-Host "Run Pi with: fcc-pi"
}
}