Files
hermes-agent/scripts/install.ps1
Brooklyn Nicholson 1dca7c6207 fix(install): require Node >=20.19/22.12 for the desktop build
The "Build desktop app" install step failed with an opaque "exit code 1"
on machines with an old Node, and nothing in the logs explained it.

Reproduced: on Node 20.5.1, `npm run pack`'s `vite build` crashes with

  You are using Node.js 20.5.1. Vite requires Node.js version 20.19+ or 22.12+.
  SyntaxError: The requested module 'node:util' does not provide an
  export named 'styleText'

Vite 8 (rolldown) imports node:util.styleText, which doesn't exist before
Node 20.12, so the build dies before producing the app. The installer's
check_node / Test-Node accepted ANY pre-existing Node with no version
floor, so a too-old system Node was used for the build instead of the
bundled Node 22.

Add a version floor (^20.19 || >=22.12) to check_node (install.sh) and
Test-Node (install.ps1): a too-old system Node is replaced with the
Hermes-managed Node 22 LTS, and the desktop stage re-resolves Node so the
build always runs on a satisfying version. Declare the same range in
apps/desktop/package.json engines.

Verified: build succeeds on Node 22, fails on 20.5.1 with the error above;
the floor logic matches Vite's range across boundary versions (20.18/20.19,
21.x, 22.11/22.12).
2026-06-03 09:19:04 -05:00

2811 lines
126 KiB
PowerShell

