# ============================================================================ # Koodos Installer - Windows (PowerShell) # ============================================================================ # Turns the bare `pipx install` into a guided, no-prerequisite setup: # * checks for Python 3.8+ (installs it via winget if missing) # * bootstraps pipx # * installs the koodos CLI from the official tarball # * registers THIS computer as a Koodos device (background agent, one UAC) # # Usage (no key needed - it pairs, like connecting a remote control): # irm https://api.kprcli.com/dl/install.ps1 | iex # # ...prints a short code, you approve it from a device you're already on. # # Unattended installs can still hand it a key up front: # $env:KOODOS_KEY = "kood_xxx"; irm https://api.kprcli.com/dl/install.ps1 | iex # # Or download and run with a parameter: # .\install.ps1 kood_xxx # .\install.ps1 -DryRun # everything EXCEPT registering the device # ============================================================================ # NOTE: deliberately no param() block. PowerShell rejects one when the script # is piped into iex, which is the documented install path, so options come # from the environment and from a plain $args scan instead. $KoodosKey = if ($env:KOODOS_KEY) { $env:KOODOS_KEY } elseif ($env:KOODOS_API_KEY) { $env:KOODOS_API_KEY } else { "" } $DryRun = [bool]$env:KOODOS_DRYRUN $NoRegister = [bool]$env:KOODOS_NOREGISTER if ($args) { for ($i = 0; $i -lt $args.Count; $i++) { $a = "$($args[$i])" if ($a -match '^-{1,2}(koodoskey|key)$' -and ($i + 1) -lt $args.Count) { $KoodosKey = "$($args[++$i])" } elseif ($a -match '^-{1,2}dry-?run$') { $DryRun = $true } elseif ($a -match '^-{1,2}no-?register$') { $NoRegister = $true } elseif ($a -notmatch '^-') { $KoodosKey = $a } } } $ErrorActionPreference = "Stop" # Invoke-WebRequest's per-byte progress bar throttles downloads badly on # Windows PowerShell 5.1; we never need it here. $ProgressPreference = "SilentlyContinue" $Tarball = "https://api.kprcli.com/dl/koodos.tar.gz" $Dashboard = "https://kprcli.com" $LocalApp = "http://localhost:6045" if ($NoRegister) { $DryRun = $true } # ---------------------------------------------------------------------------- # Terminal capabilities # ---------------------------------------------------------------------------- $UseColor = $true if ($env:NO_COLOR) { $UseColor = $false } # Force UTF-8 so the block banner + check glyphs render instead of mojibake. $UseUnicode = $true try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() } catch { $UseUnicode = $false } if ($UseUnicode) { $SArrow = [char]0x2192; $SOk = [char]0x2713; $SWarn = [char]0x26A0; $SErr = [char]0x2717 } else { $SArrow = ">"; $SOk = "[OK]"; $SWarn = "[!]"; $SErr = "[X]" } # Koodos green (#3fb950) has no exact 16-color console match; DarkGreen/Green # are the closest. Cyan/Yellow/Red carry the rest of the visual language. $ColGreen = "Green"; $ColDim = "DarkGreen"; $ColCyan = "Cyan"; $ColYellow = "Yellow"; $ColRed = "Red" function Write-C { param([string]$Text, [string]$Color) if ($UseColor -and $Color) { Write-Host $Text -ForegroundColor $Color } else { Write-Host $Text } } function Step { param([string]$m) Write-C "$SArrow $m" $ColCyan } function Ok { param([string]$m) Write-C "$SOk $m" $ColGreen } function Warn { param([string]$m) Write-C "$SWarn $m" $ColYellow } function Err { param([string]$m) Write-C "$SErr $m" $ColRed } function Plain { param([string]$m) Write-Host " $m" } # ---------------------------------------------------------------------------- # Banner # ---------------------------------------------------------------------------- function Write-Banner { Write-Host "" if ($UseUnicode) { # Same block-letter wordmark as install.sh. We forced the console to # UTF-8 above; if that failed, $UseUnicode is false and we drop to the # pure-ASCII box below. $banner = @( " ##7 ##7 ######7 ######7 ######7 ######7 #######7", " ##| ##rJ##r===##7##r===##7##r==##7##r===##7##r====J", " #####rJ ##| ##|##| ##|##| ##|##| ##|#######7", " ##r=##7 ##| ##|##| ##|##| ##|##| ##|L====##|", " ##| ##7L######rJL######rJ######rJL######rJ#######|", " L=J L=J L=====J L=====J L=====J L=====J L======J" ) # The rows above are pure ASCII on purpose: this file is fetched over # HTTP and piped to iex, so any non-ASCII byte here is at the mercy of # whatever encoding the client guesses. Build the real box-drawing # glyphs at runtime instead - they can never arrive mojibake. foreach ($l in $banner) { Write-C ($l.Replace('#', [char]0x2588). Replace('7', [char]0x2557). Replace('|', [char]0x2551). Replace('r', [char]0x2554). Replace('J', [char]0x255D). Replace('L', [char]0x255A). Replace('=', [char]0x2550)) $ColGreen } Write-C " your personal AI agent - kprcli.com" $ColDim } else { Write-C "+-----------------------------------------------+" $ColGreen Write-C "| K O O D O S |" $ColGreen Write-C "| your personal AI agent |" $ColGreen Write-C "+-----------------------------------------------+" $ColGreen } Write-Host "" } # ---------------------------------------------------------------------------- # Python 3.8+ # ---------------------------------------------------------------------------- $script:Py = $null function Test-PythonCandidate { param([string]$Exe) try { # Skip the Microsoft Store 0-byte stub, which exits non-zero and only # nags the user to "install from the Store". $cmd = Get-Command $Exe -ErrorAction SilentlyContinue if (-not $cmd) { return $false } if ($cmd.Source -and ($cmd.Source -like "*\WindowsApps\*")) { $item = Get-Item $cmd.Source -ErrorAction SilentlyContinue if ($item -and $item.Length -eq 0) { return $false } } $ok = & $Exe -c "import sys; sys.exit(0 if sys.version_info >= (3,8) else 1)" 2>$null return ($LASTEXITCODE -eq 0) } catch { return $false } } function Find-Python { Step "Checking for Python 3.8+" foreach ($cand in @("python", "python3", "py")) { if (Test-PythonCandidate $cand) { $script:Py = $cand $ver = & $cand -c "import platform;print(platform.python_version())" 2>$null Ok "Python $ver ($cand)" return } } Warn "Python 3.8+ was not found." if (Get-Command winget -ErrorAction SilentlyContinue) { Step "Installing Python 3.12 via winget" try { winget install -e --id Python.Python.3.12 --accept-source-agreements --accept-package-agreements --silent | Out-Null } catch { } # winget updates PATH in the registry, not this session; refresh it. $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") foreach ($cand in @("python", "python3", "py")) { if (Test-PythonCandidate $cand) { $script:Py = $cand $ver = & $cand -c "import platform;print(platform.python_version())" 2>$null Ok "Python $ver installed ($cand)" return } } Warn "Python was installed but isn't on PATH yet." Plain "Close this window, open a NEW terminal, and re-run the installer." exit 1 } Err "Could not install Python automatically (winget not available)." Plain "Install Python 3.8+ from https://www.python.org/downloads/" Plain "(check 'Add python.exe to PATH'), then re-run this installer." exit 1 } # ---------------------------------------------------------------------------- # pipx # ---------------------------------------------------------------------------- $script:Pipx = $null function Ensure-Pipx { Step "Ensuring pipx is available" if (Get-Command pipx -ErrorAction SilentlyContinue) { $script:Pipx = @("pipx") Ok "pipx already installed" return } & $script:Py -m pipx --version 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { $script:Pipx = @($script:Py, "-m", "pipx") Ok "pipx available via '$($script:Py) -m pipx'" return } Step "Installing pipx (user-level, no admin)" & $script:Py -m pip install --user --upgrade pipx 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { & $script:Py -m ensurepip --upgrade 2>$null | Out-Null & $script:Py -m pip install --user --upgrade pipx 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Err "Could not install pipx automatically." Plain "Try: $($script:Py) -m pip install --user pipx" exit 1 } } & $script:Py -m pipx ensurepath 2>$null | Out-Null $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") $script:Pipx = @($script:Py, "-m", "pipx") Ok "pipx installed" } # ---------------------------------------------------------------------------- # koodos # ---------------------------------------------------------------------------- $script:PrevVer = $null function Install-Koodos { # Note what's already here so a second run can report an update, not a # first install. if (Get-Command koodos -ErrorAction SilentlyContinue) { $script:PrevVer = (koodos version 2>$null) -replace '^koodos\s*', '' Step "Updating koodos (found $($script:PrevVer))" } else { Step "Installing koodos from $Tarball" } # Already current? Then don't reinstall at all. Reinstalling over a RUNNING # agent fails on Windows - pipx can't replace the venv's python.exe while # the agent holds it open ("[Errno 13] Permission denied"). $latest = $null try { $latest = (Invoke-RestMethod "https://api.kprcli.com/v1/version" -TimeoutSec 10).latest } catch { } if ($script:PrevVer -and $latest -and $script:PrevVer -eq $latest) { Ok "koodos $latest - already the latest" return } # An upgrade IS needed, so stop the running agent first to release the lock. # `koodos link` starts it again at the end. if ($script:PrevVer) { $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & koodos agent --stop 2>&1 | Out-Null $ErrorActionPreference = $prevEAP Start-Sleep -Milliseconds 900 } $pipxExe = $script:Pipx[0] $pipxArgs = @() if ($script:Pipx.Count -gt 1) { $pipxArgs += $script:Pipx[1..($script:Pipx.Count - 1)] } # --force keeps this idempotent: re-running upgrades in place. $pipxArgs += @("install", "--force", $Tarball) $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & $pipxExe @pipxArgs 2>&1 | Out-Null $code = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($code -ne 0) { Err "koodos install failed." Plain "Re-run for details: $($script:Pipx -join ' ') install --force $Tarball" exit 1 } $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if (Get-Command koodos -ErrorAction SilentlyContinue) { $ver = (koodos version 2>$null) -replace '^koodos\s*', '' # Second run on the same machine is an UPDATE, so say what changed # rather than pretending this was a fresh install. if ($script:PrevVer -and $script:PrevVer -eq $ver) { Ok "koodos $ver - already the latest" } elseif ($script:PrevVer) { Ok "koodos updated $($script:PrevVer) -> $ver" } else { Ok "koodos $ver installed" } } else { Ok "koodos installed" Warn "The 'koodos' command isn't on PATH yet - open a new terminal to use it." } } # ---------------------------------------------------------------------------- # Register this device # ---------------------------------------------------------------------------- $script:Registered = $false function Register-Device { # No key? Then pair instead of asking anyone to handle a secret. `koodos # link` prints a short code and waits for you to approve it from a device # you're already on - the key is minted server-side and never typed here. if (-not $KoodosKey) { if ($DryRun) { Warn "dry-run: NOT pairing. Would run: koodos link" return } if (-not [Environment]::UserInteractive) { Warn "No device key and not an interactive session - skipping pairing." Plain "Run this on that machine when you're at it: koodos link" return } Step "Pairing this device" $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & koodos link $code = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($code -eq 0) { Ok "Device paired and background agent installed" $script:Registered = $true } else { Warn "Pairing didn't finish - you can retry any time with:" Plain " koodos link" } return } Step "Registering this device" if ($DryRun) { Warn "dry-run: NOT registering. Would run:" Plain " `$env:KOODOS_API_KEY = '$KoodosKey'; koodos agent --install" return } # koodos agent --install reads KOODOS_API_KEY, self-elevates for a single # UAC prompt, and registers the KoodosAgent background task. Idempotent. $env:KOODOS_API_KEY = $KoodosKey $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & koodos agent --install $code = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($code -eq 0) { Ok "Device registered and background agent installed" $script:Registered = $true } else { Warn "Registration command returned an error - you can retry with:" Plain " `$env:KOODOS_API_KEY = '$KoodosKey'; koodos agent --install" } } # ---------------------------------------------------------------------------- # Summary # ---------------------------------------------------------------------------- function Write-Summary { Write-Host "" Write-C "+-----------------------------------------------------------+" $ColGreen Write-Host " Koodos is installed." if ($script:Registered) { Write-C " $SOk running in the background - it auto-updates itself" $ColGreen } else { Write-C " not paired yet - run 'koodos link' to finish" $ColDim } Write-Host " dashboard $Dashboard" # The local app is served BY the agent process, so advertising it while the # agent isn't running just sends people to a dead port. if ($script:Registered) { Write-Host " local app $LocalApp" } Write-C "+-----------------------------------------------------------+" $ColGreen Write-Host "" if ($script:Registered) { Plain "Next: open $Dashboard or talk to it locally at $LocalApp" } else { Plain "Next: run koodos link to connect this machine to your fleet." } Write-Host "" } # ---------------------------------------------------------------------------- # Main # ---------------------------------------------------------------------------- Write-Banner Find-Python Ensure-Pipx Install-Koodos Register-Device Write-Summary