# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). # Uses uv for fast Python provisioning and package management. # # Usage: # iex (irm https://hermes-agent.nousresearch.com/install.ps1) # # Or download and run with options: # .\install.ps1 -NoVenv -SkipSetup # # ============================================================================ param( [switch]$NoVenv, [switch]$SkipSetup, [string]$Branch = "main", # -Commit and -Tag are higher-precedence variants of -Branch for users # who need reproducible installs (desktop installer pinning, CI, release # bundles). When set, the repository stage clones $Branch (faster than # cloning the full default-branch history) and then `git checkout`s the # exact ref. Precedence: Commit > Tag > Branch. [string]$Commit = "", [string]$Tag = "", [string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }), [string]$InstallDir = $(if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }), # --- Stage protocol (additive; default invocation behaves as before) ---- # See the "Stage protocol" section near the bottom of the file for the # full contract. Intended for programmatic drivers (the desktop GUI's # onboarding wizard, CI, future install.sh parity, etc.). CLI users # running the canonical `irm | iex` one-liner never touch these flags. [switch]$Manifest, [string]$Stage, [switch]$ProtocolVersion, [switch]$NonInteractive, [switch]$Json, # --- Ensure mode (dep_ensure.py entry point) --- [string]$Ensure = "", [switch]$PostInstall, # --- Desktop GUI build (opt-in) --- # When set, install.ps1 includes Stage-Desktop in the manifest and # builds apps/desktop into a launchable Hermes.exe. # # Why opt-in: # * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes # -IncludeDesktop so a user who installed via the GUI ends up # with a launchable desktop binary. # * The Electron desktop's own bootstrap-runner.cjs runs install.ps1 # from inside an already-launched Hermes.exe; if THAT recursively # built apps/desktop it would try to overwrite the live Hermes.exe # on disk and fail. The recursive path omits the flag. # * The canonical CLI one-liner (irm | iex) omits the flag too; # terminal users don't need a desktop binary built for them, and # `hermes desktop` already builds on demand. [switch]$IncludeDesktop ) $ErrorActionPreference = "Stop" # Suppress Invoke-WebRequest's per-chunk progress bar. Windows PowerShell # 5.1's progress UI repaints synchronously on every received byte, which # pegs CPU on a single core and throttles downloads by 10-100x (a 57MB # PortableGit grab can take 5 minutes with progress on vs 20 seconds # with progress off, on the same network). Every IWR call in this # script is fire-and-forget so we never need to see the bar. Restored # automatically when the script exits. $ProgressPreference = "SilentlyContinue" # Force the console to UTF-8 so non-ASCII output from native commands # (e.g. playwright's box-drawing progress bars and download banners, # git's bullet glyphs, npm's check marks) renders correctly instead of # as IBM437/Windows-1252 mojibake (sequences like 0xE2 0x95 0x94 box- # drawing chars decoded under the legacy DOS codepage). This is a # DISPLAY-only fix; the underlying bytes are already correct. We do # NOT change the file's own encoding (it remains pure ASCII for PS 5.1 # parser compatibility; see comments at the top of the entry-point # dispatch). This affects only what the user sees in their terminal # during this install run, and reverts automatically when the script # exits and the host's console encoding is restored. try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() } catch { # Some constrained PowerShell hosts disallow encoding mutation. # Mojibake on output is then cosmetic-only, install still works. } # ============================================================================ # 8.3 short-path normalization # ============================================================================ # When the Windows user-profile folder name contains a space (e.g. # "First Last"), Windows generates an 8.3 short alias for it (e.g. FIRST~1.LAS) # and may expose %TEMP%/%TMP% in that short form: # C:\Users\FIRST~1.LAS\AppData\Local\Temp # PowerShell's FileSystem provider mishandles the "~1.ext" component when such a # path is handed to a provider cmdlet like `Tee-Object -FilePath` / # `Out-File -FilePath`, throwing: # "An object at the specified path C:\Users\FIRST~1.LAS does not exist." # Every Node/Electron build+install stage streams its log to %TEMP% via # Tee-Object, so they all abort with that error, while the Python/uv stages -- # which never write a side log to %TEMP% through a provider cmdlet -- complete # fine. Expanding %TEMP%/%TMP% back to their long form once, up front, lets # every downstream cmdlet (and child process) see a path the provider can # resolve. (GH: Windows desktop installer fails at Node/Electron stages.) function ConvertTo-LongPath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $Path } # Only 8.3 short names carry a tilde+digit ("~1"); skip the COM round-trip # for ordinary long paths. if ($Path -notmatch '~\d') { return $Path } try { $fso = New-Object -ComObject Scripting.FileSystemObject if ($fso.FolderExists($Path)) { return $fso.GetFolder($Path).Path } if ($fso.FileExists($Path)) { return $fso.GetFile($Path).Path } } catch { # COM unavailable / locked-down host: fall back to the original path. } return $Path } foreach ($tmpVar in @('TEMP', 'TMP')) { $current = [Environment]::GetEnvironmentVariable($tmpVar) if ($current) { $expanded = ConvertTo-LongPath $current if ($expanded -and $expanded -ne $current) { Set-Item -Path "Env:$tmpVar" -Value $expanded } } } # ============================================================================ # Configuration # ============================================================================ $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git" $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git" $PythonVersion = "3.11" # Minor versions the installer accepts when the requested $PythonVersion isn't # available, in preference order. uv discovers both uv-managed and system # interpreters, so this list also matches a pre-existing system Python. Single # source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") $NodeVersion = "22" # Stage-protocol version. Bumped only for genuinely breaking changes to the # manifest schema, stage-name set semantics, or stdout JSON shape. Adding a # new stage does NOT bump this -- drivers iterate the manifest dynamically. $InstallStageProtocolVersion = 1 # ============================================================================ # Helper functions # Return the real OS processor architecture as a lowercase string suitable for # Node.js / electron download URL slugs: "arm64", "x64", or "x86". # # Why not just trust [Environment]::Is64BitOperatingSystem or # [RuntimeInformation]::OSArchitecture? On Windows on ARM, when this script # is invoked from Windows PowerShell 5.1 (the default `powershell.exe`) or # any x64 PowerShell host, the process runs under Prism x64 emulation and # BOTH of those APIs report `X64` -- they describe the emulated view, not # the real OS. We've seen this concretely on Snapdragon X1 hardware: an # ARM64-based Surface Laptop returns OSArchitecture=X64 from an emulated # PowerShell session. # # Win32_Processor.Architecture is invariant to emulation. Values: # 0=x86, 5=ARM, 9=AMD64/x64, 12=ARM64. We fall back to # PROCESSOR_ARCHITEW6432 (set on WoW64 with the real OS arch) and then # PROCESSOR_ARCHITECTURE so we still produce a sensible answer if CIM # isn't available (locked-down WMI, container, etc.). function Get-WindowsArch { try { $proc = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | Select-Object -First 1 switch ([int]$proc.Architecture) { 12 { return "arm64" } 9 { return "x64" } 0 { return "x86" } 5 { return "arm" } } } catch { # CIM unavailable -- fall through to env-var path } $envArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } switch ($envArch) { "ARM64" { return "arm64" } "AMD64" { return "x64" } "x86" { return "x86" } default { # Last-resort: respect 64-bitness so we don't ship a 32-bit # toolchain to anyone. if ([Environment]::Is64BitOperatingSystem) { return "x64" } else { return "x86" } } } } # ============================================================================ function Write-Banner { Write-Host "" Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta Write-Host "| * Hermes Agent Installer |" -ForegroundColor Magenta Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta Write-Host "| An open source AI agent by Nous Research. |" -ForegroundColor Magenta Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta Write-Host "" } function Write-Info { param([string]$Message) Write-Host "-> $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Warn { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } function Write-Err { param([string]$Message) Write-Host "[X] $Message" -ForegroundColor Red } function Invoke-NativeWithRelaxedErrorAction { param([scriptblock]$Script) $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" try { & $Script } finally { $ErrorActionPreference = $prevEAP } } function Discard-LockfileChurn { param([string]$Repo = $InstallDir) if (-not $Repo -or -not (Test-Path (Join-Path $Repo ".git"))) { return } try { $diff = & git -c windows.appendAtomically=false -C $Repo diff --name-only 2>$null if ($LASTEXITCODE -ne 0 -or -not $diff) { return } $dirtyPackageDirs = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) foreach ($path in $diff) { if ($path -like "*package.json") { $null = $dirtyPackageDirs.Add((Split-Path $path -Parent)) } } $dirtyLocks = [System.Collections.Generic.List[string]]::new() foreach ($path in $diff) { if ($path -notlike "*package-lock.json") { continue } $lockDir = Split-Path $path -Parent if ($dirtyPackageDirs.Contains($lockDir)) { continue } $dirtyLocks.Add($path) } if ($dirtyLocks.Count -eq 0) { return } & git -c windows.appendAtomically=false -C $Repo checkout -- @($dirtyLocks) 2>$null if ($LASTEXITCODE -eq 0) { Write-Info "Discarded npm lockfile churn ($($dirtyLocks.Count) file(s))" } } catch { # Best-effort only; never let cleanup block the installer update path. } } # Inspect npm output for a TLS-trust failure and, if found, print actionable # remediation. npm/Node surface corporate MITM proxies and missing root CAs as # "unable to get local issuer certificate" / "self-signed certificate in # certificate chain" / UNABLE_TO_GET_ISSUER_CERT_LOCALLY -- most commonly while # Electron's install.js postinstall downloads the Electron binary. The reporter # usually misreads this as an admin-rights or generic install failure (see # issue #38016), so detect it once here and route every npm stage through this # hint. Returns $true when a cert error was detected (caller may adjust its own # messaging), $false otherwise. function Show-NpmCertHint { param([string]$NpmOutput) if (-not $NpmOutput) { return $false } $isCertError = $NpmOutput -match "unable to get local issuer certificate" ` -or $NpmOutput -match "self.signed certificate" ` -or $NpmOutput -match "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" ` -or $NpmOutput -match "SELF_SIGNED_CERT_IN_CHAIN" ` -or $NpmOutput -match "CERT_HAS_EXPIRED" if (-not $isCertError) { return $false } Write-Warn "This looks like a TLS certificate-trust failure, not a permissions problem." Write-Info " A corporate proxy or antivirus is likely intercepting HTTPS and presenting a" Write-Info " certificate Node.js doesn't trust. To fix, point Node at your org's root CA:" Write-Info " 1. Get the corporate root CA as a .pem/.crt from your IT team." Write-Info " 2. setx NODE_EXTRA_CA_CERTS `"C:\path\to\corp-ca.pem`"" Write-Info " 3. Open a NEW terminal (so the env var takes effect) and re-run the installer." Write-Info " Quick (less secure) alternative -- disable TLS verification just for the install:" Write-Info " npm config set strict-ssl false (re-enable afterwards: npm config set strict-ssl true)" return $true } # --- Ensure-mode helpers --- function Resolve-NpmCmd { $npmCmd = Get-Command npm -ErrorAction SilentlyContinue if (-not $npmCmd) { return $null } $npmExe = $npmCmd.Source if ($npmExe -like "*.ps1") { $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" if (Test-Path $npmCmdSibling) { return $npmCmdSibling } } return $npmExe } function Find-SystemBrowser { # Honor ONLY an explicit, user-set AGENT_BROWSER_EXECUTABLE_PATH override. # # We no longer scan well-known install locations for a system browser. # Auto-detection silently bound the install to an arbitrary binary instead # of the bundled Playwright Chromium, which made the browser tool behave # differently across hosts (and, on Linux, picked up a sandboxed Snap # Chromium that hangs every browser_navigate). Every install now uses the # bundled Chromium unless the user explicitly points elsewhere. $override = $env:AGENT_BROWSER_EXECUTABLE_PATH if ([string]::IsNullOrWhiteSpace($override)) { return $null } if (Test-Path $override) { return $override } return $null } function Write-BrowserEnv { param([string]$BrowserPath) if (-not (Test-Path $HermesHome)) { New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null } $envFile = Join-Path $HermesHome ".env" if (-not (Test-Path $envFile)) { Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 return } $content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return } Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 } function Install-AgentBrowser { param([switch]$SkipChromium) $npm = Resolve-NpmCmd if (-not $npm) { Write-Err "npm not found -- install Node.js first" throw "npm not found" } Write-Info "Installing agent-browser via npm -g --prefix..." $prefixDir = Join-Path $HermesHome "node" if (-not (Test-Path $prefixDir)) { New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null } $npmLog = [System.IO.Path]::GetTempFileName() $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & $npm install -g --prefix $prefixDir --silent --ignore-scripts "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null $npmExit = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($npmExit -ne 0) { $npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue Remove-Item $npmLog -Force -ErrorAction SilentlyContinue Write-Err "npm install -g failed (exit $npmExit): $npmDetail" Show-NpmCertHint $npmDetail | Out-Null throw "npm install failed" } Remove-Item $npmLog -Force -ErrorAction SilentlyContinue if (-not $SkipChromium) { $sysBrowser = Find-SystemBrowser if ($sysBrowser) { Write-BrowserEnv -BrowserPath $sysBrowser Write-Info "Explicit browser override set -- skipping bundled Chromium download" } else { $abExe = Join-Path $prefixDir "agent-browser.cmd" if (Test-Path $abExe) { Write-Info "Installing Chromium via agent-browser install..." $abLog = [System.IO.Path]::GetTempFileName() $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & $abExe install 2>&1 | Tee-Object -FilePath $abLog | Out-Null $abExit = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($abExit -ne 0) { $abDetail = Get-Content $abLog -Raw -ErrorAction SilentlyContinue Write-Warn "Chromium install failed (exit $abExit): $abDetail" } Remove-Item $abLog -Force -ErrorAction SilentlyContinue } else { Write-Warn "agent-browser.cmd not found at $abExe" } } } Write-Success "Agent-browser ready" } # ============================================================================ # Dependency checks # ============================================================================ # Resolve the PowerShell host executable used to spawn child PowerShell # processes (the astral uv installer below). We must NOT hardcode the bare # name `powershell`: it names *Windows PowerShell* and only resolves when its # System32 directory is on PATH. When install.ps1 is run under PowerShell 7+ # (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare # `powershell` spawn dies with "The term 'powershell' is not recognized", # aborting uv installation (field report: Windows install stuck, uv install # failed with exactly that message). Prefer the absolute path of the host we # are already running in (PATH-independent), then fall back to whichever of # powershell/pwsh is resolvable, and only then to the bare name. function Get-PowerShellHostExe { try { $hostExe = (Get-Process -Id $PID).Path if ($hostExe -and (Test-Path $hostExe)) { $leaf = Split-Path $hostExe -Leaf # Only trust the current host when it is a real PowerShell CLI # (not e.g. powershell_ise.exe or an embedded host that can't take # `-ExecutionPolicy`/`-Command`). if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe } } } catch { } foreach ($candidate in @("powershell", "pwsh")) { $cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd -and $cmd.Source) { return $cmd.Source } } # Last-ditch: hand back the bare name so the spawn surfaces its own error. return "powershell" } function Install-Uv { # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there — # no PATH probing, no conda guards, no multi-location resolution chains. # The runtime update path (hermes_cli/managed_uv.py) looks in the same # place, so install.ps1 and `hermes update` stay in sync. $managedUv = Join-Path $HermesHome "bin\uv.exe" if (Test-Path $managedUv) { $script:UvCmd = $managedUv $version = & $managedUv --version Write-Success "Managed uv found ($version)" return $true } Write-Info "Installing managed uv into $HermesHome\bin ..." New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null # UV_INSTALL_DIR tells the astral installer to place the binary # directly into $HermesHome\bin instead of ~/.local/bin. $prevEAP = $ErrorActionPreference try { $ErrorActionPreference = "Continue" $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" # Spawn via the resolved host exe (see Get-PowerShellHostExe) rather # than a bare `powershell`, which isn't guaranteed to be on PATH under # PowerShell 7 / pwsh-only setups. $psHostExe = Get-PowerShellHostExe & $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null $ErrorActionPreference = $prevEAP if (Test-Path $managedUv) { $script:UvCmd = $managedUv $version = & $managedUv --version Write-Success "Managed uv installed ($version)" return $true } Write-Err "uv installed but not found at $managedUv" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } catch { if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } } # Refresh $env:Path from the User + Machine registry hives. Stage drivers # invoke each stage in a fresh powershell process, but those processes # inherit env from the parent driver shell, NOT from the registry. When # an earlier stage (Stage-Git, Stage-Node, ...) installs a binary and # pushes its directory into User PATH, the next child process's $env:Path # is stale and the binary appears missing. This helper re-reads PATH # from the registry so every Invoke-Stage starts from a fresh, up-to-date # PATH view. Cheap (registry reads, no I/O elsewhere) and idempotent. function Sync-EnvPath { $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") } # npm lifecycle scripts on Windows spawn ``cmd.exe /d /s /c node