|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| param(
|
| [switch]$NoVenv,
|
| [switch]$SkipSetup,
|
| [string]$Branch = "main",
|
|
|
|
|
|
|
|
|
|
|
| [string]$Commit = "",
|
| [string]$Tag = "",
|
| [string]$HermesHome = "$env:LOCALAPPDATA\hermes",
|
| [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent",
|
|
|
|
|
|
|
|
|
|
|
|
|
| [switch]$Manifest,
|
| [string]$Stage,
|
| [switch]$ProtocolVersion,
|
| [switch]$NonInteractive,
|
| [switch]$Json,
|
|
|
|
|
| [string]$Ensure = "",
|
| [switch]$PostInstall
|
| )
|
|
|
| $ErrorActionPreference = "Stop"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $ProgressPreference = "SilentlyContinue"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| try {
|
| [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
|
| } catch {
|
|
|
|
|
| }
|
|
|
|
|
|
|
|
|
|
|
| $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
|
| $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
|
| $PythonVersion = "3.11"
|
| $NodeVersion = "22"
|
|
|
|
|
|
|
|
|
| $InstallStageProtocolVersion = 1
|
|
|
|
|
|
|
|
|
|
|
| function Write-Banner {
|
| Write-Host ""
|
| Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
|
| Write-Host "| * Hermes Agent Installer |" -ForegroundColor Magenta
|
| Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
|
| Write-Host "| An open source AI agent by Nous Research. |" -ForegroundColor Magenta
|
| Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
|
| Write-Host ""
|
| }
|
|
|
| function Write-Info {
|
| param([string]$Message)
|
| Write-Host "-> $Message" -ForegroundColor Cyan
|
| }
|
|
|
| function Write-Success {
|
| param([string]$Message)
|
| Write-Host "[OK] $Message" -ForegroundColor Green
|
| }
|
|
|
| function Write-Warn {
|
| param([string]$Message)
|
| Write-Host "[!] $Message" -ForegroundColor Yellow
|
| }
|
|
|
| function Write-Err {
|
| param([string]$Message)
|
| Write-Host "[X] $Message" -ForegroundColor Red
|
| }
|
|
|
|
|
|
|
| function 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"
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function Install-Uv {
|
| Write-Info "Checking for uv package manager..."
|
|
|
|
|
| if (Get-Command uv -ErrorAction SilentlyContinue) {
|
| $version = uv --version
|
| $script:UvCmd = "uv"
|
| Write-Success "uv found ($version)"
|
| return $true
|
| }
|
|
|
|
|
| $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
|
| }
|
| }
|
|
|
|
|
| Write-Info "Installing uv (fast Python package manager)..."
|
|
|
|
|
|
|
|
|
| $prevEAP = $ErrorActionPreference
|
| try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $ErrorActionPreference = "Continue"
|
| powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
| $ErrorActionPreference = $prevEAP
|
|
|
|
|
| $uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
|
| if (-not (Test-Path $uvExe)) {
|
| $uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
|
| }
|
| if (-not (Test-Path $uvExe)) {
|
|
|
| $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 {
|
|
|
| if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
| Write-Err "Failed to install uv: $_"
|
| Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
| return $false
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function Sync-EnvPath {
|
| $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function Resolve-UvCmd {
|
|
|
|
|
| if ($script:UvCmd) {
|
| if ($script:UvCmd -eq "uv") {
|
|
|
|
|
| if (Get-Command uv -ErrorAction SilentlyContinue) { return }
|
| } elseif (Test-Path $script:UvCmd) {
|
| return
|
| }
|
|
|
| }
|
|
|
|
|
|
|
|
|
| if (Get-Command uv -ErrorAction SilentlyContinue) {
|
| $script:UvCmd = "uv"
|
| return
|
| }
|
|
|
|
|
|
|
| $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
| if (Get-Command uv -ErrorAction SilentlyContinue) {
|
| $script:UvCmd = "uv"
|
| return
|
| }
|
|
|
|
|
|
|
| foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
|
| if (Test-Path $uvPath) {
|
| $script:UvCmd = $uvPath
|
| return
|
| }
|
| }
|
|
|
| throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
|
| }
|
|
|
| function Test-Python {
|
| Write-Info "Checking Python $PythonVersion..."
|
|
|
|
|
| try {
|
| $pythonPath = & $UvCmd python find $PythonVersion 2>$null
|
| if ($pythonPath) {
|
| $ver = & $pythonPath --version 2>$null
|
| Write-Success "Python found: $ver"
|
| return $true
|
| }
|
| } catch { }
|
|
|
|
|
| Write-Info "Python $PythonVersion not found, installing via uv..."
|
|
|
|
|
| $prevEAP = $ErrorActionPreference
|
| try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $ErrorActionPreference = "Continue"
|
| $uvOutput = & $UvCmd python install $PythonVersion 2>&1
|
| $uvExitCode = $LASTEXITCODE
|
| $ErrorActionPreference = $prevEAP
|
|
|
|
|
|
|
| $pythonPath = & $UvCmd python find $PythonVersion 2>$null
|
| if ($pythonPath) {
|
| $ver = & $pythonPath --version 2>$null
|
| Write-Success "Python installed: $ver"
|
| return $true
|
| }
|
|
|
|
|
| if ($uvExitCode -ne 0) {
|
| Write-Warn "uv python install output:"
|
| Write-Host $uvOutput -ForegroundColor DarkGray
|
| }
|
| } catch {
|
|
|
| if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
| Write-Warn "uv python install error: $_"
|
| }
|
|
|
|
|
| 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 { }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
| if ($pythonCmd) {
|
| $isStoreStub = $false
|
| try {
|
| $pythonSource = $pythonCmd.Source
|
| if ($pythonSource -and $pythonSource -like "*\WindowsApps\*") {
|
| $isStoreStub = $true
|
| } else {
|
|
|
| $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 {
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| Write-Info "Checking Git..."
|
|
|
| if (Get-Command git -ErrorAction SilentlyContinue) {
|
| $version = git --version
|
| Write-Success "Git found ($version)"
|
| Set-GitBashEnvVar
|
| return $true
|
| }
|
|
|
|
|
|
|
|
|
| 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) {
|
|
|
| if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
|
| "arm64"
|
| } else {
|
| "64-bit"
|
| }
|
| } else {
|
|
|
|
|
| "32-bit-mingit"
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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 {
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| $gitExe = "$gitDir\cmd\git.exe"
|
| if (-not (Test-Path $gitExe)) {
|
| throw "Git extraction did not produce git.exe at $gitExe"
|
| }
|
|
|
|
|
| $env:Path = "$gitDir\cmd;$env:Path"
|
|
|
|
|
|
|
|
|
| $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 {
|
| |
| |
| |
| |
| |
|
|
| $candidates = @()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $candidates += "$HermesHome\git\bin\bash.exe"
|
| $candidates += "$HermesHome\git\usr\bin\bash.exe"
|
|
|
|
|
| $gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
| if ($gitCmd) {
|
| $gitExe = $gitCmd.Source
|
|
|
|
|
| $gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent
|
| $candidates += "$gitRoot\bin\bash.exe"
|
| $candidates += "$gitRoot\usr\bin\bash.exe"
|
| }
|
|
|
|
|
|
|
|
|
| $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
|
| }
|
|
|
|
|
| $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..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..."
|
| Write-Info "(no admin rights required; isolated from any system Node install)"
|
| 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"
|
|
|
|
|
|
|
|
|
| $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: $_"
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (Get-Command winget -ErrorAction SilentlyContinue) {
|
| Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..."
|
|
|
|
|
| $prevEAP = $ErrorActionPreference
|
| try {
|
|
|
|
|
|
|
|
|
| $ErrorActionPreference = "Continue"
|
| winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
|
| $ErrorActionPreference = $prevEAP
|
|
|
| $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 }
|
|
|
|
|
| $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
|
|
|
|
|
| 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 { }
|
| }
|
|
|
| $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 }
|
| }
|
|
|
|
|
| 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
|
| }
|
| }
|
|
|
|
|
| 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
|
| }
|
| }
|
|
|
|
|
| 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"
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| function Install-Repository {
|
| Write-Info "Installing to $InstallDir..."
|
|
|
| $didUpdate = $false
|
|
|
| if (Test-Path $InstallDir) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $repoValid = $false
|
| if (Test-Path "$InstallDir\.git") {
|
| Push-Location $InstallDir
|
| try {
|
|
|
|
|
| $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
|
|
|
|
|
|
|
|
|
| $prevEAP = $ErrorActionPreference
|
| $ErrorActionPreference = "Continue"
|
| try {
|
| git -c windows.appendAtomically=false fetch origin
|
| if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
|
|
|
|
|
|
|
| if ($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) {
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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 { }
|
| }
|
|
|
|
|
| if (-not $cloneSuccess) {
|
| if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
| Write-Warn "Git clone failed -- downloading ZIP archive instead..."
|
| try {
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| $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"
|
|
|
|
|
| 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
|
| }
|
|
|
|
|
| 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)"
|
| }
|
| }
|
|
|
|
|
| Push-Location $InstallDir
|
| git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
|
|
|
|
|
|
|
|
|
|
|
| if (-not $didUpdate) {
|
|
|
|
|
|
|
| $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-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"
|
| }
|
|
|
|
|
| & $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) {
|
|
|
| $env:VIRTUAL_ENV = "$InstallDir\venv"
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (Test-Path "uv.lock") {
|
| Write-Info "Trying tier: hash-verified (uv.lock) ..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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)"
|
|
|
|
|
| $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
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $brokenExtras = @()
|
|
|
|
|
|
|
| $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."
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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"
|
| }
|
|
|
|
|
|
|
|
|
|
|
| $pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
|
| if (Test-Path $pythonExe) {
|
| $webOk = $false
|
|
|
|
|
|
|
|
|
|
|
| $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"
|
| }
|
|
|
|
|
|
|
| $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"
|
| }
|
|
|
|
|
|
|
|
|
| $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
|
|
|
|
|
| $env:Path = "$hermesBin;$env:Path"
|
|
|
| Write-Success "hermes command ready"
|
| }
|
|
|
| function Copy-ConfigTemplates {
|
| Write-Info "Setting up configuration files..."
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| $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"
|
| }
|
|
|
|
|
| $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"
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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/"
|
|
|
|
|
| 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 {
|
|
|
| $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
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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."
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
|
| Push-Location $installDir
|
|
|
|
|
| $prevEAP = $ErrorActionPreference
|
| try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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
|
| }
|
| }
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if ($browserNpmOk) {
|
| Write-Info "Installing browser engine (Playwright Chromium)..."
|
|
|
|
|
| $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
|
|
|
|
|
|
|
| $prevEAP = $ErrorActionPreference
|
| try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Write-Info "(this can take several minutes -- streaming progress below)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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
|
| }
|
| }
|
| }
|
| }
|
|
|
|
|
| $tuiDir = "$InstallDir\ui-tui"
|
| if (Test-Path "$tuiDir\package.json") {
|
| Write-Info "Installing TUI dependencies..."
|
| $tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
|
| [void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
|
| }
|
| }
|
|
|
| function Install-PlatformSdks {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| $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" }
|
| )
|
|
|
|
|
| $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 ..."
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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 }
|
|
|
|
|
|
|
| $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) {
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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"
|
| }
|
|
|
|
|
| $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 ""
|
|
|
|
|
|
|
| if (-not $NonInteractive) {
|
| $response = Read-Host "Pair WhatsApp now? [Y/n]"
|
| if ($response -eq "" -or $response -match "^[Yy]") {
|
| try {
|
| & $hermesCmd whatsapp
|
| } catch {
|
|
|
| }
|
| }
|
| } 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 ""
|
|
|
|
|
|
|
|
|
| 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 ""
|
|
|
|
|
| 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 ""
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $InstallStages = @(
|
| @{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" }
|
| @{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" }
|
| @{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" }
|
| @{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" }
|
| @{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" }
|
| @{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" }
|
| @{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" }
|
| @{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" }
|
| @{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" }
|
| @{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" }
|
| @{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" }
|
| @{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" }
|
|
|
|
|
| @{ 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" }
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" } }
|
|
|
|
|
|
|
|
|
|
|
|
|
| function Stage-Node {
|
| if (-not (Test-Node)) {
|
| $script:_StageSkippedReason = "Node.js not available; browser tools will be unavailable until node is installed manually from https://nodejs.org/en/download/"
|
| }
|
| }
|
| function Stage-SystemPackages { Install-SystemPackages }
|
| function Stage-Repository { Install-Repository }
|
| function Stage-Venv { Resolve-UvCmd; Install-Venv }
|
| function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies }
|
| function Stage-NodeDeps { Install-NodeDeps }
|
| function Stage-Path { Set-PathVariable }
|
| function Stage-ConfigTemplates { Copy-ConfigTemplates }
|
| function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks }
|
| function Stage-Configure { Invoke-SetupWizard }
|
| function Stage-Gateway { Start-GatewayIfConfigured }
|
|
|
| function Get-InstallStage {
|
| param([string]$Name)
|
| foreach ($s in $InstallStages) {
|
| if ($s.Name -eq $Name) { return $s }
|
| }
|
| return $null
|
| }
|
|
|
| function Step-OutOfInstallDir {
|
|
|
|
|
|
|
|
|
| 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
|
| )
|
|
|
|
|
|
|
|
|
|
|
| Sync-EnvPath
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| $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) {
|
|
|
|
|
|
|
| $result | ConvertTo-Json -Compress | Write-Output
|
|
|
|
|
|
|
|
|
| if (-not $result.ok) {
|
| $script:_StageEmittedErrorFrame = $true
|
| }
|
| }
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| }
|
|
|
|
|
|
|
| Main
|
| } catch {
|
| if ($Json -or $Stage) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (-not $script:_StageEmittedErrorFrame) {
|
| $err = @{
|
| ok = $false
|
| stage = if ($Stage) { $Stage } else { $null }
|
| reason = "$_"
|
| }
|
| $err | ConvertTo-Json -Compress | Write-Output
|
| }
|
| exit 1
|
| }
|
|
|
|
|
| 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 ""
|
| }
|
|
|