# Install or uninstall CatPaw, a headless-first browser for AI agents. # # irm https://catpaw.sh/install.ps1 | iex # & ([scriptblock]::Create((irm https://catpaw.sh/install.ps1))) [options] # # It downloads a release from GitHub and checks it against the release's # SHA256SUMS, then shows what it will do and asks first. The one file it # writes is catpaw.exe, by default in %LOCALAPPDATA%\Programs\CatPaw, and # it adds that directory to your user PATH. It needs no administrator # rights. Running it again upgrades in place. It works in Windows # PowerShell 5.1 and in PowerShell 7. # # Options (a parameter wins over its environment variable): # -Yes do not ask CATPAW_YES=1 # -Dir install into that one CATPAW_INSTALL_DIR # -Version install that release CATPAW_VERSION # -Uninstall remove catpaw.exe instead # -Help show this help # Also CATPAW_DOWNLOAD_BASE (the URL the release files are fetched from) # and CATPAW_NO_MODIFY_PATH=1 (leave the user PATH as it is). # # https://github.com/KernelErr/CatPaw - Apache-2.0 OR MIT [CmdletBinding()] param( [string] $Dir, [string] $Version, [switch] $Yes, [switch] $Uninstall, [switch] $Help ) function Install-CatPaw { [CmdletBinding()] param( [string] $Dir, [string] $Version, [switch] $Yes, [switch] $Uninstall, [switch] $Help ) # Everything is set inside this function, so nothing changes in the # session that runs it except PATH. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' Set-StrictMode -Version 2.0 $repo = 'https://github.com/KernelErr/CatPaw' $scriptUrl = 'https://catpaw.sh/install.ps1' $target = 'x86_64-pc-windows-msvc' $asset = "catpaw-$target.zip" function Say([string] $Message) { Write-Host "catpaw-install: $Message" } function Row([string] $Label, [string] $Text) { Write-Host (' {0,-13}{1}' -f $Label, $Text) } function Get-Setting([string] $Name) { $value = [Environment]::GetEnvironmentVariable($Name) if ($null -ne $value) { $value = $value.Trim() } if ($value) { return $value } return $null } function Test-Interactive { if (-not [Environment]::UserInteractive) { return $false } foreach ($arg in [Environment]::GetCommandLineArgs()) { if ($arg -like '-NonI*') { return $false } } if ($Host.Name -eq 'ConsoleHost') { try { if ([Console]::IsInputRedirected) { return $false } } catch { } } return $true } # Asks on the console; yes only for y or yes. function Ask([string] $Question) { $answer = Read-Host "$Question [y/N]" return ($answer -match '^\s*(y|yes)\s*$') } # As Ask, but -Yes answers it. function Confirm-Step([string] $Question) { if ($assumeYes) { Write-Host "$Question [y/N]: yes (-Yes)" return $true } return (Ask $Question) } function Get-File([string] $Url, [string] $OutFile) { try { Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $OutFile } catch { throw "catpaw-install: could not download ${Url}: $($_.Exception.Message)" } } # Get-FileHash, or .NET where a stray PSModulePath hides that cmdlet. function Get-Sha256([string] $File) { if (Get-Command Get-FileHash -ErrorAction SilentlyContinue) { return (Get-FileHash -LiteralPath $File -Algorithm SHA256).Hash.ToLowerInvariant() } $sha = [Security.Cryptography.SHA256]::Create() $stream = [IO.File]::OpenRead($File) try { return ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace '-', '').ToLowerInvariant() } finally { $stream.Dispose() $sha.Dispose() } } function Get-Version([string] $Exe) { try { return [string] (& $Exe --version 2>$null | Select-Object -First 1) } catch { return $null } } function Test-SameDir([string] $A, [string] $B) { $a2 = [Environment]::ExpandEnvironmentVariables($A).Trim().TrimEnd('\') $b2 = [Environment]::ExpandEnvironmentVariables($B).Trim().TrimEnd('\') return ($a2 -eq $b2) } function Get-RunningCatPaw([string] $Exe) { return @(Get-Process -Name 'catpaw' -ErrorAction SilentlyContinue | Where-Object { $path = $null try { $path = $_.Path } catch { } $path -and ($path -eq $Exe) }) } # The user PATH as written in the registry, so that entries such as # %USERPROFILE%\bin stay as they are. function Get-UserPathRaw { $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') if ($null -eq $key) { return '' } try { return [string] $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } finally { $key.Close() } } function Test-InUserPath([string] $PathDir) { foreach ($entry in ((Get-UserPathRaw) -split ';')) { if ($entry -and (Test-SameDir $entry $PathDir)) { return $true } } return $false } function Set-UserPathRaw([string] $Value) { $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') try { $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString if ($key.GetValueNames() -contains 'Path') { $kind = $key.GetValueKind('Path') } if ($Value) { $key.SetValue('Path', $Value, $kind) } elseif ($key.GetValueNames() -contains 'Path') { $key.DeleteValue('Path') } } finally { $key.Close() } # Setting a user variable through .NET tells running programs that # the environment changed, so new terminals see the new PATH. $nudge = 'CATPAW_INSTALL_' + [Guid]::NewGuid().ToString('N') [Environment]::SetEnvironmentVariable($nudge, '1', 'User') [Environment]::SetEnvironmentVariable($nudge, $null, 'User') } function Add-UserPath([string] $PathDir) { if (Test-InUserPath $PathDir) { return } $raw = Get-UserPathRaw if ($raw) { Set-UserPathRaw ($raw.TrimEnd(';') + ';' + $PathDir) } else { Set-UserPathRaw $PathDir } $session = @($env:Path -split ';' | Where-Object { $_ -and -not (Test-SameDir $_ $PathDir) }) $env:Path = (@($session) + $PathDir) -join ';' } function Remove-UserPath([string] $PathDir) { $kept = @((Get-UserPathRaw) -split ';' | Where-Object { $_ -and -not (Test-SameDir $_ $PathDir) }) Set-UserPathRaw ($kept -join ';') $env:Path = (@($env:Path -split ';' | Where-Object { $_ -and -not (Test-SameDir $_ $PathDir) })) -join ';' } function Show-Help { Write-Host @" Install or uninstall CatPaw, a headless-first browser for AI agents. irm $scriptUrl | iex & ([scriptblock]::Create((irm $scriptUrl))) [options] Options (a parameter wins over its environment variable): -Yes do not ask `$env:CATPAW_YES = '1' -Dir install into that one `$env:CATPAW_INSTALL_DIR (default: %LOCALAPPDATA%\Programs\CatPaw) -Version install that release `$env:CATPAW_VERSION (default: the latest) -Uninstall remove catpaw.exe instead -Help show this help The installer writes one file, catpaw.exe, and adds its directory to your user PATH (`$env:CATPAW_NO_MODIFY_PATH = '1' leaves PATH alone). It asks first, and needs no administrator rights. "@ } if ($Help) { Show-Help return } if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { throw 'catpaw-install: this script is for Windows; elsewhere run curl -fsSL https://catpaw.sh/install.sh | sh' } # Parameters win over the environment. $assumeYes = [bool] $Yes if (-not $assumeYes) { $assumeYes = ((Get-Setting 'CATPAW_YES') -match '^(1|y|yes|true)$') } if (-not $Version) { $Version = Get-Setting 'CATPAW_VERSION' } if ($Version -and $Version -match '^[0-9]') { $Version = "v$Version" } $installDir = $Dir if (-not $installDir) { $installDir = Get-Setting 'CATPAW_INSTALL_DIR' } if (-not $installDir) { $localAppData = Get-Setting 'LOCALAPPDATA' if (-not $localAppData) { $localAppData = [Environment]::GetFolderPath('LocalApplicationData') } $installDir = Join-Path $localAppData 'Programs\CatPaw' } $installDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($installDir).TrimEnd('\') $dest = Join-Path $installDir 'catpaw.exe' $modifyPath = ((Get-Setting 'CATPAW_NO_MODIFY_PATH') -ne '1') if (-not $assumeYes -and -not (Test-Interactive)) { $flags = if ($Uninstall) { '-Uninstall -Yes' } else { '-Yes' } throw "catpaw-install: this session cannot ask questions. To go ahead without being asked, run`n & ([scriptblock]::Create((irm $scriptUrl))) $flags`nor set `$env:CATPAW_YES = '1' first." } if ($Uninstall) { $hasExe = Test-Path -LiteralPath $dest -PathType Leaf $onUserPath = Test-InUserPath $installDir if ($hasExe -or $onUserPath) { if ($hasExe) { $running = @(Get-RunningCatPaw $dest) if ($running.Count -gt 0) { $ids = ($running | ForEach-Object { $_.Id }) -join ', ' throw "catpaw-install: $dest is running (process $ids), so it cannot be removed. Quit the agent host or session that started it, or stop the process, then run this again." } } Write-Host '' Write-Host 'CatPaw will be removed like this:' Write-Host '' if ($hasExe) { $installed = Get-Version $dest $suffix = if ($installed) { " ($installed)" } else { '' } Row 'Removes' "$dest$suffix" Row '' "$installDir, if nothing else is left in it" } if ($onUserPath) { Row 'PATH' "removes $installDir from your user PATH" } Row 'Keeps' "everything else (CatPaw's own data is asked about next)" Write-Host '' if (-not (Confirm-Step 'Uninstall?')) { Say 'nothing was removed' return } if ($hasExe) { try { Remove-Item -LiteralPath $dest -Force } catch { throw "catpaw-install: could not remove $dest ($($_.Exception.Message)). If catpaw is running, quit the agent host or session that started it, then run this again." } Say "removed $dest" if (@(Get-ChildItem -LiteralPath $installDir -Force).Count -eq 0) { Remove-Item -LiteralPath $installDir -Force Say "removed $installDir" } else { Say "kept ${installDir}: other files are in it" } } if ($onUserPath) { Remove-UserPath $installDir Say "removed $installDir from your user PATH" } } else { Say "there is no catpaw.exe in $installDir" } Write-Host '' $appData = Get-Setting 'APPDATA' if ($appData) { $dataDir = Join-Path $appData 'catpaw' $key = Join-Path $dataDir 'approval-key' if (Test-Path -LiteralPath $key -PathType Leaf) { Say "CatPaw's own data: its approval key, made the first time it was needed, is in" Write-Host " $key" if ($assumeYes) { Say "kept it (-Yes does not delete data). To delete it yourself: Remove-Item -Recurse '$dataDir'" } elseif (Ask 'Delete it too?') { Remove-Item -LiteralPath $key -Force Say "deleted $key" if (@(Get-ChildItem -LiteralPath $dataDir -Force).Count -eq 0) { Remove-Item -LiteralPath $dataDir -Force Say "removed $dataDir" } else { Say "kept ${dataDir}: other files are in it" } } else { Say 'kept it' } } else { Say "CatPaw's own data: there is no approval key at $key" } } Write-Host '' Say 'if you registered CatPaw with an agent host, remove it there yourself:' Row 'Claude Code' "claude mcp remove catpaw (or the catpaw entry in a project's .mcp.json)" Row 'Codex' 'the [mcp_servers.catpaw] table in %USERPROFILE%\.codex\config.toml' Row 'Cursor' 'the catpaw entry in %USERPROFILE%\.cursor\mcp.json' Say 'profiles and flight logs are wherever you pointed --profile and --flight-log; they were not touched' return } if ($PSVersionTable.PSVersion.Major -lt 6) { # Windows PowerShell 5.1 may not offer TLS 1.2, which GitHub requires. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } $osArch = $null try { $osArch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { } $cpu = Get-Setting 'PROCESSOR_ARCHITEW6432' if (-not $cpu) { $cpu = Get-Setting 'PROCESSOR_ARCHITECTURE' } $onArm = ($osArch -eq 'Arm64' -or $cpu -eq 'ARM64') if (-not $onArm -and -not [Environment]::Is64BitOperatingSystem) { throw 'catpaw-install: CatPaw needs 64-bit Windows' } $base = Get-Setting 'CATPAW_DOWNLOAD_BASE' if ($base) { $base = $base.TrimEnd('/') } elseif ($Version) { $base = "$repo/releases/download/$Version" } else { $base = "$repo/releases/latest/download" } if (-not $base.StartsWith('https://')) { Say "warning: $base is not https" } $tmp = Join-Path ([IO.Path]::GetTempPath()) ('catpaw-install-' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $tmp | Out-Null try { # Download and check everything first; nothing goes into the install # directory until the user has said yes. Say "downloading $base/$asset" $sums = Join-Path $tmp 'SHA256SUMS' $zip = Join-Path $tmp $asset Get-File "$base/SHA256SUMS" $sums Get-File "$base/$asset" $zip $expected = $null foreach ($line in [IO.File]::ReadAllLines($sums)) { if ($line -match '^\s*([0-9A-Fa-f]{64})\s+\*?(\S+)\s*$' -and $Matches[2] -eq $asset) { $expected = $Matches[1].ToLowerInvariant() break } } if (-not $expected) { throw "catpaw-install: SHA256SUMS has no line for $asset" } $actual = Get-Sha256 $zip if ($actual -ne $expected) { throw "catpaw-install: the checksum of $asset does not match SHA256SUMS (expected $expected, got $actual); nothing was installed" } $unpack = Join-Path $tmp 'unpack' Add-Type -AssemblyName System.IO.Compression.FileSystem [IO.Compression.ZipFile]::ExtractToDirectory($zip, $unpack) $binary = Join-Path $unpack "catpaw-$target\catpaw.exe" if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw "catpaw-install: $asset does not hold catpaw-$target\catpaw.exe" } # Nothing downloaded runs before the user says yes. $previous = $null $exists = Test-Path -LiteralPath $dest -PathType Leaf if ($exists) { $running = @(Get-RunningCatPaw $dest) if ($running.Count -gt 0) { $ids = ($running | ForEach-Object { $_.Id }) -join ', ' throw "catpaw-install: $dest is running (process $ids), so it cannot be replaced. Quit the agent host or session that started it, or stop the process, then run this again." } $previous = Get-Version $dest } $onUserPath = Test-InUserPath $installDir Write-Host '' Write-Host 'CatPaw will be installed like this:' Write-Host '' Row 'Download' "$base/$asset" Row '' "SHA-256 $actual, as in SHA256SUMS" if ($Version) { Row 'Release' $Version } else { Row 'Release' 'the latest' } if ($onArm) { Row '' 'the x64 build, which Windows 11 on ARM runs under emulation' } Row 'Writes' $dest if (-not (Test-Path -LiteralPath $installDir -PathType Container)) { Row '' "(creating $installDir)" } if ($exists) { $suffix = if ($previous) { " ($previous)" } else { '' } Row 'Replaces' "the catpaw.exe already there$suffix" } if (-not $modifyPath) { Row 'PATH' 'left as it is (CATPAW_NO_MODIFY_PATH is set)' } elseif ($onUserPath) { Row 'PATH' "$installDir is already on your user PATH" } else { Row 'PATH' "adds $installDir to your user PATH" } Row 'Does not' 'need administrator rights, or write any other file' Write-Host '' if (-not (Confirm-Step 'Install?')) { Say 'nothing was installed' return } New-Item -ItemType Directory -Path $installDir -Force | Out-Null $staged = "$dest.new" Copy-Item -LiteralPath $binary -Destination $staged -Force try { Move-Item -LiteralPath $staged -Destination $dest -Force } catch { Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue throw "catpaw-install: could not replace $dest ($($_.Exception.Message)). If catpaw is running, quit the agent host or session that started it, then run this again." } } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } $current = Get-Version $dest if ($current -and $previous -and $current -ne $previous) { Say "replaced $previous with $current in $dest" } elseif ($current) { Say "installed $current in $dest" } else { Say "installed $dest" } if ($modifyPath -and -not $onUserPath) { Add-UserPath $installDir Say "added $installDir to your user PATH; terminals opened from now on find catpaw, and so does this one" } $onSessionPath = $false foreach ($entry in ($env:Path -split ';')) { if ($entry -and (Test-SameDir $entry $installDir)) { $onSessionPath = $true } } $command = if ($onSessionPath) { 'catpaw' } else { "& '$dest'" } Write-Host '' Say 'next, register it with your agent host:' Write-Host " $command setup claude-code # prints the claude mcp add command" Write-Host " $command setup codex --write # adds it to %USERPROFILE%\.codex\config.toml" Write-Host " $command setup cursor --write # adds it to %USERPROFILE%\.cursor\mcp.json" Write-Host ' More: https://catpaw.sh/#connect' } Install-CatPaw -Dir $Dir -Version $Version -Yes:$Yes -Uninstall:$Uninstall -Help:$Help