Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/upload_to_pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ jobs:
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }

- name: Bundle install.sh into wheel
- name: Bundle install scripts into wheel
run: |
mkdir -p hermes_cli/scripts
cp scripts/install.sh hermes_cli/scripts/install.sh
cp scripts/install.ps1 hermes_cli/scripts/install.ps1

- name: Build wheel and sdist
run: uv build --sdist --wheel
Expand Down
89 changes: 71 additions & 18 deletions hermes_cli/dep_ensure.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@
from __future__ import annotations

import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path

_IS_WINDOWS = platform.system() == "Windows"

_DEP_CHECKS = {
"node": lambda: shutil.which("node") is not None,
"browser": lambda: (
Expand All @@ -41,47 +44,79 @@


def _has_system_browser() -> bool:
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
if _IS_WINDOWS:
names = ("chrome", "msedge", "chromium")
else:
names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome")
for name in names:
if shutil.which(name):
return True
return False


def _has_hermes_agent_browser() -> bool:
from hermes_constants import get_hermes_home
return (get_hermes_home() / "node_modules" / ".bin" / "agent-browser").is_file()
home = get_hermes_home()
if _IS_WINDOWS:
# npm -g --prefix puts .cmd shims directly in the prefix dir on Windows
return (home / "node" / "agent-browser.cmd").is_file()
# install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix
# Also check legacy node_modules/.bin/ path for git-clone installs.
return (
(home / "node" / "bin" / "agent-browser").is_file()
or (home / "node_modules" / ".bin" / "agent-browser").is_file()
)


def _find_install_script(
package_dir: Path | None = None,
repo_root: Path | None = None,
) -> Path | None:
"""Locate install.sh — bundled in wheel or in git checkout."""
) -> tuple[Path | None, str | None]:
"""Locate the install script — bundled in wheel or in git checkout.

On Windows, prefers install.ps1; on POSIX, prefers install.sh.
Returns a (path, shell) tuple, or (None, None) if neither is found.
"""
if package_dir is None:
package_dir = Path(__file__).parent
if repo_root is None:
repo_root = package_dir.parent

bundled = package_dir / "scripts" / "install.sh"
if bundled.is_file():
return bundled
repo = repo_root / "scripts" / "install.sh"
if repo.is_file():
return repo
return None
if _IS_WINDOWS:
preferred = ("install.ps1", "powershell")
fallback = ("install.sh", "bash")
else:
preferred = ("install.sh", "bash")
fallback = ("install.ps1", "powershell")

for script_name, shell in (preferred, fallback):
bundled = package_dir / "scripts" / script_name
if bundled.is_file():
return bundled, shell
repo = repo_root / "scripts" / script_name
if repo.is_file():
return repo, shell

return None, None

def ensure_dependency(dep: str, interactive: bool = True) -> bool:

def ensure_dependency(
dep: str,
interactive: bool = True,
) -> bool:
"""Ensure a non-Python dependency is available. Returns True if available."""
check = _DEP_CHECKS.get(dep)
if check and check():
if check is None:
# Unknown dep — don't silently forward to install script.
return False
if check():
return True

script = _find_install_script()
script, shell = _find_install_script()
if script is None:
if interactive:
desc = _DEP_DESCRIPTIONS.get(dep, dep)
print(f" {desc} is not installed and install.sh was not found.")
print(f" {desc} is not installed and no install script was found.")
print(f" Install {dep} manually and try again.")
return False

Expand All @@ -91,12 +126,30 @@ def ensure_dependency(dep: str, interactive: bool = True) -> bool:
reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
if reply not in {"", "y", "yes"}:
if reply not in ("", "y", "yes"):
return False

if shell == "powershell":
from hermes_constants import get_hermes_home
ps_bin = shutil.which("powershell") or shutil.which("pwsh")
if not ps_bin:
if interactive:
print(" PowerShell not found. Install PowerShell or run install.ps1 manually.")
return False
cmd = [
ps_bin,
"-ExecutionPolicy", "Bypass",
"-File", str(script),
"-Ensure", dep,
"-HermesHome", str(get_hermes_home()),
]
else:
cmd = ["bash", str(script), "--ensure", dep]

run_env = {**os.environ, "IS_INTERACTIVE": "false"}
result = subprocess.run(
["bash", str(script), "--ensure", dep],
env={**os.environ, "IS_INTERACTIVE": "false"},
cmd,
env=run_env,
)
if result.returncode != 0:
return False
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ hermes-acp = "acp_adapter.entry:main"
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"]

[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
gateway = ["assets/**/*"]

[tool.setuptools.packages.find]
Expand Down
160 changes: 159 additions & 1 deletion scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ param(
[string]$Stage,
[switch]$ProtocolVersion,
[switch]$NonInteractive,
[switch]$Json
[switch]$Json,

# --- Ensure mode (dep_ensure.py entry point) ---
[string]$Ensure = "",
[switch]$PostInstall
)

$ErrorActionPreference = "Stop"
Expand Down Expand Up @@ -108,6 +112,105 @@ function Write-Err {
Write-Host "[X] $Message" -ForegroundColor Red
}

# --- Ensure-mode helpers ---

function Resolve-NpmCmd {
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) { return $null }
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $npmCmdSibling) { return $npmCmdSibling }
}
return $npmExe
}

