public/Install-AITool.ps1
|
function Install-AITool { <# .SYNOPSIS Installs the specified AI CLI tool. .DESCRIPTION Installs AI CLI tools (Claude Code, Aider, Gemini CLI, GitHub Copilot CLI, or OpenAI Codex CLI) with cross-platform support for Windows, Linux, and MacOS. .PARAMETER Name The name of the AI tool to install. Valid values: Claude, Aider, Gemini, Copilot, Codex .PARAMETER SkipInitialization Skip the automatic initialization/login command after installation. By default, initialization runs automatically after successful installation. .PARAMETER Scope Installation scope: CurrentUser (default) or LocalMachine (requires sudo/admin privileges). CurrentUser installs to user-local directories without requiring elevated permissions. LocalMachine installs system-wide and requires sudo on Linux/MacOS or admin privileges on Windows. .EXAMPLE Install-AITool -Name Claude Installs Claude Code for the current user, runs initialization, and returns installation details. .EXAMPLE Install-AITool -Name Aider -SkipInitialization Installs Aider for the current user without running initialization. .EXAMPLE Install-AITool -Name Aider -Scope LocalMachine Installs Aider system-wide (requires sudo/admin privileges). .EXAMPLE Install-AITool -Name All Installs all available AI tools sequentially for the current user. .OUTPUTS AITools.InstallResult An object containing Tool name, Result (Success/Failed), Version, Path, and Installer command used. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [Alias('Tool')] [string]$Name, [Parameter()] [switch]$SkipInitialization, [Parameter()] [ValidateSet('CurrentUser', 'LocalMachine')] [string]$Scope = 'CurrentUser', [Parameter()] [switch]$SuppressAlreadyInstalledWarning ) begin { Write-PSFMessage -Level Verbose -Message "Starting installation of $Name" # Handle "All" tool selection $toolsToInstall = @() if ($Name -eq 'All') { Write-PSFMessage -Level Verbose -Message "Name is 'All' - will install all available tools" $toolsToInstall = $script:ToolDefinitions.Keys | Sort-Object { $script:ToolDefinitions[$_].Priority } Write-PSFMessage -Level Verbose -Message "Tools to install: $($toolsToInstall -join ', ')" } else { # Resolve tool alias to canonical name $resolvedName = Resolve-ToolAlias -ToolName $Name Write-PSFMessage -Level Verbose -Message "Resolved tool name: $resolvedName" $toolsToInstall = @($resolvedName) } } process { foreach ($currentToolName in $toolsToInstall) { Write-Progress -Activity "Installing $currentToolName" -Status "Retrieving tool definition for $currentToolName" -PercentComplete 5 Write-PSFMessage -Level Verbose -Message "Retrieving tool definition for $currentToolName" $tool = $script:ToolDefinitions[$currentToolName] Write-Progress -Activity "Installing $currentToolName" -Status "Getting OS information" -PercentComplete 5 $os = Get-OperatingSystem if (-not $tool) { Write-Progress -Activity "Installing $currentToolName" -Completed Write-PSFMessage -Level Warning -Message "Unknown tool: $currentToolName, skipping" continue } Write-Progress -Activity "Installing $currentToolName" -Status "Checking if $currentToolName is already installed" -PercentComplete 10 Write-PSFMessage -Level Verbose -Message "Checking if $currentToolName is already installed" if (Test-Command -Command $tool.Command) { # If SuppressAlreadyInstalledWarning is set, we're being called from Update-AITool # so we should continue with installation/update instead of skipping if (-not $SuppressAlreadyInstalledWarning) { # Get version differently for PowerShell modules vs CLIs if ($tool.IsWrapper) { $module = Get-Module -ListAvailable -Name $tool.Command | Sort-Object Version -Descending | Select-Object -First 1 $version = $module.Version.ToString() $commandPath = $module.Path } else { $version = & $tool.Command --version 2>&1 | Select-Object -First 1 # Get the full path to the command $commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Source if (-not $commandPath) { $commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Path } } Write-PSFMessage -Level Output -Message "$currentToolName is already installed (version: $($version.Trim()))" Write-PSFMessage -Level Verbose -Message "Skipping installation. To reinstall, first run: Uninstall-AITool -Name $currentToolName" Write-Progress -Activity "Installing $currentToolName" -Completed # Output existing installation details [PSCustomObject]@{ PSTypeName = 'AITools.InstallResult' Tool = $currentToolName Result = 'Success' Version = ($version -replace '^.*?(\d+\.\d+\.\d+).*$', '$1').Trim() Path = $commandPath Installer = 'Already Installed' } continue } else { Write-PSFMessage -Level Verbose -Message "$currentToolName is already installed, proceeding with update check..." } } Write-Progress -Activity "Installing $currentToolName" -Status "Getting installation command for $os" -PercentComplete 15 Write-PSFMessage -Level Verbose -Message "Getting installation command for $os" $installCmd = $tool.InstallCommands[$os] if (-not $installCmd) { Write-Progress -Activity "Installing $currentToolName" -Completed # Special message for Cursor on Windows if ($currentToolName -eq 'Cursor' -and $os -eq 'Windows') { Stop-PSFFunction -Message "Native Windows installation is not supported for $currentToolName. Please use WSL (Windows Subsystem for Linux) or install on Linux/MacOS." -EnableException $true } else { Stop-PSFFunction -Message "No installation command defined for $currentToolName on $os" -EnableException $true } return } # Ensure $installCmd is an array (convert single command to array) if ($installCmd -isnot [array]) { $installCmd = @($installCmd) } # For Claude on Windows with winget, check if winget is available and fallback to PowerShell installer if not if ($currentToolName -eq 'Claude' -and $os -eq 'Windows' -and $installCmd[0] -match '^winget') { Write-Progress -Activity "Installing $currentToolName" -Status "Checking for winget availability" -PercentComplete 18 Write-PSFMessage -Level Verbose -Message "Checking if winget is available..." if (-not (Test-Command -Command 'winget')) { Write-PSFMessage -Level Warning -Message "winget is not available. Falling back to PowerShell installer..." $installCmd = @('irm https://claude.ai/install.ps1 | iex') Write-PSFMessage -Level Verbose -Message "Using fallback command: $($installCmd[0])" } else { Write-PSFMessage -Level Verbose -Message "winget is available, proceeding with winget installation" } } # Check for pipx prerequisite if using pipx installation if ($installCmd[0] -match '^pipx install') { Write-Progress -Activity "Installing $currentToolName" -Status "Checking prerequisites" -PercentComplete 20 Write-PSFMessage -Level Verbose -Message "Checking for pipx prerequisite (pipx-based installation)" if (-not (Test-Command -Command 'pipx')) { Write-PSFMessage -Level Warning -Message "pipx is not installed or not in PATH. Installing pipx..." if ($os -eq 'Linux') { Write-Progress -Activity "Installing $currentToolName" -Status "Installing pipx prerequisite" -PercentComplete 25 # Choose installation method based on Scope if ($Scope -eq 'LocalMachine') { Write-PSFMessage -Level Verbose -Message "Installing pipx system-wide (requires sudo)..." $pipxInstallCmd = 'sudo apt-get update && sudo apt-get install -y pipx && pipx ensurepath' } else { Write-PSFMessage -Level Verbose -Message "Installing pipx for current user (no sudo required)..." $pipxInstallCmd = 'python3 -m pip install --user pipx && python3 -m pipx ensurepath' } try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = '/bin/bash' $psi.Arguments = "-c `"$pipxInstallCmd`"" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 0) { Write-Progress -Activity "Installing $currentToolName" -Completed if ($Scope -eq 'LocalMachine') { Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: sudo apt-get install pipx" -EnableException $true } else { Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually: python3 -m pip install --user pipx" -EnableException $true } return } # Refresh PATH to pick up pipx $pipxBin = "${env:HOME}/.local/bin" if (-not ($env:PATH -like "*$pipxBin*")) { $env:PATH = "${pipxBin}:${env:PATH}" } if (-not (Test-Command -Command 'pipx')) { Write-Progress -Activity "Installing $currentToolName" -Completed Stop-PSFFunction -Message "pipx installation failed. Please install pipx manually and try again." -EnableException $true return } Write-PSFMessage -Level Verbose -Message "pipx installed successfully." } catch { Write-Progress -Activity "Installing $currentToolName" -Completed Stop-PSFFunction -Message "Failed to install pipx: $_" -EnableException $true return } } elseif ($os -eq 'MacOS') { Write-Progress -Activity "Installing $currentToolName" -Completed if ($Scope -eq 'LocalMachine') { Stop-PSFFunction -Message "pipx is required but not installed. Please install pipx using: brew install pipx" -EnableException $true } else { Stop-PSFFunction -Message "pipx is required but not installed. Please install pipx using: python3 -m pip install --user pipx && python3 -m pipx ensurepath" -EnableException $true } return } else { Write-Progress -Activity "Installing $currentToolName" -Completed Stop-PSFFunction -Message "pipx is required but not installed. Please install pipx using: python -m pip install --user pipx" -EnableException $true return } } else { $pipxVersion = (& pipx --version 2>&1 | Out-String).Trim() if ($pipxVersion) { Write-PSFMessage -Level Verbose -Message "pipx is available: $pipxVersion" } } } # Check for Node.js prerequisite if using npm installation if ($installCmd[0] -match '^npm install') { Write-Progress -Activity "Installing $currentToolName" -Status "Checking prerequisites" -PercentComplete 20 Write-PSFMessage -Level Verbose -Message "Checking for Node.js prerequisite (npm-based installation)" if (-not (Test-Command -Command 'node')) { Write-PSFMessage -Level Warning -Message "Node.js is not installed or not in PATH. Installing Node.js..." if ($os -eq 'Linux') { Write-Progress -Activity "Installing $currentToolName" -Status "Installing Node.js prerequisite" -PercentComplete 25 # Choose installation method based on Scope if ($Scope -eq 'LocalMachine') { Write-PSFMessage -Level Verbose -Message "Installing Node.js system-wide (requires sudo)..." $nodeInstallCmd = 'curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs' } else { Write-PSFMessage -Level Verbose -Message "Installing Node.js for current user using nvm (no sudo required)..." $nodeInstallCmd = 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash && export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && nvm install --lts && nvm use --lts' } try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = '/bin/bash' $psi.Arguments = "-c `"$nodeInstallCmd`"" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() if ($process.ExitCode -ne 0) { Write-Progress -Activity "Installing $currentToolName" -Completed if ($Scope -eq 'LocalMachine') { Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually: sudo apt-get install nodejs" -EnableException $true } else { Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually using nvm or from https://nodejs.org/" -EnableException $true } return } # Refresh PATH to pick up Node.js if ($Scope -eq 'CurrentUser') { # For nvm, we need to source the nvm script and add to PATH $nvmDir = "${env:HOME}/.nvm" if (Test-Path "$nvmDir/nvm.sh") { # Get the node path from nvm $nvmNodePath = & /bin/bash -c "export NVM_DIR=`"$nvmDir`" && [ -s `"`$NVM_DIR/nvm.sh`" ] && \. `"`$NVM_DIR/nvm.sh`" && command -v node" 2>&1 if ($nvmNodePath) { $nvmBinPath = Split-Path -Parent $nvmNodePath if (-not ($env:PATH -like "*$nvmBinPath*")) { $env:PATH = "${nvmBinPath}:${env:PATH}" } } } } if (-not (Test-Command -Command 'node')) { Write-Progress -Activity "Installing $currentToolName" -Completed Stop-PSFFunction -Message "Node.js installation failed. Please install Node.js manually and try again." -EnableException $true return } Write-PSFMessage -Level Verbose -Message "Node.js installed successfully." } catch { Write-Progress -Activity "Installing $currentToolName" -Completed Stop-PSFFunction -Message "Failed to install Node.js: $_" -EnableException $true return } } elseif ($os -eq 'MacOS') { Write-Progress -Activity "Installing $currentToolName" -Completed if ($Scope -eq 'LocalMachine') { Stop-PSFFunction -Message "Node.js is required but not installed. Please install Node.js using: brew install node" -EnableException $true } else { Stop-PSFFunction -Message "Node.js is required but not installed. Please install Node.js using nvm or from https://nodejs.org/" -EnableException $true } return } else { Write-Progress -Activity "Installing $currentToolName" -Completed Stop-PSFFunction -Message "Node.js is required but not installed. Please install Node.js from https://nodejs.org/" -EnableException $true return } } else { $nodeVersion = (& node --version 2>&1 | Out-String).Trim() if ($nodeVersion) { Write-PSFMessage -Level Verbose -Message "Node.js is available: $nodeVersion" } } } if ($PSCmdlet.ShouldProcess($currentToolName, "Install AI tool")) { Write-Progress -Activity "Installing $currentToolName" -Status "Installing (this may take a while)" -PercentComplete 30 # Sometimes it doesn't update the progress bar right away, do it again Write-Progress -Activity "Installing $currentToolName" -Status "Installing (this may take a while)" -PercentComplete 33 Write-PSFMessage -Level Verbose -Message "Installing $currentToolName on $os..." Write-PSFMessage -Level Verbose -Message "Command(s): $($installCmd -join ' ; ')" Write-PSFMessage -Level Verbose -Message "Executing installation command(s)" try { # Execute each command in the array $commandIndex = 0 foreach ($cmd in $installCmd) { $commandIndex++ Write-PSFMessage -Level Verbose -Message "Executing command $commandIndex of $($installCmd.Count): $cmd" # Check if this is a PowerShell cmdlet (for wrapper modules like PSOpenAI) # PowerShell cmdlets must be executed via Invoke-Expression, not Start-Process $isPowerShellCmdlet = $tool.IsWrapper -or $cmd -match '^(Install-Module|Uninstall-Module|Update-Module|Import-Module)' # Check if command contains shell operators (pipes, redirects, etc.) # These require shell execution and can't be handled by Start-Process $requiresShell = $cmd -match '[|&><]|&&|\|\||iex|Invoke-Expression' -or $isPowerShellCmdlet # Handle PowerShell cmdlets directly if ($isPowerShellCmdlet) { Write-PSFMessage -Level Verbose -Message "Executing PowerShell cmdlet directly" try { # Parse command and arguments $cmdParts = $cmd -split '\s+', 2 $cmdletName = $cmdParts[0] # Build parameter hashtable from remaining arguments $params = @{} if ($cmdParts.Count -gt 1) { # Simple parsing for -Name value -Scope value patterns $argString = $cmdParts[1] if ($argString -match '-Name\s+(\S+)') { $params['Name'] = $matches[1] } if ($argString -match '-Scope\s+(\S+)') { $params['Scope'] = $matches[1] } if ($argString -match '-Force') { $params['Force'] = $true } } Write-PSFMessage -Level Verbose -Message "Cmdlet: $cmdletName" Write-PSFMessage -Level Verbose -Message "Parameters: $($params | Out-String)" $output = & $cmdletName @params 2>&1 $exitCode = 0 $stdout = $output | Out-String $stderr = '' $outputText = $stdout } catch { $exitCode = 1 $stdout = '' $stderr = $_.Exception.Message $outputText = $stderr Write-PSFMessage -Level Verbose -Message "PowerShell cmdlet failed: $stderr" } } elseif ($requiresShell) { Write-PSFMessage -Level Verbose -Message "Command contains shell operators, using shell execution" # Use appropriate shell based on OS if ($os -eq 'Windows') { # On Windows, use PowerShell for commands with iex/Invoke-Expression if ($cmd -match 'iex|Invoke-Expression') { Write-PSFMessage -Level Verbose -Message "Executing via Invoke-Expression" Invoke-Expression $cmd $exitCode = $LASTEXITCODE if (-not $exitCode) { $exitCode = 0 } } else { # Use cmd.exe for other shell operators Write-PSFMessage -Level Verbose -Message "Executing via cmd.exe" $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = 'cmd.exe' $psi.Arguments = "/c $cmd" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $exitCode = $process.ExitCode if ($stdout) { $stdout -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } if ($stderr) { $stderr -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } } } else { # On Unix, use bash or sh Write-PSFMessage -Level Verbose -Message "Executing via shell" $shellCmd = if (Test-Path '/bin/bash') { '/bin/bash' } else { '/bin/sh' } $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $shellCmd $psi.Arguments = "-c `"$cmd`"" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $exitCode = $process.ExitCode if ($stdout) { $stdout -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } if ($stderr) { $stderr -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } } $outputText = "$stdout`n$stderr" } else { # Simple command without shell operators - use Start-Process directly Write-PSFMessage -Level Verbose -Message "Simple command, using Start-Process" # Split the command into executable and arguments $cmdParts = $cmd -split ' ', 2 $executable = $cmdParts[0] $arguments = if ($cmdParts.Count -gt 1) { $cmdParts[1] } else { '' } # Handle python/python3 fallback on Linux/MacOS if ($executable -eq 'python' -and $os -ne 'Windows') { Write-PSFMessage -Level Verbose -Message "Checking for python3 alternative on Unix system" if (-not (Test-Command -Command 'python') -and (Test-Command -Command 'python3')) { Write-PSFMessage -Level Verbose -Message "python not found, using python3 instead" $executable = 'python3' } } Write-PSFMessage -Level Verbose -Message "Executable: $executable" Write-PSFMessage -Level Verbose -Message "Arguments: $arguments" # Resolve the executable path using Get-Command (handles .cmd, .exe, etc. on Windows) $resolvedExecutable = $null try { # Get all available commands with this name $allCommands = @(Get-Command $executable -All -ErrorAction Stop) # Prefer executables in this order: .exe, .cmd, .bat, then others # Exclude .ps1 files as they can't be run directly by ProcessStartInfo $preferredExtensions = @('.exe', '.cmd', '.bat', '') $selectedCommand = $null foreach ($ext in $preferredExtensions) { $selectedCommand = $allCommands | Where-Object { $_.Source -and ( ($ext -eq '' -and -not $_.Source.EndsWith('.ps1')) -or $_.Source.EndsWith($ext) ) } | Select-Object -First 1 if ($selectedCommand) { break } } if ($selectedCommand) { $resolvedExecutable = $selectedCommand.Source if (-not $resolvedExecutable) { $resolvedExecutable = $selectedCommand.Path } Write-PSFMessage -Level Verbose -Message "Resolved executable: $resolvedExecutable" } else { # Fallback to original executable name $resolvedExecutable = $executable Write-PSFMessage -Level Verbose -Message "No suitable executable found, using: $resolvedExecutable" } } catch { # If Get-Command fails, use the original executable name $resolvedExecutable = $executable Write-PSFMessage -Level Verbose -Message "Could not resolve executable path, using: $resolvedExecutable" } $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $resolvedExecutable $psi.Arguments = $arguments $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $exitCode = $process.ExitCode $outputText = "$stdout`n$stderr" # Send output to verbose (filter out empty lines) if ($stdout) { $stdout -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } if ($stderr) { $stderr -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } } Write-PSFMessage -Level Verbose -Message "Command $commandIndex completed with exit code: $exitCode" # Check if the installation command failed # Exit code -1978335189 (0x8A15002B) = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE # This occurs when winget finds the package already installed at the latest version $isAlreadyLatestVersion = ($exitCode -eq -1978335189) -and ($outputText -match 'No available upgrade found|No newer package versions') if ($exitCode -ne 0 -and -not $isAlreadyLatestVersion) { # Check for npm ENOTEMPTY error (exit code 217 or stderr contains ENOTEMPTY) $isNpmEnotemptyError = ($exitCode -eq 217 -or $outputText -match 'ENOTEMPTY|directory not empty') -and $cmd -match '^npm install' if ($isNpmEnotemptyError) { Write-PSFMessage -Level Verbose -Message "npm ENOTEMPTY error detected. Attempting automatic cleanup and retry..." # Extract package name from npm install command $packageName = $null if ($cmd -match 'npm install -g (.+)') { $packageName = $Matches[1].Trim() Write-PSFMessage -Level Verbose -Message "Extracted package name: $packageName" # Determine npm global lib path $npmPrefix = & npm config get prefix 2>&1 | Out-String $npmPrefix = $npmPrefix.Trim() if ($npmPrefix) { $packagePath = Join-Path $npmPrefix "lib/node_modules/$packageName" Write-PSFMessage -Level Verbose -Message "Package path: $packagePath" # Remove the problematic directory if (Test-Path $packagePath) { Write-PSFMessage -Level Verbose -Message "Removing problematic directory: $packagePath" try { Remove-Item -Path $packagePath -Recurse -Force -ErrorAction Stop Write-PSFMessage -Level Verbose -Message "Directory removed successfully" } catch { Write-PSFMessage -Level Warning -Message "Failed to remove directory: $_" } } else { Write-PSFMessage -Level Verbose -Message "Package directory not found at expected location" } # Retry the installation Write-PSFMessage -Level Verbose -Message "Retrying installation command: $cmd" Write-Progress -Activity "Installing $currentToolName" -Status "Retrying after cleanup (this may take a while)" -PercentComplete 40 # Re-execute the same command logic if ($requiresShell) { if ($os -eq 'Windows') { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = 'cmd.exe' $psi.Arguments = "/c $cmd" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $exitCode = $process.ExitCode } else { $shellCmd = if (Test-Path '/bin/bash') { '/bin/bash' } else { '/bin/sh' } $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $shellCmd $psi.Arguments = "-c `"$cmd`"" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $exitCode = $process.ExitCode } $outputText = "$stdout`n$stderr" } else { # Re-execute using Start-Process for simple commands $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $resolvedExecutable $psi.Arguments = $arguments $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $process.WaitForExit() $exitCode = $process.ExitCode $outputText = "$stdout`n$stderr" } if ($stdout) { $stdout -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } if ($stderr) { $stderr -split "`n" | Where-Object { $_.Trim() } | ForEach-Object { $trimmed = $_.Trim(); if ($trimmed) { Write-PSFMessage -Level Verbose -Message $trimmed } } } Write-PSFMessage -Level Verbose -Message "Retry completed with exit code: $exitCode" # If retry still failed, fall through to normal error handling if ($exitCode -ne 0) { Write-PSFMessage -Level Warning -Message "Retry failed. Falling back to normal error handling." } else { Write-PSFMessage -Level Verbose -Message "Retry successful!" # Continue to next command continue } } } } # Normal error handling if not npm ENOTEMPTY or retry failed if ($exitCode -ne 0) { Write-Progress -Activity "Installing $currentToolName" -Completed # Set default parameter for cleaner error output $PSDefaultParameterValues['Write-PSFMessage:Level'] = 'Output' Write-PSFMessage -Message "Installation command $commandIndex failed with exit code ${exitCode}." Write-PSFMessage -Message "Command: $cmd" # Include the actual error output if ($outputText.Trim()) { Write-PSFMessage -Message "Error output:" Write-PSFMessage $outputText.Trim() } Stop-PSFFunction -Message "Installation failed. See error details above." -EnableException $true return } } elseif ($isAlreadyLatestVersion) { Write-PSFMessage -Level Verbose -Message "Package is already at the latest version (exit code: $exitCode)" } } Write-Progress -Activity "Installing $currentToolName" -Status "Refreshing PATH" -PercentComplete 80 # Refresh PATH to pick up newly installed tools in the current session Write-PSFMessage -Level Verbose -Message "Refreshing PATH environment variable" if ($os -eq 'Windows') { # For winget installations, explicitly check the WinGet Packages directory first # This works around timing issues where the User PATH registry hasn't propagated yet if ($installCmd[0] -match '^winget install') { Write-PSFMessage -Level Verbose -Message "Winget installation detected - checking WinGet Packages directory" # Find the specific package directory that winget just created $wingetPackagesPath = "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" Write-PSFMessage -Level Verbose -Message "Checking for winget packages at: $wingetPackagesPath" if (Test-Path $wingetPackagesPath) { Write-PSFMessage -Level Verbose -Message "WinGet Packages directory exists" # Look for the package directory (e.g., Anthropic.ClaudeCode_*) Write-PSFMessage -Level Verbose -Message "Searching for package directories matching: *$currentToolName*" $packageDirs = Get-ChildItem -Path $wingetPackagesPath -Directory -Filter "*$currentToolName*" -ErrorAction SilentlyContinue Write-PSFMessage -Level Verbose -Message "Found $($packageDirs.Count) package directories with initial filter" if (-not $packageDirs) { # Try alternate package name patterns $alternateNames = @{ 'Claude' = 'Anthropic.ClaudeCode*' } if ($alternateNames.ContainsKey($currentToolName)) { $alternateFilter = $alternateNames[$currentToolName] Write-PSFMessage -Level Verbose -Message "Trying alternate filter: $alternateFilter" $packageDirs = Get-ChildItem -Path $wingetPackagesPath -Directory -Filter $alternateFilter -ErrorAction SilentlyContinue Write-PSFMessage -Level Verbose -Message "Found $($packageDirs.Count) package directories with alternate filter" } } foreach ($packageDir in $packageDirs) { $packagePath = $packageDir.FullName Write-PSFMessage -Level Verbose -Message "Found winget package directory: $packagePath" # Check if the command executable exists in this directory $exePath = Join-Path $packagePath "$($tool.Command).exe" Write-PSFMessage -Level Verbose -Message "Checking for executable at: $exePath" if (Test-Path $exePath) { Write-PSFMessage -Level Verbose -Message "Executable found!" if (-not ($env:Path -like "*$packagePath*")) { $env:Path = "$packagePath;$env:Path" Write-PSFMessage -Level Verbose -Message "Added winget package path to current session PATH: $packagePath" } else { Write-PSFMessage -Level Verbose -Message "Package path already in PATH" } break } else { Write-PSFMessage -Level Verbose -Message "Executable not found at expected path" } } } else { Write-PSFMessage -Level Verbose -Message "WinGet Packages directory does not exist at: $wingetPackagesPath" } } # Now refresh from registry (this may not have propagated yet, but try anyway) $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") Write-PSFMessage -Level Verbose -Message "Windows PATH refreshed from Machine and User scopes" # For Claude on Windows, also check traditional installation paths as fallback if ($currentToolName -eq 'Claude') { $claudePaths = @( "$env:LOCALAPPDATA\Programs\Claude\resources\app\bin", "$env:LOCALAPPDATA\Programs\Claude\resources\bin", "$env:LOCALAPPDATA\Programs\Claude" ) foreach ($claudePath in $claudePaths) { if ((Test-Path $claudePath) -and (-not ($env:Path -like "*$claudePath*"))) { $env:Path = "$claudePath;$env:Path" Write-PSFMessage -Level Verbose -Message "Added Claude installation path to PATH: $claudePath" break } } } } else { # On Unix, npm global installs go to different locations $npmBin = npm config get prefix 2>$null if ($npmBin) { $env:PATH = "${npmBin}/bin:${env:PATH}" Write-PSFMessage -Level Verbose -Message "Added npm global bin to PATH: $npmBin/bin" } # pipx, Cursor Agent, and Claude Code install to ~/.local/bin if ($currentToolName -eq 'Cursor' -or $currentToolName -eq 'Aider' -or $currentToolName -eq 'Claude') { $localBin = "${env:HOME}/.local/bin" if (-not ($env:PATH -like "*$localBin*")) { $env:PATH = "${localBin}:${env:PATH}" Write-PSFMessage -Level Verbose -Message "Added ~/.local/bin to PATH: $localBin" } # Auto-configure persistent PATH for Claude, Aider, and Cursor if ($currentToolName -eq 'Claude' -or $currentToolName -eq 'Aider' -or $currentToolName -eq 'Cursor') { Write-PSFMessage -Level Verbose -Message "Checking if ~/.local/bin is in persistent shell configuration..." # Detect the shell and configure accordingly $shellConfigFiles = @() # Bash configuration files (most common on Linux) if (Test-Path "${env:HOME}/.bashrc") { $shellConfigFiles += "${env:HOME}/.bashrc" } if (Test-Path "${env:HOME}/.bash_profile") { $shellConfigFiles += "${env:HOME}/.bash_profile" } # Zsh configuration (common on macOS) if (Test-Path "${env:HOME}/.zshrc") { $shellConfigFiles += "${env:HOME}/.zshrc" } # PowerShell profile (for PowerShell on Linux/macOS) if ($PROFILE) { $profileDir = Split-Path -Parent $PROFILE if (-not (Test-Path $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null } if (-not (Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null } $shellConfigFiles += $PROFILE } $pathExportLine = 'export PATH="$HOME/.local/bin:$PATH"' $pwshPathLine = '$env:PATH = "$env:HOME/.local/bin:$env:PATH"' foreach ($configFile in $shellConfigFiles) { if (Test-Path $configFile) { $configContent = Get-Content -Path $configFile -Raw -ErrorAction SilentlyContinue # Determine which PATH line to use based on file type if ($configFile -eq $PROFILE) { $pathLineToAdd = $pwshPathLine $searchPattern = '\.local/bin.*PATH' } else { $pathLineToAdd = $pathExportLine $searchPattern = '\.local/bin.*PATH' } # Check if PATH configuration already exists if (-not ($configContent -match $searchPattern)) { Write-PSFMessage -Level Verbose -Message "Adding ~/.local/bin to PATH in $configFile" # Add PATH configuration to the config file Add-Content -Path $configFile -Value "`n# Added by AITools module for $currentToolName`n$pathLineToAdd" Write-PSFMessage -Level Output -Message "✅ Added ~/.local/bin to PATH in $configFile" } else { Write-PSFMessage -Level Verbose -Message "~/.local/bin already in PATH configuration in $configFile" } } } if ($shellConfigFiles.Count -eq 0) { Write-PSFMessage -Level Warning -Message "No shell configuration files found. Please add ~/.local/bin to your PATH manually." } } } } Write-Progress -Activity "Installing $currentToolName" -Status "Verifying installation" -PercentComplete 85 Write-PSFMessage -Level Verbose -Message "Verifying installation" if (Test-Command -Command $tool.Command) { Write-PSFMessage -Level Verbose -Message "$currentToolName installed successfully!" # Get version differently for PowerShell modules vs CLIs if ($tool.IsWrapper) { $module = Get-Module -ListAvailable -Name $tool.Command | Sort-Object Version -Descending | Select-Object -First 1 $version = $module.Version.ToString() $commandPath = $module.Path } else { $version = & $tool.Command --version 2>&1 | Select-Object -First 1 # Get the full path to the command $commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Source if (-not $commandPath) { $commandPath = (Get-Command $tool.Command -ErrorAction SilentlyContinue).Path } } Write-PSFMessage -Level Verbose -Message "Version: $version" # Run initialization by default unless explicitly skipped if (-not $SkipInitialization) { Write-Progress -Activity "Installing $currentToolName" -Status "Running initialization" -PercentComplete 90 Write-PSFMessage -Level Verbose -Message "Running automatic initialization (use -SkipInitialization to skip)" Initialize-AITool -Tool $currentToolName } else { Write-PSFMessage -Level Verbose -Message "Skipping initialization (use Initialize-AITool -Tool $currentToolName to initialize later)" } Write-Progress -Activity "Installing $currentToolName" -Status "Complete" -PercentComplete 100 Write-Progress -Activity "Installing $currentToolName" -Completed # Output directly to pipeline [PSCustomObject]@{ PSTypeName = 'AITools.InstallResult' Tool = $currentToolName Result = 'Success' Version = ($version -replace '^.*?(\d+\.\d+\.\d+).*$', '$1').Trim() Path = $commandPath Installer = ($installCmd -join ' && ') } } else { Write-Progress -Activity "Installing $currentToolName" -Completed # Provide tool-specific guidance for post-install issues $additionalMessage = "" if ($currentToolName -eq 'Cursor' -and $os -ne 'Windows') { $additionalMessage = " Add ~/.local/bin to your PATH by running: echo 'export PATH=`$HOME/.local/bin:`$PATH' >> ~/.bashrc && source ~/.bashrc" } Write-PSFMessage -Level Warning -Message "$currentToolName installation completed but command not found. You may need to restart your shell.$additionalMessage" [PSCustomObject]@{ PSTypeName = 'AITools.InstallResult' Tool = $currentToolName Result = 'Failed' Version = 'N/A' Path = 'N/A' Installer = ($installCmd -join ' && ') } } } catch { Write-Progress -Activity "Installing $currentToolName" -Completed Write-PSFMessage -Level Warning -Message "Failed to install $currentToolName : $_" } } } # End of foreach ($currentToolName in $toolsToInstall) } } |