mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-10-21 21:59:54 +00:00
2006 lines
70 KiB
PowerShell
2006 lines
70 KiB
PowerShell
param(
|
|
[switch] $VoiceLocal,
|
|
[string] $TorchBackend = "",
|
|
[switch] $RtkHeadroom,
|
|
[switch] $DryRun,
|
|
[switch] $Help,
|
|
[Parameter(ValueFromRemainingArguments = $true)]
|
|
[object[]] $RemainingArgs = @()
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = "Stop"
|
|
$ProgressPreference = "SilentlyContinue"
|
|
|
|
# Windows on ARM emulates x64, whose Python package ecosystem has broader wheel support.
|
|
$PythonRequest = "cpython-3.14.7-windows-x86_64-none"
|
|
$MinUvVersion = "0.12.13"
|
|
$ClaudeInstallUrl = "https://claude.ai/install.ps1"
|
|
$CodexInstallUrl = "https://chatgpt.com/codex/install.ps1"
|
|
$PiInstallUrl = "https://pi.dev/install.ps1"
|
|
$OpenCodeReleaseBaseUrl = "https://opencode.ai/files/bin"
|
|
$HermesInstallUrl = "https://hermes-agent.nousresearch.com/install.ps1"
|
|
$DshMinimumVersion = "0.2.0-rc.2"
|
|
$DshPackage = "@deepseek-ai/dsh@latest"
|
|
$GrokInstallUrl = "https://x.ai/cli/install.ps1"
|
|
$MuseInstallUrl = "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install-muse.ps1"
|
|
$RtkVersion = "0.44.2"
|
|
$HeadroomPackage = "headroom-ai[mcp]==0.40.0"
|
|
$RtkReleaseBaseUrl = "https://github.com/rtk-ai/rtk/releases/download/v$RtkVersion"
|
|
$RtkWindowsAssetName = "rtk-x86_64-pc-windows-msvc.zip"
|
|
$RtkWindowsAssetSha256 = "3a1e114edce9080f8a10663e9c87488363a82f14a5ca8aab2ad416817f89d47c"
|
|
$UvInstallUrl = "https://astral.sh/uv/install.ps1"
|
|
$script:InstallClaudeCode = $true
|
|
$script:InstallCodex = $true
|
|
$script:InstallPi = $true
|
|
$script:InstallOpenCode = $true
|
|
$script:InstallCline = $false
|
|
$script:InstallHermes = $true
|
|
$script:InstallDsh = $true
|
|
$script:InstallGrok = $true
|
|
$script:InstallMuse = $true
|
|
$script:InstallAider = $true
|
|
$script:PiAvailable = $false
|
|
$script:MuseAvailable = $false
|
|
$script:EnableRtkHeadroom = $RtkHeadroom.IsPresent
|
|
$FccInstallVersion = [Environment]::GetEnvironmentVariable("FCC_INSTALL_VERSION")
|
|
$script:InstallLog = ""
|
|
$script:InstallLogWarning = $false
|
|
$script:InstallSteps = [ordered] @{}
|
|
$script:CurrentInstallStep = ""
|
|
$script:InstallStepOutcome = "Completed"
|
|
$script:InstallerPath = $PSCommandPath
|
|
$script:InstallStageNumber = 0
|
|
$script:InstallStageCount = 0
|
|
$script:FccVerified = $false
|
|
$script:DesktopReady = $false
|
|
$script:HeadroomPath = ""
|
|
$FccCommands = @(
|
|
# Include retired entry points so updates reject older FCC processes before replacement.
|
|
"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-doctor",
|
|
"fcc-update",
|
|
"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:
|
|
-VoiceLocal Install local Whisper voice transcription support.
|
|
-TorchBackend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice.
|
|
-RtkHeadroom Install and configure RTK + Headroom token optimization.
|
|
-DryRun Print commands without running them.
|
|
-Help Show this help text.
|
|
"@
|
|
}
|
|
|
|
function Write-Step {
|
|
param([string] $Message)
|
|
|
|
Write-Host ""
|
|
Write-Host "==> $Message"
|
|
}
|
|
|
|
function Write-InstallLog {
|
|
param([string] $Message)
|
|
|
|
if (-not $script:InstallLog) { return }
|
|
try {
|
|
[IO.File]::AppendAllText($script:InstallLog, "$(Get-Date -Format o) $Message`n", [Text.UTF8Encoding]::new($false))
|
|
}
|
|
catch {
|
|
$script:InstallLog = ""
|
|
if (-not $script:InstallLogWarning) {
|
|
$script:InstallLogWarning = $true
|
|
Write-Warning "Could not write the installer log. Installation will continue. $($_.Exception.Message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Initialize-InstallLog {
|
|
if ($DryRun) { return }
|
|
try {
|
|
$directory = Join-Path $env:USERPROFILE ".fcc\logs"
|
|
[void] [IO.Directory]::CreateDirectory($directory)
|
|
$script:InstallLog = Join-Path $directory ("install-$(Get-Date -Format yyyyMMdd-HHmmss)-$([guid]::NewGuid().ToString('N')).log")
|
|
Write-InstallLog "Starting Free Claude Code installer"
|
|
}
|
|
catch {
|
|
$script:InstallLogWarning = $true
|
|
Write-Warning "Could not create the installer log. Installation will continue. $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Complete-InstallStep {
|
|
param([string] $Outcome = $script:InstallStepOutcome)
|
|
|
|
if (-not $script:CurrentInstallStep) { return }
|
|
$script:InstallSteps[$script:CurrentInstallStep] = $Outcome
|
|
Write-InstallLog "$Outcome`: $script:CurrentInstallStep"
|
|
$script:CurrentInstallStep = ""
|
|
}
|
|
|
|
function Start-InstallStep {
|
|
param([string] $Name, [string] $Message = $Name)
|
|
|
|
if ($DryRun) { Write-Step $Message; return }
|
|
if ($script:InstallSteps.Count -eq 0) { return }
|
|
Complete-InstallStep
|
|
$script:CurrentInstallStep = $Name
|
|
$script:InstallStepOutcome = "Completed"
|
|
$script:InstallSteps[$Name] = "Running"
|
|
Write-InstallLog "Starting: $Name"
|
|
if ($script:InstallStageCount -gt 0) {
|
|
$script:InstallStageNumber++
|
|
Write-Host "`n[$($script:InstallStageNumber)/$($script:InstallStageCount)] $Message"
|
|
}
|
|
}
|
|
|
|
function Get-InstallerRetryCommand {
|
|
$options = @()
|
|
$prefix = ""
|
|
$installerRef = "main"
|
|
if ($FccInstallVersion) {
|
|
$prefix = "`$env:FCC_INSTALL_VERSION = '$FccInstallVersion'; "
|
|
$installerRef = "v$FccInstallVersion"
|
|
}
|
|
if ($VoiceLocal) { $options += "-VoiceLocal" }
|
|
if ($TorchBackend) { $options += @("-TorchBackend", $TorchBackend) }
|
|
if ($script:EnableRtkHeadroom) { $options += "-RtkHeadroom" }
|
|
if ($script:InstallerPath -and (Test-Path -LiteralPath $script:InstallerPath -PathType Leaf)) {
|
|
return $prefix + "& " + (Format-Command $script:InstallerPath $options)
|
|
}
|
|
return $prefix + "& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/Alishahryar1/free-claude-code/$installerRef/scripts/install.ps1'))) " + (($options | ForEach-Object { Format-Argument $_ }) -join " ")
|
|
}
|
|
|
|
function Show-InstallReport {
|
|
param([bool] $Succeeded, [bool] $Cancelled = $false)
|
|
|
|
if ($DryRun) { return }
|
|
if (-not $Succeeded) {
|
|
if ($Cancelled) { Write-Host "`nInstallation cancelled." }
|
|
else { Write-Host "`nInstallation did not finish." }
|
|
foreach ($outcome in @("Completed", "Reused", "Skipped", "Failed", "Cancelled", "Not attempted")) {
|
|
$labels = @($script:InstallSteps.GetEnumerator() | Where-Object { $_.Value -eq $outcome } | ForEach-Object { $_.Key })
|
|
if ($labels.Count -gt 0) { Write-Host "${outcome}: $($labels -join ', ')" }
|
|
}
|
|
Write-Host "Completed changes have been kept. Rerun the installer to try again."
|
|
Write-Host "Retry: $(Get-InstallerRetryCommand)"
|
|
if (-not $Cancelled) { Write-Host "For help, include the terminal error and the installer log." }
|
|
}
|
|
if ($script:InstallLog) { Write-Host "Installer log: $script:InstallLog" }
|
|
}
|
|
|
|
function Write-WindowsLaunchGuidance {
|
|
param([string] $FilePath, [Exception] $Exception, [string] $Stderr = "")
|
|
|
|
$policyFailure = $Stderr -match '\(os error (4551|1260)\)'
|
|
for ($cause = $Exception; $null -ne $cause; $cause = $cause.InnerException) {
|
|
if ($cause -is [ComponentModel.Win32Exception] -and $cause.NativeErrorCode -in @(4551, 1260)) {
|
|
$policyFailure = $true
|
|
}
|
|
# Start-Process on Windows PowerShell drops the native error code.
|
|
foreach ($code in @(4551, 1260)) {
|
|
if ($cause.Message.Contains([ComponentModel.Win32Exception]::new($code).Message)) {
|
|
$policyFailure = $true
|
|
}
|
|
}
|
|
}
|
|
if ($policyFailure) {
|
|
$guidance = @"
|
|
Windows application policy prevented this command from running: '$FilePath'.
|
|
Inspect recent policy events to identify the blocked file, then ask your administrator to allow the required application:
|
|
Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-CodeIntegrity/Operational'; Id=3077,3089; StartTime=(Get-Date).AddMinutes(-15)} | Format-List TimeCreated,Id,Message
|
|
"@
|
|
Write-Host $guidance
|
|
Write-InstallLog $guidance
|
|
}
|
|
}
|
|
|
|
function Test-InteractiveInstaller {
|
|
return (-not [Console]::IsInputRedirected) -and (-not [Console]::IsOutputRedirected)
|
|
}
|
|
|
|
function Read-YesNo {
|
|
param(
|
|
[string] $Prompt,
|
|
[bool] $DefaultYes = $false
|
|
)
|
|
|
|
while ($true) {
|
|
$hint = if ($DefaultYes) { "[Y/n]" } else { "[y/N]" }
|
|
$answer = ([string] (Read-Host "$Prompt $hint")).Trim().ToLowerInvariant()
|
|
if ($answer -eq "") {
|
|
return $DefaultYes
|
|
}
|
|
if ($answer -in @("y", "yes")) {
|
|
return $true
|
|
}
|
|
if ($answer -in @("n", "no")) {
|
|
return $false
|
|
}
|
|
Write-Host "Please answer Y or N."
|
|
}
|
|
}
|
|
|
|
function Find-InstalledCodingAgent {
|
|
param([string] $CommandName)
|
|
|
|
$originalPath = $env:Path
|
|
try {
|
|
if ($CommandName -eq "opencode" -and $script:OriginalOpenCode) {
|
|
return $script:OriginalOpenCode
|
|
}
|
|
if ($CommandName -in @("pi", "cline", "dsh")) {
|
|
try {
|
|
Add-NpmBinDirectories
|
|
}
|
|
catch {
|
|
# An optional lookup must not prevent choosing other harnesses.
|
|
}
|
|
}
|
|
$command = Get-ApplicationCommand $CommandName
|
|
if (-not $command) {
|
|
if ($CommandName -eq "aider") {
|
|
if ($env:UV_TOOL_BIN_DIR) {
|
|
Add-PathEntry $env:UV_TOOL_BIN_DIR
|
|
}
|
|
elseif ($env:XDG_BIN_HOME) {
|
|
Add-PathEntry $env:XDG_BIN_HOME
|
|
}
|
|
elseif ($env:XDG_DATA_HOME) {
|
|
Add-PathEntry (Join-Path $env:XDG_DATA_HOME "..\bin")
|
|
}
|
|
elseif ($env:USERPROFILE) {
|
|
Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin")
|
|
}
|
|
$command = Get-ApplicationCommand $CommandName
|
|
}
|
|
elseif ($CommandName -eq "muse") {
|
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
if (-not [string]::IsNullOrWhiteSpace($userPath)) {
|
|
$env:Path = "$originalPath$([IO.Path]::PathSeparator)$userPath"
|
|
$command = Get-ApplicationCommand $CommandName
|
|
}
|
|
}
|
|
}
|
|
if ($CommandName -eq "pi" -and $command -and (-not $DryRun)) {
|
|
if (-not (Test-PiApplication $command)) {
|
|
return $null
|
|
}
|
|
}
|
|
return $command
|
|
}
|
|
finally {
|
|
# Environment variables are process-wide, even inside a function.
|
|
$env:Path = $originalPath
|
|
}
|
|
}
|
|
|
|
function Read-CodingAgentSelection {
|
|
param(
|
|
[string] $CommandName,
|
|
[string] $DisplayName,
|
|
[string] $FccCommand
|
|
)
|
|
|
|
if (Find-InstalledCodingAgent $CommandName) {
|
|
Write-Host "$DisplayName already installed; will verify."
|
|
return $true
|
|
}
|
|
return Read-YesNo -Prompt "Install $DisplayName for ${FccCommand}?"
|
|
}
|
|
|
|
function Select-CodingAgents {
|
|
while ($true) {
|
|
$script:InstallClaudeCode = Read-CodingAgentSelection claude "Claude Code" fcc-claude
|
|
$script:InstallCodex = Read-CodingAgentSelection codex Codex fcc-codex
|
|
$script:InstallPi = Read-CodingAgentSelection pi Pi fcc-pi
|
|
$script:InstallOpenCode = Read-CodingAgentSelection opencode OpenCode fcc-opencode
|
|
$script:InstallCline = Read-CodingAgentSelection cline "Cline CLI" fcc-cline
|
|
$script:InstallHermes = Read-CodingAgentSelection hermes "Hermes Agent" fcc-hermes
|
|
$script:InstallDsh = Read-CodingAgentSelection dsh "DeepSeek Harness" fcc-dsh
|
|
$script:InstallGrok = Read-CodingAgentSelection grok "Grok Build" fcc-grok
|
|
$script:InstallMuse = Read-CodingAgentSelection muse "Muse Code" fcc-muse
|
|
$script:InstallAider = Read-CodingAgentSelection aider Aider fcc-aider
|
|
|
|
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."
|
|
Write-Host ""
|
|
}
|
|
|
|
if (-not $script:EnableRtkHeadroom) {
|
|
$script:EnableRtkHeadroom = Read-YesNo `
|
|
-Prompt "Enable RTK + Headroom token optimization?" `
|
|
-DefaultYes $false
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
Write-InstallLog "+ $commandText"
|
|
try {
|
|
$global:LASTEXITCODE = 0
|
|
& $FilePath @Arguments
|
|
$exitCode = $LASTEXITCODE
|
|
Write-InstallLog "Exit code ${exitCode}: $commandText"
|
|
if ($exitCode -ne 0) {
|
|
throw "Command failed with exit code ${exitCode}: $commandText"
|
|
}
|
|
}
|
|
catch {
|
|
Write-InstallLog $_.Exception.Message
|
|
Write-WindowsLaunchGuidance -FilePath $FilePath -Exception $_.Exception
|
|
throw
|
|
}
|
|
}
|
|
|
|
function Invoke-Utf8NativeCapture {
|
|
param(
|
|
[string] $FilePath,
|
|
[string[]] $Arguments = @()
|
|
)
|
|
|
|
$commandText = Format-Command -FilePath $FilePath -Arguments $Arguments
|
|
Write-Host "+ $commandText"
|
|
if ($DryRun) { return "" }
|
|
Write-InstallLog "+ $commandText"
|
|
$originalOutputEncoding = [Console]::OutputEncoding
|
|
$originalErrorPreference = $ErrorActionPreference
|
|
try {
|
|
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
|
$ErrorActionPreference = "Continue"
|
|
$global:LASTEXITCODE = $null
|
|
$captured = @(& $FilePath @Arguments 2>&1)
|
|
$exitCode = $LASTEXITCODE
|
|
}
|
|
catch {
|
|
Write-InstallLog $_.Exception.Message
|
|
Write-WindowsLaunchGuidance -FilePath $FilePath -Exception $_.Exception
|
|
throw
|
|
}
|
|
finally {
|
|
[Console]::OutputEncoding = $originalOutputEncoding
|
|
$ErrorActionPreference = $originalErrorPreference
|
|
}
|
|
$output = ($captured | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] } | Out-String).Trim()
|
|
$errors = @($captured | Where-Object { $_ -is [Management.Automation.ErrorRecord] })
|
|
$stderr = ($errors | ForEach-Object { $_.ToString() } | Out-String).Trim()
|
|
Write-InstallLog "Exit code ${exitCode}: $commandText`n$output`n$stderr"
|
|
if ($null -eq $exitCode -or $exitCode -ne 0) {
|
|
$cause = if ($errors.Count) { $errors[0].Exception } else { $null }
|
|
Write-WindowsLaunchGuidance -FilePath $FilePath -Exception $cause -Stderr $stderr
|
|
throw "Command failed with exit code ${exitCode}: $commandText`n$output`n$stderr"
|
|
}
|
|
|
|
return $output
|
|
}
|
|
|
|
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 Prioritize-PathEntry {
|
|
param([string] $PathEntry)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($PathEntry)) {
|
|
return
|
|
}
|
|
|
|
$separator = [IO.Path]::PathSeparator
|
|
$env:Path = "$PathEntry$separator$env:Path"
|
|
}
|
|
|
|
function Add-KnownBinDirectories {
|
|
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
|
|
Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin")
|
|
Add-PathEntry (Join-Path $env:USERPROFILE ".opencode\bin")
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
|
Add-PathEntry (Join-Path $env:LOCALAPPDATA "hermes\hermes-agent\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")
|
|
Add-PathEntry (Join-Path $env:LOCALAPPDATA "Programs\Muse Code\bin")
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
|
|
Add-PathEntry (Join-Path $env:APPDATA "npm")
|
|
}
|
|
if ($env:GROK_BIN_DIR) {
|
|
Add-PathEntry $env:GROK_BIN_DIR
|
|
}
|
|
elseif (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
|
|
Add-PathEntry (Join-Path $env:USERPROFILE ".grok\bin")
|
|
}
|
|
}
|
|
|
|
function Add-NpmBinDirectories {
|
|
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,
|
|
[string[]] $ScriptArguments = @()
|
|
)
|
|
|
|
if ($DryRun) {
|
|
Write-Host "+ irm $Url -OutFile <temporary-script>"
|
|
$prefix = if ($NonInteractive) { "CODEX_NON_INTERACTIVE=1 " } else { "" }
|
|
$suffix = if ($ScriptArguments.Count -gt 0) {
|
|
" " + (($ScriptArguments | ForEach-Object { Format-Argument $_ }) -join " ")
|
|
}
|
|
else {
|
|
""
|
|
}
|
|
Write-Host "+ ${prefix}powershell -NoProfile -ExecutionPolicy Bypass -File <temporary-script>$suffix"
|
|
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"
|
|
}
|
|
$installerArguments = @(
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
$temporaryScript
|
|
) + $ScriptArguments
|
|
Invoke-NativeCommand -FilePath $powerShellPath -Arguments $installerArguments
|
|
}
|
|
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."
|
|
}
|
|
Write-Host (Invoke-Utf8NativeCapture -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."
|
|
}
|
|
Write-Host (Invoke-Utf8NativeCapture -FilePath $command.Source -Arguments @("--version"))
|
|
}
|
|
|
|
function Install-Rtk {
|
|
$archiveUrl = "$RtkReleaseBaseUrl/$RtkWindowsAssetName"
|
|
if ($DryRun) {
|
|
Write-Host "+ irm $archiveUrl -OutFile <temporary-archive>"
|
|
Write-Host "+ verify pinned SHA-256 for $RtkWindowsAssetName"
|
|
Write-Host "+ extract and install rtk.exe to ~/.local/bin"
|
|
return
|
|
}
|
|
|
|
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("fcc-rtk-" + [guid]::NewGuid().ToString("N"))
|
|
$archivePath = Join-Path $temporaryRoot $RtkWindowsAssetName
|
|
$extractPath = Join-Path $temporaryRoot "extracted"
|
|
try {
|
|
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
|
|
|
Write-Host "+ irm $archiveUrl -OutFile $(Format-Argument $archivePath)"
|
|
Invoke-RestMethod -Uri $archiveUrl -OutFile $archivePath -ErrorAction Stop
|
|
if ((-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) -or ((Get-Item -LiteralPath $archivePath).Length -eq 0)) {
|
|
throw "The RTK release archive was empty."
|
|
}
|
|
|
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
|
$archiveStream = [IO.File]::OpenRead($archivePath)
|
|
try {
|
|
$actualHash = [BitConverter]::ToString($sha256.ComputeHash($archiveStream)).Replace("-", "").ToLowerInvariant()
|
|
}
|
|
finally {
|
|
$archiveStream.Dispose()
|
|
$sha256.Dispose()
|
|
}
|
|
if ($actualHash -ne $RtkWindowsAssetSha256) {
|
|
throw "RTK checksum verification failed for $RtkWindowsAssetName."
|
|
}
|
|
|
|
Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath
|
|
$extractedExecutable = Join-Path $extractPath "rtk.exe"
|
|
if (-not (Test-Path -LiteralPath $extractedExecutable -PathType Leaf)) {
|
|
throw "The verified RTK archive did not contain rtk.exe."
|
|
}
|
|
|
|
$installDirectory = Join-Path $env:USERPROFILE ".local\bin"
|
|
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
|
|
Copy-Item -LiteralPath $extractedExecutable -Destination (Join-Path $installDirectory "rtk.exe") -Force
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $temporaryRoot) {
|
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-RtkCommand {
|
|
param([string[]] $Arguments)
|
|
|
|
if ($DryRun) {
|
|
Write-Host "+ RTK_TELEMETRY_DISABLED=1 $(Format-Command -FilePath 'rtk' -Arguments $Arguments)"
|
|
return
|
|
}
|
|
|
|
$command = Get-ApplicationCommand "rtk"
|
|
if (-not $command) {
|
|
throw "RTK was installed, but 'rtk' is not available on PATH."
|
|
}
|
|
|
|
$hadTelemetryDisabled = Test-Path Env:RTK_TELEMETRY_DISABLED
|
|
$previousTelemetryDisabled = $env:RTK_TELEMETRY_DISABLED
|
|
try {
|
|
$env:RTK_TELEMETRY_DISABLED = "1"
|
|
if ($Arguments[0] -in @("--version", "gain")) {
|
|
Write-Host (Invoke-Utf8NativeCapture -FilePath $command.Source -Arguments $Arguments)
|
|
}
|
|
else {
|
|
Invoke-NativeCommand -FilePath $command.Source -Arguments $Arguments
|
|
}
|
|
}
|
|
finally {
|
|
if ($hadTelemetryDisabled) {
|
|
$env:RTK_TELEMETRY_DISABLED = $previousTelemetryDisabled
|
|
}
|
|
else {
|
|
Remove-Item Env:RTK_TELEMETRY_DISABLED -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
|
|
function Ensure-RtkClaudeConfigDirectory {
|
|
$claudeConfigDirectory = $env:CLAUDE_CONFIG_DIR
|
|
if ([string]::IsNullOrWhiteSpace($claudeConfigDirectory)) {
|
|
$claudeConfigDirectory = Join-Path $env:USERPROFILE ".claude"
|
|
}
|
|
|
|
if ($DryRun) {
|
|
Write-Host "+ mkdir $(Format-Argument $claudeConfigDirectory)"
|
|
return
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path $claudeConfigDirectory | Out-Null
|
|
}
|
|
|
|
function Confirm-RtkApplication {
|
|
if ($DryRun) {
|
|
Invoke-RtkCommand -Arguments @("--version")
|
|
Invoke-RtkCommand -Arguments @("gain")
|
|
return
|
|
}
|
|
|
|
$command = Get-ApplicationCommand "rtk"
|
|
if (-not $command) {
|
|
throw "RTK was installed, but 'rtk' is not available on PATH."
|
|
}
|
|
|
|
try {
|
|
Invoke-RtkCommand -Arguments @("--version")
|
|
Invoke-RtkCommand -Arguments @("gain")
|
|
}
|
|
catch {
|
|
throw "The 'rtk' command at '$($command.Source)' is not a compatible Rust Token Killer installation. Remove the conflicting command from PATH, then rerun the installer. $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Ensure-Rtk {
|
|
if (Get-ApplicationCommand "rtk") {
|
|
Write-Host "RTK already found on PATH; verifying it without updating it."
|
|
}
|
|
else {
|
|
Install-Rtk
|
|
Add-KnownBinDirectories
|
|
}
|
|
|
|
Confirm-RtkApplication
|
|
}
|
|
|
|
function Configure-RtkForSelectedAgents {
|
|
if (-not $script:EnableRtkHeadroom) {
|
|
return
|
|
}
|
|
|
|
Write-Step "Installing and configuring RTK token optimization"
|
|
Ensure-Rtk
|
|
|
|
if ($script:InstallClaudeCode) {
|
|
Ensure-RtkClaudeConfigDirectory
|
|
Invoke-RtkCommand -Arguments @("init", "--global", "--auto-patch")
|
|
}
|
|
if ($script:InstallCodex) {
|
|
Invoke-RtkCommand -Arguments @("init", "--global", "--codex")
|
|
}
|
|
if ($script:InstallPi -and $script:PiAvailable) {
|
|
Invoke-RtkCommand -Arguments @("init", "--global", "--agent", "pi")
|
|
}
|
|
if ($script:InstallCline) {
|
|
Write-Host "Optional for each project: cd <project>; `$env:RTK_TELEMETRY_DISABLED='1'; rtk init --agent cline"
|
|
}
|
|
}
|
|
|
|
function Ensure-Headroom {
|
|
$command = Get-ApplicationCommand "headroom"
|
|
if ($command) {
|
|
$script:HeadroomPath = $command.Source
|
|
Write-Host "Headroom already found on PATH; verifying it without updating it."
|
|
}
|
|
else {
|
|
Invoke-NativeCommand -FilePath "uv" -Arguments @("tool", "install", "--python", $PythonRequest, $HeadroomPackage)
|
|
if ($DryRun) {
|
|
$script:HeadroomPath = "<uv-tool-bin>\headroom.exe"
|
|
}
|
|
else {
|
|
$uvCommand = Get-ApplicationCommand "uv"
|
|
$toolBin = Add-UvToolBinDirectory -UvPath $uvCommand.Source
|
|
$command = Get-ApplicationCommand "headroom"
|
|
if (-not $command -or (Split-Path -Parent $command.Source) -ne $toolBin) {
|
|
throw "Headroom installation did not create its command in the uv tool bin directory."
|
|
}
|
|
$script:HeadroomPath = $command.Source
|
|
}
|
|
}
|
|
Invoke-NativeCommand -FilePath $script:HeadroomPath -Arguments @("--version")
|
|
Invoke-NativeCommand -FilePath $script:HeadroomPath -Arguments @("mcp", "install", "--help")
|
|
}
|
|
|
|
function Configure-HeadroomForSelectedAgents {
|
|
# Headroom 0.40.0's OpenCode registrar uses the pre-v2 schema.
|
|
Write-Host "Headroom setup supports Claude Code, Codex, and Grok. Other selected agents are skipped."
|
|
$agents = @()
|
|
if ($script:InstallClaudeCode) { $agents += "claude" }
|
|
if ($script:InstallCodex) { $agents += "codex" }
|
|
if ($script:InstallGrok) { $agents += "grok" }
|
|
if ($agents.Count -eq 0) {
|
|
Write-Host "Headroom registration skipped: no supported agents selected."
|
|
return
|
|
}
|
|
$previousPath = $env:PATH
|
|
try {
|
|
# Upstream resolves the server executable from PATH.
|
|
$env:PATH = (Split-Path -Parent $script:HeadroomPath) + [IO.Path]::PathSeparator + $env:PATH
|
|
foreach ($agent in $agents) {
|
|
Invoke-NativeCommand -FilePath $script:HeadroomPath -Arguments @("mcp", "install", "--agent", $agent)
|
|
}
|
|
}
|
|
finally {
|
|
$env:PATH = $previousPath
|
|
}
|
|
}
|
|
|
|
function Ensure-ClaudeCode {
|
|
if (Get-ApplicationCommand "claude") {
|
|
$script:InstallStepOutcome = "Reused"
|
|
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") {
|
|
$script:InstallStepOutcome = "Reused"
|
|
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-NpmBinDirectories
|
|
$existingPi = Get-ApplicationCommand "pi"
|
|
if ($existingPi -and ($DryRun -or (Test-PiApplication $existingPi))) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
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-NpmBinDirectories
|
|
|
|
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) {
|
|
$script:InstallStepOutcome = "Skipped"
|
|
Write-Host "Pi was not installed; continuing without it."
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
Confirm-PiApplication
|
|
$script:PiAvailable = $true
|
|
}
|
|
|
|
function Convert-SemanticVersionOutput {
|
|
param([string] $Output)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Output)) {
|
|
return ""
|
|
}
|
|
if ($Output -match '(?m)^\s*(?:(?:uv|opencode|cline|dsh|node)(?:\s+version)?\s+|Hermes Agent\s+v?|v)?(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?)(?:\s+\([^\r\n]*\))?\s*$') {
|
|
return $Matches["version"]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function Test-SupportedStableVersion {
|
|
param(
|
|
[string] $Version,
|
|
[string] $Minimum
|
|
)
|
|
|
|
$parsedVersion = Convert-SemanticVersionOutput $Version
|
|
$parsedMinimum = Convert-SemanticVersionOutput $Minimum
|
|
if ([string]::IsNullOrWhiteSpace($parsedVersion) -or [string]::IsNullOrWhiteSpace($parsedMinimum)) {
|
|
throw "Unable to compare semantic versions."
|
|
}
|
|
if ($parsedVersion.Contains("-")) {
|
|
return $false
|
|
}
|
|
|
|
$normalizedVersion = $parsedVersion -replace '\+.*$', ''
|
|
$normalizedMinimum = $parsedMinimum -replace '\+.*$', ''
|
|
return ([version] $normalizedVersion) -ge ([version] $normalizedMinimum)
|
|
}
|
|
|
|
function Read-OpenCodeVersionOutput {
|
|
param([string] $OpenCodePath)
|
|
|
|
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("fcc-opencode-version-" + [guid]::NewGuid().ToString("N"))
|
|
$stdoutPath = Join-Path $temporaryRoot "stdout.txt"
|
|
$stderrPath = Join-Path $temporaryRoot "stderr.txt"
|
|
$process = $null
|
|
try {
|
|
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
|
$process = Start-Process -FilePath $OpenCodePath -ArgumentList @("--version") -WindowStyle Hidden -PassThru -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
|
|
# Windows PowerShell needs the handle retained to report the exit code.
|
|
[void] $process.Handle
|
|
if (-not $process.WaitForExit(10000)) {
|
|
& "$env:SYSTEMROOT\System32\taskkill.exe" /PID $process.Id /T /F *> $null
|
|
[void] $process.WaitForExit(5000)
|
|
throw "OpenCode version probe timed out at '$OpenCodePath'."
|
|
}
|
|
if ($process.ExitCode -ne 0) {
|
|
throw "OpenCode version probe failed at '$OpenCodePath' (exit code $($process.ExitCode))."
|
|
}
|
|
return [IO.File]::ReadAllText($stdoutPath)
|
|
}
|
|
finally {
|
|
if ($null -ne $process) { $process.Dispose() }
|
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Get-OpenCodeVersion {
|
|
param([string] $OpenCodePath)
|
|
|
|
$output = Read-OpenCodeVersionOutput $OpenCodePath
|
|
if ($output -match '(?m)^\s*(?:opencode(?:\s+version)?\s+)?v?(?<version>\d+\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?)\s*$') {
|
|
return $Matches["version"]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function Get-OpenCodeRtkPlugin {
|
|
$pluginPath = Join-Path $env:USERPROFILE ".config\opencode\plugins\rtk.ts"
|
|
try {
|
|
$plugin = Get-Item -LiteralPath $pluginPath -Force -ErrorAction Stop
|
|
}
|
|
catch [System.Management.Automation.ItemNotFoundException] {
|
|
return $null
|
|
}
|
|
if ($plugin.PSIsContainer -or ($plugin.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
|
throw "Disable or migrate the RTK plugin at '$pluginPath' manually, then rerun the installer."
|
|
}
|
|
# These include the backup's parent; a junction must not redirect either move.
|
|
foreach ($relativePath in @(".config", ".config\opencode", ".config\opencode\plugins")) {
|
|
$parent = Get-Item -LiteralPath (Join-Path $env:USERPROFILE $relativePath) -Force -ErrorAction Stop
|
|
if ($parent.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
|
throw "The RTK plugin directory is linked: '$($parent.FullName)'. Disable or migrate it manually, then rerun the installer."
|
|
}
|
|
}
|
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$hash = [BitConverter]::ToString($sha256.ComputeHash([IO.File]::ReadAllBytes($pluginPath))).Replace("-", "").ToLowerInvariant()
|
|
}
|
|
finally {
|
|
$sha256.Dispose()
|
|
}
|
|
if ($hash -ne "6530c131946c84892f9522abd68d4e513e1e658d8ddbad1f59388c86ebbcb6bb") {
|
|
throw "The RTK plugin at '$pluginPath' was modified or is unrecognized. Disable or migrate it manually, then rerun the installer."
|
|
}
|
|
return $pluginPath
|
|
}
|
|
|
|
function Get-OpenCodeWindowsAssetName {
|
|
$architecture = $env:PROCESSOR_ARCHITEW6432
|
|
if ([string]::IsNullOrWhiteSpace($architecture)) {
|
|
$architecture = $env:PROCESSOR_ARCHITECTURE
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($architecture)) {
|
|
$architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
|
}
|
|
|
|
switch ($architecture.ToUpperInvariant()) {
|
|
"ARM64" { return "opencode-windows-arm64.zip" }
|
|
"AMD64" { return "opencode-windows-x64-baseline.zip" }
|
|
"X64" { return "opencode-windows-x64-baseline.zip" }
|
|
"X86_64" { return "opencode-windows-x64-baseline.zip" }
|
|
default { throw "OpenCode does not provide a supported Windows release for architecture '$architecture'." }
|
|
}
|
|
}
|
|
|
|
function Assert-NoOpenCodeProcessesRunning {
|
|
if (@(Get-Process -Name opencode,opencode2 -ErrorAction SilentlyContinue).Count -gt 0) {
|
|
throw "Close OpenCode before replacing its executable or RTK plugin, then rerun the installer."
|
|
}
|
|
}
|
|
|
|
function Install-OpenCode {
|
|
$assetName = Get-OpenCodeWindowsAssetName
|
|
$installDirectory = Join-Path $env:USERPROFILE ".opencode\bin"
|
|
if ($DryRun) {
|
|
Write-Host "+ resolve latest stable OpenCode 2 and download $assetName"
|
|
Write-Host "+ extract and install opencode.exe to $(Format-Argument $installDirectory)"
|
|
return
|
|
}
|
|
|
|
$release = Invoke-RestMethod -Uri "https://opencode.ai/update/api/latest/cli/npm" -ErrorAction Stop
|
|
if ($release.version -isnot [string] -or $release.version -notmatch '^2\.\d+\.\d+$') {
|
|
throw "The OpenCode release channel did not return a stable OpenCode 2 version."
|
|
}
|
|
$archiveUrl = "$OpenCodeReleaseBaseUrl/$($release.version)/$assetName"
|
|
|
|
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("fcc-opencode-" + [guid]::NewGuid().ToString("N"))
|
|
$archivePath = Join-Path $temporaryRoot $assetName
|
|
$extractPath = Join-Path $temporaryRoot "extracted"
|
|
$temporaryInstallPath = Join-Path $installDirectory (".opencode-" + [guid]::NewGuid().ToString("N") + ".exe")
|
|
try {
|
|
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
|
|
Write-Host "+ irm $archiveUrl -OutFile $(Format-Argument $archivePath)"
|
|
Invoke-RestMethod -Uri $archiveUrl -OutFile $archivePath -ErrorAction Stop
|
|
if ((-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) -or ((Get-Item -LiteralPath $archivePath).Length -eq 0)) {
|
|
throw "The OpenCode release archive was empty."
|
|
}
|
|
|
|
Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath
|
|
$executables = @(Get-ChildItem -LiteralPath $extractPath -Recurse -File -Filter "opencode.exe")
|
|
if ($executables.Count -ne 1) {
|
|
throw "The OpenCode release archive did not contain exactly one opencode.exe."
|
|
}
|
|
$version = Get-OpenCodeVersion $executables[0].FullName
|
|
if (($version -replace '\+.*$', '') -ne $release.version) {
|
|
throw "The OpenCode archive did not contain the selected stable version $($release.version)."
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
|
|
Copy-Item -LiteralPath $executables[0].FullName -Destination $temporaryInstallPath
|
|
if ((-not (Test-Path -LiteralPath $temporaryInstallPath -PathType Leaf)) -or ((Get-Item -LiteralPath $temporaryInstallPath).Length -eq 0)) {
|
|
throw "The extracted OpenCode executable was empty."
|
|
}
|
|
Assert-NoOpenCodeProcessesRunning
|
|
Move-Item -LiteralPath $temporaryInstallPath -Destination (Join-Path $installDirectory "opencode.exe") -Force
|
|
}
|
|
finally {
|
|
Remove-Item -LiteralPath $temporaryInstallPath -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Get-OpenCodeInstallState {
|
|
$command = if ($script:OriginalOpenCode) { $script:OriginalOpenCode } else { Get-ApplicationCommand "opencode" }
|
|
$nativePath = Join-Path $env:USERPROFILE ".opencode\bin\opencode.exe"
|
|
$install = $true
|
|
if ($command) {
|
|
$version = Get-OpenCodeVersion $command.Source
|
|
if ($version -match '^2\.') {
|
|
$install = $false
|
|
}
|
|
elseif ($version -match '^1\.') {
|
|
if (-not (Test-EquivalentPath $command.Source $nativePath)) {
|
|
throw "OpenCode 1 at '$($command.Source)' requires manual migration. Remove it with its package manager (npm: npm uninstall -g opencode-ai), then rerun this installer. See https://opencode.ai/v2/docs/migrate-v1/"
|
|
}
|
|
}
|
|
else {
|
|
throw "OpenCode at '$($command.Source)' is not a recognized stable v1 or v2. Correct that installation, then rerun the installer. See https://opencode.ai/v2/docs/migrate-v1/"
|
|
}
|
|
}
|
|
$pluginPath = Get-OpenCodeRtkPlugin
|
|
if ($install -or $pluginPath) {
|
|
Assert-NoOpenCodeProcessesRunning
|
|
}
|
|
if ($install) {
|
|
foreach ($target in @((Join-Path $env:USERPROFILE ".opencode"), (Split-Path -Parent $nativePath), $nativePath)) {
|
|
$item = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
|
|
if ($null -ne $item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
|
throw "OpenCode installation path is linked: '$target'. Migrate it manually."
|
|
}
|
|
}
|
|
}
|
|
return [pscustomobject] @{ Install = $install; PluginPath = $pluginPath }
|
|
}
|
|
|
|
function Ensure-OpenCode {
|
|
$command = if ($script:OriginalOpenCode) { $script:OriginalOpenCode } else { Get-ApplicationCommand "opencode" }
|
|
$nativePath = Join-Path $env:USERPROFILE ".opencode\bin\opencode.exe"
|
|
if ($DryRun) {
|
|
Write-Host "+ opencode --version"
|
|
Write-Host "Install stable OpenCode 2 if absent, or migrate v1 at '$nativePath'; external v1 requires manual upgrade."
|
|
Write-Host "Check and back up the recognized old OpenCode RTK plugin if present."
|
|
if (-not $command) { Install-OpenCode }
|
|
return
|
|
}
|
|
|
|
$state = Get-OpenCodeInstallState
|
|
$pluginPath = $state.PluginPath
|
|
if ($state.Install) {
|
|
Install-OpenCode
|
|
Add-KnownBinDirectories
|
|
if ((Get-OpenCodeVersion $nativePath) -notmatch '^2\.') {
|
|
throw "The OpenCode installer did not install stable OpenCode 2 at '$nativePath'."
|
|
}
|
|
}
|
|
else {
|
|
if (-not $pluginPath) { $script:InstallStepOutcome = "Reused" }
|
|
}
|
|
# Check the command selected after the installer's PATH additions.
|
|
$command = Get-ApplicationCommand "opencode"
|
|
if (-not $command) { throw "OpenCode is not available on PATH after installation." }
|
|
$version = Get-OpenCodeVersion $command.Source
|
|
if ($version -notmatch '^2\.') {
|
|
throw "OpenCode at '$($command.Source)' is not stable OpenCode 2. Correct PATH, then rerun the installer."
|
|
}
|
|
Write-Host "Verified OpenCode $version at '$($command.Source)'."
|
|
if ($pluginPath) {
|
|
$pluginPath = Get-OpenCodeRtkPlugin
|
|
if (-not $pluginPath) { return }
|
|
$backupPath = Join-Path (Split-Path -Parent (Split-Path -Parent $pluginPath)) ("rtk-v1-" + [guid]::NewGuid().ToString("N") + ".bak")
|
|
Assert-NoOpenCodeProcessesRunning
|
|
Move-Item -LiteralPath $pluginPath -Destination $backupPath -ErrorAction Stop
|
|
Write-Host "OpenCode 2 RTK support is unavailable; the old plugin was saved at '$backupPath'."
|
|
}
|
|
}
|
|
|
|
function Ensure-Cline {
|
|
Add-NpmBinDirectories
|
|
|
|
$command = Get-ApplicationCommand "cline"
|
|
if ($command) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
Write-Host "Cline already found on PATH; verifying it."
|
|
}
|
|
else {
|
|
$npm = Get-ApplicationCommand "npm"
|
|
if (-not $npm) {
|
|
throw "Cline installation requires npm. Install Node.js from https://nodejs.org/en/download, then rerun the installer."
|
|
}
|
|
Invoke-NativeCommand -FilePath $npm.Source -Arguments @("install", "-g", "cline")
|
|
Add-NpmBinDirectories
|
|
}
|
|
|
|
Confirm-Application -CommandName "cline" -DisplayName "Cline"
|
|
}
|
|
|
|
function Confirm-HermesArchitecture {
|
|
$architecture = $env:PROCESSOR_ARCHITEW6432
|
|
if ([string]::IsNullOrWhiteSpace($architecture)) {
|
|
$architecture = $env:PROCESSOR_ARCHITECTURE
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($architecture)) {
|
|
$architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
|
}
|
|
if ($architecture.ToUpperInvariant() -notin @("ARM64", "AMD64", "X64", "X86_64")) {
|
|
throw "Hermes Agent does not provide a supported Windows release for architecture '$architecture'."
|
|
}
|
|
}
|
|
|
|
function Install-Hermes {
|
|
Confirm-HermesArchitecture
|
|
Invoke-DownloadedPowerShellInstaller `
|
|
-Url $HermesInstallUrl `
|
|
-Name "Hermes Agent" `
|
|
-ScriptArguments @("-NonInteractive")
|
|
Add-KnownBinDirectories
|
|
}
|
|
|
|
function Ensure-Hermes {
|
|
$command = Get-ApplicationCommand "hermes"
|
|
if ($command) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
Write-Host "Hermes Agent already found on PATH; verifying it."
|
|
}
|
|
else {
|
|
Install-Hermes
|
|
}
|
|
|
|
Confirm-Application -CommandName "hermes" -DisplayName "Hermes Agent"
|
|
}
|
|
|
|
function Install-Grok {
|
|
Invoke-DownloadedPowerShellInstaller -Url $GrokInstallUrl -Name "Grok Build"
|
|
Add-KnownBinDirectories
|
|
}
|
|
|
|
function Ensure-Grok {
|
|
$command = Get-ApplicationCommand "grok"
|
|
if ($command) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
Write-Host "Grok Build already found on PATH; verifying it."
|
|
}
|
|
else {
|
|
Install-Grok
|
|
}
|
|
|
|
Confirm-Application -CommandName "grok" -DisplayName "Grok Build"
|
|
}
|
|
|
|
function Install-Aider {
|
|
$uvPath = "uv"
|
|
if (-not $DryRun) {
|
|
$uvCommand = Get-ApplicationCommand "uv"
|
|
if (-not $uvCommand) {
|
|
throw "Aider installation requires the verified uv command, but it is not available on PATH."
|
|
}
|
|
$uvPath = $uvCommand.Source
|
|
}
|
|
|
|
Invoke-NativeCommand -FilePath $uvPath -Arguments @(
|
|
"tool",
|
|
"install",
|
|
"--force",
|
|
"--python",
|
|
"python3.12",
|
|
"--with",
|
|
"pip",
|
|
"aider-chat@latest"
|
|
)
|
|
}
|
|
|
|
function Add-UvToolBinDirectory {
|
|
param([string] $UvPath)
|
|
|
|
$toolBin = Invoke-Utf8NativeCapture -FilePath $UvPath -Arguments @("tool", "dir", "--bin")
|
|
if ([string]::IsNullOrWhiteSpace($toolBin)) {
|
|
throw "uv returned an empty tool bin directory."
|
|
}
|
|
|
|
Add-PathEntry $toolBin
|
|
return $toolBin
|
|
}
|
|
|
|
function Ensure-Aider {
|
|
$command = Get-ApplicationCommand "aider"
|
|
if ((-not $command) -and (-not $DryRun)) {
|
|
$uvCommand = Get-ApplicationCommand "uv"
|
|
if (-not $uvCommand) {
|
|
throw "Aider installation requires the verified uv command, but it is not available on PATH."
|
|
}
|
|
$null = Add-UvToolBinDirectory -UvPath $uvCommand.Source
|
|
$command = Get-ApplicationCommand "aider"
|
|
}
|
|
|
|
if ($command) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
Write-Host "Aider already found on PATH; verifying it."
|
|
}
|
|
else {
|
|
Install-Aider
|
|
}
|
|
|
|
Confirm-Application -CommandName "aider" -DisplayName "Aider"
|
|
}
|
|
|
|
function Ensure-Muse {
|
|
$script:MuseAvailable = $false
|
|
Invoke-DownloadedPowerShellInstaller -Url $MuseInstallUrl -Name "Muse Code"
|
|
Add-KnownBinDirectories
|
|
$commandName = "muse"
|
|
if (-not $DryRun) {
|
|
$command = Find-InstalledCodingAgent "muse"
|
|
if (-not $command) {
|
|
throw "Muse Code was installed, but 'muse' is not available on PATH."
|
|
}
|
|
$commandName = $command.Source
|
|
}
|
|
Confirm-Application -CommandName $commandName -DisplayName "Muse Code"
|
|
$script:MuseAvailable = $true
|
|
}
|
|
|
|
function Get-DshVersion {
|
|
param([string] $DshPath)
|
|
|
|
$output = Invoke-Utf8NativeCapture -FilePath $DshPath -Arguments @("--version")
|
|
if ($output -notmatch '(?m)^\s*(?:dsh\s+)?v?(?<version>(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)\s*$') {
|
|
throw "DeepSeek Harness is present, but 'dsh --version' did not return a semantic version."
|
|
}
|
|
return $Matches['version']
|
|
}
|
|
|
|
function Test-DshVersion {
|
|
param([string] $Version)
|
|
|
|
$versionParts = ($Version -replace '\+.*$', '') -split '-', 2
|
|
$minimumParts = $DshMinimumVersion -split '-', 2
|
|
if ($versionParts[0] -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
|
|
return $false
|
|
}
|
|
$preview = @()
|
|
if ($versionParts.Count -gt 1) {
|
|
$preview = @($versionParts[1] -split '\.')
|
|
foreach ($part in $preview) {
|
|
if ($part -notmatch '^[0-9A-Za-z-]+$' -or $part -match '^0[0-9]+$') { return $false }
|
|
}
|
|
}
|
|
$release = [version] $versionParts[0]
|
|
$minimum = [version] $minimumParts[0]
|
|
if ($release -ne $minimum) { return $release -gt $minimum }
|
|
if ($preview.Count -eq 0) { return $true }
|
|
$floor = @($minimumParts[1] -split '\.')
|
|
for ($index = 0; $index -lt [Math]::Min($preview.Count, $floor.Count); $index++) {
|
|
$part = $preview[$index]
|
|
$base = $floor[$index]
|
|
if ($part -ceq $base) { continue }
|
|
$numeric = $part -match '^[0-9]+$'
|
|
$baseNumeric = $base -match '^[0-9]+$'
|
|
if ($numeric -and $baseNumeric) {
|
|
if ($part.Length -ne $base.Length) { return $part.Length -gt $base.Length }
|
|
}
|
|
elseif ($numeric -ne $baseNumeric) { return $baseNumeric }
|
|
return [string]::CompareOrdinal($part, $base) -gt 0
|
|
}
|
|
return $preview.Count -ge $floor.Count
|
|
}
|
|
|
|
function Get-DshNodeVersion {
|
|
param([string] $NodePath)
|
|
|
|
$output = Invoke-Utf8NativeCapture -FilePath $NodePath -Arguments @("--version")
|
|
$version = Convert-SemanticVersionOutput $output
|
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
|
throw "DeepSeek Harness requires a readable Node.js version."
|
|
}
|
|
return $version
|
|
}
|
|
|
|
function Test-DshNodeVersion {
|
|
param([string] $Version)
|
|
|
|
try {
|
|
$parsed = [version] (($Version -replace '^v', '') -replace '[-+].*$', '')
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
return (
|
|
(($parsed.Major -eq 22) -and ($parsed.Minor -ge 19)) -or
|
|
($parsed.Major -ge 24)
|
|
)
|
|
}
|
|
|
|
function Test-DshToolchain {
|
|
$node = Get-ApplicationCommand "node"
|
|
$npm = Get-ApplicationCommand "npm"
|
|
if ((-not $node) -or (-not $npm)) {
|
|
return $false
|
|
}
|
|
try {
|
|
return (Test-DshNodeVersion -Version (Get-DshNodeVersion $node.Source))
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Confirm-DshToolchain {
|
|
$node = Get-ApplicationCommand "node"
|
|
if (-not $node) {
|
|
throw "DeepSeek Harness requires Node.js ^22.19.0 or >=24.0.0 and npm. Install Node.js, then rerun the installer."
|
|
}
|
|
$npm = Get-ApplicationCommand "npm"
|
|
if (-not $npm) {
|
|
throw "DeepSeek Harness requires npm. Install npm, then rerun the installer."
|
|
}
|
|
$version = Get-DshNodeVersion $node.Source
|
|
if (-not (Test-DshNodeVersion $version)) {
|
|
throw "DeepSeek Harness requires Node.js ^22.19.0 or >=24.0.0; found Node.js $version."
|
|
}
|
|
return $npm.Source
|
|
}
|
|
|
|
function Confirm-DshApplication {
|
|
if ($DryRun) {
|
|
Write-Host "+ dsh --version"
|
|
return
|
|
}
|
|
|
|
$command = Get-ApplicationCommand "dsh"
|
|
if (-not $command) {
|
|
throw "DeepSeek Harness was installed, but 'dsh' is not available on PATH."
|
|
}
|
|
$version = Get-DshVersion $command.Source
|
|
if (-not (Test-DshVersion $version)) {
|
|
throw "DeepSeek Harness requires >=$DshMinimumVersion; found $version after installation."
|
|
}
|
|
Write-Host "Verified DeepSeek Harness $version."
|
|
}
|
|
|
|
function Install-Dsh {
|
|
$npmPath = Confirm-DshToolchain
|
|
Invoke-NativeCommand -FilePath $npmPath -Arguments @("install", "-g", $DshPackage)
|
|
Add-NpmBinDirectories
|
|
}
|
|
|
|
function Ensure-Dsh {
|
|
Add-NpmBinDirectories
|
|
|
|
if ($DryRun) {
|
|
if (Get-ApplicationCommand "dsh") {
|
|
Write-Host "+ dsh --version"
|
|
Write-Host "DeepSeek Harness >=$DshMinimumVersion will be preserved; an older version will be upgraded to latest."
|
|
}
|
|
else {
|
|
$node = Get-ApplicationCommand "node"
|
|
$npm = Get-ApplicationCommand "npm"
|
|
if ((-not $node) -or (-not $npm)) {
|
|
throw "DeepSeek Harness requires Node.js ^22.19.0 or >=24.0.0 and npm. Install Node.js, then rerun the installer."
|
|
}
|
|
$npmPath = $npm.Source
|
|
Write-Host "+ $(Format-Command -FilePath $npmPath -Arguments @('install', '-g', $DshPackage))"
|
|
}
|
|
Confirm-DshApplication
|
|
return
|
|
}
|
|
|
|
[void] (Confirm-DshToolchain)
|
|
$command = Get-ApplicationCommand "dsh"
|
|
if ($command) {
|
|
$version = Get-DshVersion $command.Source
|
|
if (Test-DshVersion $version) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
Write-Host "DeepSeek Harness $version already satisfies >=$DshMinimumVersion; leaving it unchanged."
|
|
return
|
|
}
|
|
Write-Host "DeepSeek Harness requires >=$DshMinimumVersion; upgrading $version to latest."
|
|
}
|
|
|
|
Install-Dsh
|
|
Confirm-DshApplication
|
|
}
|
|
|
|
function Confirm-SelectedPrerequisites {
|
|
if ($DryRun) { return }
|
|
# Pi can bootstrap Node/npm before the dependent agents are installed.
|
|
$installingPi = $script:InstallPi -and -not (Find-InstalledCodingAgent "pi")
|
|
if (-not $installingPi) {
|
|
if ($script:InstallDsh) { [void] (Confirm-DshToolchain) }
|
|
if ($script:InstallCline -and -not (Get-ApplicationCommand "cline") -and -not (Get-ApplicationCommand "npm")) {
|
|
throw "Cline installation requires npm. Install Node.js from https://nodejs.org/en/download, then rerun the installer."
|
|
}
|
|
}
|
|
if ($script:InstallHermes -and -not (Get-ApplicationCommand "hermes")) { Confirm-HermesArchitecture }
|
|
if ($script:InstallOpenCode) {
|
|
$state = Get-OpenCodeInstallState
|
|
if ($state.Install) { [void] (Get-OpenCodeWindowsAssetName) }
|
|
}
|
|
}
|
|
|
|
function Ensure-SelectedCodingAgents {
|
|
if ($script:InstallClaudeCode) {
|
|
Start-InstallStep "Claude Code" "Ensuring Claude Code is installed"
|
|
Ensure-ClaudeCode
|
|
}
|
|
|
|
if ($script:InstallCodex) {
|
|
Start-InstallStep "Codex" "Ensuring Codex is installed"
|
|
Ensure-Codex
|
|
}
|
|
|
|
if ($script:InstallPi) {
|
|
Start-InstallStep "Pi" "Checking or installing Pi"
|
|
Ensure-Pi
|
|
}
|
|
|
|
if ($script:InstallOpenCode) {
|
|
Start-InstallStep "OpenCode" "Ensuring OpenCode is installed"
|
|
Ensure-OpenCode
|
|
}
|
|
|
|
if ($script:InstallCline) {
|
|
Start-InstallStep "Cline" "Ensuring Cline CLI is installed"
|
|
Ensure-Cline
|
|
}
|
|
|
|
if ($script:InstallHermes) {
|
|
Start-InstallStep "Hermes" "Ensuring Hermes Agent is installed"
|
|
Ensure-Hermes
|
|
}
|
|
|
|
if ($script:InstallDsh) {
|
|
Start-InstallStep "DeepSeek Harness" "Ensuring DeepSeek Harness is installed"
|
|
Ensure-Dsh
|
|
}
|
|
|
|
if ($script:InstallGrok) {
|
|
Start-InstallStep "Grok" "Ensuring Grok Build is installed"
|
|
Ensure-Grok
|
|
}
|
|
|
|
if ($script:InstallMuse) {
|
|
Start-InstallStep "Muse" "Ensuring Muse Code is installed"
|
|
Ensure-Muse
|
|
}
|
|
|
|
if ($script:InstallAider) {
|
|
Start-InstallStep "Aider" "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."
|
|
}
|
|
}
|
|
|
|
function Get-UvVersion {
|
|
param([string] $UvPath)
|
|
|
|
$output = Invoke-Utf8NativeCapture -FilePath $UvPath -Arguments @("--version")
|
|
$version = Convert-SemanticVersionOutput $output
|
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
|
throw "uv is present, but 'uv --version' did not return a valid version."
|
|
}
|
|
|
|
return $version
|
|
}
|
|
|
|
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-SupportedStableVersion -Version $version -Minimum $MinUvVersion)) {
|
|
throw "Stable uv $MinUvVersion or newer is required; found uv $version after installation."
|
|
}
|
|
Write-Host "Verified uv $version."
|
|
}
|
|
|
|
function Get-UvInstallBinDirectory {
|
|
$forceInstallDirectory = if (-not [string]::IsNullOrWhiteSpace($env:UV_INSTALL_DIR)) {
|
|
$env:UV_INSTALL_DIR
|
|
}
|
|
elseif (-not [string]::IsNullOrWhiteSpace($env:UV_UNMANAGED_INSTALL)) {
|
|
$env:UV_UNMANAGED_INSTALL
|
|
}
|
|
else {
|
|
$null
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($forceInstallDirectory)) {
|
|
$cargoHome = if (-not [string]::IsNullOrWhiteSpace($env:CARGO_HOME)) {
|
|
$env:CARGO_HOME
|
|
}
|
|
elseif (-not [string]::IsNullOrWhiteSpace($HOME)) {
|
|
Join-Path $HOME ".cargo"
|
|
}
|
|
else {
|
|
$null
|
|
}
|
|
if ($cargoHome -and $forceInstallDirectory.Replace("\\", "\") -eq $cargoHome) {
|
|
return Join-Path $forceInstallDirectory "bin"
|
|
}
|
|
return $forceInstallDirectory
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($env:XDG_BIN_HOME)) {
|
|
return $env:XDG_BIN_HOME
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($env:XDG_DATA_HOME)) {
|
|
return Join-Path $env:XDG_DATA_HOME "..\bin"
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
|
|
return Join-Path $env:USERPROFILE ".local\bin"
|
|
}
|
|
|
|
throw "Could not determine where the standalone uv installer places uv."
|
|
}
|
|
|
|
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-SupportedStableVersion -Version $version -Minimum $MinUvVersion) {
|
|
$script:InstallStepOutcome = "Reused"
|
|
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"
|
|
Prioritize-PathEntry (Get-UvInstallBinDirectory)
|
|
Confirm-Uv
|
|
}
|
|
|
|
function Get-PackageSpec {
|
|
$spec = if ($VoiceLocal) { "free-claude-code[voice_local]" } else { "free-claude-code" }
|
|
if ($FccInstallVersion) { $spec += "==$FccInstallVersion" }
|
|
return $spec
|
|
}
|
|
|
|
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 {
|
|
Start-InstallStep "PATH configuration"
|
|
$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, 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" `
|
|
-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 = Add-UvToolBinDirectory -UvPath $uvCommand.Source
|
|
Start-InstallStep "FCC verification"
|
|
$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", "fcc-opencode", "fcc-cline", "fcc-hermes", "fcc-dsh", "fcc-grok", "fcc-muse", "fcc-aider", "fcc-doctor", "fcc-update.cmd")) {
|
|
$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
|
|
}
|
|
|
|
Write-Host (Invoke-Utf8NativeCapture -FilePath $installedCommands["fcc-server"] -Arguments @("--version"))
|
|
$script:FccVerified = $true
|
|
Start-InstallStep "Desktop integration"
|
|
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 ($shortcutPath -eq $shortcutPaths[0]) { $script:DesktopReady = $true }
|
|
}
|
|
}
|
|
|
|
function Show-InstallerNextSteps {
|
|
Write-Host "`nFree Claude Code is installed and verified."
|
|
if ($script:DesktopReady) {
|
|
Write-Host "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"
|
|
}
|
|
if ($script:InstallOpenCode) {
|
|
Write-Host "Run OpenCode with: fcc-opencode"
|
|
}
|
|
if ($script:InstallCline) {
|
|
Write-Host "Run Cline with: fcc-cline"
|
|
}
|
|
if ($script:InstallHermes) {
|
|
Write-Host "Run Hermes Agent with: fcc-hermes"
|
|
}
|
|
if ($script:InstallDsh) {
|
|
Write-Host "Run DeepSeek Harness with: fcc-dsh"
|
|
}
|
|
if ($script:InstallGrok) {
|
|
Write-Host "Run Grok Build with: fcc-grok"
|
|
}
|
|
if ($script:MuseAvailable) {
|
|
Write-Host "Run Muse Code with: fcc-muse"
|
|
}
|
|
if ($script:InstallAider) {
|
|
Write-Host "Run Aider with: fcc-aider"
|
|
}
|
|
|
|
}
|
|
|
|
if ($Help) {
|
|
Show-Usage
|
|
return
|
|
}
|
|
|
|
if ($RemainingArgs.Count -gt 0) {
|
|
Show-Usage
|
|
throw "Unknown option: $($RemainingArgs -join ' ')"
|
|
}
|
|
|
|
if ($FccInstallVersion -and $FccInstallVersion -notmatch '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z') {
|
|
throw "FCC_INSTALL_VERSION must be a canonical major.minor.patch release."
|
|
}
|
|
|
|
if ((-not [string]::IsNullOrWhiteSpace($TorchBackend)) -and (-not $VoiceLocal)) {
|
|
throw "-TorchBackend requires -VoiceLocal."
|
|
}
|
|
|
|
Initialize-InstallLog
|
|
$script:InstallSteps["Preflight"] = "Not attempted"
|
|
$installSucceeded = $false
|
|
$installError = $null
|
|
try {
|
|
Start-InstallStep "Preflight"
|
|
# Preserve the user's winning command before adding installer search paths.
|
|
$script:OriginalOpenCode = Get-ApplicationCommand "opencode"
|
|
Add-KnownBinDirectories
|
|
$script:InstallCline = [bool] ((Get-ApplicationCommand "cline") -or (Get-ApplicationCommand "npm"))
|
|
Write-Step "Checking for running Free Claude Code processes"
|
|
Assert-NoFccProcessesRunning
|
|
|
|
if (-not (Test-InteractiveInstaller)) {
|
|
$hasDsh = [bool] (Get-ApplicationCommand "dsh")
|
|
$hasDryRunToolchain = [bool] (
|
|
$DryRun -and
|
|
(Get-ApplicationCommand "node") -and
|
|
(Get-ApplicationCommand "npm")
|
|
)
|
|
$script:InstallDsh = $hasDsh -or $hasDryRunToolchain -or (Test-DshToolchain)
|
|
}
|
|
|
|
if (Test-InteractiveInstaller) {
|
|
Write-Step "Choosing coding agents"
|
|
Select-CodingAgents
|
|
}
|
|
|
|
$script:InstallSteps["uv"] = "Not attempted"
|
|
$selectedAgents = @()
|
|
foreach ($choice in @(
|
|
@("Claude Code", $script:InstallClaudeCode), @("Codex", $script:InstallCodex),
|
|
@("Pi", $script:InstallPi), @("OpenCode", $script:InstallOpenCode),
|
|
@("Cline", $script:InstallCline), @("Hermes", $script:InstallHermes),
|
|
@("DeepSeek Harness", $script:InstallDsh), @("Grok", $script:InstallGrok),
|
|
@("Muse", $script:InstallMuse), @("Aider", $script:InstallAider)
|
|
)) {
|
|
if ($choice[1]) {
|
|
$script:InstallSteps[$choice[0]] = "Not attempted"
|
|
$selectedAgents += $choice[0]
|
|
}
|
|
}
|
|
foreach ($name in @("FCC package", "PATH configuration", "FCC verification", "Desktop integration")) {
|
|
$script:InstallSteps[$name] = "Not attempted"
|
|
}
|
|
if ($script:EnableRtkHeadroom) {
|
|
$script:InstallSteps["Headroom verification"] = "Not attempted"
|
|
$script:InstallSteps["RTK configuration"] = "Not attempted"
|
|
$script:InstallSteps["Headroom configuration"] = "Not attempted"
|
|
}
|
|
|
|
Write-Host "`nInstallation plan:"
|
|
Write-Host " Install or update Free Claude Code."
|
|
Write-Host " Verify or install: $($selectedAgents -join ', ')."
|
|
if ($VoiceLocal) { Write-Host " Include local voice support." }
|
|
if ($TorchBackend) { Write-Host " PyTorch backend: $TorchBackend" }
|
|
if ($script:EnableRtkHeadroom) { Write-Host " Run RTK + Headroom setup for supported selected agents." }
|
|
Write-Host "Press Ctrl+C to cancel. You can rerun the installer afterward."
|
|
$script:InstallStageCount = $script:InstallSteps.Count - 1
|
|
|
|
Confirm-SelectedPrerequisites
|
|
|
|
Start-InstallStep "uv" "Ensuring uv $MinUvVersion or newer is installed"
|
|
Ensure-Uv
|
|
|
|
Ensure-SelectedCodingAgents
|
|
|
|
Start-InstallStep "FCC package" "Installing or updating Free Claude Code"
|
|
Install-FreeClaudeCode
|
|
|
|
Configure-AndConfirmFreeClaudeCode
|
|
|
|
if ($script:EnableRtkHeadroom) {
|
|
Start-InstallStep "Headroom verification"
|
|
Ensure-Headroom
|
|
Start-InstallStep "RTK configuration"
|
|
Configure-RtkForSelectedAgents
|
|
Start-InstallStep "Headroom configuration"
|
|
Configure-HeadroomForSelectedAgents
|
|
}
|
|
Complete-InstallStep
|
|
$installSucceeded = $true
|
|
}
|
|
catch {
|
|
$installError = $_
|
|
Write-InstallLog $_.Exception.Message
|
|
throw
|
|
}
|
|
finally {
|
|
$cancelled = (-not $installSucceeded) -and ($null -eq $installError)
|
|
if ($cancelled) { Complete-InstallStep "Cancelled" }
|
|
elseif (-not $installSucceeded) { Complete-InstallStep "Failed" }
|
|
Show-InstallReport -Succeeded $installSucceeded -Cancelled $cancelled
|
|
if ($script:FccVerified) { Show-InstallerNextSteps }
|
|
}
|
|
|
|
if ($DryRun) { Write-Host "Dry run complete. No changes were made." }
|