Git for Windows can fail during clone when copying hook template files
from the system templates directory. The error:
fatal: cannot copy '.../templates/hooks/fsmonitor-watchman.sample'
to '.git/hooks/...': Invalid argument
The script already set windows.appendAtomically=false but only AFTER
clone, which is too late since clone itself triggers the error.
Fix:
- Set git config --global windows.appendAtomically false BEFORE clone
- Add a third fallback: clone with --template='' to skip hook template
copying entirely (they're optional .sample files)
895 lines
33 KiB
PowerShell
895 lines
33 KiB
PowerShell
# ============================================================================
|
|
# Hermes Agent Installer for Windows
|
|
# ============================================================================
|
|
# Installation script for Windows (PowerShell).
|
|
# Uses uv for fast Python provisioning and package management.
|
|
#
|
|
# Usage:
|
|
# irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
|
|
#
|
|
# Or download and run with options:
|
|
# .\install.ps1 -NoVenv -SkipSetup
|
|
#
|
|
# ============================================================================
|
|
|
|
param(
|
|
[switch]$NoVenv,
|
|
[switch]$SkipSetup,
|
|
[string]$Branch = "main",
|
|
[string]$HermesHome = "$env:USERPROFILE\.hermes",
|
|
[string]$InstallDir = "$env:USERPROFILE\.hermes\hermes-agent"
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
|
|
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
|
|
$PythonVersion = "3.11"
|
|
$NodeVersion = "22"
|
|
|
|
# ============================================================================
|
|
# Helper functions
|
|
# ============================================================================
|
|
|
|
function Write-Banner {
|
|
Write-Host ""
|
|
Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Magenta
|
|
Write-Host "│ ⚕ Hermes Agent Installer │" -ForegroundColor Magenta
|
|
Write-Host "├─────────────────────────────────────────────────────────┤" -ForegroundColor Magenta
|
|
Write-Host "│ An open source AI agent by Nous Research. │" -ForegroundColor Magenta
|
|
Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Magenta
|
|
Write-Host ""
|
|
}
|
|
|
|
function Write-Info {
|
|
param([string]$Message)
|
|
Write-Host "→ $Message" -ForegroundColor Cyan
|
|
}
|
|
|
|
function Write-Success {
|
|
param([string]$Message)
|
|
Write-Host "✓ $Message" -ForegroundColor Green
|
|
}
|
|
|
|
function Write-Warn {
|
|
param([string]$Message)
|
|
Write-Host "⚠ $Message" -ForegroundColor Yellow
|
|
}
|
|
|
|
function Write-Err {
|
|
param([string]$Message)
|
|
Write-Host "✗ $Message" -ForegroundColor Red
|
|
}
|
|
|
|
# ============================================================================
|
|
# Dependency checks
|
|
# ============================================================================
|
|
|
|
function Install-Uv {
|
|
Write-Info "Checking for uv package manager..."
|
|
|
|
# Check if uv is already available
|
|
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
|
$version = uv --version
|
|
$script:UvCmd = "uv"
|
|
Write-Success "uv found ($version)"
|
|
return $true
|
|
}
|
|
|
|
# Check common install locations
|
|
$uvPaths = @(
|
|
"$env:USERPROFILE\.local\bin\uv.exe",
|
|
"$env:USERPROFILE\.cargo\bin\uv.exe"
|
|
)
|
|
foreach ($uvPath in $uvPaths) {
|
|
if (Test-Path $uvPath) {
|
|
$script:UvCmd = $uvPath
|
|
$version = & $uvPath --version
|
|
Write-Success "uv found at $uvPath ($version)"
|
|
return $true
|
|
}
|
|
}
|
|
|
|
# Install uv
|
|
Write-Info "Installing uv (fast Python package manager)..."
|
|
try {
|
|
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
|
|
|
# Find the installed binary
|
|
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
|
|
if (-not (Test-Path $uvExe)) {
|
|
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
|
|
}
|
|
if (-not (Test-Path $uvExe)) {
|
|
# Refresh PATH and try again
|
|
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
|
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
|
$uvExe = (Get-Command uv).Source
|
|
}
|
|
}
|
|
|
|
if (Test-Path $uvExe) {
|
|
$script:UvCmd = $uvExe
|
|
$version = & $uvExe --version
|
|
Write-Success "uv installed ($version)"
|
|
return $true
|
|
}
|
|
|
|
Write-Err "uv installed but not found on PATH"
|
|
Write-Info "Try restarting your terminal and re-running"
|
|
return $false
|
|
} catch {
|
|
Write-Err "Failed to install uv"
|
|
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Test-Python {
|
|
Write-Info "Checking Python $PythonVersion..."
|
|
|
|
# Let uv find or install Python
|
|
try {
|
|
$pythonPath = & $UvCmd python find $PythonVersion 2>$null
|
|
if ($pythonPath) {
|
|
$ver = & $pythonPath --version 2>$null
|
|
Write-Success "Python found: $ver"
|
|
return $true
|
|
}
|
|
} catch { }
|
|
|
|
# Python not found — use uv to install it (no admin needed!)
|
|
Write-Info "Python $PythonVersion not found, installing via uv..."
|
|
try {
|
|
$uvOutput = & $UvCmd python install $PythonVersion 2>&1
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$pythonPath = & $UvCmd python find $PythonVersion 2>$null
|
|
if ($pythonPath) {
|
|
$ver = & $pythonPath --version 2>$null
|
|
Write-Success "Python installed: $ver"
|
|
return $true
|
|
}
|
|
} else {
|
|
Write-Warn "uv python install output:"
|
|
Write-Host $uvOutput -ForegroundColor DarkGray
|
|
}
|
|
} catch {
|
|
Write-Warn "uv python install error: $_"
|
|
}
|
|
|
|
# Fallback: check if ANY Python 3.10+ is already available on the system
|
|
Write-Info "Trying to find any existing Python 3.10+..."
|
|
foreach ($fallbackVer in @("3.12", "3.13", "3.10")) {
|
|
try {
|
|
$pythonPath = & $UvCmd python find $fallbackVer 2>$null
|
|
if ($pythonPath) {
|
|
$ver = & $pythonPath --version 2>$null
|
|
Write-Success "Found fallback: $ver"
|
|
$script:PythonVersion = $fallbackVer
|
|
return $true
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# Fallback: try system python
|
|
if (Get-Command python -ErrorAction SilentlyContinue) {
|
|
$sysVer = python --version 2>$null
|
|
if ($sysVer -match "3\.(1[0-9]|[1-9][0-9])") {
|
|
Write-Success "Using system Python: $sysVer"
|
|
return $true
|
|
}
|
|
}
|
|
|
|
Write-Err "Failed to install Python $PythonVersion"
|
|
Write-Info "Install Python 3.11 manually, then re-run this script:"
|
|
Write-Info " https://www.python.org/downloads/"
|
|
Write-Info " Or: winget install Python.Python.3.11"
|
|
return $false
|
|
}
|
|
|
|
function Test-Git {
|
|
Write-Info "Checking Git..."
|
|
|
|
if (Get-Command git -ErrorAction SilentlyContinue) {
|
|
$version = git --version
|
|
Write-Success "Git found ($version)"
|
|
return $true
|
|
}
|
|
|
|
Write-Err "Git not found"
|
|
Write-Info "Please install Git from:"
|
|
Write-Info " https://git-scm.com/download/win"
|
|
return $false
|
|
}
|
|
|
|
function Test-Node {
|
|
Write-Info "Checking Node.js (for browser tools)..."
|
|
|
|
if (Get-Command node -ErrorAction SilentlyContinue) {
|
|
$version = node --version
|
|
Write-Success "Node.js $version found"
|
|
$script:HasNode = $true
|
|
return $true
|
|
}
|
|
|
|
# Check our own managed install from a previous run
|
|
$managedNode = "$HermesHome\node\node.exe"
|
|
if (Test-Path $managedNode) {
|
|
$version = & $managedNode --version
|
|
$env:Path = "$HermesHome\node;$env:Path"
|
|
Write-Success "Node.js $version found (Hermes-managed)"
|
|
$script:HasNode = $true
|
|
return $true
|
|
}
|
|
|
|
Write-Info "Node.js not found — installing Node.js $NodeVersion LTS..."
|
|
|
|
# Try winget first (cleanest on modern Windows)
|
|
if (Get-Command winget -ErrorAction SilentlyContinue) {
|
|
Write-Info "Installing via winget..."
|
|
try {
|
|
winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
|
|
# Refresh PATH
|
|
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
|
if (Get-Command node -ErrorAction SilentlyContinue) {
|
|
$version = node --version
|
|
Write-Success "Node.js $version installed via winget"
|
|
$script:HasNode = $true
|
|
return $true
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# Fallback: download binary zip to ~/.hermes/node/
|
|
Write-Info "Downloading Node.js $NodeVersion binary..."
|
|
try {
|
|
$arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }
|
|
$indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/"
|
|
$indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
|
|
$zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value
|
|
|
|
if ($zipName) {
|
|
$downloadUrl = "${indexUrl}${zipName}"
|
|
$tmpZip = "$env:TEMP\$zipName"
|
|
$tmpDir = "$env:TEMP\hermes-node-extract"
|
|
|
|
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
|
|
if (Test-Path $tmpDir) { Remove-Item -Recurse -Force $tmpDir }
|
|
Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force
|
|
|
|
$extractedDir = Get-ChildItem $tmpDir -Directory | Select-Object -First 1
|
|
if ($extractedDir) {
|
|
if (Test-Path "$HermesHome\node") { Remove-Item -Recurse -Force "$HermesHome\node" }
|
|
Move-Item $extractedDir.FullName "$HermesHome\node"
|
|
$env:Path = "$HermesHome\node;$env:Path"
|
|
|
|
$version = & "$HermesHome\node\node.exe" --version
|
|
Write-Success "Node.js $version installed to ~/.hermes/node/"
|
|
$script:HasNode = $true
|
|
|
|
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
|
|
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
|
|
return $true
|
|
}
|
|
}
|
|
} catch {
|
|
Write-Warn "Download failed: $_"
|
|
}
|
|
|
|
Write-Warn "Could not auto-install Node.js"
|
|
Write-Info "Install manually: https://nodejs.org/en/download/"
|
|
$script:HasNode = $false
|
|
return $true
|
|
}
|
|
|
|
function Install-SystemPackages {
|
|
$script:HasRipgrep = $false
|
|
$script:HasFfmpeg = $false
|
|
$needRipgrep = $false
|
|
$needFfmpeg = $false
|
|
|
|
Write-Info "Checking ripgrep (fast file search)..."
|
|
if (Get-Command rg -ErrorAction SilentlyContinue) {
|
|
$version = rg --version | Select-Object -First 1
|
|
Write-Success "$version found"
|
|
$script:HasRipgrep = $true
|
|
} else {
|
|
$needRipgrep = $true
|
|
}
|
|
|
|
Write-Info "Checking ffmpeg (TTS voice messages)..."
|
|
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
|
|
Write-Success "ffmpeg found"
|
|
$script:HasFfmpeg = $true
|
|
} else {
|
|
$needFfmpeg = $true
|
|
}
|
|
|
|
if (-not $needRipgrep -and -not $needFfmpeg) { return }
|
|
|
|
# Build description and package lists for each package manager
|
|
$descParts = @()
|
|
$wingetPkgs = @()
|
|
$chocoPkgs = @()
|
|
$scoopPkgs = @()
|
|
|
|
if ($needRipgrep) {
|
|
$descParts += "ripgrep for faster file search"
|
|
$wingetPkgs += "BurntSushi.ripgrep.MSVC"
|
|
$chocoPkgs += "ripgrep"
|
|
$scoopPkgs += "ripgrep"
|
|
}
|
|
if ($needFfmpeg) {
|
|
$descParts += "ffmpeg for TTS voice messages"
|
|
$wingetPkgs += "Gyan.FFmpeg"
|
|
$chocoPkgs += "ffmpeg"
|
|
$scoopPkgs += "ffmpeg"
|
|
}
|
|
|
|
$description = $descParts -join " and "
|
|
$hasWinget = Get-Command winget -ErrorAction SilentlyContinue
|
|
$hasChoco = Get-Command choco -ErrorAction SilentlyContinue
|
|
$hasScoop = Get-Command scoop -ErrorAction SilentlyContinue
|
|
|
|
# Try winget first (most common on modern Windows)
|
|
if ($hasWinget) {
|
|
Write-Info "Installing $description via winget..."
|
|
foreach ($pkg in $wingetPkgs) {
|
|
try {
|
|
winget install $pkg --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
|
|
} catch { }
|
|
}
|
|
# Refresh PATH and recheck
|
|
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
|
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
|
Write-Success "ripgrep installed"
|
|
$script:HasRipgrep = $true
|
|
$needRipgrep = $false
|
|
}
|
|
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
|
Write-Success "ffmpeg installed"
|
|
$script:HasFfmpeg = $true
|
|
$needFfmpeg = $false
|
|
}
|
|
if (-not $needRipgrep -and -not $needFfmpeg) { return }
|
|
}
|
|
|
|
# Fallback: choco
|
|
if ($hasChoco -and ($needRipgrep -or $needFfmpeg)) {
|
|
Write-Info "Trying Chocolatey..."
|
|
foreach ($pkg in $chocoPkgs) {
|
|
try { choco install $pkg -y 2>&1 | Out-Null } catch { }
|
|
}
|
|
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
|
Write-Success "ripgrep installed via chocolatey"
|
|
$script:HasRipgrep = $true
|
|
$needRipgrep = $false
|
|
}
|
|
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
|
Write-Success "ffmpeg installed via chocolatey"
|
|
$script:HasFfmpeg = $true
|
|
$needFfmpeg = $false
|
|
}
|
|
}
|
|
|
|
# Fallback: scoop
|
|
if ($hasScoop -and ($needRipgrep -or $needFfmpeg)) {
|
|
Write-Info "Trying Scoop..."
|
|
foreach ($pkg in $scoopPkgs) {
|
|
try { scoop install $pkg 2>&1 | Out-Null } catch { }
|
|
}
|
|
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
|
Write-Success "ripgrep installed via scoop"
|
|
$script:HasRipgrep = $true
|
|
$needRipgrep = $false
|
|
}
|
|
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
|
Write-Success "ffmpeg installed via scoop"
|
|
$script:HasFfmpeg = $true
|
|
$needFfmpeg = $false
|
|
}
|
|
}
|
|
|
|
# Show manual instructions for anything still missing
|
|
if ($needRipgrep) {
|
|
Write-Warn "ripgrep not installed (file search will use findstr fallback)"
|
|
Write-Info " winget install BurntSushi.ripgrep.MSVC"
|
|
}
|
|
if ($needFfmpeg) {
|
|
Write-Warn "ffmpeg not installed (TTS voice messages will be limited)"
|
|
Write-Info " winget install Gyan.FFmpeg"
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# Installation
|
|
# ============================================================================
|
|
|
|
function Install-Repository {
|
|
Write-Info "Installing to $InstallDir..."
|
|
|
|
if (Test-Path $InstallDir) {
|
|
if (Test-Path "$InstallDir\.git") {
|
|
Write-Info "Existing installation found, updating..."
|
|
Push-Location $InstallDir
|
|
git fetch origin
|
|
git checkout $Branch
|
|
git pull origin $Branch
|
|
Pop-Location
|
|
} else {
|
|
Write-Err "Directory exists but is not a git repository: $InstallDir"
|
|
Write-Info "Remove it or choose a different directory with -InstallDir"
|
|
throw "Directory exists but is not a git repository: $InstallDir"
|
|
}
|
|
} else {
|
|
# Fix Windows git "copy-fd: write returned: Invalid argument" error.
|
|
# Must be set BEFORE clone, otherwise git fails copying hook templates.
|
|
# Also fixes "unable to write loose object file" for later operations.
|
|
Write-Info "Configuring git for Windows compatibility..."
|
|
git config --global windows.appendAtomically false
|
|
|
|
# Try SSH first (for private repo access), fall back to HTTPS.
|
|
# GIT_SSH_COMMAND with BatchMode=yes prevents SSH from hanging
|
|
# when no key is configured (fails immediately instead of prompting).
|
|
#
|
|
# IMPORTANT: Do NOT use 2>&1 on git commands in PowerShell.
|
|
# With $ErrorActionPreference = "Stop", PowerShell wraps captured
|
|
# stderr lines in ErrorRecord objects, turning git's normal progress
|
|
# messages ("Cloning into ...") into terminating NativeCommandErrors.
|
|
# Let stderr flow to the console naturally (like OpenClaw does).
|
|
Write-Info "Trying SSH clone..."
|
|
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
|
|
try {
|
|
git clone --branch $Branch --recurse-submodules $RepoUrlSsh $InstallDir
|
|
$sshExitCode = $LASTEXITCODE
|
|
} catch {
|
|
$sshExitCode = 1
|
|
}
|
|
$env:GIT_SSH_COMMAND = $null
|
|
|
|
if ($sshExitCode -eq 0) {
|
|
Write-Success "Cloned via SSH"
|
|
} else {
|
|
# Clean up partial SSH clone before retrying
|
|
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
|
Write-Info "SSH failed, trying HTTPS..."
|
|
git clone --branch $Branch --recurse-submodules $RepoUrlHttps $InstallDir
|
|
|
|
if ($LASTEXITCODE -eq 0) {
|
|
Write-Success "Cloned via HTTPS"
|
|
} else {
|
|
# Last resort: skip hook templates entirely (they're optional sample files)
|
|
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
|
|
Write-Warn "Standard clone failed, retrying without hook templates..."
|
|
git clone --branch $Branch --recurse-submodules --template="" $RepoUrlHttps $InstallDir
|
|
|
|
if ($LASTEXITCODE -eq 0) {
|
|
Write-Success "Cloned via HTTPS (no templates)"
|
|
} else {
|
|
Write-Err "Failed to clone repository"
|
|
throw "Failed to clone repository"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Also set per-repo (in case global wasn't persisted)
|
|
Push-Location $InstallDir
|
|
git config windows.appendAtomically false
|
|
|
|
# Ensure submodules are initialized and updated
|
|
Write-Info "Initializing submodules (mini-swe-agent, tinker-atropos)..."
|
|
git submodule update --init --recursive
|
|
Pop-Location
|
|
Write-Success "Submodules ready"
|
|
|
|
Write-Success "Repository ready"
|
|
}
|
|
|
|
function Install-Venv {
|
|
if ($NoVenv) {
|
|
Write-Info "Skipping virtual environment (-NoVenv)"
|
|
return
|
|
}
|
|
|
|
Write-Info "Creating virtual environment with Python $PythonVersion..."
|
|
|
|
Push-Location $InstallDir
|
|
|
|
if (Test-Path "venv") {
|
|
Write-Info "Virtual environment already exists, recreating..."
|
|
Remove-Item -Recurse -Force "venv"
|
|
}
|
|
|
|
# uv creates the venv and pins the Python version in one step
|
|
& $UvCmd venv venv --python $PythonVersion
|
|
|
|
Pop-Location
|
|
|
|
Write-Success "Virtual environment ready (Python $PythonVersion)"
|
|
}
|
|
|
|
function Install-Dependencies {
|
|
Write-Info "Installing dependencies..."
|
|
|
|
Push-Location $InstallDir
|
|
|
|
if (-not $NoVenv) {
|
|
# Tell uv to install into our venv (no activation needed)
|
|
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
|
}
|
|
|
|
# Install main package with all extras
|
|
try {
|
|
& $UvCmd pip install -e ".[all]" 2>&1 | Out-Null
|
|
} catch {
|
|
& $UvCmd pip install -e "." | Out-Null
|
|
}
|
|
|
|
Write-Success "Main package installed"
|
|
|
|
# Install submodules
|
|
Write-Info "Installing mini-swe-agent (terminal tool backend)..."
|
|
if (Test-Path "mini-swe-agent\pyproject.toml") {
|
|
try {
|
|
& $UvCmd pip install -e ".\mini-swe-agent" 2>&1 | Out-Null
|
|
Write-Success "mini-swe-agent installed"
|
|
} catch {
|
|
Write-Warn "mini-swe-agent install failed (terminal tools may not work)"
|
|
}
|
|
} else {
|
|
Write-Warn "mini-swe-agent not found (run: git submodule update --init)"
|
|
}
|
|
|
|
Write-Info "Installing tinker-atropos (RL training backend)..."
|
|
if (Test-Path "tinker-atropos\pyproject.toml") {
|
|
try {
|
|
& $UvCmd pip install -e ".\tinker-atropos" 2>&1 | Out-Null
|
|
Write-Success "tinker-atropos installed"
|
|
} catch {
|
|
Write-Warn "tinker-atropos install failed (RL tools may not work)"
|
|
}
|
|
} else {
|
|
Write-Warn "tinker-atropos not found (run: git submodule update --init)"
|
|
}
|
|
|
|
Pop-Location
|
|
|
|
Write-Success "All dependencies installed"
|
|
}
|
|
|
|
function Set-PathVariable {
|
|
Write-Info "Setting up hermes command..."
|
|
|
|
if ($NoVenv) {
|
|
$hermesBin = "$InstallDir"
|
|
} else {
|
|
$hermesBin = "$InstallDir\venv\Scripts"
|
|
}
|
|
|
|
# Add the venv Scripts dir to user PATH so hermes is globally available
|
|
# On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in
|
|
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
|
|
if ($currentPath -notlike "*$hermesBin*") {
|
|
[Environment]::SetEnvironmentVariable(
|
|
"Path",
|
|
"$hermesBin;$currentPath",
|
|
"User"
|
|
)
|
|
Write-Success "Added to user PATH: $hermesBin"
|
|
} else {
|
|
Write-Info "PATH already configured"
|
|
}
|
|
|
|
# Update current session
|
|
$env:Path = "$hermesBin;$env:Path"
|
|
|
|
Write-Success "hermes command ready"
|
|
}
|
|
|
|
function Copy-ConfigTemplates {
|
|
Write-Info "Setting up configuration files..."
|
|
|
|
# Create ~/.hermes directory structure
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
|
|
New-Item -ItemType Directory -Force -Path "$HermesHome\whatsapp\session" | Out-Null
|
|
|
|
# Create .env
|
|
$envPath = "$HermesHome\.env"
|
|
if (-not (Test-Path $envPath)) {
|
|
$examplePath = "$InstallDir\.env.example"
|
|
if (Test-Path $examplePath) {
|
|
Copy-Item $examplePath $envPath
|
|
Write-Success "Created ~/.hermes/.env from template"
|
|
} else {
|
|
New-Item -ItemType File -Force -Path $envPath | Out-Null
|
|
Write-Success "Created ~/.hermes/.env"
|
|
}
|
|
} else {
|
|
Write-Info "~/.hermes/.env already exists, keeping it"
|
|
}
|
|
|
|
# Create config.yaml
|
|
$configPath = "$HermesHome\config.yaml"
|
|
if (-not (Test-Path $configPath)) {
|
|
$examplePath = "$InstallDir\cli-config.yaml.example"
|
|
if (Test-Path $examplePath) {
|
|
Copy-Item $examplePath $configPath
|
|
Write-Success "Created ~/.hermes/config.yaml from template"
|
|
}
|
|
} else {
|
|
Write-Info "~/.hermes/config.yaml already exists, keeping it"
|
|
}
|
|
|
|
# Create SOUL.md if it doesn't exist (global persona file)
|
|
$soulPath = "$HermesHome\SOUL.md"
|
|
if (-not (Test-Path $soulPath)) {
|
|
@"
|
|
# 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.
|
|
-->
|
|
"@ | Set-Content -Path $soulPath -Encoding UTF8
|
|
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
|
|
}
|
|
|
|
Write-Success "Configuration directory ready: ~/.hermes/"
|
|
|
|
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
|
|
Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..."
|
|
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
|
if (Test-Path $pythonExe) {
|
|
try {
|
|
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
|
|
Write-Success "Skills synced to ~/.hermes/skills/"
|
|
} catch {
|
|
# Fallback: simple directory copy
|
|
$bundledSkills = "$InstallDir\skills"
|
|
$userSkills = "$HermesHome\skills"
|
|
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
|
|
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
|
|
Write-Success "Skills copied to ~/.hermes/skills/"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Install-NodeDeps {
|
|
if (-not $HasNode) {
|
|
Write-Info "Skipping Node.js dependencies (Node not installed)"
|
|
return
|
|
}
|
|
|
|
Push-Location $InstallDir
|
|
|
|
if (Test-Path "package.json") {
|
|
Write-Info "Installing Node.js dependencies (browser tools)..."
|
|
try {
|
|
npm install --silent 2>&1 | Out-Null
|
|
Write-Success "Node.js dependencies installed"
|
|
} catch {
|
|
Write-Warn "npm install failed (browser tools may not work)"
|
|
}
|
|
}
|
|
|
|
# Install WhatsApp bridge dependencies
|
|
$bridgeDir = "$InstallDir\scripts\whatsapp-bridge"
|
|
if (Test-Path "$bridgeDir\package.json") {
|
|
Write-Info "Installing WhatsApp bridge dependencies..."
|
|
Push-Location $bridgeDir
|
|
try {
|
|
npm install --silent 2>&1 | Out-Null
|
|
Write-Success "WhatsApp bridge dependencies installed"
|
|
} catch {
|
|
Write-Warn "WhatsApp bridge npm install failed (WhatsApp may not work)"
|
|
}
|
|
Pop-Location
|
|
}
|
|
|
|
Pop-Location
|
|
}
|
|
|
|
function Invoke-SetupWizard {
|
|
if ($SkipSetup) {
|
|
Write-Info "Skipping setup wizard (-SkipSetup)"
|
|
return
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Info "Starting setup wizard..."
|
|
Write-Host ""
|
|
|
|
Push-Location $InstallDir
|
|
|
|
# Run hermes setup using the venv Python directly (no activation needed)
|
|
if (-not $NoVenv) {
|
|
& ".\venv\Scripts\python.exe" -m hermes_cli.main setup
|
|
} else {
|
|
python -m hermes_cli.main setup
|
|
}
|
|
|
|
Pop-Location
|
|
}
|
|
|
|
function Start-GatewayIfConfigured {
|
|
$envPath = "$HermesHome\.env"
|
|
if (-not (Test-Path $envPath)) { return }
|
|
|
|
$hasMessaging = $false
|
|
$content = Get-Content $envPath -ErrorAction SilentlyContinue
|
|
foreach ($var in @("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WHATSAPP_ENABLED")) {
|
|
$match = $content | Where-Object { $_ -match "^${var}=.+" -and $_ -notmatch "your-token-here" }
|
|
if ($match) { $hasMessaging = $true; break }
|
|
}
|
|
|
|
if (-not $hasMessaging) { return }
|
|
|
|
$hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
|
|
if (-not (Test-Path $hermesCmd)) {
|
|
$hermesCmd = "hermes"
|
|
}
|
|
|
|
# If WhatsApp is enabled but not yet paired, run foreground for QR scan
|
|
$whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" }
|
|
$whatsappSession = "$HermesHome\whatsapp\session\creds.json"
|
|
if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) {
|
|
Write-Host ""
|
|
Write-Info "WhatsApp is enabled but not yet paired."
|
|
Write-Info "Running 'hermes whatsapp' to pair via QR code..."
|
|
Write-Host ""
|
|
$response = Read-Host "Pair WhatsApp now? [Y/n]"
|
|
if ($response -eq "" -or $response -match "^[Yy]") {
|
|
try {
|
|
& $hermesCmd whatsapp
|
|
} catch {
|
|
# Expected after pairing completes
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Info "Messaging platform token detected!"
|
|
Write-Info "The gateway handles messaging platforms and cron job execution."
|
|
Write-Host ""
|
|
$response = Read-Host "Would you like to start the gateway now? [Y/n]"
|
|
|
|
if ($response -eq "" -or $response -match "^[Yy]") {
|
|
Write-Info "Starting gateway in background..."
|
|
try {
|
|
$logFile = "$HermesHome\logs\gateway.log"
|
|
Start-Process -FilePath $hermesCmd -ArgumentList "gateway" `
|
|
-RedirectStandardOutput $logFile `
|
|
-RedirectStandardError "$HermesHome\logs\gateway-error.log" `
|
|
-WindowStyle Hidden
|
|
Write-Success "Gateway started! Your bot is now online."
|
|
Write-Info "Logs: $logFile"
|
|
Write-Info "To stop: close the gateway process from Task Manager"
|
|
} catch {
|
|
Write-Warn "Failed to start gateway. Run manually: hermes gateway"
|
|
}
|
|
} else {
|
|
Write-Info "Skipped. Start the gateway later with: hermes gateway"
|
|
}
|
|
}
|
|
|
|
function Write-Completion {
|
|
Write-Host ""
|
|
Write-Host "┌─────────────────────────────────────────────────────────┐" -ForegroundColor Green
|
|
Write-Host "│ ✓ Installation Complete! │" -ForegroundColor Green
|
|
Write-Host "└─────────────────────────────────────────────────────────┘" -ForegroundColor Green
|
|
Write-Host ""
|
|
|
|
# Show file locations
|
|
Write-Host "📁 Your files (all in ~/.hermes/):" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host " Config: " -NoNewline -ForegroundColor Yellow
|
|
Write-Host "$HermesHome\config.yaml"
|
|
Write-Host " API Keys: " -NoNewline -ForegroundColor Yellow
|
|
Write-Host "$HermesHome\.env"
|
|
Write-Host " Data: " -NoNewline -ForegroundColor Yellow
|
|
Write-Host "$HermesHome\cron\, sessions\, logs\"
|
|
Write-Host " Code: " -NoNewline -ForegroundColor Yellow
|
|
Write-Host "$HermesHome\hermes-agent\"
|
|
Write-Host ""
|
|
|
|
Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host "🚀 Commands:" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host " hermes " -NoNewline -ForegroundColor Green
|
|
Write-Host "Start chatting"
|
|
Write-Host " hermes setup " -NoNewline -ForegroundColor Green
|
|
Write-Host "Configure API keys & settings"
|
|
Write-Host " hermes config " -NoNewline -ForegroundColor Green
|
|
Write-Host "View/edit configuration"
|
|
Write-Host " hermes config edit " -NoNewline -ForegroundColor Green
|
|
Write-Host "Open config in editor"
|
|
Write-Host " hermes gateway " -NoNewline -ForegroundColor Green
|
|
Write-Host "Start messaging gateway (Telegram, Discord, etc.)"
|
|
Write-Host " hermes update " -NoNewline -ForegroundColor Green
|
|
Write-Host "Update to latest version"
|
|
Write-Host ""
|
|
|
|
Write-Host "─────────────────────────────────────────────────────────" -ForegroundColor Cyan
|
|
Write-Host ""
|
|
Write-Host "⚡ Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
|
|
if (-not $HasNode) {
|
|
Write-Host "Note: Node.js could not be installed automatically." -ForegroundColor Yellow
|
|
Write-Host "Browser tools need Node.js. Install manually:" -ForegroundColor Yellow
|
|
Write-Host " https://nodejs.org/en/download/" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|
|
|
|
if (-not $HasRipgrep) {
|
|
Write-Host "Note: ripgrep (rg) was not installed. For faster file search:" -ForegroundColor Yellow
|
|
Write-Host " winget install BurntSushi.ripgrep.MSVC" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# Main
|
|
# ============================================================================
|
|
|
|
function Main {
|
|
Write-Banner
|
|
|
|
if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" }
|
|
if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" }
|
|
if (-not (Test-Git)) { throw "Git not found — install from https://git-scm.com/download/win" }
|
|
Test-Node # Auto-installs if missing
|
|
Install-SystemPackages # ripgrep + ffmpeg in one step
|
|
|
|
Install-Repository
|
|
Install-Venv
|
|
Install-Dependencies
|
|
Install-NodeDeps
|
|
Set-PathVariable
|
|
Copy-ConfigTemplates
|
|
Invoke-SetupWizard
|
|
Start-GatewayIfConfigured
|
|
|
|
Write-Completion
|
|
}
|
|
|
|
# Wrap in try/catch so errors don't kill the terminal when run via:
|
|
# irm https://...install.ps1 | iex
|
|
# (exit/throw inside iex kills the entire PowerShell session)
|
|
try {
|
|
Main
|
|
} catch {
|
|
Write-Host ""
|
|
Write-Err "Installation failed: $_"
|
|
Write-Host ""
|
|
Write-Info "If the error is unclear, try downloading and running the script directly:"
|
|
Write-Host " Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow
|
|
Write-Host " .\install.ps1" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
}
|