# Clipcat CLI installer for native Windows (no bash required). # # Designed to be piped straight into PowerShell: # irm https://clipcat.ai/cli.ps1 | iex # Because `iex` runs the script in the caller's scope with no arguments, this # file MUST NOT declare a top-level param() block -- every knob is read from an # environment variable instead: # $env:CLIPCAT_INSTALL_DIR override the install directory # Works on both Windows PowerShell 5.1 and PowerShell 7 (pwsh). $ErrorActionPreference = 'Stop' # PS 5.1 renders a progress bar during Invoke-WebRequest that slows large # downloads by an order of magnitude; silence it. $ProgressPreference = 'SilentlyContinue' # Some Windows builds still default Schannel to TLS 1.0/1.1, which the CDN # rejects. Force TLS 1.2 so the .NET HTTP stack negotiates successfully. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ProgramName = 'clipcat' $DownloadBase = 'https://static.clipcat.ai/public/cli/latest' $SkillUrl = 'https://static.clipcat.ai/public/cli/SKILL.md' $SkillName = 'clipcat' function Write-Info { param([string]$Message) Write-Host $Message } # Error output uses Write-Host, NOT Write-Error: with $ErrorActionPreference set # to 'Stop' above, Write-Error itself becomes a terminating error, which would # cut off multi-line guidance and never reach the explicit exit code. function Write-Err { param([string]$Message) Write-Host $Message -ForegroundColor Red } function Fail { param([string]$Message) Write-Err "install.ps1: $Message" # `throw` rather than `exit`: under `irm | iex` this script runs inside the # caller's session, where `exit` would close the user's interactive shell and # take the error text above with it. An uncaught throw keeps the session # alive and still yields a non-zero exit code under `-File` / `-Command`. throw 'clipcat install aborted (see message above)' } # Only amd64 binaries are published. On ARM64 Windows the x64 emulation layer # runs the amd64 build fine, so install it and note the emulation; anything else # has no compatible binary. function Resolve-Arch { # A 32-bit PowerShell on a 64-bit OS reports x86 in PROCESSOR_ARCHITECTURE; # the real machine architecture then lives in PROCESSOR_ARCHITEW6432. $arch = $env:PROCESSOR_ARCHITEW6432 if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } switch ($arch) { 'AMD64' { return } 'ARM64' { Write-Info 'Note: ARM64 Windows detected -- installing the amd64 build (runs under x64 emulation).' return } default { Fail "Unsupported Windows architecture: $arch" } } } # Default to %USERPROFILE%\bin, matching install.sh's Windows branch ($HOME/bin). function Resolve-InstallDir { if ($env:CLIPCAT_INSTALL_DIR) { return $env:CLIPCAT_INSTALL_DIR } return (Join-Path $env:USERPROFILE 'bin') } # zh-CN localized fragment of the Schannel "underlying connection was closed" # error ("基础连接已经关闭"), built from code points so the match survives # however this file's bytes are decoded -- PS 5.1 running it via -File on a # non-UTF8 codepage would otherwise mangle a literal CJK string. $script:ZhConnClosed = -join (@(0x57FA,0x7840,0x8FDE,0x63A5,0x5DF2,0x7ECF,0x5173,0x95ED) | ForEach-Object { [char]$_ }) # True when a download error looks like the restricted-sandbox Schannel bug # (Codex Windows sandbox, openai/codex#17459): the sandbox's restricted token # makes AcquireCredentialsHandle fail with SEC_E_NO_CREDENTIALS, so both # Invoke-WebRequest (.NET->Schannel) and the system curl.exe die while DNS/TCP # are fine. -like is case-insensitive, covering the lowercase hex variant. function Test-SchannelFailure { param([string]$Message) if (-not $Message) { return $false } $patterns = @( 'SEC_E_NO_CREDENTIALS', 'AcquireCredentialsHandle', '0x8009030E', 'underlying connection was closed', 'Authentication failed', $script:ZhConnClosed ) foreach ($p in $patterns) { if ($Message -like "*$p*") { return $true } } return $false } # Node's bundled OpenSSL does not use Schannel, so it downloads fine inside the # same sandbox that breaks Invoke-WebRequest -- our reliable fallback channel. function Test-NodeAvailable { return [bool](Get-Command node -ErrorAction SilentlyContinue) } # `CLIPCAT_DOWNLOADER=powershell|node` forces a single downloader (mirrors # install.sh's same-named knob), so each path can be tested in isolation; unset # = default PowerShell with automatic Node fallback. function Get-DownloaderOverride { if (-not $env:CLIPCAT_DOWNLOADER) { return '' } return $env:CLIPCAT_DOWNLOADER.ToLower() } # Windows PowerShell 5.1's Invoke-WebRequest ignores the HTTPS_PROXY/HTTP_PROXY # environment variables (pwsh 7 honors them). Pass the proxy explicitly so the # documented `$env:HTTPS_PROXY = '...'` retry advice works on both editions. function Invoke-WebRequestDownload { param([string]$Url, [string]$OutFile) $extra = @{} if ($env:HTTPS_PROXY) { $extra.Proxy = $env:HTTPS_PROXY } Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $OutFile @extra } # Download via Node's `https` module (streamed to disk, binary-safe, redirect # aware). The tiny script is written to a temp .js file and the URL / output # path are passed through environment variables -- both dodge PowerShell's quote # escaping. `https` is chosen over global `fetch` so this also works on Node < 18. function Invoke-NodeDownload { param([string]$Url, [string]$OutFile) $nodeScript = @' var https = require('https'); var fs = require('fs'); var url = process.env.CLIPCAT_DL_URL; var out = process.env.CLIPCAT_DL_OUT; function get(u, redir) { if (redir > 5) { console.error('too many redirects'); process.exit(1); } https.get(u, function (res) { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); get(require('url').resolve(u, res.headers.location), redir + 1); return; } if (res.statusCode !== 200) { console.error('HTTP ' + res.statusCode); process.exit(1); } var f = fs.createWriteStream(out); res.pipe(f); f.on('finish', function () { f.close(function () { process.exit(0); }); }); f.on('error', function (e) { console.error(e.message); process.exit(1); }); }).on('error', function (e) { console.error(e.message); process.exit(1); }); } get(url, 0); '@ $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ("clipcat-dl-" + [System.Guid]::NewGuid().ToString('N') + ".js") Set-Content -LiteralPath $scriptPath -Value $nodeScript -Encoding UTF8 $prevUrl = $env:CLIPCAT_DL_URL $prevOut = $env:CLIPCAT_DL_OUT try { $env:CLIPCAT_DL_URL = $Url $env:CLIPCAT_DL_OUT = $OutFile & node $scriptPath if ($LASTEXITCODE -ne 0) { throw "node downloader exited with code $LASTEXITCODE" } } finally { $env:CLIPCAT_DL_URL = $prevUrl $env:CLIPCAT_DL_OUT = $prevOut Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue } } # Emit an actionable failure message: sandbox (Schannel) guidance when it looks # like the Codex Windows bug, always followed by the proxy retry advice. function Write-DownloadFailure { param([string]$Url, [string]$Message) Write-Err "install.ps1: failed to download $Url" Write-Err " $Message" if (Test-SchannelFailure -Message $Message) { Write-Err 'The Windows TLS stack (Schannel) could not acquire credentials. This is common' Write-Err 'inside a restricted sandbox (e.g. the Codex Windows sandbox, openai/codex#17459):' Write-Err 'Invoke-WebRequest and the system curl.exe both fail there even though DNS/TCP work.' Write-Err 'Fix: run the install in a normal PowerShell OUTSIDE the sandbox:' Write-Err ' irm https://clipcat.ai/cli.ps1 | iex' Write-Err 'or approve running it outside the sandbox. (Installing Node in the sandbox also' Write-Err 'works -- this script auto-falls back to it; its bundled OpenSSL bypasses Schannel.)' } Write-Err 'Behind a proxy, set it for this session and re-run the command, e.g.:' Write-Err " `$env:HTTPS_PROXY = 'http://127.0.0.1:7890'" } # Download a URL to a local path. Default route is the .NET/Schannel stack via # Invoke-WebRequest; on ANY failure, if `node` is available we retry once through # Node (whose OpenSSL sidesteps the sandbox Schannel bug). $env:CLIPCAT_DOWNLOADER # pins a single channel for testing. function Get-RemoteFile { param([string]$Url, [string]$OutFile) $forced = Get-DownloaderOverride if ($forced -eq 'node') { if (-not (Test-NodeAvailable)) { Write-Err "install.ps1: CLIPCAT_DOWNLOADER=node but 'node' was not found on PATH." throw 'clipcat install aborted (see message above)' } try { Invoke-NodeDownload -Url $Url -OutFile $OutFile Write-Info ' (downloaded via Node)' } catch { Write-DownloadFailure -Url $Url -Message $_.Exception.Message throw 'clipcat install aborted (see message above)' } return } try { Invoke-WebRequestDownload -Url $Url -OutFile $OutFile return } catch { $psMessage = $_.Exception.Message # Pinned to PowerShell: don't fall back, surface the real error. if ($forced -eq 'powershell') { Write-DownloadFailure -Url $Url -Message $psMessage throw 'clipcat install aborted (see message above)' } # Auto-fallback to Node on any failure when it is available -- this is what # rescues the Schannel-broken sandbox. if (Test-NodeAvailable) { if (Test-SchannelFailure -Message $psMessage) { Write-Info 'Windows TLS (Schannel) failed -- looks like a restricted sandbox (e.g. Codex Windows). Retrying via Node...' } else { Write-Info 'PowerShell download failed -- retrying via Node...' } try { Invoke-NodeDownload -Url $Url -OutFile $OutFile Write-Info ' (downloaded via Node)' return } catch { Write-DownloadFailure -Url $Url -Message "PowerShell: $psMessage | Node: $($_.Exception.Message)" throw 'clipcat install aborted (see message above)' } } Write-DownloadFailure -Url $Url -Message $psMessage throw 'clipcat install aborted (see message above)' } } # Copy SKILL.md into every installed agent's skill directory plus a neutral # fallback. Each agent is probed independently so all present agents get a copy. # Mirrors install.sh's install_skill_everywhere, including the rule that the # ~/.agents fallback is only written when NO explicit agent dir matched (agents # like Codex read both their own dir and ~/.agents, so a double copy would make # them load the skill twice). function Install-SkillEverywhere { param([string]$TmpSkill) if (-not (Test-Path -LiteralPath $TmpSkill)) { Write-Info 'Warning: SKILL.md not available, skipping skill installation' return } $userProfile = $env:USERPROFILE $appData = $env:APPDATA # Probe dir -> target skill dir. Order mirrors install.sh; %APPDATA% variants # cover agents that store XDG-style config there on Windows. $agents = @( @{ Home = (Join-Path $userProfile '.claude'); Dest = (Join-Path $userProfile ".claude\skills\$SkillName") }, @{ Home = (Join-Path $userProfile '.openclaw'); Dest = (Join-Path $userProfile ".openclaw\skills\$SkillName") }, @{ Home = (Join-Path $userProfile '.codex'); Dest = (Join-Path $userProfile ".codex\skills\$SkillName") }, @{ Home = (Join-Path $userProfile '.workbuddy'); Dest = (Join-Path $userProfile ".workbuddy\skills\$SkillName") }, @{ Home = (Join-Path $userProfile '.gemini'); Dest = (Join-Path $userProfile ".gemini\skills\$SkillName") }, @{ Home = (Join-Path $userProfile '.config\opencode'); Dest = (Join-Path $userProfile ".config\opencode\skills\$SkillName") }, @{ Home = (Join-Path $userProfile '.config\goose'); Dest = (Join-Path $userProfile ".config\goose\skills\$SkillName") } ) if ($appData) { $agents += @{ Home = (Join-Path $appData 'opencode'); Dest = (Join-Path $appData "opencode\skills\$SkillName") } $agents += @{ Home = (Join-Path $appData 'goose'); Dest = (Join-Path $appData "goose\skills\$SkillName") } } $installedAny = $false foreach ($agent in $agents) { if (Test-Path -LiteralPath $agent.Home -PathType Container) { New-Item -ItemType Directory -Force -Path $agent.Dest | Out-Null Copy-Item -LiteralPath $TmpSkill -Destination (Join-Path $agent.Dest 'SKILL.md') -Force Write-Info "Installed clipcat skill -> $($agent.Dest)" $installedAny = $true } } if (-not $installedAny) { $fallback = Join-Path $userProfile ".agents\skills\$SkillName" New-Item -ItemType Directory -Force -Path $fallback | Out-Null Copy-Item -LiteralPath $TmpSkill -Destination (Join-Path $fallback 'SKILL.md') -Force Write-Info "Installed clipcat skill -> $fallback (universal fallback)" } else { Write-Info 'Skipped ~/.agents/skills fallback (already installed to explicit agent dirs)' } } # Persist the install dir into the *user* PATH (idempotent, case-insensitive to # match Windows PATH semantics) and add it to the current session so `clipcat` # is callable immediately without opening a new terminal. function Add-ToUserPath { param([string]$InstallDir) $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') if (-not $userPath) { $userPath = '' } $entries = $userPath -split ';' | Where-Object { $_ -ne '' } $present = $entries | Where-Object { $_.TrimEnd('\') -ieq $InstallDir.TrimEnd('\') } if ($present) { Write-Info "PATH already contains $InstallDir" } else { $newPath = ($userPath.TrimEnd(';') + ';' + $InstallDir).TrimStart(';') [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') Write-Info "Added $InstallDir to your Windows user PATH" } # Also update this session's PATH so the current shell can run clipcat now. $sessionEntries = $env:Path -split ';' | Where-Object { $_ -ne '' } if (-not ($sessionEntries | Where-Object { $_.TrimEnd('\') -ieq $InstallDir.TrimEnd('\') })) { $env:Path = ($env:Path.TrimEnd(';') + ';' + $InstallDir).TrimStart(';') } } function Main { Resolve-Arch $installDir = Resolve-InstallDir $downloadName = "${ProgramName}_windows_amd64.exe" $downloadUrl = "$DownloadBase/$downloadName" $targetPath = Join-Path $installDir "${ProgramName}.exe" New-Item -ItemType Directory -Force -Path $installDir | Out-Null $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("clipcat-install-" + [System.Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null try { $tmpBinary = Join-Path $tmpDir "${ProgramName}.exe" Write-Info "Downloading $downloadUrl" Get-RemoteFile -Url $downloadUrl -OutFile $tmpBinary if (-not (Test-Path -LiteralPath $tmpBinary) -or (Get-Item -LiteralPath $tmpBinary).Length -eq 0) { Fail "Failed to download $downloadUrl (no data written). Check your network/proxy and try again." } Write-Info "Installing to $targetPath" # Windows cannot delete/overwrite a running exe -- and during `clipcat # update` the running clipcat.exe is exactly this target -- but it CAN be # renamed. Move the old binary aside first (the running process keeps working # from the renamed file), then drop the new one in place. The stale .old from # a previous update is removed best-effort: it only deletes once no process # holds it. if (Test-Path -LiteralPath $targetPath) { Remove-Item -LiteralPath "$targetPath.old" -Force -ErrorAction SilentlyContinue Move-Item -LiteralPath $targetPath -Destination "$targetPath.old" -Force -ErrorAction SilentlyContinue } try { Move-Item -LiteralPath $tmpBinary -Destination $targetPath -Force } catch { Fail "Could not write $targetPath (is another program locking it?)" } Write-Info 'Downloading SKILL.md' $tmpSkill = Join-Path $tmpDir 'SKILL.md' try { Get-RemoteFile -Url $SkillUrl -OutFile $tmpSkill Install-SkillEverywhere -TmpSkill $tmpSkill } catch { Write-Info 'Warning: failed to download SKILL.md (non-fatal)' } Add-ToUserPath -InstallDir $installDir } finally { Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue } Write-Info '' Write-Info "clipcat installed to: $targetPath" Write-Info '' Write-Info 'Next step -- configure your API key:' Write-Info ' clipcat config --api-key --base-url https://clipcat.ai' Write-Info '' Write-Info 'Get your API key at: https://clipcat.ai/workspace?modal=settings&tab=apikeys' Write-Info '' Write-Info 'Quick start:' Write-Info ' clipcat -h' Write-Info " clipcat search --query 'fashion' --limit 5" Write-Info '' Write-Info 'Tip: restart your AI agent (Codex / Claude Code / etc.) so it rescans skills.' Write-Info 'Tip: open a NEW terminal for the PATH change to take effect everywhere.' } Main