function Find-SystemBrowser {
$candidates = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe",
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
"${env:ProgramFiles}\Chromium\Application\chrome.exe",
"${env:LOCALAPPDATA}\Chromium\Application\chrome.exe"
)
foreach ($p in $candidates) {
if (Test-Path $p) { return $p }
}
return $null
}

function Write-BrowserEnv {
param([string]$BrowserPath)
if (-not (Test-Path $HermesHome)) {
New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null
}
$envFile = Join-Path $HermesHome ".env"
if (-not (Test-Path $envFile)) {
Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
return
}
$content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue
if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return }
Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
}

function Install-AgentBrowser {
param([switch]$SkipChromium)
$npm = Resolve-NpmCmd
if (-not $npm) {
Write-Err "npm not found -- install Node.js first"
throw "npm not found"
}

Write-Info "Installing agent-browser via npm -g --prefix..."
$prefixDir = Join-Path $HermesHome "node"
if (-not (Test-Path $prefixDir)) {
New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null
}
$npmLog = [System.IO.Path]::GetTempFileName()
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $npm install -g --prefix $prefixDir --silent --ignore-scripts "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null
$npmExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($npmExit -ne 0) {
$npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
Write-Err "npm install -g failed (exit $npmExit): $npmDetail"
throw "npm install failed"
}
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue

if (-not $SkipChromium) {
$sysBrowser = Find-SystemBrowser
if ($sysBrowser) {
Write-BrowserEnv -BrowserPath $sysBrowser
Write-Info "System browser detected -- skipping Chromium download"
} else {
$abExe = Join-Path $prefixDir "agent-browser.cmd"
if (Test-Path $abExe) {
Write-Info "Installing Chromium via agent-browser install..."
$abLog = [System.IO.Path]::GetTempFileName()
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $abExe install 2>&1 | Tee-Object -FilePath $abLog | Out-Null
$abExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($abExit -ne 0) {
$abDetail = Get-Content $abLog -Raw -ErrorAction SilentlyContinue
Write-Warn "Chromium install failed (exit $abExit): $abDetail"
}
Remove-Item $abLog -Force -ErrorAction SilentlyContinue
} else {
Write-Warn "agent-browser.cmd not found at $abExe"
}
}
}
Write-Success "Agent-browser ready"
}

# ============================================================================
# Dependency checks
# ============================================================================
Expand Down Expand Up @@ -2043,6 +2146,48 @@ function Invoke-AllStages {
}
}

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
Expand All @@ -2062,6 +2207,19 @@ function Main {
# structured JSON error frame instead of a bare exception.

try {
if ($Ensure -ne "") {
if ($PSBoundParameters.ContainsKey("Stage")) {
Write-Err "Cannot use -Ensure and -Stage simultaneously"
exit 1
}
Invoke-EnsureMode -Deps $Ensure
exit 0
}
if ($PostInstall) {
Invoke-PostInstallMode
exit 0
}

if ($ProtocolVersion) {
Write-Output $InstallStageProtocolVersion
exit 0
Expand Down
Loading
Loading