# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). # Uses uv for fast Python provisioning and package management. # # Usage: # iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/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 = "$env:LOCALAPPDATA\hermes", [string]$InstallDir = "$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 ) $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. } # ============================================================================ # Configuration # ============================================================================ $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git" $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git" $PythonVersion = "3.11" $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 } # --- 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 { $candidates = @( "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe", "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "${env:ProgramFiles}\Chromium\Application\chrome.exe", "${env:LOCALAPPDATA}\Chromium\Application\chrome.exe" ) foreach ($p in $candidates) { if (Test-Path $p) { return $p } } 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" throw "npm install failed" } Remove-Item $npmLog -Force -ErrorAction SilentlyContinue if (-not $SkipChromium) { $sysBrowser = Find-SystemBrowser if ($sysBrowser) { Write-BrowserEnv -BrowserPath $sysBrowser Write-Info "System browser detected -- skipping 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 # ============================================================================ function Install-Uv { Write-Info "Checking for uv package manager..." # Check if uv is already available if (Get-Command uv -ErrorAction SilentlyContinue) { $version = uv --version $script:UvCmd = "uv" Write-Success "uv found ($version)" return $true } # Check common install locations $uvPaths = @( "$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe" ) foreach ($uvPath in $uvPaths) { if (Test-Path $uvPath) { $script:UvCmd = $uvPath $version = & $uvPath --version Write-Success "uv found at $uvPath ($version)" return $true } } # Install uv Write-Info "Installing uv (fast Python package manager)..." # Capture EAP outside the try block so the catch's restore call always # has a meaningful value -- if the assignment lived inside try and the # try body threw before reaching it, the catch would see $prevEAP # unset and leave EAP at whatever the previous protected call set. $prevEAP = $ErrorActionPreference try { # Relax ErrorActionPreference around the nested astral installer. # The astral installer (a separate `powershell -c "irm ... | iex"`) # writes download progress to stderr. With $ErrorActionPreference # = "Stop" set at the top of this script, PowerShell wraps stderr # lines from native commands (which `powershell -c` is, from our # perspective) as ErrorRecord objects when captured via 2>&1, then # throws a terminating exception on the first one -- even though # uv installs successfully and the child exits 0. Same fix # pattern Test-Python uses for `uv python install`; verify success # via Test-Path on the expected binary afterwards, which is more # reliable than exit-code/stderr signal anyway. $ErrorActionPreference = "Continue" powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null $ErrorActionPreference = $prevEAP # Find the installed binary $uvExe = "$env:USERPROFILE\.local\bin\uv.exe" if (-not (Test-Path $uvExe)) { $uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe" } if (-not (Test-Path $uvExe)) { # Refresh PATH and try again $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if (Get-Command uv -ErrorAction SilentlyContinue) { $uvExe = (Get-Command uv).Source } } if (Test-Path $uvExe) { $script:UvCmd = $uvExe $version = & $uvExe --version Write-Success "uv installed ($version)" return $true } Write-Err "uv installed but not found on PATH" Write-Info "Try restarting your terminal and re-running" return $false } catch { # Restore EAP in case the try block threw before the assignment 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") } # Re-discover uv without re-installing it. Cross-process stage drivers # (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage # in a fresh powershell process, so $script:UvCmd set by Install-Uv in a # prior process is not visible here. Later stages (Test-Python, # Install-Venv, Install-Dependencies, Install-PlatformSdks) call this # at the top to populate $script:UvCmd from PATH or known install paths. # Throws if uv is not findable -- the caller's stage then surfaces a # clean error via the stage-driver's try/catch. Fast path is a single # Get-Command call when uv is on PATH (the common case after Stage-Uv # ran path-modifying installs in a sibling process). function Resolve-UvCmd { # Already resolved (default invocation path: Install-Uv ran earlier # in the same process and set $script:UvCmd). if ($script:UvCmd) { if ($script:UvCmd -eq "uv") { # "uv" on PATH -- verify it's still resolvable (PATH could have # changed mid-session; cheap to recheck). if (Get-Command uv -ErrorAction SilentlyContinue) { return } } elseif (Test-Path $script:UvCmd) { return } # Stale; fall through to re-discover. } # Try PATH first (covers `winget install astral.uv`, manual installs, # and the post-Install-Uv state where uv.exe lives in # %USERPROFILE%\.local\bin which the installer added to PATH). if (Get-Command uv -ErrorAction SilentlyContinue) { $script:UvCmd = "uv" return } # Refresh PATH from registry in case the current process started before # Install-Uv updated User PATH. $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if (Get-Command uv -ErrorAction SilentlyContinue) { $script:UvCmd = "uv" return } # Check the well-known install locations the astral.sh installer drops # uv into. Mirrors the probe order Install-Uv uses. foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) { if (Test-Path $uvPath) { $script:UvCmd = $uvPath return } } throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first." } function Test-Python { Write-Info "Checking Python $PythonVersion..." # Let uv find or install Python try { $pythonPath = & $UvCmd python find $PythonVersion 2>$null if ($pythonPath) { $ver = & $pythonPath --version 2>$null Write-Success "Python found: $ver" return $true } } catch { } # Python not found -- use uv to install it (no admin needed!) Write-Info "Python $PythonVersion not found, installing via uv..." # Capture EAP outside the try block so the catch's restore call always # has a meaningful value (see Install-Uv for the full rationale). $prevEAP = $ErrorActionPreference try { # Temporarily relax ErrorActionPreference: uv writes download progress # ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to # stderr. With $ErrorActionPreference = "Stop" (set at the top of this # script) PowerShell wraps stderr lines from native commands as # ErrorRecord objects when captured via 2>&1, then throws a terminating # exception on the first one -- even though uv exits 0 and Python was # installed successfully. Verify success via `uv python find` # afterwards, which is the reliable signal regardless of exit-code # semantics or stderr noise. This fix was previously landed as # commit ec1714e71 and then lost in a release squash; reapplied here. $ErrorActionPreference = "Continue" $uvOutput = & $UvCmd python install $PythonVersion 2>&1 $uvExitCode = $LASTEXITCODE $ErrorActionPreference = $prevEAP # Check if Python is now available (more reliable than exit code # since uv may return non-zero due to "already installed" etc.) $pythonPath = & $UvCmd python find $PythonVersion 2>$null if ($pythonPath) { $ver = & $pythonPath --version 2>$null Write-Success "Python installed: $ver" return $true } # uv ran but Python still not findable -- show what happened if ($uvExitCode -ne 0) { Write-Warn "uv python install output:" Write-Host $uvOutput -ForegroundColor DarkGray } } catch { # Restore EAP in case the try block threw before the assignment if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "uv python install error: $_" } # Fallback: check if ANY Python 3.10+ is already available on the system Write-Info "Trying to find any existing Python 3.10+..." foreach ($fallbackVer in @("3.12", "3.13", "3.10")) { try { $pythonPath = & $UvCmd python find $fallbackVer 2>$null if ($pythonPath) { $ver = & $pythonPath --version 2>$null Write-Success "Found fallback: $ver" $script:PythonVersion = $fallbackVer return $true } } catch { } } # Fallback: try system python -- but skip the Microsoft Store stub. # On Windows, %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe is a 0-byte # reparse-point stub that prints "Python was not found; run without # arguments to install from the Microsoft Store..." to stdout and exits # non-zero. Get-Command finds it; invoking it produces a confusing error # that the user sees as our installer crashing. $pythonCmd = Get-Command python -ErrorAction SilentlyContinue if ($pythonCmd) { $isStoreStub = $false try { $pythonSource = $pythonCmd.Source if ($pythonSource -and $pythonSource -like "*\WindowsApps\*") { $isStoreStub = $true } else { # Even outside WindowsApps, a 0-byte file is the stub $item = Get-Item $pythonSource -ErrorAction SilentlyContinue if ($item -and $item.Length -eq 0) { $isStoreStub = $true } } } catch { } if (-not $isStoreStub) { try { $prevEAP2 = $ErrorActionPreference $ErrorActionPreference = "Continue" $sysVer = & python --version 2>&1 $ErrorActionPreference = $prevEAP2 if ($sysVer -match "Python 3\.(1[0-9]|[1-9][0-9])") { Write-Success "Using system Python: $sysVer" return $true } } catch { if ($prevEAP2) { $ErrorActionPreference = $prevEAP2 } } } } Write-Err "Failed to install Python $PythonVersion" Write-Info "Install Python 3.11 manually, then re-run this script:" Write-Info " https://www.python.org/downloads/" Write-Info " Or: winget install Python.Python.3.11" return $false } function Install-Git { <# .SYNOPSIS Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe which Hermes uses to run shell commands. Priority order (deliberately simple -- no winget, no registry, no system package manager): 1. Existing ``git`` on PATH -- use it as-is (the common fast path). 2. Download **PortableGit** from the official git-for-windows GitHub release (self-extracting 7z.exe) and unpack it to ``%LOCALAPPDATA%\hermes\git`` -- never touches system Git, never requires admin, works even on locked-down machines and machines with a broken system Git install. **Why PortableGit, not MinGit:** MinGit is the minimal-automation distribution and ships ONLY ``git.exe`` -- no bash, no POSIX utilities. Hermes needs ``bash.exe`` to run shell commands. PortableGit is the full Git for Windows distribution without the installer UI; it ships ``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``, ``ssh``, etc. in ``usr\bin\``. We deliberately skip winget because it fails badly when the system Git install is in a half-installed state (partially registered, or uninstall- blocked). Owning the Hermes copy of Git ourselves is predictable and recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-running this installer fully recovers. After install we locate ``bash.exe`` and persist the path in ``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh shell without a second PATH refresh. #> Write-Info "Checking Git..." if (Get-Command git -ErrorAction SilentlyContinue) { $version = git --version Write-Success "Git found ($version)" Set-GitBashEnvVar return $true } # Download PortableGit into $HermesHome\git. Always works as long as # we can reach github.com -- no admin, no winget, no reliance on the # user's possibly-broken system Git install. Write-Info "Git not found -- downloading PortableGit to $HermesHome\git\ ..." Write-Info "(no admin rights required; isolated from any system Git install)" try { $arch = Get-WindowsArch if ($arch -eq 'arm64') { $assetTag = 'arm64' $downloadIsZip = $false } elseif ($arch -eq 'x64') { $assetTag = '64-bit' $downloadIsZip = $false } else { # PortableGit does not ship 32-bit / arm builds -- fall back to MinGit # 32-bit with a warning that bash-based features will be unavailable. $assetTag = '32-bit-mingit' $downloadIsZip = $true } # Pinned git-for-windows release. We deliberately do NOT hit # api.github.com/repos/.../releases/latest here: that endpoint # is rate-limited to 60 requests/hour/IP for unauthenticated # callers, and users behind CGNAT / corporate NAT / dorm WiFi # routinely hit the limit, breaking the installer. # Static github.com/.../releases/download// URLs # are not subject to the API rate limit. $gitTag = "v2.54.0.windows.1" $gitVer = "2.54.0" $gitVerTag = "$gitVer.windows.1" if ($arch -eq "32-bit-mingit") { Write-Warn "32-bit Windows detected -- PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine." $assetName = "MinGit-$gitVer-32-bit.zip" $downloadIsZip = $true } elseif ($arch -eq "arm64") { $assetName = "PortableGit-$gitVer-arm64.7z.exe" $downloadIsZip = $false } else { $assetName = "PortableGit-$gitVer-64-bit.7z.exe" $downloadIsZip = $false } $downloadUrl = "https://github.com/git-for-windows/git/releases/download/$gitTag/$assetName" $downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" } $tmpFile = "$env:TEMP\$assetName" $gitDir = "$HermesHome\git" Write-Info "Downloading $assetName (Git for Windows $gitVerTag)..." Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing if (Test-Path $gitDir) { Write-Info "Removing previous Git install at $gitDir ..." Remove-Item -Recurse -Force $gitDir } New-Item -ItemType Directory -Path $gitDir -Force | Out-Null if ($downloadIsZip) { Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force } else { # PortableGit is a self-extracting 7z archive. Invoke it with # `-o -y` (silent) to extract to $gitDir. No 7z install # required; it's fully self-contained. Write-Info "Extracting PortableGit to $gitDir ..." $extractProc = Start-Process -FilePath $tmpFile ` -ArgumentList "-o`"$gitDir`"", "-y" ` -NoNewWindow -Wait -PassThru if ($extractProc.ExitCode -ne 0) { throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))" } } Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue # PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils) # MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present) $gitExe = "$gitDir\cmd\git.exe" if (-not (Test-Path $gitExe)) { throw "Git extraction did not produce git.exe at $gitExe" } # Add to session PATH so the rest of this install run can use git. $env:Path = "$gitDir\cmd;$env:Path" # Persist to User PATH so fresh shells see it. PortableGit needs # cmd\ (for git.exe), bin\ (for bash.exe + core tools), and # usr\bin\ (for perl, ssh, curl, and other POSIX coreutils). $newPathEntries = @( "$gitDir\cmd", "$gitDir\bin", "$gitDir\usr\bin" ) $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } $changed = $false foreach ($entry in $newPathEntries) { if ($userPathItems -notcontains $entry) { $userPathItems += $entry $changed = $true } } if ($changed) { [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") } $version = & $gitExe --version Write-Success "Git $version installed to $gitDir (portable, user-scoped)" Set-GitBashEnvVar return $true } catch { Write-Err "Could not install portable Git: $_" Write-Info "" Write-Info "Fallback: install Git manually from https://git-scm.com/download/win" Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run" Write-Info "shell commands (same as Claude Code and other coding agents)." return $false } } function Set-GitBashEnvVar { <# .SYNOPSIS Locate ``bash.exe`` from an already-installed Git and persist the path in ``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before PATH propagation completes in a newly-spawned shell. #> $candidates = @() # Our own portable Git install is ALWAYS checked first, so a broken # system Git doesn't hijack us. If the user had a working system Git # we'd have returned early from Install-Git's fast path and never called # this with a system-Git-only installation anyway. # # Layouts: # PortableGit (our default): $HermesHome\git\bin\bash.exe # MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe $candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary) $candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback # git.exe on PATH can tell us where the install root is $gitCmd = Get-Command git -ErrorAction SilentlyContinue if ($gitCmd) { $gitExe = $gitCmd.Source # Git for Windows (full installer): \cmd\git.exe + \bin\bash.exe # MinGit: \cmd\git.exe + \usr\bin\bash.exe $gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent $candidates += "$gitRoot\bin\bash.exe" $candidates += "$gitRoot\usr\bin\bash.exe" } # Standard system install locations as a final fallback. Note: # ProgramFiles(x86) can't be referenced via ${env:...} string interpolation # because of the parens -- use [Environment]::GetEnvironmentVariable(). $candidates += "${env:ProgramFiles}\Git\bin\bash.exe" $pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" } $candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe" foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate)) { [Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User") $env:HERMES_GIT_BASH_PATH = $candidate Write-Info "Set HERMES_GIT_BASH_PATH=$candidate" return } } Write-Warn "Could not locate bash.exe -- Hermes may not find Git Bash." Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } function Test-Node { Write-Info "Checking Node.js (for browser tools)..." if (Get-Command node -ErrorAction SilentlyContinue) { $version = node --version Write-Success "Node.js $version found" $script:HasNode = $true return $true } # Check our own managed install from a previous run $managedNode = "$HermesHome\node\node.exe" if (Test-Path $managedNode) { $version = & $managedNode --version $env:Path = "$HermesHome\node;$env:Path" Write-Success "Node.js $version found (Hermes-managed)" $script:HasNode = $true return $true } Write-Info "Node.js not found -- installing Node.js $NodeVersion LTS..." # Try the portable-zip path FIRST -- no UAC, no admin, no winget MSI. # winget install OpenJS.NodeJS.LTS triggers a system-wide MSI install # which prompts UAC (the dialog often appears minimized in the taskbar # and the install silently waits for consent, looking like a hang). # The portable zip path drops node.exe + npm into $HermesHome\node\ # which is user-scoped and identical to how Install-Git handles # PortableGit. Same UX guarantee: works on locked-down enterprise # machines with no admin rights. Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..." Write-Info "(no admin rights required; isolated from any system Node install)" try { $arch = Get-WindowsArch $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing $zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value if ($zipName) { $downloadUrl = "${indexUrl}${zipName}" $tmpZip = "$env:TEMP\$zipName" $tmpDir = "$env:TEMP\hermes-node-extract" Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing if (Test-Path $tmpDir) { Remove-Item -Recurse -Force $tmpDir } Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force $extractedDir = Get-ChildItem $tmpDir -Directory | Select-Object -First 1 if ($extractedDir) { if (Test-Path "$HermesHome\node") { Remove-Item -Recurse -Force "$HermesHome\node" } Move-Item $extractedDir.FullName "$HermesHome\node" # Session PATH so the rest of this run sees node/npm. $env:Path = "$HermesHome\node;$env:Path" # Persist to User PATH so fresh shells (and future stages # in cross-process driver mode) see it. Matches the # pattern Install-Git uses for PortableGit. $nodeDir = "$HermesHome\node" $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } if ($userPathItems -notcontains $nodeDir) { $userPathItems += $nodeDir [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") } $version = & "$HermesHome\node\node.exe" --version Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)" $script:HasNode = $true Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue return $true } } } catch { Write-Warn "Portable Node.js download failed: $_" } # Fallback: try winget (used to be primary, demoted because the MSI # install triggers a UAC prompt that frequently appears minimized in # the taskbar -- looks like a hang to users on stock Windows). # Kept for environments where the portable download fails (proxy, # locked firewall, etc.) but the user is willing to consent to UAC. if (Get-Command winget -ErrorAction SilentlyContinue) { Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..." # Capture EAP outside the try block so the catch's restore call always # has a meaningful value (see Install-Uv for the full rationale). $prevEAP = $ErrorActionPreference try { # Relax EAP=Stop so stderr lines from winget don't get wrapped # as ErrorRecords and short-circuit the 2>&1 pipe before we can # check the post-condition. See the long comment in Install-Uv # for the same pattern. $ErrorActionPreference = "Continue" # On ARM64, force winget to fetch the ARM64 installer. Without # the explicit override, winget on WoW64 sometimes still resolves # to x64 manifests, leaving us with an emulated Node toolchain # even after a "successful" install. The OpenJS manifest does # publish an arm64 installer, so this is safe. $wingetArgs = @( 'install','OpenJS.NodeJS.LTS','--silent', '--accept-package-agreements','--accept-source-agreements' ) if ((Get-WindowsArch) -eq 'arm64') { $wingetArgs += @('--architecture','arm64') } winget @wingetArgs 2>&1 | Out-Null $ErrorActionPreference = $prevEAP # Refresh PATH $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if (Get-Command node -ErrorAction SilentlyContinue) { $version = node --version Write-Success "Node.js $version installed via winget" $script:HasNode = $true return $true } } catch { if ($prevEAP) { $ErrorActionPreference = $prevEAP } } } Write-Info "Install manually: https://nodejs.org/en/download/" $script:HasNode = $false return $true } function Install-SystemPackages { $script:HasRipgrep = $false $script:HasFfmpeg = $false $needRipgrep = $false $needFfmpeg = $false Write-Info "Checking ripgrep (fast file search)..." if (Get-Command rg -ErrorAction SilentlyContinue) { $version = rg --version | Select-Object -First 1 Write-Success "$version found" $script:HasRipgrep = $true } else { $needRipgrep = $true } Write-Info "Checking ffmpeg (TTS voice messages)..." if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { Write-Success "ffmpeg found" $script:HasFfmpeg = $true } else { $needFfmpeg = $true } if (-not $needRipgrep -and -not $needFfmpeg) { return } # Build description and package lists for each package manager $descParts = @() $wingetPkgs = @() $chocoPkgs = @() $scoopPkgs = @() if ($needRipgrep) { $descParts += "ripgrep for faster file search" $wingetPkgs += "BurntSushi.ripgrep.MSVC" $chocoPkgs += "ripgrep" $scoopPkgs += "ripgrep" } if ($needFfmpeg) { $descParts += "ffmpeg for TTS voice messages" $wingetPkgs += "Gyan.FFmpeg" $chocoPkgs += "ffmpeg" $scoopPkgs += "ffmpeg" } $description = $descParts -join " and " $hasWinget = Get-Command winget -ErrorAction SilentlyContinue $hasChoco = Get-Command choco -ErrorAction SilentlyContinue $hasScoop = Get-Command scoop -ErrorAction SilentlyContinue # Try winget first (most common on modern Windows) if ($hasWinget) { Write-Info "Installing $description via winget..." # Per-package log paths -- key the lookup by package id so we can # decide AFTER the post-install Get-Command check whether to keep # the log (still missing -> keep as breadcrumb) or delete it (now # present -> happy path, no clutter). $pkgLogs = @{} foreach ($pkg in $wingetPkgs) { $log = "$env:TEMP\hermes-winget-$($pkg -replace '[^A-Za-z0-9]','_')-$(Get-Random).log" $pkgLogs[$pkg] = $log # --source winget pins us to the github-backed source. Without this, # a broken msstore source (cert validation failures like 0x8a15005e # are common on Windows-on-ARM and some corporate networks) makes # winget bail with "please specify --source" *before* attempting any # install -- and it exits 0, so the surrounding try/catch never fires. # We don't ship anything from msstore, so pinning is safe. try { $output = winget install --exact --id $pkg --source winget --silent ` --accept-package-agreements --accept-source-agreements 2>&1 $output | Out-File -FilePath $log -Encoding utf8 "winget exit: $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append } catch { $_ | Out-File -FilePath $log -Encoding utf8 -Append "winget exit: " | Out-File -FilePath $log -Encoding utf8 -Append } } # Refresh PATH from both env-var hives AND winget's alias shim directory. # winget exposes packages via "command line aliases" in %LOCALAPPDATA%\ # Microsoft\WinGet\Links, which is added to PATH by the AppExecutionAlias # machinery only in *newly-spawned* shells -- not the current process. # Without this addition, Get-Command rg below would falsely return null # immediately after a successful install. $wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links" $envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if (Test-Path $wingetLinks) { $envPath = "$envPath;$wingetLinks" } $env:Path = $envPath if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed" $script:HasRipgrep = $true $needRipgrep = $false Remove-Item -Path $pkgLogs["BurntSushi.ripgrep.MSVC"] -ErrorAction SilentlyContinue } elseif ($pkgLogs.ContainsKey("BurntSushi.ripgrep.MSVC")) { Write-Warn "winget could not install ripgrep; details: $($pkgLogs['BurntSushi.ripgrep.MSVC'])" } if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { Write-Success "ffmpeg installed" $script:HasFfmpeg = $true $needFfmpeg = $false Remove-Item -Path $pkgLogs["Gyan.FFmpeg"] -ErrorAction SilentlyContinue } elseif ($pkgLogs.ContainsKey("Gyan.FFmpeg")) { Write-Warn "winget could not install ffmpeg; details: $($pkgLogs['Gyan.FFmpeg'])" } if (-not $needRipgrep -and -not $needFfmpeg) { return } } # Fallback: choco if ($hasChoco -and ($needRipgrep -or $needFfmpeg)) { Write-Info "Trying Chocolatey..." foreach ($pkg in $chocoPkgs) { try { choco install $pkg -y 2>&1 | Out-Null } catch { } } if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed via chocolatey" $script:HasRipgrep = $true $needRipgrep = $false } if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { Write-Success "ffmpeg installed via chocolatey" $script:HasFfmpeg = $true $needFfmpeg = $false } } # Fallback: scoop if ($hasScoop -and ($needRipgrep -or $needFfmpeg)) { Write-Info "Trying Scoop..." foreach ($pkg in $scoopPkgs) { try { scoop install $pkg 2>&1 | Out-Null } catch { } } if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed via scoop" $script:HasRipgrep = $true $needRipgrep = $false } if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { Write-Success "ffmpeg installed via scoop" $script:HasFfmpeg = $true $needFfmpeg = $false } } # Show manual instructions for anything still missing if ($needRipgrep) { Write-Warn "ripgrep not installed (file search will use findstr fallback)" Write-Info " winget install BurntSushi.ripgrep.MSVC" } if ($needFfmpeg) { Write-Warn "ffmpeg not installed (TTS voice messages will be limited)" Write-Info " winget install Gyan.FFmpeg" } } # ============================================================================ # Installation # ============================================================================ function Install-Repository { Write-Info "Installing to $InstallDir..." $didUpdate = $false if (Test-Path $InstallDir) { # Test-Path "$InstallDir\.git" returns True when .git is a file OR a # directory OR a symlink OR a submodule-style gitfile -- and also when # it's a broken stub left over from a failed previous install (e.g. # a partial Remove-Item that couldn't delete a locked index.lock). # Validate the repo properly by asking git itself. Two checks # belt-and-braces: rev-parse AND git status. If either fails the # repo is broken and we fall through to a fresh clone. $repoValid = $false if (Test-Path "$InstallDir\.git") { Push-Location $InstallDir try { # Reset $LASTEXITCODE before the probe so we don't pick up # a stale 0 from an earlier git call in this session. $global:LASTEXITCODE = 0 $revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1 $revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true") $global:LASTEXITCODE = 0 $null = & git -c windows.appendAtomically=false status --short 2>&1 $statusOk = ($LASTEXITCODE -eq 0) if ($revParseOk -and $statusOk) { $repoValid = $true } } catch {} Pop-Location } if ($repoValid) { Write-Info "Existing installation found, updating..." Push-Location $InstallDir # Wrap the entire fetch+checkout block in EAP=Continue so git's # routine stderr output (e.g. 'From ' info lines emitted by # `git fetch`) doesn't terminate the script under the global # EAP=Stop. We rely on $LASTEXITCODE for actual failures. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" try { git -c windows.appendAtomically=false fetch origin if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" } # Precedence: Commit > Tag > Branch. Commit and Tag check # out as detached HEAD intentionally -- they're meant to be # reproducible pins, not branches the user pulls into. if ($Commit) { # Make sure we have the commit locally (a tag-less commit # SHA isn't always reachable from any one branch fetch). git -c windows.appendAtomically=false fetch origin $Commit git -c windows.appendAtomically=false checkout --detach $Commit if ($LASTEXITCODE -ne 0) { throw "git checkout $Commit failed (exit $LASTEXITCODE)" } } elseif ($Tag) { git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}" git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag" if ($LASTEXITCODE -ne 0) { throw "git checkout tag $Tag failed (exit $LASTEXITCODE)" } } else { git -c windows.appendAtomically=false checkout $Branch if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" } git -c windows.appendAtomically=false pull origin $Branch if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" } } } finally { $ErrorActionPreference = $prevEAP Pop-Location } $didUpdate = $true } else { # Directory exists but isn't a usable git repo. Wipe it and # fall through to a fresh clone. A leftover ``.git`` stub from # a partial uninstall used to lock the installer into the # "update" branch forever, emitting three ``fatal: not a git # repository`` errors and failing with "not in a git directory". Write-Warn "Existing directory at $InstallDir is not a valid git repo -- replacing it." try { Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop } catch { Write-Err "Could not remove $InstallDir : $_" Write-Info "Close any programs that might be using files in $InstallDir (editors," Write-Info "terminals, running hermes processes) and try again." throw } } } if (-not $didUpdate) { $cloneSuccess = $false # Fix Windows git "copy-fd: write returned: Invalid argument" error. # Git for Windows can fail on atomic file operations (hook templates, # config lock files) due to antivirus, OneDrive, or NTFS filter drivers. # The -c flag injects config before any file I/O occurs. Write-Info "Configuring git for Windows compatibility..." $env:GIT_CONFIG_COUNT = "1" $env:GIT_CONFIG_KEY_0 = "windows.appendAtomically" $env:GIT_CONFIG_VALUE_0 = "false" git config --global windows.appendAtomically false 2>$null # Try SSH first, then HTTPS, with -c flag for atomic write fix Write-Info "Trying SSH clone..." $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5" try { git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlSsh $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } $env:GIT_SSH_COMMAND = $null if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Info "SSH failed, trying HTTPS..." try { git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlHttps $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } } # Fallback: download ZIP archive (bypasses git file I/O issues entirely) if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Warn "Git clone failed -- downloading ZIP archive instead..." try { # Pick the ZIP URL for the most-specific ref the caller asked # for. GitHub supports archive URLs for commits, tags, and # branches; we honour Commit > Tag > Branch. if ($Commit) { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/$Commit.zip" $zipLabel = $Commit } elseif ($Tag) { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/tags/$Tag.zip" $zipLabel = $Tag } else { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip" $zipLabel = $Branch } $zipPath = "$env:TEMP\hermes-agent-$zipLabel.zip" $extractPath = "$env:TEMP\hermes-agent-extract" Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath } Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force # GitHub ZIPs extract to repo-branch/ subdirectory $extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1 if ($extractedDir) { New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null Move-Item $extractedDir.FullName $InstallDir -Force Write-Success "Downloaded and extracted" # Initialize git repo so updates work later Push-Location $InstallDir git -c windows.appendAtomically=false init 2>$null git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null git remote add origin $RepoUrlHttps 2>$null Pop-Location Write-Success "Git repo initialized for future updates" $cloneSuccess = $true } # Cleanup temp files Remove-Item -Force $zipPath -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue } catch { Write-Err "ZIP download also failed: $_" } } if (-not $cloneSuccess) { throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)" } } # Set per-repo config (harmless if it fails) Push-Location $InstallDir git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null # Post-clone pin: when a clone (or ZIP-fallback init) just landed us on # $Branch's tip, honour the higher-precedence $Commit / $Tag by checking # the exact ref out as a detached HEAD. Skipped for the in-place update # path (above) since that already routed via the same precedence. if (-not $didUpdate) { # Same EAP=Continue wrap as the update path -- git fetch's 'From ' # info line goes to stderr and would terminate the script under the # global EAP=Stop otherwise. We check $LASTEXITCODE for real errors. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" try { if ($Commit) { Write-Info "Pinning to commit $Commit..." git -c windows.appendAtomically=false fetch origin $Commit git -c windows.appendAtomically=false checkout --detach $Commit if ($LASTEXITCODE -ne 0) { throw "git checkout $Commit failed (exit $LASTEXITCODE)" } } elseif ($Tag) { Write-Info "Pinning to tag $Tag..." git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}" git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag" if ($LASTEXITCODE -ne 0) { throw "git checkout tag $Tag failed (exit $LASTEXITCODE)" } } } finally { $ErrorActionPreference = $prevEAP } } # Ensure submodules are initialized and updated Write-Info "Initializing submodules..." git -c windows.appendAtomically=false submodule update --init --recursive 2>$null if ($LASTEXITCODE -ne 0) { Write-Warn "Submodule init failed (terminal/RL tools may need manual setup)" } else { Write-Success "Submodules ready" } Pop-Location Write-Success "Repository ready" } function Install-Venv { if ($NoVenv) { Write-Info "Skipping virtual environment (-NoVenv)" return } Write-Info "Creating virtual environment with Python $PythonVersion..." Push-Location $InstallDir if (Test-Path "venv") { Write-Info "Virtual environment already exists, recreating..." Remove-Item -Recurse -Force "venv" } # uv creates the venv and pins the Python version in one step & $UvCmd venv venv --python $PythonVersion Pop-Location Write-Success "Virtual environment ready (Python $PythonVersion)" } function Install-Dependencies { Write-Info "Installing dependencies..." Push-Location $InstallDir if (-not $NoVenv) { # Tell uv to install into our venv (no activation needed) $env:VIRTUAL_ENV = "$InstallDir\venv" } # Hash-verified install (Tier 0) -- when uv.lock is present, prefer # `uv sync --locked`. The lockfile records SHA256 hashes for every # transitive dependency, so a compromised transitive (different hash # than what we shipped) is REJECTED by the resolver. This is the # *only* path that protects against the "direct dep is fine, but the # dep's dep got worm-poisoned overnight" failure mode. The # `uv pip install` tiers below re-resolve transitives fresh from PyPI # without any hash verification -- they exist to keep installs working # when the lockfile is stale, missing, or out-of-sync with the # current extras spec, NOT because they're equivalent in posture. if (Test-Path "uv.lock") { Write-Info "Trying tier: hash-verified (uv.lock) ..." # Critical flag choice: `--extra all`, NOT `--all-extras`. # --all-extras = every [project.optional-dependencies] key, # bypassing the curated [all] extra. On Windows # that means [matrix] -> python-olm (no wheel, # needs `make` to build from sdist) and the # install fails. # --extra all = just the [all] extra's contents (curated). # # UV_PROJECT_ENVIRONMENT pins the sync target to our venv\. # Without it, modern uv (>=0.5) ignores VIRTUAL_ENV for `sync` # and creates a sibling .venv\ inside the repo -- leaving venv\ # empty and producing the broken state where `hermes.exe` exists # in the wrong directory and imports fail with ModuleNotFoundError. # (Mirrors the same flag in scripts/install.sh::install_deps.) $env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv" & $UvCmd sync --extra all --locked if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed (hash-verified via uv.lock)" $script:InstalledTier = "hash-verified (uv.lock)" # Skip the rest of the tiered cascade -- we already have a # complete, hash-verified install. $skipPipFallback = $true } else { Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." $skipPipFallback = $false } } else { Write-Info "uv.lock not found -- falling back to PyPI resolve (no hash verification)" $skipPipFallback = $false } # Install main package. Tiered fallback so a single flaky transitive # doesn't silently drop everything. Each tier's stdout/stderr is # preserved -- no Out-Null swallowing -- so the user can see what failed. # # Tier 1: [all] -- the curated extra in pyproject.toml. # Tier 2: [all] minus the currently-broken extras list ($brokenExtras). # Edit $brokenExtras below when something on PyPI breaks; this # lets users keep the rest of [all] when one transitive is # unavailable. The list of [all]'s contents is parsed from # pyproject.toml at runtime -- there is NO hand-mirrored copy # to drift out of sync. # Tier 3: bare `.` -- last-resort so at least the core CLI launches. # Currently-broken extras. Edit this list when an upstream package # gets quarantined / yanked / breaks resolution. Empty means everything # in [all] should be installable; populate with the names of extras # whose deps are temporarily unavailable. $brokenExtras = @() # Parse [project.optional-dependencies].all from pyproject.toml. # tomllib is stdlib on Python 3.11+ which the bootstrap guarantees. $pythonExeForParse = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } $allExtras = @() if (Test-Path $pythonExeForParse) { $parsed = & $pythonExeForParse -c @" import re, sys, tomllib try: with open('pyproject.toml', 'rb') as fh: data = tomllib.load(fh) specs = data['project']['optional-dependencies']['all'] out = [] for s in specs: m = re.search(r'hermes-agent\[([\w-]+)\]', s) if m: out.append(m.group(1)) print(','.join(out)) except Exception: sys.exit(1) "@ 2>$null if ($LASTEXITCODE -eq 0 -and $parsed) { $allExtras = $parsed.Trim().Split(',') } } if (-not $allExtras -or $allExtras.Count -eq 0) { Write-Warn "Could not parse [all] from pyproject.toml; Tier 2 will be a no-op." $safeAll = "all" } else { $safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," } $brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" } $installTiers = @( @{ Name = "all"; Spec = ".[all]" }, @{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" }, @{ Name = "core only (no extras)"; Spec = "." } ) $installed = $skipPipFallback if (-not $skipPipFallback) { foreach ($tier in $installTiers) { Write-Info "Trying tier: $($tier.Name) ..." & $UvCmd pip install -e $tier.Spec if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed ($($tier.Name))" $script:InstalledTier = $tier.Name $installed = $true break } Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..." } } if (-not $installed) { throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above." } # Baseline-import gate. Even if a tier reported success above, the # actual deps may have landed somewhere other than $InstallDir\venv\ # (e.g. uv 0.5+ syncing into a sibling .venv\ when UV_PROJECT_ENVIRONMENT # isn't set, leaving venv\ empty and hermes.exe broken with # `ModuleNotFoundError: No module named 'dotenv'` on first run). # We probe via the venv's own python so a misdirected sync is caught # here, not 30 seconds later when the user runs `hermes`. if (-not $NoVenv) { $venvPython = "$InstallDir\venv\Scripts\python.exe" if (-not (Test-Path $venvPython)) { throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" } # Relax EAP=Stop while running the import probe. Python writes # deprecation warnings and import-system info to stderr; under # EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and # throws even when the imports succeed. $LASTEXITCODE is the # reliable signal (it's 0 iff the python invocation exited 0, # regardless of what was written to stderr). $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" & $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null $importExitCode = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($importExitCode -ne 0) { $sibling = "$InstallDir\.venv" $hint = if (Test-Path $sibling) { "Detected sibling .venv\ at $sibling -- uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv" } else { "Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked" } throw "Baseline imports failed in $InstallDir\venv (dotenv/openai/rich/prompt_toolkit). The install completed but dependencies are not in the venv. $hint" } Write-Success "Baseline imports verified in venv" } # Verify the dashboard deps specifically -- they're the most common thing # users hit and lazy-import errors from `hermes dashboard` are confusing. # If tier 1 failed (the common case), [web] was still picked up by tiers # 2-3; only tier 4 leaves you without it. $pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } if (Test-Path $pythonExe) { $webOk = $false # Relax EAP=Stop while running the import probe; see the matching # comment on the baseline-imports check above. Python writes # deprecation warnings to stderr and we don't want those wrapped # as ErrorRecords that silently force the "not importable" path # even when fastapi/uvicorn are actually installed. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" try { & $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { $webOk = $true } } catch { } $ErrorActionPreference = $prevEAP if (-not $webOk) { Write-Warn "fastapi/uvicorn not importable -- `hermes dashboard` will not work." Write-Info "Attempting targeted install of [web] extra as last resort..." & $UvCmd pip install -e ".[web]" if ($LASTEXITCODE -eq 0) { Write-Success "[web] extra installed; `hermes dashboard` should now work." } else { Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`"" } } } Pop-Location Write-Success "All dependencies installed" } function Set-PathVariable { Write-Info "Setting up hermes command..." if ($NoVenv) { $hermesBin = "$InstallDir" } else { $hermesBin = "$InstallDir\venv\Scripts" } # Add the venv Scripts dir to user PATH so hermes is globally available # On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($currentPath -notlike "*$hermesBin*") { [Environment]::SetEnvironmentVariable( "Path", "$hermesBin;$currentPath", "User" ) Write-Success "Added to user PATH: $hermesBin" } else { Write-Info "PATH already configured" } # Set HERMES_HOME so the Python code finds config/data in the right place. # Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead # of the Unix default ~/.hermes $currentHermesHome = [Environment]::GetEnvironmentVariable("HERMES_HOME", "User") if (-not $currentHermesHome -or $currentHermesHome -ne $HermesHome) { [Environment]::SetEnvironmentVariable("HERMES_HOME", $HermesHome, "User") Write-Success "Set HERMES_HOME=$HermesHome" } $env:HERMES_HOME = $HermesHome # Update current session $env:Path = "$hermesBin;$env:Path" Write-Success "hermes command ready" } function Copy-ConfigTemplates { Write-Info "Setting up configuration files..." # Create ~/.hermes directory structure New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null # Create .env $envPath = "$HermesHome\.env" if (-not (Test-Path $envPath)) { $examplePath = "$InstallDir\.env.example" if (Test-Path $examplePath) { Copy-Item $examplePath $envPath Write-Success "Created ~/.hermes/.env from template" } else { New-Item -ItemType File -Force -Path $envPath | Out-Null Write-Success "Created ~/.hermes/.env" } } else { Write-Info "~/.hermes/.env already exists, keeping it" } # Create config.yaml $configPath = "$HermesHome\config.yaml" if (-not (Test-Path $configPath)) { $examplePath = "$InstallDir\cli-config.yaml.example" if (Test-Path $examplePath) { Copy-Item $examplePath $configPath Write-Success "Created ~/.hermes/config.yaml from template" } } else { Write-Info "~/.hermes/config.yaml already exists, keeping it" } # Create SOUL.md if it doesn't exist (global persona file). # IMPORTANT: write without a BOM. Windows PowerShell 5.1's # ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark # (the default PS5 behaviour), and Hermes's prompt-injection scanner # flags the BOM as an invisible unicode character and refuses to # load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we # don't control which PowerShell version the user has. Go direct # to .NET with an explicit UTF8Encoding($false) -- BOM-free on every # PowerShell version. $soulPath = "$HermesHome\SOUL.md" if (-not (Test-Path $soulPath)) { $soulContent = @" # Hermes Agent Persona "@ $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom) Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)" } Write-Success "Configuration directory ready: ~/.hermes/" # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill) Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..." $pythonExe = "$InstallDir\venv\Scripts\python.exe" if (Test-Path $pythonExe) { try { & $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null Write-Success "Skills synced to ~/.hermes/skills/" } catch { # Fallback: simple directory copy $bundledSkills = "$InstallDir\skills" $userSkills = "$HermesHome\skills" if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) { Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue Write-Success "Skills copied to ~/.hermes/skills/" } } } } function Install-NodeDeps { if (-not $HasNode) { Write-Info "Skipping Node.js dependencies (Node not installed)" return } # Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows # ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim). # Get-Command's default ordering picks whichever comes first in PATHEXT, # and on many systems that's .ps1 -- but .ps1 requires scripts to be # enabled in PowerShell's execution policy, which most Windows users # don't have (the Restricted / RemoteSigned default blocks unsigned # .ps1 files). .cmd has no such restriction and works on every box. # # Strategy: look next to the npm shim we found and prefer npm.cmd if # it exists in the same directory. Fall back to whatever Get-Command # returned if we can't find a .cmd sibling. $npmCmd = Get-Command npm -ErrorAction SilentlyContinue if (-not $npmCmd) { Write-Warn "npm not found on PATH -- skipping Node.js dependencies." Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later." return } $npmExe = $npmCmd.Source if ($npmExe -like "*.ps1") { $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" if (Test-Path $npmCmdSibling) { Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)" $npmExe = $npmCmdSibling } else { Write-Warn "Only npm.ps1 available -- install may fail if script execution is disabled." Write-Info " If it fails, either enable PS script execution or install Node via winget." } } # Helper: run "npm install" in a given directory and surface the real # error when it fails. Returns $true on success. # # Implementation note: ``Start-Process -FilePath npm.cmd`` fails with # ``%1 is not a valid Win32 application`` on some PowerShell versions # because Start-Process bypasses cmd.exe / PATHEXT and expects a real # PE file. The invocation-operator ``& $npmExe`` routes through the # PowerShell command pipeline which DOES honour .cmd batch shims, so # it works uniformly for npm.cmd, npx.cmd, and bare .exe files. function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) { Push-Location $installDir # Capture EAP outside the try block so the catch's restore call always # has a meaningful value (see Install-Uv for the full rationale). $prevEAP = $ErrorActionPreference try { # Stream npm's output to BOTH the console and the log file via # Tee-Object. Previously this called ``& npm install --silent # *> $logPath`` which redirected every stream to disk and left # the user staring at a frozen "Installing..." line for the # duration of the install. On a fresh VM that's 1-3 minutes # of total silence, indistinguishable from a hang. # # Tee writes the live output to stdout AND $logPath; we still # capture the exit code afterwards and surface diagnostics # on failure. Note: 2>&1 merges npm's stderr into the success # stream first because Tee-Object only sees the success # stream of the pipeline. ForEach-Object { "$_" } coerces # each item to a string so PowerShell's NativeCommandError # formatter doesn't wrap stderr lines as alarming red blocks # (cosmetic polish; the underlying text is unchanged). # # Relax EAP around the npm invocation: with EAP=Stop (set at # the top of this script), PowerShell wraps stderr lines from # native commands captured via 2>&1 as ErrorRecord objects and # throws on the first one -- even though npm exited 0. This # is the same issue Test-Python and Install-Uv work around # for uv's stderr-emitting installer. Check success via # $LASTEXITCODE, which is reliable regardless of stderr noise. $ErrorActionPreference = "Continue" & $npmPath install --silent 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $logPath $code = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($code -eq 0) { Write-Success "$label dependencies installed" Remove-Item -Force $logPath -ErrorAction SilentlyContinue return $true } Write-Warn "$label npm install failed -- exit code $code" if (Test-Path $logPath) { $errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue) if ($errText) { $snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText } Write-Info " npm output:" foreach ($line in $snippet -split "`n") { Write-Host " $line" -ForegroundColor DarkGray } Write-Info " Full log: $logPath" } } Write-Info "Run manually later: cd `"$installDir`"; npm install" return $false } catch { if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "$label npm install could not be launched: $_" return $false } finally { Pop-Location } } # Browser tools if (Test-Path "$InstallDir\package.json") { Write-Info "Installing Node.js dependencies (browser tools)..." $browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log" $browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe # Install Playwright Chromium (mirrors scripts/install.sh behaviour for # Linux). Without this, tools/browser_tool.py::check_browser_requirements # returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the # browser_* tools are silently filtered out of the agent's tool schema. # System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by # agent-browser -- it expects a Playwright-managed Chromium. if ($browserNpmOk) { Write-Info "Installing browser engine (Playwright Chromium)..." # npx lives next to npm in the same bin dir. Prefer .cmd to dodge # the same execution-policy gotcha that affects npm.ps1 (see above). $npmDir = Split-Path $npmExe -Parent $npxExe = $null foreach ($cand in @("npx.cmd", "npx.exe", "npx")) { $try = Join-Path $npmDir $cand if (Test-Path $try) { $npxExe = $try; break } } if (-not $npxExe) { $npxCmd = Get-Command npx -ErrorAction SilentlyContinue if ($npxCmd) { $npxExe = $npxCmd.Source } } if (-not $npxExe) { Write-Warn "npx not found -- cannot install Playwright Chromium." Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } else { $pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log" Push-Location $InstallDir # Capture EAP outside the try block so the catch's restore call # always has a meaningful value (see Install-Uv for the full # rationale). $prevEAP = $ErrorActionPreference try { # Playwright Chromium is ~170MB compressed and the # download regularly takes 3-10 minutes on a fresh # VM. Tee the output to console + log so the user # sees download progress in real time instead of # staring at a silent prompt that looks hung. See # _Run-NpmInstall above for the same pattern and # the rationale behind 2>&1 before the pipe. Write-Info "(this can take several minutes -- streaming progress below)" # --yes auto-accepts npx's "Need to install playwright@X.Y.Z" # confirmation prompt. Without it, npx 7+ blocks on stdin # waiting for a y/N answer that never comes when this is # invoked through a pipeline (Tee-Object disconnects stdin # from the user's TTY), and the install hangs indefinitely # after printing "Need to install the following packages: # playwright@X.Y.Z". # # Relax EAP around the playwright invocation: playwright # emits a "Chromium downloaded to ..." success banner to # stderr after a successful install. Under EAP=Stop, the # 2>&1 merge wraps those stderr lines as ErrorRecord # objects and throws -- causing this catch block to fire # with a mangled banner as the error message even though # the install actually succeeded. Check $LASTEXITCODE # instead, which is the reliable signal. # # The ForEach-Object { "$_" } coercion BEFORE Tee-Object # is a cosmetic polish: with bare 2>&1, PowerShell still # renders stderr lines through its NativeCommandError # formatter (the red "npx.cmd : ..." block). Coercing # each pipeline item to a string strips that wrapper so # the user sees clean playwright output instead of the # alarming-looking error formatting. $ErrorActionPreference = "Continue" & $npxExe --yes playwright install chromium 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $pwLog $pwCode = $LASTEXITCODE $ErrorActionPreference = $prevEAP if ($pwCode -eq 0) { Write-Success "Playwright Chromium installed (browser tools ready)" Remove-Item -Force $pwLog -ErrorAction SilentlyContinue } else { Write-Warn "Playwright Chromium install failed -- exit code $pwCode" Write-Warn "Browser tools will not work until Chromium is installed." if (Test-Path $pwLog) { $pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue if ($pwErr) { $snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr } Write-Info " playwright output:" foreach ($line in $snippet -split "`n") { Write-Host " $line" -ForegroundColor DarkGray } Write-Info " Full log: $pwLog" } } Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } } catch { if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Warn "Playwright Chromium install could not be launched: $_" Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium" } finally { Pop-Location } } } } # TUI $tuiDir = "$InstallDir\ui-tui" if (Test-Path "$tuiDir\package.json") { Write-Info "Installing TUI dependencies..." $tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log" [void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe) } } function Install-PlatformSdks { # Ensure messaging-platform SDKs matching tokens the user added to # ~/.hermes/.env are importable. Two problems this solves: # # 1. The tiered `uv pip install` cascade above can fall through to a # lower tier when the first fails (common when RL git deps choke), # which silently skips some messaging SDKs from [messaging]. # 2. `uv` creates the venv without pip. If a messaging SDK ends up # missing, the user can't `pip install python-telegram-bot` to # recover -- pip simply isn't in their venv. # # Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then # for each token set in .env, verify the matching SDK imports. If not, # run one targeted `pip install` as last-chance recovery. Keeps fresh # Windows installs from hitting silent "python-telegram-bot not installed" # at runtime. if ($NoVenv) { Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)" return } $pythonExe = "$InstallDir\venv\Scripts\python.exe" if (-not (Test-Path $pythonExe)) { Write-Warn "Skipping platform-SDK verification: $pythonExe not found" return } $envPath = "$HermesHome\.env" if (-not (Test-Path $envPath)) { return } $envLines = Get-Content $envPath -ErrorAction SilentlyContinue # Map: env var set in .env -> (import name, pip spec matching [messaging] extra). # Specs mirror pyproject.toml to avoid version drift. $sdkMap = @( @{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" }, @{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" }, @{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" }, @{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" }, @{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" } ) # Which tokens are actually set (not placeholder)? $needed = @() foreach ($sdk in $sdkMap) { $match = $envLines | Where-Object { $_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") ` -and $_ -notmatch "your-token-here" ` -and $_ -notmatch "^\s*#" } if ($match) { $needed += $sdk } } if ($needed.Count -eq 0) { return } Write-Host "" Write-Info "Verifying platform SDKs for tokens found in $envPath ..." # Verify each SDK's import without triggering side-effect imports. # Quirk: PowerShell wraps non-zero-exit native stderr as a # NativeCommandError that prints even with `2>$null` / `*> $null` # unless we set $ErrorActionPreference to SilentlyContinue for the # span. Save + restore rather than nuking globally. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" try { $missing = @() foreach ($sdk in $needed) { & $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { $missing += $sdk Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))" } else { Write-Success " $($sdk.Import) OK" } } } finally { $ErrorActionPreference = $prevEAP } if ($missing.Count -eq 0) { return } # Bootstrap pip into the venv if it isn't there. `uv` creates venvs # without pip; ensurepip is the stdlib-blessed way to add it. $prevEAP = $ErrorActionPreference $ErrorActionPreference = "SilentlyContinue" try { & $pythonExe -m pip --version 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..." & $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Write-Warn "ensurepip failed -- can't auto-install missing SDKs." Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`"" return } } foreach ($sdk in $missing) { Write-Info " Installing $($sdk.Spec) ..." & $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" } if ($LASTEXITCODE -eq 0) { Write-Success " Installed $($sdk.Import)" } else { Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`"" } } } finally { $ErrorActionPreference = $prevEAP } } function Invoke-SetupWizard { if ($SkipSetup) { Write-Info "Skipping setup wizard (-SkipSetup)" return } if ($NonInteractive) { # The setup wizard prompts for API keys, model choice, persona, etc. # Non-interactive callers (GUI installer) own that UX themselves; let # them drive it after install.ps1 returns. Write-Info "Skipping setup wizard (non-interactive). Configure via the GUI or 'hermes setup'." return } Write-Host "" Write-Info "Starting setup wizard..." Write-Host "" Push-Location $InstallDir # Run hermes setup using the venv Python directly (no activation needed) if (-not $NoVenv) { & ".\venv\Scripts\python.exe" -m hermes_cli.main setup } else { python -m hermes_cli.main setup } Pop-Location } function Start-GatewayIfConfigured { $envPath = "$HermesHome\.env" if (-not (Test-Path $envPath)) { return } $hasMessaging = $false $content = Get-Content $envPath -ErrorAction SilentlyContinue foreach ($var in @("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WHATSAPP_ENABLED")) { $match = $content | Where-Object { $_ -match "^${var}=.+" -and $_ -notmatch "your-token-here" } if ($match) { $hasMessaging = $true; break } } if (-not $hasMessaging) { return } $hermesCmd = "$InstallDir\venv\Scripts\hermes.exe" if (-not (Test-Path $hermesCmd)) { $hermesCmd = "hermes" } # If WhatsApp is enabled but not yet paired, run foreground for QR scan $whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" } $whatsappSession = "$HermesHome\whatsapp\session\creds.json" if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) { Write-Host "" Write-Info "WhatsApp is enabled but not yet paired." Write-Info "Running 'hermes whatsapp' to pair via QR code..." Write-Host "" # Non-interactive callers (GUI installer, CI) skip the QR-pair prompt; # WhatsApp pairing requires a human looking at a phone camera, so the # downstream UI is responsible for surfacing this when it makes sense. if (-not $NonInteractive) { $response = Read-Host "Pair WhatsApp now? [Y/n]" if ($response -eq "" -or $response -match "^[Yy]") { try { & $hermesCmd whatsapp } catch { # Expected after pairing completes } } } else { Write-Info "Skipping WhatsApp pairing prompt (non-interactive)." } } Write-Host "" Write-Info "Messaging platform token detected!" Write-Info "The gateway handles messaging platforms and cron job execution." Write-Host "" # In non-interactive mode the gateway lifecycle is the caller's problem # (the GUI manages its own gateway process, CI doesn't want background # services on the build agent, etc.). Treat it like the user declined. if ($NonInteractive) { Write-Info "Skipping gateway autostart prompt (non-interactive)." Write-Info "Start the gateway later with: hermes gateway" return } $response = Read-Host "Would you like to start the gateway now? [Y/n]" if ($response -eq "" -or $response -match "^[Yy]") { Write-Info "Starting gateway in background..." try { $logFile = "$HermesHome\logs\gateway.log" Start-Process -FilePath $hermesCmd -ArgumentList "gateway" ` -RedirectStandardOutput $logFile ` -RedirectStandardError "$HermesHome\logs\gateway-error.log" ` -WindowStyle Hidden Write-Success "Gateway started! Your bot is now online." Write-Info "Logs: $logFile" Write-Info "To stop: close the gateway process from Task Manager" } catch { Write-Warn "Failed to start gateway. Run manually: hermes gateway" } } else { Write-Info "Skipped. Start the gateway later with: hermes gateway" } } function Write-Completion { Write-Host "" Write-Host "+---------------------------------------------------------+" -ForegroundColor Green Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green Write-Host "+---------------------------------------------------------+" -ForegroundColor Green Write-Host "" # Show file locations Write-Host "* Your files:" -ForegroundColor Cyan Write-Host "" Write-Host " Config: " -NoNewline -ForegroundColor Yellow Write-Host "$HermesHome\config.yaml" Write-Host " API Keys: " -NoNewline -ForegroundColor Yellow Write-Host "$HermesHome\.env" Write-Host " Data: " -NoNewline -ForegroundColor Yellow Write-Host "$HermesHome\cron\, sessions\, logs\" Write-Host " Code: " -NoNewline -ForegroundColor Yellow Write-Host "$HermesHome\hermes-agent\" Write-Host "" Write-Host "---------------------------------------------------------" -ForegroundColor Cyan Write-Host "" Write-Host "* Commands:" -ForegroundColor Cyan Write-Host "" Write-Host " hermes " -NoNewline -ForegroundColor Green Write-Host "Start chatting" Write-Host " hermes setup " -NoNewline -ForegroundColor Green Write-Host "Configure API keys & settings" Write-Host " hermes config " -NoNewline -ForegroundColor Green Write-Host "View/edit configuration" Write-Host " hermes config edit " -NoNewline -ForegroundColor Green Write-Host "Open config in editor" Write-Host " hermes gateway " -NoNewline -ForegroundColor Green Write-Host "Start messaging gateway (Telegram, Discord, etc.)" Write-Host " hermes update " -NoNewline -ForegroundColor Green Write-Host "Update to latest version" Write-Host "" Write-Host "---------------------------------------------------------" -ForegroundColor Cyan Write-Host "" Write-Host "[*] Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow Write-Host "" if (-not $HasNode) { Write-Host "Note: Node.js could not be installed automatically." -ForegroundColor Yellow Write-Host "Browser tools need Node.js. Install manually:" -ForegroundColor Yellow Write-Host " https://nodejs.org/en/download/" -ForegroundColor Yellow Write-Host "" } if (-not $HasRipgrep) { Write-Host "Note: ripgrep (rg) was not installed. For faster file search:" -ForegroundColor Yellow Write-Host " winget install BurntSushi.ripgrep.MSVC" -ForegroundColor Yellow Write-Host "" } } # ============================================================================ # Stage protocol # ============================================================================ # # install.ps1 supports a small, stable "stage protocol" that lets programmatic # callers (the desktop GUI's onboarding wizard, CI, future install.sh, etc.) # drive the install one step at a time and surface progress/errors with their # own UI. CLI users running the canonical `irm | iex` one-liner never # encounter this -- default invocation behaves exactly as before. # # Entry points: # # install.ps1 Interactive install (today's behavior). # install.ps1 -ProtocolVersion Emit the protocol version integer. # install.ps1 -Manifest Emit the stage manifest as JSON. # install.ps1 -Stage Run one stage and emit its result. # install.ps1 -NonInteractive Disable all Read-Host prompts (also # skips the setup wizard and the gateway # autostart prompt). Can be combined # with default invocation to do a full # non-interactive install. # install.ps1 -Json Emit machine-readable JSON instead of # the human-readable success banner at # the end of a full install. # # Manifest schema (the JSON returned by -Manifest): # # { # "protocol_version": 1, # "stages": [ # { # "name": "uv", # "title": "Installing uv package manager", # "category": "prereqs", # "needs_user_input": false # }, # ... # ] # } # # Stage result (the JSON written by -Stage ): # # { # "stage": "uv", # "ok": true, # "skipped": false, # "reason": null, # "duration_ms": 1234 # } # # Exit codes: # # 0 -- success (stage ran, or stage was deliberately skipped). # 1 -- generic failure; the stage threw. # 2 -- unknown stage name passed to -Stage. # # Adding a stage: # # 1. Append an entry to $InstallStages below. # 2. Make sure the worker function it points at is idempotent and respects # $NonInteractive when it has prompts. Add it before "configure" # (the wizard) or "gateway" (autostart) if it should run unconditionally; # after those if it's optional post-install glue. # 3. Do NOT bump $InstallStageProtocolVersion -- adding stages is additive. # Drivers iterate the manifest dynamically. # # ============================================================================ # Stage definitions -- the single source of truth. Each entry maps a stable # stage name (the API contract drivers depend on) to the worker function that # implements it. ``Title`` is what UIs show; ``Category`` lets UIs group # stages; ``NeedsUserInput`` tells UIs "this stage prompts -- either skip it # or arrange to provide answers another way." $InstallStages = @( @{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" } @{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" } @{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" } @{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" } @{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" } @{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" } @{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" } @{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" } @{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" } @{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" } @{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" } @{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" } # Interactive stages. In non-interactive mode these become no-ops; the # caller (GUI / CI) handles the equivalent UX themselves. @{ Name = "configure"; Title = "Configuring API keys and models"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Configure" } @{ Name = "gateway"; Title = "Starting messaging gateway"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Gateway" } ) # Stage workers -- thin wrappers that delegate to the existing Install-* / # Test-* / Invoke-* functions while preserving their error semantics. Kept # as a separate layer so the existing functions remain callable directly # (helpful for one-off recovery: ``. install.ps1; Install-Venv``). # # Stages that depend on uv (anything after Stage-Uv) call Resolve-UvCmd # first so they work in cross-process driver mode where $script:UvCmd # set by Stage-Uv in a sibling powershell process is not visible here. # Resolve-UvCmd is a fast no-op when $script:UvCmd is already populated # (the default-invocation case where Main runs everything in one # process), and throws cleanly if uv truly isn't installed yet. function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } } function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } } function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } } # Node is optional (browser tools degrade gracefully without it). Surface # failure to the JSON contract as skipped=true / reason rather than ok=true, # so a GUI driver consuming the manifest can distinguish "node ready" from # "node missing". Install flow continues either way -- matches the # existing Write-Completion behavior that prints a "Note: Node.js could # not be installed" hint instead of aborting. function Stage-Node { if (-not (Test-Node)) { $script:_StageSkippedReason = "Node.js not available; browser tools will be unavailable until node is installed manually from https://nodejs.org/en/download/" } } function Stage-SystemPackages { Install-SystemPackages } function Stage-Repository { Install-Repository } function Stage-Venv { Resolve-UvCmd; Install-Venv } function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies } function Stage-NodeDeps { Install-NodeDeps } function Stage-Path { Set-PathVariable } function Stage-ConfigTemplates { Copy-ConfigTemplates } function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks } function Stage-Configure { Invoke-SetupWizard } function Stage-Gateway { Start-GatewayIfConfigured } function Get-InstallStage { param([string]$Name) foreach ($s in $InstallStages) { if ($s.Name -eq $Name) { return $s } } return $null } function Step-OutOfInstallDir { # Windows refuses to delete a directory any shell is currently cd'd # inside -- and silently leaves orphan files behind, which then wedge # "is this a valid git repo" probes on re-install. Harmless when the # caller ran the installer from somewhere else. try { $currentResolved = (Get-Location).ProviderPath $installResolved = $null if (Test-Path $InstallDir) { $installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath } if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) { Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..." Set-Location $env:USERPROFILE } } catch {} } function Invoke-Stage { param( [Parameter(Mandatory=$true)] [hashtable]$StageDef ) # Refresh PATH from registry so this stage sees binaries installed by # prior stages, even when each stage runs in its own powershell process. # No-op in cost-relevant cases (default invocation path syncs once per # foreach pass; cross-process drivers get the necessary freshening). Sync-EnvPath # Per-stage soft-skip channel. A worker can populate # $script:_StageSkippedReason to surface "ran, but the thing it was # supposed to set up is not available" as skipped=true in the JSON # frame, without throwing. Used by Stage-Node so the install flow # doesn't abort when an optional capability is missing while still # being honest in the protocol contract. Reset before each stage so # a prior stage's reason can never leak into a later stage's frame. $script:_StageSkippedReason = $null $start = [DateTime]::UtcNow $result = @{ stage = $StageDef.Name ok = $false skipped = $false reason = $null duration_ms = 0 } try { & $StageDef.Worker $result.ok = $true if ($script:_StageSkippedReason) { $result.skipped = $true $result.reason = $script:_StageSkippedReason } } catch { $result.ok = $false $result.reason = "$_" throw } finally { $result.duration_ms = [int]([DateTime]::UtcNow - $start).TotalMilliseconds if ($Json -or $Stage) { # In stage-driver mode every stage emits a JSON line so the # caller can stream progress. In default interactive mode we # stay silent here (the worker already wrote human output). $result | ConvertTo-Json -Compress | Write-Output # Tell the entry-point catch that we've already emitted a # frame for this failure (when $result.ok = $false), so it # doesn't double-emit a second JSON object and break the # one-line-per-stage contract the driver protocol promises. if (-not $result.ok) { $script:_StageEmittedErrorFrame = $true } } } } # ============================================================================ # Main # ============================================================================ function Invoke-AllStages { Step-OutOfInstallDir foreach ($s in $InstallStages) { Invoke-Stage -StageDef $s } } function Invoke-EnsureMode { param([string]$Deps) $depList = $Deps -split "," foreach ($dep in $depList) { $dep = $dep.Trim() switch ($dep) { "node" { [void](Test-Node) if (-not $script:HasNode) { Write-Err "Node.js could not be installed" exit 1 } } "browser" { [void](Test-Node) if ($script:HasNode) { Install-AgentBrowser } else { Write-Err "Node.js is required for browser tools but could not be installed" exit 1 } } "ripgrep" { Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)" } "ffmpeg" { Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)" } default { Write-Err "Unknown dependency: $dep" exit 1 } } } } function Invoke-PostInstallMode { Write-Info "Running post-install setup..." Invoke-EnsureMode -Deps "node,browser" Write-Info "Post-install complete" } function Main { Write-Banner Invoke-AllStages if (-not $Json) { Write-Completion } else { @{ ok = $true; protocol_version = $InstallStageProtocolVersion } | ConvertTo-Json -Compress | Write-Output } } # ---------------------------------------------------------------------------- # Entry-point dispatch # ---------------------------------------------------------------------------- # # All branches funnel through one try/catch so errors don't kill an `irm | # iex` PowerShell session, and so failures in stage-driver mode produce a # structured JSON error frame instead of a bare exception. try { if ($Ensure -ne "") { if ($PSBoundParameters.ContainsKey("Stage")) { Write-Err "Cannot use -Ensure and -Stage simultaneously" exit 1 } Invoke-EnsureMode -Deps $Ensure exit 0 } if ($PostInstall) { Invoke-PostInstallMode exit 0 } if ($ProtocolVersion) { Write-Output $InstallStageProtocolVersion exit 0 } if ($Manifest) { $payload = @{ protocol_version = $InstallStageProtocolVersion stages = @($InstallStages | ForEach-Object { @{ name = $_.Name title = $_.Title category = $_.Category needs_user_input = $_.NeedsUserInput } }) } $payload | ConvertTo-Json -Depth 5 -Compress | Write-Output exit 0 } # Use PSBoundParameters rather than $Stage truthiness so that an # explicit `-Stage ""` from a misbehaving driver doesn't fall through # to the full-install Main path and silently kick off a destructive # operation. Empty string is a contract violation; surface it as # unknown-stage exit 2 with a structured JSON frame. if ($PSBoundParameters.ContainsKey("Stage")) { $def = Get-InstallStage -Name $Stage if (-not $def) { $err = @{ ok = $false stage = $Stage reason = "unknown stage: $Stage. Run install.ps1 -Manifest to list valid stages." } $err | ConvertTo-Json -Compress | Write-Output exit 2 } Step-OutOfInstallDir Invoke-Stage -StageDef $def exit 0 } # Default: full install (today's behavior, plus optional -NonInteractive # and -Json layered on by the params above). Main } catch { if ($Json -or $Stage) { # Stage-driver mode: caller wants JSON they can parse. Emit a # structured error frame and exit non-zero -- BUT only if # Invoke-Stage didn't already emit one for this same failure. # The inner finally emits the authoritative per-stage frame # (with duration_ms + skipped fields); a second emit here # would produce two concatenated JSON objects on stdout and # break drivers that parse one-line-per-invocation. if (-not $script:_StageEmittedErrorFrame) { $err = @{ ok = $false stage = if ($Stage) { $Stage } else { $null } reason = "$_" } $err | ConvertTo-Json -Compress | Write-Output } exit 1 } # Interactive mode: keep today's friendly recovery hint. Write-Host "" Write-Err "Installation failed: $_" Write-Host "" Write-Info "If the error is unclear, try downloading and running the script directly:" Write-Host " Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow Write-Host " .\install.ps1" -ForegroundColor Yellow Write-Host "" }