# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). # Uses uv for fast Python provisioning and package management. # # Usage: # irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex # # Or download and run with options: # .\install.ps1 -NoVenv -SkipSetup # # ============================================================================ param( [switch]$NoVenv, [switch]$SkipSetup, [string]$Branch = "main", [string]$HermesHome = "$env:LOCALAPPDATA\hermes", [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent" ) $ErrorActionPreference = "Stop" # ============================================================================ # Configuration # ============================================================================ $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git" $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git" $PythonVersion = "3.11" $NodeVersion = "22" # ============================================================================ # Helper functions # ============================================================================ 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 "✓ $Message" -ForegroundColor Green } function Write-Warn { param([string]$Message) Write-Host "⚠ $Message" -ForegroundColor Yellow } function Write-Err { param([string]$Message) Write-Host "✗ $Message" -ForegroundColor Red } # ============================================================================ # Dependency checks # ============================================================================ function Install-Uv { Write-Info "Checking for uv package manager..." # Check if uv is already available if (Get-Command uv -ErrorAction SilentlyContinue) { $version = uv --version $script:UvCmd = "uv" Write-Success "uv found ($version)" return $true } # Check common install locations $uvPaths = @( "$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe" ) foreach ($uvPath in $uvPaths) { if (Test-Path $uvPath) { $script:UvCmd = $uvPath $version = & $uvPath --version Write-Success "uv found at $uvPath ($version)" return $true } } # Install uv Write-Info "Installing uv (fast Python package manager)..." try { powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null # Find the installed binary $uvExe = "$env:USERPROFILE\.local\bin\uv.exe" if (-not (Test-Path $uvExe)) { $uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe" } if (-not (Test-Path $uvExe)) { # Refresh PATH and try again $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if (Get-Command uv -ErrorAction SilentlyContinue) { $uvExe = (Get-Command uv).Source } } if (Test-Path $uvExe) { $script:UvCmd = $uvExe $version = & $uvExe --version Write-Success "uv installed ($version)" return $true } Write-Err "uv installed but not found on PATH" Write-Info "Try restarting your terminal and re-running" return $false } catch { Write-Err "Failed to install uv" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } } 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..." try { $uvOutput = & $UvCmd python install $PythonVersion 2>&1 if ($LASTEXITCODE -eq 0) { $pythonPath = & $UvCmd python find $PythonVersion 2>$null if ($pythonPath) { $ver = & $pythonPath --version 2>$null Write-Success "Python installed: $ver" return $true } } else { Write-Warn "uv python install output:" Write-Host $uvOutput -ForegroundColor DarkGray } } catch { 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 if (Get-Command python -ErrorAction SilentlyContinue) { $sysVer = python --version 2>$null if ($sysVer -match "3\.(1[0-9]|[1-9][0-9])") { Write-Success "Using system Python: $sysVer" return $true } } 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 = if ([Environment]::Is64BitOperatingSystem) { # Detect ARM64 vs x64 explicitly; PortableGit ships separate assets. if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") { "arm64" } else { "64-bit" } } else { # PortableGit does not ship a 32-bit build — fall back to MinGit 32-bit # with a warning that bash-based features will be unavailable. "32-bit-mingit" } $releaseApi = "https://api.github.com/repos/git-for-windows/git/releases/latest" $release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" } 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." $assetPattern = "MinGit-*-32-bit.zip" $downloadIsZip = $true } elseif ($arch -eq "arm64") { $assetPattern = "PortableGit-*-arm64.7z.exe" $downloadIsZip = $false } else { $assetPattern = "PortableGit-*-64-bit.7z.exe" $downloadIsZip = $false } $asset = $release.assets | Where-Object { $_.name -like $assetPattern } | Select-Object -First 1 if (-not $asset) { throw "Could not find $assetPattern in latest git-for-windows release" } $downloadUrl = $asset.browser_download_url $downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" } $tmpFile = "$env:TEMP\$($asset.name)" $gitDir = "$HermesHome\git" Write-Info "Downloading $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)..." Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing if (Test-Path $gitDir) { Write-Info "Removing previous Git install at $gitDir ..." Remove-Item -Recurse -Force $gitDir } New-Item -ItemType Directory -Path $gitDir -Force | Out-Null if ($downloadIsZip) { Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force } else { # PortableGit is a self-extracting 7z archive. Invoke it with # `-o -y` (silent) to extract to $gitDir. No 7z install # required; it's fully self-contained. Write-Info "Extracting PortableGit to $gitDir ..." $extractProc = Start-Process -FilePath $tmpFile ` -ArgumentList "-o`"$gitDir`"", "-y" ` -NoNewWindow -Wait -PassThru if ($extractProc.ExitCode -ne 0) { throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))" } } Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue # PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils) # MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present) $gitExe = "$gitDir\cmd\git.exe" if (-not (Test-Path $gitExe)) { throw "Git extraction did not produce git.exe at $gitExe" } # Add to session PATH so the rest of this install run can use git. $env:Path = "$gitDir\cmd;$env:Path" # Persist to User PATH so fresh shells see it. PortableGit needs # cmd\ (for git.exe), bin\ (for bash.exe + core tools), and # usr\bin\ (for perl, ssh, curl, and other POSIX coreutils). $newPathEntries = @( "$gitDir\cmd", "$gitDir\bin", "$gitDir\usr\bin" ) $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } $changed = $false foreach ($entry in $newPathEntries) { if ($userPathItems -notcontains $entry) { $userPathItems += $entry $changed = $true } } if ($changed) { [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") } $version = & $gitExe --version Write-Success "Git $version installed to $gitDir (portable, user-scoped)" Set-GitBashEnvVar return $true } catch { Write-Err "Could not install portable Git: $_" Write-Info "" Write-Info "Fallback: install Git manually from https://git-scm.com/download/win" Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run" Write-Info "shell commands (same as Claude Code and other coding agents)." return $false } } function Set-GitBashEnvVar { <# .SYNOPSIS Locate ``bash.exe`` from an already-installed Git and persist the path in ``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before PATH propagation completes in a newly-spawned shell. #> $candidates = @() # Our own portable Git install is ALWAYS checked first, so a broken # system Git doesn't hijack us. If the user had a working system Git # we'd have returned early from Install-Git's fast path and never called # this with a system-Git-only installation anyway. # # Layouts: # PortableGit (our default): $HermesHome\git\bin\bash.exe # MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe $candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary) $candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback # git.exe on PATH can tell us where the install root is $gitCmd = Get-Command git -ErrorAction SilentlyContinue if ($gitCmd) { $gitExe = $gitCmd.Source # Git for Windows (full installer): \cmd\git.exe + \bin\bash.exe # MinGit: \cmd\git.exe + \usr\bin\bash.exe $gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent $candidates += "$gitRoot\bin\bash.exe" $candidates += "$gitRoot\usr\bin\bash.exe" } # Standard system install locations as a final fallback. Note: # ProgramFiles(x86) can't be referenced via ${env:...} string interpolation # because of the parens — use [Environment]::GetEnvironmentVariable(). $candidates += "${env:ProgramFiles}\Git\bin\bash.exe" $pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" } $candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe" foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate)) { [Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User") $env:HERMES_GIT_BASH_PATH = $candidate Write-Info "Set HERMES_GIT_BASH_PATH=$candidate" return } } Write-Warn "Could not locate bash.exe — Hermes may not find Git Bash." Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } function Test-Node { Write-Info "Checking Node.js (for browser tools)..." if (Get-Command node -ErrorAction SilentlyContinue) { $version = node --version Write-Success "Node.js $version found" $script:HasNode = $true return $true } # Check our own managed install from a previous run $managedNode = "$HermesHome\node\node.exe" if (Test-Path $managedNode) { $version = & $managedNode --version $env:Path = "$HermesHome\node;$env:Path" Write-Success "Node.js $version found (Hermes-managed)" $script:HasNode = $true return $true } Write-Info "Node.js not found — installing Node.js $NodeVersion LTS..." # Try winget first (cleanest on modern Windows) if (Get-Command winget -ErrorAction SilentlyContinue) { Write-Info "Installing via winget..." try { winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null # 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 { } } # Fallback: download binary zip to ~/.hermes/node/ Write-Info "Downloading Node.js $NodeVersion binary..." try { $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } $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" $env:Path = "$HermesHome\node;$env:Path" $version = & "$HermesHome\node\node.exe" --version Write-Success "Node.js $version installed to ~/.hermes/node/" $script:HasNode = $true Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue return $true } } } catch { Write-Warn "Download failed: $_" } Write-Warn "Could not auto-install Node.js" 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..." foreach ($pkg in $wingetPkgs) { try { winget install $pkg --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null } catch { } } # Refresh PATH and recheck $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) { Write-Success "ripgrep installed" $script:HasRipgrep = $true $needRipgrep = $false } if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { Write-Success "ffmpeg installed" $script:HasFfmpeg = $true $needFfmpeg = $false } 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 try { git -c windows.appendAtomically=false fetch origin if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" } 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 { Pop-Location } $didUpdate = $true } else { # Directory exists but isn't a usable git repo. Wipe it and # fall through to a fresh clone. A leftover ``.git`` stub from # a partial uninstall used to lock the installer into the # "update" branch forever, emitting three ``fatal: not a git # repository`` errors and failing with "not in a git directory". Write-Warn "Existing directory at $InstallDir is not a valid git repo — replacing it." try { Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop } catch { Write-Err "Could not remove $InstallDir : $_" Write-Info "Close any programs that might be using files in $InstallDir (editors," Write-Info "terminals, running hermes processes) and try again." throw } } } if (-not $didUpdate) { $cloneSuccess = $false # Fix Windows git "copy-fd: write returned: Invalid argument" error. # Git for Windows can fail on atomic file operations (hook templates, # config lock files) due to antivirus, OneDrive, or NTFS filter drivers. # The -c flag injects config before any file I/O occurs. Write-Info "Configuring git for Windows compatibility..." $env:GIT_CONFIG_COUNT = "1" $env:GIT_CONFIG_KEY_0 = "windows.appendAtomically" $env:GIT_CONFIG_VALUE_0 = "false" git config --global windows.appendAtomically false 2>$null # Try SSH first, then HTTPS, with -c flag for atomic write fix Write-Info "Trying SSH clone..." $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5" try { git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlSsh $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } $env:GIT_SSH_COMMAND = $null if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Info "SSH failed, trying HTTPS..." try { git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlHttps $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } } # Fallback: download ZIP archive (bypasses git file I/O issues entirely) if (-not $cloneSuccess) { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Warn "Git clone failed — downloading ZIP archive instead..." try { $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip" $zipPath = "$env:TEMP\hermes-agent-$Branch.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 # Ensure submodules are initialized and updated Write-Info "Initializing submodules..." git -c windows.appendAtomically=false submodule update --init --recursive 2>$null if ($LASTEXITCODE -ne 0) { Write-Warn "Submodule init failed (terminal/RL tools may need manual setup)" } else { Write-Success "Submodules ready" } Pop-Location Write-Success "Repository ready" } function Install-Venv { if ($NoVenv) { Write-Info "Skipping virtual environment (-NoVenv)" return } Write-Info "Creating virtual environment with Python $PythonVersion..." Push-Location $InstallDir if (Test-Path "venv") { Write-Info "Virtual environment already exists, recreating..." Remove-Item -Recurse -Force "venv" } # uv creates the venv and pins the Python version in one step & $UvCmd venv venv --python $PythonVersion Pop-Location Write-Success "Virtual environment ready (Python $PythonVersion)" } function Install-Dependencies { Write-Info "Installing dependencies..." Push-Location $InstallDir if (-not $NoVenv) { # Tell uv to install into our venv (no activation needed) $env:VIRTUAL_ENV = "$InstallDir\venv" } # Install main package with all extras try { & $UvCmd pip install -e ".[all]" 2>&1 | Out-Null } catch { & $UvCmd pip install -e "." | Out-Null } Write-Success "Main package installed" # Install optional submodules Write-Info "Installing tinker-atropos (RL training backend)..." if (Test-Path "tinker-atropos\pyproject.toml") { try { & $UvCmd pip install -e ".\tinker-atropos" 2>&1 | Out-Null Write-Success "tinker-atropos installed" } catch { Write-Warn "tinker-atropos install failed (RL tools may not work)" } } else { Write-Warn "tinker-atropos not found (run: git submodule update --init)" } Pop-Location Write-Success "All dependencies installed" } function Set-PathVariable { Write-Info "Setting up hermes command..." if ($NoVenv) { $hermesBin = "$InstallDir" } else { $hermesBin = "$InstallDir\venv\Scripts" } # Add the venv Scripts dir to user PATH so hermes is globally available # On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($currentPath -notlike "*$hermesBin*") { [Environment]::SetEnvironmentVariable( "Path", "$hermesBin;$currentPath", "User" ) Write-Success "Added to user PATH: $hermesBin" } else { Write-Info "PATH already configured" } # Set HERMES_HOME so the Python code finds config/data in the right place. # Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead # of the Unix default ~/.hermes $currentHermesHome = [Environment]::GetEnvironmentVariable("HERMES_HOME", "User") if (-not $currentHermesHome -or $currentHermesHome -ne $HermesHome) { [Environment]::SetEnvironmentVariable("HERMES_HOME", $HermesHome, "User") Write-Success "Set HERMES_HOME=$HermesHome" } $env:HERMES_HOME = $HermesHome # Update current session $env:Path = "$hermesBin;$env:Path" Write-Success "hermes command ready" } function Copy-ConfigTemplates { Write-Info "Setting up configuration files..." # Create ~/.hermes directory structure New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null # Create .env $envPath = "$HermesHome\.env" if (-not (Test-Path $envPath)) { $examplePath = "$InstallDir\.env.example" if (Test-Path $examplePath) { Copy-Item $examplePath $envPath Write-Success "Created ~/.hermes/.env from template" } else { New-Item -ItemType File -Force -Path $envPath | Out-Null Write-Success "Created ~/.hermes/.env" } } else { Write-Info "~/.hermes/.env already exists, keeping it" } # Create config.yaml $configPath = "$HermesHome\config.yaml" if (-not (Test-Path $configPath)) { $examplePath = "$InstallDir\cli-config.yaml.example" if (Test-Path $examplePath) { Copy-Item $examplePath $configPath Write-Success "Created ~/.hermes/config.yaml from template" } } else { Write-Info "~/.hermes/config.yaml already exists, keeping it" } # Create SOUL.md if it doesn't exist (global persona file). # IMPORTANT: write without a BOM. Windows PowerShell 5.1's # ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark # (the default PS5 behaviour), and Hermes's prompt-injection scanner # flags the BOM as an invisible unicode character and refuses to # load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we # don't control which PowerShell version the user has. Go direct # to .NET with an explicit UTF8Encoding($false) — BOM-free on every # PowerShell version. $soulPath = "$HermesHome\SOUL.md" if (-not (Test-Path $soulPath)) { $soulContent = @" # Hermes Agent Persona "@ $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom) Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)" } Write-Success "Configuration directory ready: ~/.hermes/" # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill) Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..." $pythonExe = "$InstallDir\venv\Scripts\python.exe" if (Test-Path $pythonExe) { try { & $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null Write-Success "Skills synced to ~/.hermes/skills/" } catch { # Fallback: simple directory copy $bundledSkills = "$InstallDir\skills" $userSkills = "$HermesHome\skills" if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) { Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue Write-Success "Skills copied to ~/.hermes/skills/" } } } } function Install-NodeDeps { if (-not $HasNode) { Write-Info "Skipping Node.js dependencies (Node not installed)" return } # Resolve npm.cmd to an absolute path so PATHEXT doesn't bite us when # the installer runs in a session that hasn't refreshed PATH since the # Node.js install. Get-Command respects PATHEXT. $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 # 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 try { # Redirect ALL output streams to the log file via 2>&1 and then # ``Tee-Object`` / ``Out-File``. Simpler approach: call npm # with output redirected and inspect $LASTEXITCODE afterwards. & $npmPath install --silent *> $logPath $code = $LASTEXITCODE 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 { 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" [void](_Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe) } # 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 Invoke-SetupWizard { if ($SkipSetup) { Write-Info "Skipping setup wizard (-SkipSetup)" 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 "" $response = Read-Host "Pair WhatsApp now? [Y/n]" if ($response -eq "" -or $response -match "^[Yy]") { try { & $hermesCmd whatsapp } catch { # Expected after pairing completes } } } Write-Host "" Write-Info "Messaging platform token detected!" Write-Info "The gateway handles messaging platforms and cron job execution." Write-Host "" $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 "│ ✓ 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 "" } } # ============================================================================ # Main # ============================================================================ function Main { Write-Banner # 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. If the current # working dir is under $InstallDir, step out to the user's home # BEFORE doing anything else. Harmless when the user 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 {} if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" } if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" } if (-not (Install-Git)) { throw "Git not available and auto-install failed — install from https://git-scm.com/download/win then re-run" } # Test-Node always returns $true (sets $script:HasNode on success, emits a # warning on failure and continues so non-browser installs still work). # Cast to [void] so the bare return value doesn't print "True" to the # console between the "Node found" line and the next installer step. [void](Test-Node) Install-SystemPackages # ripgrep + ffmpeg in one step Install-Repository Install-Venv Install-Dependencies Install-NodeDeps Set-PathVariable Copy-ConfigTemplates Invoke-SetupWizard Start-GatewayIfConfigured Write-Completion } # Wrap in try/catch so errors don't kill the terminal when run via: # irm https://...install.ps1 | iex # (exit/throw inside iex kills the entire PowerShell session) try { Main } catch { 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 "" }