/ scripts / install.ps1
install.ps1
  1  # ============================================================================
  2  # Hermes Agent Installer for Windows
  3  # ============================================================================
  4  # Installation script for Windows (PowerShell).
  5  # Uses uv for fast Python provisioning and package management.
  6  #
  7  # Usage:
  8  #   irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
  9  #
 10  # Or download and run with options:
 11  #   .\install.ps1 -NoVenv -SkipSetup
 12  #
 13  # ============================================================================
 14  
 15  param(
 16      [switch]$NoVenv,
 17      [switch]$SkipSetup,
 18      [string]$Branch = "main",
 19      [string]$HermesHome = "$env:LOCALAPPDATA\hermes",
 20      [string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent"
 21  )
 22  
 23  $ErrorActionPreference = "Stop"
 24  
 25  # ============================================================================
 26  # Configuration
 27  # ============================================================================
 28  
 29  $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
 30  $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
 31  $PythonVersion = "3.11"
 32  $NodeVersion = "22"
 33  
 34  # ============================================================================
 35  # Helper functions
 36  # ============================================================================
 37  
 38  function Write-Banner {
 39      Write-Host ""
 40      Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Magenta
 41      Write-Host "│             ⚕ Hermes Agent Installer                    │" -ForegroundColor Magenta
 42      Write-Host "├─────────────────────────────────────────────────────────┤" -ForegroundColor Magenta
 43      Write-Host "│  An open source AI agent by Nous Research.              │" -ForegroundColor Magenta
 44      Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Magenta
 45      Write-Host ""
 46  }
 47  
 48  function Write-Info {
 49      param([string]$Message)
 50      Write-Host "→ $Message" -ForegroundColor Cyan
 51  }
 52  
 53  function Write-Success {
 54      param([string]$Message)
 55      Write-Host "✓ $Message" -ForegroundColor Green
 56  }
 57  
 58  function Write-Warn {
 59      param([string]$Message)
 60      Write-Host "⚠ $Message" -ForegroundColor Yellow
 61  }
 62  
 63  function Write-Err {
 64      param([string]$Message)
 65      Write-Host "✗ $Message" -ForegroundColor Red
 66  }
 67  
 68  # ============================================================================
 69  # Dependency checks
 70  # ============================================================================
 71  
 72  function Install-Uv {
 73      Write-Info "Checking for uv package manager..."
 74      
 75      # Check if uv is already available
 76      if (Get-Command uv -ErrorAction SilentlyContinue) {
 77          $version = uv --version
 78          $script:UvCmd = "uv"
 79          Write-Success "uv found ($version)"
 80          return $true
 81      }
 82      
 83      # Check common install locations
 84      $uvPaths = @(
 85          "$env:USERPROFILE\.local\bin\uv.exe",
 86          "$env:USERPROFILE\.cargo\bin\uv.exe"
 87      )
 88      foreach ($uvPath in $uvPaths) {
 89          if (Test-Path $uvPath) {
 90              $script:UvCmd = $uvPath
 91              $version = & $uvPath --version
 92              Write-Success "uv found at $uvPath ($version)"
 93              return $true
 94          }
 95      }
 96      
 97      # Install uv
 98      Write-Info "Installing uv (fast Python package manager)..."
 99      try {
100          powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
101          
102          # Find the installed binary
103          $uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
104          if (-not (Test-Path $uvExe)) {
105              $uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
106          }
107          if (-not (Test-Path $uvExe)) {
108              # Refresh PATH and try again
109              $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
110              if (Get-Command uv -ErrorAction SilentlyContinue) {
111                  $uvExe = (Get-Command uv).Source
112              }
113          }
114          
115          if (Test-Path $uvExe) {
116              $script:UvCmd = $uvExe
117              $version = & $uvExe --version
118              Write-Success "uv installed ($version)"
119              return $true
120          }
121          
122          Write-Err "uv installed but not found on PATH"
123          Write-Info "Try restarting your terminal and re-running"
124          return $false
125      } catch {
126          Write-Err "Failed to install uv"
127          Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
128          return $false
129      }
130  }
131  
132  function Test-Python {
133      Write-Info "Checking Python $PythonVersion..."
134      
135      # Let uv find or install Python
136      try {
137          $pythonPath = & $UvCmd python find $PythonVersion 2>$null
138          if ($pythonPath) {
139              $ver = & $pythonPath --version 2>$null
140              Write-Success "Python found: $ver"
141              return $true
142          }
143      } catch { }
144      
145      # Python not found — use uv to install it (no admin needed!)
146      Write-Info "Python $PythonVersion not found, installing via uv..."
147      try {
148          $uvOutput = & $UvCmd python install $PythonVersion 2>&1
149          if ($LASTEXITCODE -eq 0) {
150              $pythonPath = & $UvCmd python find $PythonVersion 2>$null
151              if ($pythonPath) {
152                  $ver = & $pythonPath --version 2>$null
153                  Write-Success "Python installed: $ver"
154                  return $true
155              }
156          } else {
157              Write-Warn "uv python install output:"
158              Write-Host $uvOutput -ForegroundColor DarkGray
159          }
160      } catch {
161          Write-Warn "uv python install error: $_"
162      }
163  
164      # Fallback: check if ANY Python 3.10+ is already available on the system
165      Write-Info "Trying to find any existing Python 3.10+..."
166      foreach ($fallbackVer in @("3.12", "3.13", "3.10")) {
167          try {
168              $pythonPath = & $UvCmd python find $fallbackVer 2>$null
169              if ($pythonPath) {
170                  $ver = & $pythonPath --version 2>$null
171                  Write-Success "Found fallback: $ver"
172                  $script:PythonVersion = $fallbackVer
173                  return $true
174              }
175          } catch { }
176      }
177  
178      # Fallback: try system python
179      if (Get-Command python -ErrorAction SilentlyContinue) {
180          $sysVer = python --version 2>$null
181          if ($sysVer -match "3\.(1[0-9]|[1-9][0-9])") {
182              Write-Success "Using system Python: $sysVer"
183              return $true
184          }
185      }
186      
187      Write-Err "Failed to install Python $PythonVersion"
188      Write-Info "Install Python 3.11 manually, then re-run this script:"
189      Write-Info "  https://www.python.org/downloads/"
190      Write-Info "  Or: winget install Python.Python.3.11"
191      return $false
192  }
193  
194  function Test-Git {
195      Write-Info "Checking Git..."
196      
197      if (Get-Command git -ErrorAction SilentlyContinue) {
198          $version = git --version
199          Write-Success "Git found ($version)"
200          return $true
201      }
202      
203      Write-Err "Git not found"
204      Write-Info "Please install Git from:"
205      Write-Info "  https://git-scm.com/download/win"
206      return $false
207  }
208  
209  function Test-Node {
210      Write-Info "Checking Node.js (for browser tools)..."
211  
212      if (Get-Command node -ErrorAction SilentlyContinue) {
213          $version = node --version
214          Write-Success "Node.js $version found"
215          $script:HasNode = $true
216          return $true
217      }
218  
219      # Check our own managed install from a previous run
220      $managedNode = "$HermesHome\node\node.exe"
221      if (Test-Path $managedNode) {
222          $version = & $managedNode --version
223          $env:Path = "$HermesHome\node;$env:Path"
224          Write-Success "Node.js $version found (Hermes-managed)"
225          $script:HasNode = $true
226          return $true
227      }
228  
229      Write-Info "Node.js not found — installing Node.js $NodeVersion LTS..."
230  
231      # Try winget first (cleanest on modern Windows)
232      if (Get-Command winget -ErrorAction SilentlyContinue) {
233          Write-Info "Installing via winget..."
234          try {
235              winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
236              # Refresh PATH
237              $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
238              if (Get-Command node -ErrorAction SilentlyContinue) {
239                  $version = node --version
240                  Write-Success "Node.js $version installed via winget"
241                  $script:HasNode = $true
242                  return $true
243              }
244          } catch { }
245      }
246  
247      # Fallback: download binary zip to ~/.hermes/node/
248      Write-Info "Downloading Node.js $NodeVersion binary..."
249      try {
250          $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }
251          $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/"
252          $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
253          $zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value
254  
255          if ($zipName) {
256              $downloadUrl = "${indexUrl}${zipName}"
257              $tmpZip = "$env:TEMP\$zipName"
258              $tmpDir = "$env:TEMP\hermes-node-extract"
259  
260              Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
261              if (Test-Path $tmpDir) { Remove-Item -Recurse -Force $tmpDir }
262              Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force
263  
264              $extractedDir = Get-ChildItem $tmpDir -Directory | Select-Object -First 1
265              if ($extractedDir) {
266                  if (Test-Path "$HermesHome\node") { Remove-Item -Recurse -Force "$HermesHome\node" }
267                  Move-Item $extractedDir.FullName "$HermesHome\node"
268                  $env:Path = "$HermesHome\node;$env:Path"
269  
270                  $version = & "$HermesHome\node\node.exe" --version
271                  Write-Success "Node.js $version installed to ~/.hermes/node/"
272                  $script:HasNode = $true
273  
274                  Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
275                  Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
276                  return $true
277              }
278          }
279      } catch {
280          Write-Warn "Download failed: $_"
281      }
282  
283      Write-Warn "Could not auto-install Node.js"
284      Write-Info "Install manually: https://nodejs.org/en/download/"
285      $script:HasNode = $false
286      return $true
287  }
288  
289  function Install-SystemPackages {
290      $script:HasRipgrep = $false
291      $script:HasFfmpeg = $false
292      $needRipgrep = $false
293      $needFfmpeg = $false
294  
295      Write-Info "Checking ripgrep (fast file search)..."
296      if (Get-Command rg -ErrorAction SilentlyContinue) {
297          $version = rg --version | Select-Object -First 1
298          Write-Success "$version found"
299          $script:HasRipgrep = $true
300      } else {
301          $needRipgrep = $true
302      }
303  
304      Write-Info "Checking ffmpeg (TTS voice messages)..."
305      if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
306          Write-Success "ffmpeg found"
307          $script:HasFfmpeg = $true
308      } else {
309          $needFfmpeg = $true
310      }
311  
312      if (-not $needRipgrep -and -not $needFfmpeg) { return }
313  
314      # Build description and package lists for each package manager
315      $descParts = @()
316      $wingetPkgs = @()
317      $chocoPkgs = @()
318      $scoopPkgs = @()
319  
320      if ($needRipgrep) {
321          $descParts += "ripgrep for faster file search"
322          $wingetPkgs += "BurntSushi.ripgrep.MSVC"
323          $chocoPkgs += "ripgrep"
324          $scoopPkgs += "ripgrep"
325      }
326      if ($needFfmpeg) {
327          $descParts += "ffmpeg for TTS voice messages"
328          $wingetPkgs += "Gyan.FFmpeg"
329          $chocoPkgs += "ffmpeg"
330          $scoopPkgs += "ffmpeg"
331      }
332  
333      $description = $descParts -join " and "
334      $hasWinget = Get-Command winget -ErrorAction SilentlyContinue
335      $hasChoco = Get-Command choco -ErrorAction SilentlyContinue
336      $hasScoop = Get-Command scoop -ErrorAction SilentlyContinue
337  
338      # Try winget first (most common on modern Windows)
339      if ($hasWinget) {
340          Write-Info "Installing $description via winget..."
341          foreach ($pkg in $wingetPkgs) {
342              try {
343                  winget install $pkg --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
344              } catch { }
345          }
346          # Refresh PATH and recheck
347          $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
348          if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
349              Write-Success "ripgrep installed"
350              $script:HasRipgrep = $true
351              $needRipgrep = $false
352          }
353          if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
354              Write-Success "ffmpeg installed"
355              $script:HasFfmpeg = $true
356              $needFfmpeg = $false
357          }
358          if (-not $needRipgrep -and -not $needFfmpeg) { return }
359      }
360  
361      # Fallback: choco
362      if ($hasChoco -and ($needRipgrep -or $needFfmpeg)) {
363          Write-Info "Trying Chocolatey..."
364          foreach ($pkg in $chocoPkgs) {
365              try { choco install $pkg -y 2>&1 | Out-Null } catch { }
366          }
367          if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
368              Write-Success "ripgrep installed via chocolatey"
369              $script:HasRipgrep = $true
370              $needRipgrep = $false
371          }
372          if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
373              Write-Success "ffmpeg installed via chocolatey"
374              $script:HasFfmpeg = $true
375              $needFfmpeg = $false
376          }
377      }
378  
379      # Fallback: scoop
380      if ($hasScoop -and ($needRipgrep -or $needFfmpeg)) {
381          Write-Info "Trying Scoop..."
382          foreach ($pkg in $scoopPkgs) {
383              try { scoop install $pkg 2>&1 | Out-Null } catch { }
384          }
385          if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
386              Write-Success "ripgrep installed via scoop"
387              $script:HasRipgrep = $true
388              $needRipgrep = $false
389          }
390          if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
391              Write-Success "ffmpeg installed via scoop"
392              $script:HasFfmpeg = $true
393              $needFfmpeg = $false
394          }
395      }
396  
397      # Show manual instructions for anything still missing
398      if ($needRipgrep) {
399          Write-Warn "ripgrep not installed (file search will use findstr fallback)"
400          Write-Info "  winget install BurntSushi.ripgrep.MSVC"
401      }
402      if ($needFfmpeg) {
403          Write-Warn "ffmpeg not installed (TTS voice messages will be limited)"
404          Write-Info "  winget install Gyan.FFmpeg"
405      }
406  }
407  
408  # ============================================================================
409  # Installation
410  # ============================================================================
411  
412  function Install-Repository {
413      Write-Info "Installing to $InstallDir..."
414      
415      if (Test-Path $InstallDir) {
416          if (Test-Path "$InstallDir\.git") {
417              Write-Info "Existing installation found, updating..."
418              Push-Location $InstallDir
419              git -c windows.appendAtomically=false fetch origin
420              git -c windows.appendAtomically=false checkout $Branch
421              git -c windows.appendAtomically=false pull origin $Branch
422              Pop-Location
423          } else {
424              Write-Err "Directory exists but is not a git repository: $InstallDir"
425              Write-Info "Remove it or choose a different directory with -InstallDir"
426              throw "Directory exists but is not a git repository: $InstallDir"
427          }
428      } else {
429          $cloneSuccess = $false
430  
431          # Fix Windows git "copy-fd: write returned: Invalid argument" error.
432          # Git for Windows can fail on atomic file operations (hook templates,
433          # config lock files) due to antivirus, OneDrive, or NTFS filter drivers.
434          # The -c flag injects config before any file I/O occurs.
435          Write-Info "Configuring git for Windows compatibility..."
436          $env:GIT_CONFIG_COUNT = "1"
437          $env:GIT_CONFIG_KEY_0 = "windows.appendAtomically"
438          $env:GIT_CONFIG_VALUE_0 = "false"
439          git config --global windows.appendAtomically false 2>$null
440  
441          # Try SSH first, then HTTPS, with -c flag for atomic write fix
442          Write-Info "Trying SSH clone..."
443          $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
444          try {
445              git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlSsh $InstallDir
446              if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
447          } catch { }
448          $env:GIT_SSH_COMMAND = $null
449          
450          if (-not $cloneSuccess) {
451              if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
452              Write-Info "SSH failed, trying HTTPS..."
453              try {
454                  git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlHttps $InstallDir
455                  if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
456              } catch { }
457          }
458  
459          # Fallback: download ZIP archive (bypasses git file I/O issues entirely)
460          if (-not $cloneSuccess) {
461              if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
462              Write-Warn "Git clone failed — downloading ZIP archive instead..."
463              try {
464                  $zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
465                  $zipPath = "$env:TEMP\hermes-agent-$Branch.zip"
466                  $extractPath = "$env:TEMP\hermes-agent-extract"
467                  
468                  Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
469                  if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath }
470                  Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
471                  
472                  # GitHub ZIPs extract to repo-branch/ subdirectory
473                  $extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1
474                  if ($extractedDir) {
475                      New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null
476                      Move-Item $extractedDir.FullName $InstallDir -Force
477                      Write-Success "Downloaded and extracted"
478                      
479                      # Initialize git repo so updates work later
480                      Push-Location $InstallDir
481                      git -c windows.appendAtomically=false init 2>$null
482                      git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
483                      git remote add origin $RepoUrlHttps 2>$null
484                      Pop-Location
485                      Write-Success "Git repo initialized for future updates"
486                      
487                      $cloneSuccess = $true
488                  }
489                  
490                  # Cleanup temp files
491                  Remove-Item -Force $zipPath -ErrorAction SilentlyContinue
492                  Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue
493              } catch {
494                  Write-Err "ZIP download also failed: $_"
495              }
496          }
497  
498          if (-not $cloneSuccess) {
499              throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)"
500          }
501      }
502      
503      # Set per-repo config (harmless if it fails)
504      Push-Location $InstallDir
505      git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
506  
507      # Ensure submodules are initialized and updated
508      Write-Info "Initializing submodules..."
509      git -c windows.appendAtomically=false submodule update --init --recursive 2>$null
510      if ($LASTEXITCODE -ne 0) {
511          Write-Warn "Submodule init failed (terminal/RL tools may need manual setup)"
512      } else {
513          Write-Success "Submodules ready"
514      }
515      Pop-Location
516      
517      Write-Success "Repository ready"
518  }
519  
520  function Install-Venv {
521      if ($NoVenv) {
522          Write-Info "Skipping virtual environment (-NoVenv)"
523          return
524      }
525      
526      Write-Info "Creating virtual environment with Python $PythonVersion..."
527      
528      Push-Location $InstallDir
529      
530      if (Test-Path "venv") {
531          Write-Info "Virtual environment already exists, recreating..."
532          Remove-Item -Recurse -Force "venv"
533      }
534      
535      # uv creates the venv and pins the Python version in one step
536      & $UvCmd venv venv --python $PythonVersion
537      
538      Pop-Location
539      
540      Write-Success "Virtual environment ready (Python $PythonVersion)"
541  }
542  
543  function Install-Dependencies {
544      Write-Info "Installing dependencies..."
545      
546      Push-Location $InstallDir
547      
548      if (-not $NoVenv) {
549          # Tell uv to install into our venv (no activation needed)
550          $env:VIRTUAL_ENV = "$InstallDir\venv"
551      }
552      
553      # Install main package with all extras
554      try {
555          & $UvCmd pip install -e ".[all]" 2>&1 | Out-Null
556      } catch {
557          & $UvCmd pip install -e "." | Out-Null
558      }
559      
560      Write-Success "Main package installed"
561      
562      # Install optional submodules
563      Write-Info "Installing tinker-atropos (RL training backend)..."
564      if (Test-Path "tinker-atropos\pyproject.toml") {
565          try {
566              & $UvCmd pip install -e ".\tinker-atropos" 2>&1 | Out-Null
567              Write-Success "tinker-atropos installed"
568          } catch {
569              Write-Warn "tinker-atropos install failed (RL tools may not work)"
570          }
571      } else {
572          Write-Warn "tinker-atropos not found (run: git submodule update --init)"
573      }
574      
575      Pop-Location
576      
577      Write-Success "All dependencies installed"
578  }
579  
580  function Set-PathVariable {
581      Write-Info "Setting up hermes command..."
582      
583      if ($NoVenv) {
584          $hermesBin = "$InstallDir"
585      } else {
586          $hermesBin = "$InstallDir\venv\Scripts"
587      }
588      
589      # Add the venv Scripts dir to user PATH so hermes is globally available
590      # On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in
591      $currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
592      
593      if ($currentPath -notlike "*$hermesBin*") {
594          [Environment]::SetEnvironmentVariable(
595              "Path",
596              "$hermesBin;$currentPath",
597              "User"
598          )
599          Write-Success "Added to user PATH: $hermesBin"
600      } else {
601          Write-Info "PATH already configured"
602      }
603      
604      # Set HERMES_HOME so the Python code finds config/data in the right place.
605      # Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead
606      # of the Unix default ~/.hermes
607      $currentHermesHome = [Environment]::GetEnvironmentVariable("HERMES_HOME", "User")
608      if (-not $currentHermesHome -or $currentHermesHome -ne $HermesHome) {
609          [Environment]::SetEnvironmentVariable("HERMES_HOME", $HermesHome, "User")
610          Write-Success "Set HERMES_HOME=$HermesHome"
611      }
612      $env:HERMES_HOME = $HermesHome
613      
614      # Update current session
615      $env:Path = "$hermesBin;$env:Path"
616      
617      Write-Success "hermes command ready"
618  }
619  
620  function Copy-ConfigTemplates {
621      Write-Info "Setting up configuration files..."
622      
623      # Create ~/.hermes directory structure
624      New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
625      New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
626      New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
627      New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null
628      New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null
629      New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null
630      New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
631      New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
632      New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
633  
634      
635      # Create .env
636      $envPath = "$HermesHome\.env"
637      if (-not (Test-Path $envPath)) {
638          $examplePath = "$InstallDir\.env.example"
639          if (Test-Path $examplePath) {
640              Copy-Item $examplePath $envPath
641              Write-Success "Created ~/.hermes/.env from template"
642          } else {
643              New-Item -ItemType File -Force -Path $envPath | Out-Null
644              Write-Success "Created ~/.hermes/.env"
645          }
646      } else {
647          Write-Info "~/.hermes/.env already exists, keeping it"
648      }
649      
650      # Create config.yaml
651      $configPath = "$HermesHome\config.yaml"
652      if (-not (Test-Path $configPath)) {
653          $examplePath = "$InstallDir\cli-config.yaml.example"
654          if (Test-Path $examplePath) {
655              Copy-Item $examplePath $configPath
656              Write-Success "Created ~/.hermes/config.yaml from template"
657          }
658      } else {
659          Write-Info "~/.hermes/config.yaml already exists, keeping it"
660      }
661      
662      # Create SOUL.md if it doesn't exist (global persona file)
663      $soulPath = "$HermesHome\SOUL.md"
664      if (-not (Test-Path $soulPath)) {
665          @"
666  # Hermes Agent Persona
667  
668  <!-- 
669  This file defines the agent's personality and tone.
670  The agent will embody whatever you write here.
671  Edit this to customize how Hermes communicates with you.
672  
673  Examples:
674    - "You are a warm, playful assistant who uses kaomoji occasionally."
675    - "You are a concise technical expert. No fluff, just facts."
676    - "You speak like a friendly coworker who happens to know everything."
677  
678  This file is loaded fresh each message -- no restart needed.
679  Delete the contents (or this file) to use the default personality.
680  -->
681  "@ | Set-Content -Path $soulPath -Encoding UTF8
682          Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
683      }
684      
685      Write-Success "Configuration directory ready: ~/.hermes/"
686      
687      # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
688      Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..."
689      $pythonExe = "$InstallDir\venv\Scripts\python.exe"
690      if (Test-Path $pythonExe) {
691          try {
692              & $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
693              Write-Success "Skills synced to ~/.hermes/skills/"
694          } catch {
695              # Fallback: simple directory copy
696              $bundledSkills = "$InstallDir\skills"
697              $userSkills = "$HermesHome\skills"
698              if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
699                  Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
700                  Write-Success "Skills copied to ~/.hermes/skills/"
701              }
702          }
703      }
704  }
705  
706  function Install-NodeDeps {
707      if (-not $HasNode) {
708          Write-Info "Skipping Node.js dependencies (Node not installed)"
709          return
710      }
711      
712      Push-Location $InstallDir
713      
714      if (Test-Path "package.json") {
715          Write-Info "Installing Node.js dependencies (browser tools)..."
716          try {
717              npm install --silent 2>&1 | Out-Null
718              Write-Success "Node.js dependencies installed"
719          } catch {
720              Write-Warn "npm install failed (browser tools may not work)"
721          }
722      }
723      
724      # Install TUI dependencies
725      $tuiDir = "$InstallDir\ui-tui"
726      if (Test-Path "$tuiDir\package.json") {
727          Write-Info "Installing TUI dependencies..."
728          Push-Location $tuiDir
729          try {
730              npm install --silent 2>&1 | Out-Null
731              Write-Success "TUI dependencies installed"
732          } catch {
733              Write-Warn "TUI npm install failed (hermes --tui may not work)"
734          }
735          Pop-Location
736      }
737  
738  
739      
740      Pop-Location
741  }
742  
743  function Invoke-SetupWizard {
744      if ($SkipSetup) {
745          Write-Info "Skipping setup wizard (-SkipSetup)"
746          return
747      }
748      
749      Write-Host ""
750      Write-Info "Starting setup wizard..."
751      Write-Host ""
752      
753      Push-Location $InstallDir
754      
755      # Run hermes setup using the venv Python directly (no activation needed)
756      if (-not $NoVenv) {
757          & ".\venv\Scripts\python.exe" -m hermes_cli.main setup
758      } else {
759          python -m hermes_cli.main setup
760      }
761      
762      Pop-Location
763  }
764  
765  function Start-GatewayIfConfigured {
766      $envPath = "$HermesHome\.env"
767      if (-not (Test-Path $envPath)) { return }
768  
769      $hasMessaging = $false
770      $content = Get-Content $envPath -ErrorAction SilentlyContinue
771      foreach ($var in @("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WHATSAPP_ENABLED")) {
772          $match = $content | Where-Object { $_ -match "^${var}=.+" -and $_ -notmatch "your-token-here" }
773          if ($match) { $hasMessaging = $true; break }
774      }
775  
776      if (-not $hasMessaging) { return }
777  
778      $hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
779      if (-not (Test-Path $hermesCmd)) {
780          $hermesCmd = "hermes"
781      }
782  
783      # If WhatsApp is enabled but not yet paired, run foreground for QR scan
784      $whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" }
785      $whatsappSession = "$HermesHome\whatsapp\session\creds.json"
786      if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) {
787          Write-Host ""
788          Write-Info "WhatsApp is enabled but not yet paired."
789          Write-Info "Running 'hermes whatsapp' to pair via QR code..."
790          Write-Host ""
791          $response = Read-Host "Pair WhatsApp now? [Y/n]"
792          if ($response -eq "" -or $response -match "^[Yy]") {
793              try {
794                  & $hermesCmd whatsapp
795              } catch {
796                  # Expected after pairing completes
797              }
798          }
799      }
800  
801      Write-Host ""
802      Write-Info "Messaging platform token detected!"
803      Write-Info "The gateway handles messaging platforms and cron job execution."
804      Write-Host ""
805      $response = Read-Host "Would you like to start the gateway now? [Y/n]"
806  
807      if ($response -eq "" -or $response -match "^[Yy]") {
808          Write-Info "Starting gateway in background..."
809          try {
810              $logFile = "$HermesHome\logs\gateway.log"
811              Start-Process -FilePath $hermesCmd -ArgumentList "gateway" `
812                  -RedirectStandardOutput $logFile `
813                  -RedirectStandardError "$HermesHome\logs\gateway-error.log" `
814                  -WindowStyle Hidden
815              Write-Success "Gateway started! Your bot is now online."
816              Write-Info "Logs: $logFile"
817              Write-Info "To stop: close the gateway process from Task Manager"
818          } catch {
819              Write-Warn "Failed to start gateway. Run manually: hermes gateway"
820          }
821      } else {
822          Write-Info "Skipped. Start the gateway later with: hermes gateway"
823      }
824  }
825  
826  function Write-Completion {
827      Write-Host ""
828      Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Green
829      Write-Host "│              ✓ Installation Complete!                   │" -ForegroundColor Green
830      Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Green
831      Write-Host ""
832      
833      # Show file locations
834      Write-Host "📁 Your files:" -ForegroundColor Cyan
835      Write-Host ""
836      Write-Host "   Config:    " -NoNewline -ForegroundColor Yellow
837      Write-Host "$HermesHome\config.yaml"
838      Write-Host "   API Keys:  " -NoNewline -ForegroundColor Yellow
839      Write-Host "$HermesHome\.env"
840      Write-Host "   Data:      " -NoNewline -ForegroundColor Yellow
841      Write-Host "$HermesHome\cron\, sessions\, logs\"
842      Write-Host "   Code:      " -NoNewline -ForegroundColor Yellow
843      Write-Host "$HermesHome\hermes-agent\"
844      Write-Host ""
845      
846      Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan
847      Write-Host ""
848      Write-Host "🚀 Commands:" -ForegroundColor Cyan
849      Write-Host ""
850      Write-Host "   hermes              " -NoNewline -ForegroundColor Green
851      Write-Host "Start chatting"
852      Write-Host "   hermes setup        " -NoNewline -ForegroundColor Green
853      Write-Host "Configure API keys & settings"
854      Write-Host "   hermes config       " -NoNewline -ForegroundColor Green
855      Write-Host "View/edit configuration"
856      Write-Host "   hermes config edit  " -NoNewline -ForegroundColor Green
857      Write-Host "Open config in editor"
858      Write-Host "   hermes gateway      " -NoNewline -ForegroundColor Green
859      Write-Host "Start messaging gateway (Telegram, Discord, etc.)"
860      Write-Host "   hermes update       " -NoNewline -ForegroundColor Green
861      Write-Host "Update to latest version"
862      Write-Host ""
863      
864      Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan
865      Write-Host ""
866      Write-Host "⚡ Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow
867      Write-Host ""
868      
869      if (-not $HasNode) {
870          Write-Host "Note: Node.js could not be installed automatically." -ForegroundColor Yellow
871          Write-Host "Browser tools need Node.js. Install manually:" -ForegroundColor Yellow
872          Write-Host "  https://nodejs.org/en/download/" -ForegroundColor Yellow
873          Write-Host ""
874      }
875      
876      if (-not $HasRipgrep) {
877          Write-Host "Note: ripgrep (rg) was not installed. For faster file search:" -ForegroundColor Yellow
878          Write-Host "  winget install BurntSushi.ripgrep.MSVC" -ForegroundColor Yellow
879          Write-Host ""
880      }
881  }
882  
883  # ============================================================================
884  # Main
885  # ============================================================================
886  
887  function Main {
888      Write-Banner
889      
890      if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" }
891      if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" }
892      if (-not (Test-Git)) { throw "Git not found — install from https://git-scm.com/download/win" }
893      Test-Node              # Auto-installs if missing
894      Install-SystemPackages  # ripgrep + ffmpeg in one step
895      
896      Install-Repository
897      Install-Venv
898      Install-Dependencies
899      Install-NodeDeps
900      Set-PathVariable
901      Copy-ConfigTemplates
902      Invoke-SetupWizard
903      Start-GatewayIfConfigured
904      
905      Write-Completion
906  }
907  
908  # Wrap in try/catch so errors don't kill the terminal when run via:
909  #   irm https://...install.ps1 | iex
910  # (exit/throw inside iex kills the entire PowerShell session)
911  try {
912      Main
913  } catch {
914      Write-Host ""
915      Write-Err "Installation failed: $_"
916      Write-Host ""
917      Write-Info "If the error is unclear, try downloading and running the script directly:"
918      Write-Host "  Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow
919      Write-Host "  .\install.ps1" -ForegroundColor Yellow
920      Write-Host ""
921  }