# ============================================================================
# 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 = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }),
[string]$InstallDir = $(if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }),
# --- Stage protocol (additive; default invocation behaves as before) ----
# See the "Stage protocol" section near the bottom of the file for the
# full contract. Intended for programmatic drivers (the desktop GUI's
# onboarding wizard, CI, future install.sh parity, etc.). CLI users
# running the canonical `irm | iex` one-liner never touch these flags.
[switch]$Manifest,
[string]$Stage,
[switch]$ProtocolVersion,
[switch]$NonInteractive,
[switch]$Json,
# --- Ensure mode (dep_ensure.py entry point) ---
[string]$Ensure = "",
[switch]$PostInstall,
# --- Desktop GUI build (opt-in) ---
# When set, install.ps1 includes Stage-Desktop in the manifest and
# builds apps/desktop into a launchable Hermes.exe.
#
# Why opt-in:
# * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes
# -IncludeDesktop so a user who installed via the GUI ends up
# with a launchable desktop binary.
# * The Electron desktop's own bootstrap-runner.cjs runs install.ps1
# from inside an already-launched Hermes.exe; if THAT recursively
# built apps/desktop it would try to overwrite the live Hermes.exe
# on disk and fail. The recursive path omits the flag.
# * The canonical CLI one-liner (irm | iex) omits the flag too;
# terminal users don't need a desktop binary built for them, and
# `hermes desktop` already builds on demand.
[switch]$IncludeDesktop
)
$ErrorActionPreference = "Stop"
# Suppress Invoke-WebRequest's per-chunk progress bar. Windows PowerShell
# 5.1's progress UI repaints synchronously on every received byte, which
# pegs CPU on a single core and throttles downloads by 10-100x (a 57MB
# PortableGit grab can take 5 minutes with progress on vs 20 seconds
# with progress off, on the same network). Every IWR call in this
# script is fire-and-forget so we never need to see the bar. Restored
# automatically when the script exits.
$ProgressPreference = "SilentlyContinue"
# Force the console to UTF-8 so non-ASCII output from native commands
# (e.g. playwright's box-drawing progress bars and download banners,
# git's bullet glyphs, npm's check marks) renders correctly instead of
# as IBM437/Windows-1252 mojibake (sequences like 0xE2 0x95 0x94 box-
# drawing chars decoded under the legacy DOS codepage). This is a
# DISPLAY-only fix; the underlying bytes are already correct. We do
# NOT change the file's own encoding (it remains pure ASCII for PS 5.1
# parser compatibility; see comments at the top of the entry-point
# dispatch). This affects only what the user sees in their terminal
# during this install run, and reverts automatically when the script
# exits and the host's console encoding is restored.
try {
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
} catch {
# Some constrained PowerShell hosts disallow encoding mutation.
# Mojibake on output is then cosmetic-only, install still works.
}
# ============================================================================
# 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 {
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.ps1 and `hermes update` stay in sync.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv found ($version)"
return $true
}
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
$prevEAP = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv installed ($version)"
return $true
}
Write-Err "uv installed but not found at $managedUv"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Err "Failed to install uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
}
}
# Refresh $env:Path from the User + Machine registry hives. Stage drivers
# invoke each stage in a fresh powershell process, but those processes
# inherit env from the parent driver shell, NOT from the registry. When
# an earlier stage (Stage-Git, Stage-Node, ...) installs a binary and
# pushes its directory into User PATH, the next child process's $env:Path
# is stale and the binary appears missing. This helper re-reads PATH
# from the registry so every Invoke-Stage starts from a fresh, up-to-date
# PATH view. Cheap (registry reads, no I/O elsewhere) and idempotent.
function Sync-EnvPath {
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
}
# 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 the managed location.
# Throws if uv is not findable — the caller's stage then surfaces a
# clean error via the stage-driver's try/catch.
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.
}
# Check the managed location first — this is where Install-Uv puts it.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
return
}
# Fall back to PATH (covers edge cases where the installer ran in a
# sibling process and HERMES_HOME wasn't propagated).
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
}
throw "uv is not installed. 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/<tag>/<asset> 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<target> -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): <root>\cmd\git.exe + <root>\bin\bash.exe
# MinGit: <root>\cmd\git.exe + <root>\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."
}
# The desktop build runs Vite ^8, which refuses to start on Node outside
# `^20.19 || >=22.12` -- older Node lacks node:util.styleText, so `vite build`
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
# app ... exit code 1" install failure. Returns $true when a `node --version`
# string clears that floor.
function Test-NodeVersionOk {
param([string]$Version)
try {
$v = [version]($Version -replace '^v', '' -replace '-.*$', '')
} catch {
return $false
}
if ($v.Major -eq 20 -and $v.Minor -ge 19) { return $true }
if ($v.Major -ge 22 -and ($v.Major -gt 22 -or $v.Minor -ge 12)) { return $true }
return $false
}
function Test-Node {
Write-Info "Checking Node.js (for browser tools)..."
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
if (Test-NodeVersionOk $version) {
Write-Success "Node.js $version found"
$script:HasNode = $true
return $true
}
Write-Warn "Node.js $version is too old for the desktop build (need ^20.19 or >=22.12)"
}
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
$managedNode = "$HermesHome\node\node.exe"
if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) {
$version = & $managedNode --version
$env:Path = "$HermesHome\node;$env:Path"
Write-Success "Node.js $version found (Hermes-managed)"
$script:HasNode = $true
return $true
}
Write-Info "Installing Hermes-managed 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: <exception>" | 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 <url>' 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 {
# This is a MANAGED checkout, not a repo the user edits. Git for
# Windows defaults to core.autocrlf=true, which renormalizes the
# repo's LF-only text files to CRLF in the working tree -- so
# tracked files (.envrc, AGENTS.md, agent/*.py, workflows, ...)
# show as locally modified even though nobody touched them. A
# bare `git checkout` then aborts with "Your local changes would
# be overwritten by checkout", which is exactly the failure GUI
# users hit on update. Two-part fix: (1) stop creating the dirt
# by pinning autocrlf=false on this clone, (2) discard any
# pre-existing dirt with a hard reset before the checkout. Safe
# because nothing here is user-authored.
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
git -c windows.appendAtomically=false reset --hard HEAD 2>$null
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 $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 $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
# Pin autocrlf=false on the managed clone so git never renormalizes the
# repo's LF text files to CRLF in the working tree. Without this, the very
# next `hermes update` checkout aborts on a "dirty" tree the user never
# touched (see the update path above).
git -c windows.appendAtomically=false config core.autocrlf 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 <url>'
# 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
}
}
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 Write-BootstrapMarker {
# Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes
# desktop app (apps/desktop/electron/main.cjs) "install.ps1 ran
# successfully — DON'T trigger the legacy first-launch bootstrap
# runner."
#
# Schema mirrors what main.cjs's writeBootstrapMarker() / isBootstrap
# Complete() expect. Keep this in lockstep when either side changes:
# apps/desktop/electron/main.cjs lines 1199-1222
# BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 (line 187)
#
# Pinned commit/branch come from -Commit + -Branch flags (passed by
# Hermes-Setup.exe) or fall back to whatever git resolves in the
# checkout. The desktop validates schemaVersion + pinnedCommit
# length but doesn't enforce that HEAD matches the pin (users
# update via `hermes update` which moves HEAD legitimately).
if (-not (Test-Path $InstallDir)) {
Write-Warn "Skipping bootstrap marker: $InstallDir doesn't exist"
return
}
# Resolve the pinned commit: explicit -Commit wins, otherwise read
# the checkout's HEAD via git. If git can't run, leave commit empty
# and the marker will fail desktop validation (pinnedCommit.length
# >= 7) — better to be invalid than wrong.
$pinnedCommit = $Commit
if (-not $pinnedCommit) {
# PS 5.1 doesn't support the ?. null-conditional operator, so
# check Get-Command's result explicitly before reading .Source.
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
$gitExe = if ($gitCmd) { $gitCmd.Source } else { $null }
if ($gitExe) {
Push-Location $InstallDir
try {
$resolved = & $gitExe rev-parse HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $resolved) {
$pinnedCommit = $resolved.Trim()
}
} catch {
# Ignore — pinnedCommit stays empty, marker stays invalid,
# desktop falls through to its legacy bootstrap path.
} finally {
Pop-Location
}
}
}
$pinnedBranch = $Branch
if (-not $pinnedBranch) {
$pinnedBranch = "main" # install.ps1's own default for -Branch
}
$markerPath = Join-Path $InstallDir ".hermes-bootstrap-complete"
$marker = [ordered]@{
schemaVersion = 1
pinnedCommit = $pinnedCommit
pinnedBranch = $pinnedBranch
completedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
# desktopVersion field intentionally omitted — only the desktop
# app knows its own version, and the marker validator doesn't
# require it. The desktop fills it in if/when it writes its
# own marker (e.g. after a future in-app upgrade).
}
$json = $marker | ConvertTo-Json -Compress:$false
# Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8`
# always emits a BOM, and Node's plain JSON.parse rejects the BOM as an
# unexpected character — so a BOM'd marker would silently fail the
# desktop's readJson(), make isBootstrapComplete() return null, and the
# desktop would re-run the legacy bootstrap runner anyway. Defeats the
# whole point. Use the .NET API directly for BOM-less UTF-8.
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($markerPath, $json, $utf8NoBom)
Write-Success "Bootstrap marker written: $markerPath"
}
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
<!--
This file defines the agent's personality and tone.
The agent will embody whatever you write here.
Edit this to customize how Hermes communicates with you.
Examples:
- "You are a warm, playful assistant who uses kaomoji occasionally."
- "You are a concise technical expert. No fluff, just facts."
- "You speak like a friendly coworker who happens to know everything."
This file is loaded fresh each message -- no restart needed.
Delete the contents (or this file) to use the default personality.
-->
"@
$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) {
# Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME
# in a fresh powershell.exe) means $script:HasNode set by Stage-Node
# in the previous process isn't visible here. Re-probe rather than
# trust the stale global — Stage-Node already ran successfully or
# the bootstrap would've aborted, so npm is reachable.
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
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-Desktop {
# Build apps/desktop into a launchable Hermes.exe. Only called from
# Stage-Desktop, which is itself only included in the manifest when
# -IncludeDesktop was passed to install.ps1.
#
# The workspace npm install at repo root (done by Install-NodeDeps for
# browser tools) does NOT pull apps/desktop's dependencies, because the
# browser-tools workspace at $InstallDir\package.json is a separate
# workspace from apps/*. We do a full root-level `npm install` here
# so the workspace resolves apps/desktop's deps (including Electron
# itself, ~150MB), then run `npm run pack` in apps/desktop which
# produces the unpacked binary at apps/desktop/release/<os>-unpacked/.
#
# The Tauri bootstrap installer's launch_hermes_desktop command
# resolves apps/desktop/release/win-unpacked/Hermes.exe directly,
# so an "unpacked" build (electron-builder --dir) is enough — we
# don't need to produce an NSIS/MSI artifact here.
# Always re-resolve Node here. Stages run in separate PowerShell processes,
# so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node
# enforces the build floor (^20.19 || >=22.12) and prepends the Hermes-managed
# Node to PATH, so the build never runs on a too-old system Node -- the cause
# of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on
# old Node).
Test-Node | Out-Null
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Warn "Skipping desktop build (Node.js / npm not on PATH)"
$script:_StageSkippedReason = "Node.js not available"
return
}
$desktopDir = "$InstallDir\apps\desktop"
if (-not (Test-Path "$desktopDir\package.json")) {
Write-Warn "Skipping desktop build (apps/desktop not present in checkout)"
$script:_StageSkippedReason = "apps/desktop not present"
return
}
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) {
Write-Warn "Skipping desktop build (npm not on PATH)"
$script:_StageSkippedReason = "npm not found"
return
}
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $sibling) { $npmExe = $sibling }
}
# 1. Workspace-level install so apps/desktop's deps (Electron, Vite,
# node-pty prebuilds, etc.) actually land in node_modules. This is
# the SAME `npm install` Install-NodeDeps does for browser tools,
# but at the root rather than the browser-tools workspace, so all
# apps/* workspaces resolve.
Write-Info "Installing desktop workspace dependencies (this includes Electron ~150MB, takes 1-3min)..."
Push-Location $InstallDir
$prevEAP = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
# Drop --silent so npm emits its full progress + error trail.
# When this fails on a non-dev box (e.g. native-module build
# without VS Build Tools, ETARGET on a transitive, etc.), the
# actual reason needs to reach the Tauri installer's log; with
# --silent it was completely suppressed and the user just saw
# "exit 1" with no actionable detail.
#
# The streaming sink in bootstrap.rs's run_install_script
# captures every stdout/stderr line as it's emitted, so we don't
# need a side TEMP log file — the installer's bootstrap log
# IS the artifact a support engineer reads.
& $npmExe install 2>&1 | ForEach-Object { "$_" }
$code = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
}
Write-Success "Desktop workspace dependencies installed"
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Pop-Location
throw
}
Pop-Location
# 2. Build apps/desktop. `npm run pack` runs:
# assert-root-install + write-build-stamp + stage-native-deps +
# tsc -b + vite build + electron-builder --dir
# The --dir mode produces an unpacked Hermes.exe in
# apps/desktop/release/win-unpacked/ without bundling NSIS/MSI;
# we don't need a distributable installer artifact, just a
# launchable binary the Tauri installer can spawn.
#
# CSC_IDENTITY_AUTO_DISCOVERY=false tells electron-builder we are
# NOT signing the output. Combined with signAndEditExecutable=false in
# apps/desktop/package.json's build.win block, electron-builder never
# invokes signtool and therefore never fetches/extracts winCodeSign
# (whose macOS symlinks crash 7-Zip on non-admin Windows — a dead end we
# are NOT trying to work around). The Hermes icon + product name are
# stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)
# AFTER this build, completely decoupled from electron-builder signing.
#
# WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD explicitly cleared as
# belt-and-suspenders: if the user's environment has them set
# for some other tool, electron-builder would still try to sign.
Write-Info "Building desktop app (this takes 1-3 minutes)..."
$buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log"
Push-Location $desktopDir
$prevEAP = $ErrorActionPreference
$prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY
$prevWinCscLink = $env:WIN_CSC_LINK
$prevWinCscKeyPassword = $env:WIN_CSC_KEY_PASSWORD
try {
$ErrorActionPreference = "Continue"
$env:CSC_IDENTITY_AUTO_DISCOVERY = "false"
$env:WIN_CSC_LINK = ""
$env:WIN_CSC_KEY_PASSWORD = ""
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue
if ($errText) {
$snippet = if ($errText.Length -gt 1800) { $errText.Substring(0, 1800) + "..." } else { $errText }
Write-Info " desktop build output:"
foreach ($line in $snippet -split "`n") { Write-Host " $line" -ForegroundColor DarkGray }
Write-Info " Full log: $buildLog"
}
throw "apps/desktop build failed (exit $code)"
}
Write-Success "Desktop app built"
Remove-Item -Force $buildLog -ErrorAction SilentlyContinue
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Pop-Location
throw
} finally {
# Restore env to whatever the caller had — don't leak our
# signing-off override into anything install.ps1 invokes later
# (Stage-PlatformSdks, etc.).
$env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto
$env:WIN_CSC_LINK = $prevWinCscLink
$env:WIN_CSC_KEY_PASSWORD = $prevWinCscKeyPassword
}
Pop-Location
# 3. Sanity-check the produced binary. Probe both arches so this works
# on x64 and arm64 build machines.
$exeCandidates = @(
"$desktopDir\release\win-unpacked\Hermes.exe",
"$desktopDir\release\win-arm64-unpacked\Hermes.exe"
)
$found = $false
$desktopExe = $null
foreach ($cand in $exeCandidates) {
if (Test-Path $cand) {
Write-Success "Desktop ready: $cand"
$desktopExe = $cand
$found = $true
break
}
}
if (-not $found) {
throw "Desktop build completed but no Hermes.exe was found under $desktopDir\release\*-unpacked\"
}
# 3b. The Hermes icon + identity are stamped onto Hermes.exe by the
# electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.cjs)
# during `npm run pack` above — for every build, so the installer's
# --update rebuild stays branded too. No separate stamp step needed here.
# electron-builder's own rcedit step stays disabled (signAndEditExecutable
# =false) because enabling it drags in signtool -> winCodeSign -> the
# unfixable symlink crash; the afterPack hook runs rcedit directly.
# 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
# Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
# command rebuilds (npm install + electron-builder) on every launch,
# which would cost minutes each time. The packed exe is the consumer —
# launching it directly is instant, and updates flow through the
# installer's --update path (which rebuilds once, then relaunches).
New-DesktopShortcuts -TargetExe $desktopExe
}
function New-DesktopShortcuts {
param([Parameter(Mandatory = $true)][string]$TargetExe)
# Best-effort: a shortcut failure must never fail an otherwise-good install.
try {
$shell = New-Object -ComObject WScript.Shell
$workDir = Split-Path -Parent $TargetExe
# Prefer the standalone icon.ico (shipped beside the exe via
# electron-builder extraResources -> resources/icon.ico) over the exe's
# embedded resource. An explicit .ico path is more stable across update
# cycles: pointing at "$TargetExe,0" makes Windows cache the icon it
# extracted from the exe at shortcut-creation time, and that cached
# bitmap can persist (showing the OLD/Electron icon) even after the exe
# is re-stamped on update. A dedicated .ico sidesteps that extraction.
$iconIco = Join-Path $workDir 'resources\icon.ico'
if (Test-Path $iconIco) {
$iconLocation = "$iconIco,0"
} else {
$iconLocation = "$TargetExe,0"
}
$targets = @(
(Join-Path ([Environment]::GetFolderPath('Programs')) 'Hermes.lnk'),
(Join-Path ([Environment]::GetFolderPath('Desktop')) 'Hermes.lnk')
)
foreach ($lnkPath in $targets) {
try {
$parent = Split-Path -Parent $lnkPath
if (-not (Test-Path $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
$sc = $shell.CreateShortcut($lnkPath)
$sc.TargetPath = $TargetExe
$sc.WorkingDirectory = $workDir
$sc.IconLocation = $iconLocation
$sc.Description = 'Hermes Agent'
$sc.Save()
Write-Success "Shortcut created: $lnkPath"
} catch {
Write-Warn "Could not create shortcut $lnkPath : $($_.Exception.Message)"
}
}
# Bust the Windows shell icon cache so the desktop/Start-Menu shortcut
# repaints with the (possibly newly-stamped) icon instead of a stale
# cached bitmap. Critical on the --update path: the exe was re-stamped
# with the Hermes icon, but without this the shortcut can keep drawing
# the old Electron icon until the user manually refreshes / reboots.
# Best-effort and silent — never fail the install over a cosmetic cache.
try {
& ie4uinit.exe -show 2>$null
} catch {
# ie4uinit may be absent/renamed on some SKUs — ignore.
}
} catch {
Write-Warn "Skipping shortcut creation: $($_.Exception.Message)"
}
}
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 <name> 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 <name>):
#
# {
# "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" }
)
if ($IncludeDesktop) {
# Insert AFTER node-deps so workspace npm is already installed when
# the desktop build runs. Inserted only when explicitly requested
# (Hermes-Setup.exe), never via the irm|iex CLI one-liner.
$InstallStages += @{ Name = "desktop"; Title = "Building desktop app"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Desktop" }
}
$InstallStages += @(
@{ 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" }
@{ Name = "bootstrap-marker"; Title = "Marking install complete"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-BootstrapMarker" }
# 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-Desktop { Install-Desktop }
function Stage-Path { Set-PathVariable }
function Stage-ConfigTemplates { Copy-ConfigTemplates }
function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks }
function Stage-BootstrapMarker { Write-BootstrapMarker }
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 ""
}