From 1b1bd8663a992bf88415535878c2d88057565a05 Mon Sep 17 00:00:00 2001 From: Advait Jayant Date: Fri, 26 Jun 2026 15:51:37 +0100 Subject: [PATCH] Add native Windows support (PowerShell installer + Task Scheduler services) Hermes runs natively on Windows (uv-based git clone at %LOCALAPPDATA%\hermes), so ghost can too. The one blocker -- the bash fork relocates the engine venv by rewriting paths, which can't work on Windows (Scripts\*.exe launchers bake the interpreter path into the binary) -- is solved by having the Windows fork RECREATE the venv with uv instead (uv venv + uv sync), the same way Hermes builds it. - install.ps1: PowerShell installer mirroring install.sh (self-bootstrap clone, uv, install Hermes if missing, fork+debrand, isolated privacy venv via uv sync, Task Scheduler services for scrubber + og-veil, ghost/.cmd command shims, PATH). - scripts/fork-engine.ps1: copy engine + recreate venv with uv + debrand.py + skills isolation + verify. - README: Windows PowerShell one-liner + platform badge. NOTE: authored without a Windows machine -- both .ps1 files are PowerShell-parse- checked but the install flow is UNVERIFIED on real Windows. Needs a Windows box to validate and debug. macOS/Linux install.sh is untouched. --- README.md | 12 ++- install.ps1 | 175 ++++++++++++++++++++++++++++++++++++++++ scripts/fork-engine.ps1 | 56 +++++++++++++ 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 install.ps1 create mode 100644 scripts/fork-engine.ps1 diff --git a/README.md b/README.md index fe91b2c..26a2f52 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ghost 👻 -[![License: MIT](https://img.shields.io/badge/License-MIT-3DDC97?style=flat-square)](LICENSE)  ![Platform](https://img.shields.io/badge/platform-macOS%20%C2%B7%20Linux%20%C2%B7%20WSL2-lightgrey?style=flat-square)  [![Built on Hermes Agent](https://img.shields.io/badge/built%20on-Hermes%20Agent-7C5CFF?style=flat-square)](https://github.com/NousResearch/hermes-agent)  ![Open-weight only](https://img.shields.io/badge/models-open--weight%20only-FF8A3D?style=flat-square) +[![License: MIT](https://img.shields.io/badge/License-MIT-3DDC97?style=flat-square)](LICENSE)  ![Platform](https://img.shields.io/badge/platform-macOS%20%C2%B7%20Linux%20%C2%B7%20Windows-lightgrey?style=flat-square)  [![Built on Hermes Agent](https://img.shields.io/badge/built%20on-Hermes%20Agent-7C5CFF?style=flat-square)](https://github.com/NousResearch/hermes-agent)  ![Open-weight only](https://img.shields.io/badge/models-open--weight%20only-FF8A3D?style=flat-square) **A private, unrestricted agentic harness.** A real terminal agent that runs commands, edits files, executes code, and searches the web, with every hosted request routed through OpenGradient's TEE gateway so the model provider never sees your prompts. It answers what you actually ask, drops to a fully-offline local model on demand, and phones home to no one. @@ -12,12 +12,20 @@ Built on the [Hermes Agent](https://github.com/NousResearch/hermes-agent) engine ## Install (30 seconds) -One deterministic command, no LLM and nothing agentic, installs **and** updates everything (the engine, the privacy stack, the `ghost` commands) on macOS, Linux, or WSL2. uv provisions an isolated Python 3.11 under the hood, so the only prerequisite is `git`: +One deterministic command, no LLM and nothing agentic, installs **and** updates everything (the engine, the privacy stack, the `ghost` commands). uv provisions an isolated Python 3.11 under the hood, so the only prerequisite is `git`. + +**macOS, Linux, WSL2:** ```bash curl -fsSL https://raw.githubusercontent.com/OpenGradient/ghost/main/install.sh | bash ``` +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/OpenGradient/ghost/main/install.ps1 | iex +``` + Then: ```bash diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..8dcabc4 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,175 @@ +# ============================================================================ +# ghost -- native Windows installer (PowerShell). The macOS/Linux path is install.sh. +# ============================================================================ +# One command, deterministic, no LLM: +# irm https://raw.githubusercontent.com/OpenGradient/ghost/main/install.ps1 | iex +# From a clone: powershell -ExecutionPolicy Bypass -File .\install.ps1 +# +# EXPERIMENTAL: this was authored without a Windows machine to test on. The logic mirrors the +# (tested) install.sh and Hermes's own Windows installer, but expect to debug it on a real +# Windows box. Please report failures. The mac/linux install.sh is unaffected. +# +# Options (env vars or switches): -Local (offline model), -Scrub (outbound PII/secret redaction). +# ============================================================================ +param([switch]$Local, [switch]$Scrub) +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$GhostHome = "$env:USERPROFILE\.ghost" +$ProfileDir = "$GhostHome\profiles\uncensored" +$Priv = "$GhostHome\privacy" +$Eng = "$env:USERPROFILE\.ghost-engine" +$HermesSrc = "$env:LOCALAPPDATA\hermes\hermes-agent" +$BinDir = "$env:USERPROFILE\.local\bin" +$Venv = "$GhostHome\venv" +function Say($m) { Write-Host "`n==> $m" -ForegroundColor Yellow } +function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) } + +# --- self-bootstrap: run via `irm | iex` (no checkout) -> clone + re-exec ------------------ +$RepoRoot = if ($PSScriptRoot) { $PSScriptRoot } else { "" } +if (-not $RepoRoot -or -not (Test-Path "$RepoRoot\profile\config.yaml")) { + if (-not (Have git)) { throw "ghost needs git. Install Git for Windows, then re-run." } + $Src = "$env:USERPROFILE\.ghost-src" + if (Test-Path "$Src\.git") { Say "Updating ghost source ($Src)"; git -C $Src pull --ff-only } + else { Say "Fetching ghost into $Src"; if (Test-Path $Src) { Remove-Item -Recurse -Force $Src }; git clone https://github.com/OpenGradient/ghost.git $Src } + & powershell -ExecutionPolicy Bypass -NoProfile -File "$Src\install.ps1" @PSBoundParameters + exit $LASTEXITCODE +} + +# Record source + options so `ghost update` can re-run the same way. +New-Item -ItemType Directory -Force -Path $GhostHome | Out-Null +Set-Content -Path "$GhostHome\.src" -Value $RepoRoot -Encoding UTF8 +$envLines = @(); if ($Local) { $envLines += "GHOST_LOCAL=1" }; if ($Scrub) { $envLines += "GHOST_SCRUB=1" } +Set-Content -Path "$GhostHome\.install-env" -Value ($envLines -join "`n") -Encoding UTF8 + +# --- 0. dependencies: uv (provisions Python 3.11) + the Hermes engine ---------------------- +Say "Dependencies" +if (-not (Have uv)) { Say "Installing uv (Astral's Python manager)"; irm https://astral.sh/uv/install.ps1 | iex; $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" } +if (-not (Have uv)) { throw "uv install failed; install it from https://docs.astral.sh/uv/ and re-run." } + +if (-not (Test-Path $HermesSrc)) { + Say "Installing the Hermes Agent engine (official Windows installer)" + iex (irm https://hermes-agent.nousresearch.com/install.ps1) +} +if (-not (Test-Path $HermesSrc)) { throw "Hermes engine not found at $HermesSrc after install." } + +# --- 1. fork + debrand the engine ---------------------------------------------------------- +Say "Forking + debranding the engine -> $Eng" +& powershell -ExecutionPolicy Bypass -NoProfile -File "$RepoRoot\scripts\fork-engine.ps1" -Src $HermesSrc -Eng $Eng +if ($LASTEXITCODE -ne 0) { throw "fork-engine.ps1 failed" } + +# --- 2. privacy stack: isolated uv venv (Python 3.11) -------------------------------------- +Say "Privacy stack (isolated uv venv, Python 3.11)" +New-Item -ItemType Directory -Force -Path $Priv | Out-Null +$env:UV_PROJECT_ENVIRONMENT = $Venv +$extra = @(); if ($Scrub) { $extra = @("--extra","presidio") } +Push-Location $RepoRoot +try { & uv sync --python 3.11 --frozen @extra } catch { & uv sync --python 3.11 @extra } +finally { Pop-Location } +$Py = "$Venv\Scripts\python.exe" +$Pyw = "$Venv\Scripts\pythonw.exe"; if (-not (Test-Path $Pyw)) { $Pyw = $Py } +if (-not (Test-Path $Py)) { throw "privacy venv not created at $Py" } +Copy-Item "$RepoRoot\privacy\*.py" $Priv -Force + +# --- 3. uncensored profile ----------------------------------------------------------------- +Say "Writing the uncensored profile" +New-Item -ItemType Directory -Force -Path $ProfileDir | Out-Null +$LocalModel = "ghost-tool:latest" +$homeFwd = ($env:USERPROFILE -replace '\\','/') +(Get-Content -Raw "$RepoRoot\profile\config.yaml") -replace '__HOME__',$homeFwd -replace '__LOCAL_MODEL__',$LocalModel | + Set-Content -NoNewline -Encoding UTF8 "$ProfileDir\config.yaml" +Copy-Item "$RepoRoot\profile\SOUL.md" "$ProfileDir\SOUL.md" -Force +if (-not (Test-Path "$ProfileDir\.env")) { Copy-Item "$RepoRoot\profile\.env.example" "$ProfileDir\.env" -Force } +if (-not $Local) { + # hosted-only: route auxiliary + fallback to hosted models (mirrors install.sh) + & $Py - "$ProfileDir\config.yaml" @' +import sys, re +p = sys.argv[1]; s = open(p, encoding="utf-8").read() +s = re.sub(r"provider: ollama-local\n(\s*)model: \S+", r"provider: opengradient\n\1model: nous/hermes-4-70b", s) +s = re.sub(r"model: ghost-tool:latest\n(\s*)provider: ollama-local", r"model: nous/hermes-4-70b\n\1provider: opengradient", s) +s = s.replace("provider: ollama-local", "provider: opengradient") +s = re.sub(r"(fallback_model:\n provider: opengradient\n model: )nous/hermes-4-70b", r"\g<1>nous/hermes-4-405b", s, count=1) +open(p, "w", encoding="utf-8").write(s); print(" hosted-only: fallback -> 405b, auxiliary -> 70b (via og-veil)") +'@ +} +# redaction markers (off by default) +if (-not (Test-Path "$Priv\pii_denylist.txt")) { Copy-Item "$RepoRoot\profile\pii_denylist.example.txt" "$Priv\pii_denylist.txt" -Force } +Copy-Item "$RepoRoot\profile\uncensored_prefill.json" "$Priv\uncensored_prefill.json" -Force +Remove-Item "$Priv\.proxy","$Priv\.no_scrub" -ErrorAction SilentlyContinue +if ($Scrub) { + Set-Content -Path "$Priv\.scrub" -Value "" -Encoding UTF8 + & $Py - "$ProfileDir\config.yaml" @' +import sys, re +p = sys.argv[1]; s = open(p, encoding="utf-8").read() +s = re.sub(r"(?m)^ redact_secrets: false$", " redact_secrets: true", s) +s = re.sub(r"(?m)^ redact_pii: false$", " redact_pii: true", s) +open(p, "w", encoding="utf-8").write(s) +'@ + Say "Outbound PII + secret redaction ON (-Scrub)" +} else { + Remove-Item "$Priv\.scrub" -ErrorAction SilentlyContinue + Say "Full-fidelity mode (default) -- no outbound redaction. Use -Scrub to enable." +} +if ($Scrub) { + & $Py -m spacy download en_core_web_md 2>$null + & $Py -c "import en_core_web_md" 2>$null + if ($LASTEXITCODE -eq 0) { Set-Content "$Priv\.presidio" "" -Encoding ascii } +} + +# --- 4. privacy services via Task Scheduler (scrubber :8788 + og-veil :11435) -------------- +Say "Registering privacy services (Task Scheduler, at-logon + auto-restart)" +$svcSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) +$svcTrigger = New-ScheduledTaskTrigger -AtLogOn +$svcs = @( + @{ Name="ghost-scrubber"; Exec=$Pyw; Args="`"$Priv\scrubbing_proxy.py`"" }, + @{ Name="ghost-veil"; Exec=$Pyw; Args="-m veil serve --foreground --skip-setup --port 11435" } +) +foreach ($s in $svcs) { + $a = New-ScheduledTaskAction -Execute $s.Exec -Argument $s.Args + Register-ScheduledTask -TaskName $s.Name -Action $a -Trigger $svcTrigger -Settings $svcSettings -Force | Out-Null + Start-ScheduledTask -TaskName $s.Name +} +Write-Host " waiting for the scrubber + og-veil" +foreach ($probe in @("http://127.0.0.1:8788/healthz","http://127.0.0.1:11435/health")) { + for ($i=0; $i -lt 20; $i++) { try { if ((Invoke-WebRequest $probe -UseBasicParsing -TimeoutSec 3).StatusCode -eq 200) { break } } catch {}; Start-Sleep 1 } +} + +# --- 5. the ghost / ghost-login / ghost-update commands ------------------------------------ +Say "Installing the ghost commands" +New-Item -ItemType Directory -Force -Path $BinDir | Out-Null +$hermesExe = "$Eng\venv\Scripts\hermes.exe" +# ghost.cmd +@" +@echo off +set "HERMES_HOME=%USERPROFILE%\.ghost" +set "ANTHROPIC_API_KEY=" +if /I "%~1"=="update" ( call "%~dp0ghost-update.cmd" & exit /b %errorlevel% ) +if /I "%~1"=="--scrub" ( type nul > "%USERPROFILE%\.ghost\privacy\.scrub" & shift ) +if /I "%~1"=="--no-scrub" ( del /q "%USERPROFILE%\.ghost\privacy\.scrub" 2>nul & shift ) +"$hermesExe" -p uncensored %* +"@ | Set-Content -Encoding ascii "$BinDir\ghost.cmd" +# ghost-login.cmd (og-veil login through the engine venv) +@" +@echo off +"$Venv\Scripts\python.exe" -m veil %* +"@ | Set-Content -Encoding ascii "$BinDir\ghost-login.cmd" +# ghost-update.cmd (pull + re-run installer) +@" +@echo off +set "SRC=%USERPROFILE%\.ghost-src" +if exist "%SRC%\.git" ( git -C "%SRC%" pull --ff-only ) else ( git clone https://github.com/OpenGradient/ghost.git "%SRC%" ) +powershell -ExecutionPolicy Bypass -NoProfile -File "%SRC%\install.ps1" %* +"@ | Set-Content -Encoding ascii "$BinDir\ghost-update.cmd" + +# add $BinDir to user PATH +$userPath = [Environment]::GetEnvironmentVariable("Path","User") +if ($userPath -notlike "*$BinDir*") { [Environment]::SetEnvironmentVariable("Path","$BinDir;$userPath","User"); $env:Path = "$BinDir;$env:Path" } + +# --- 6. connect + smoke test --------------------------------------------------------------- +Say "Connect your OpenGradient Chat account: run ghost-login (browser login)" +Say "Smoke test" +& "$BinDir\ghost.cmd" --yolo -z "Reply with one word: hi" + +Say "ghost installed -- open a new terminal and run: ghost" +Write-Host " Hosted default = deepseek/deepseek-v4-pro via the OpenGradient TEE gateway (OHTTP-private)." +Write-Host " Not connected yet? Run: ghost-login" diff --git a/scripts/fork-engine.ps1 b/scripts/fork-engine.ps1 new file mode 100644 index 0000000..cbd6d8f --- /dev/null +++ b/scripts/fork-engine.ps1 @@ -0,0 +1,56 @@ +# Fork the Hermes engine into a standalone, debranded ghost engine (Windows). +# +# The bash fork relocates the venv by rewriting paths with sed. That can't work on Windows: +# venvs use Scripts\*.exe launchers with the interpreter path baked into the binary. So here we +# RECREATE the venv with uv (the same way Hermes builds it on Windows: `uv venv` + `uv sync`), +# which produces a Scripts\hermes.exe pointing at the fork -- no relocation needed. +# +# EXPERIMENTAL: authored without a Windows test machine. Validate on Windows and report issues. +param( + [string]$Src = $(if ($env:HERMES_SRC) { $env:HERMES_SRC } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }), + [string]$Eng = $(if ($env:GHOST_ENGINE) { $env:GHOST_ENGINE } else { "$env:USERPROFILE\.ghost-engine" }), + [string]$PyVer = "3.11" +) +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not (Test-Path $Src)) { throw "upstream Hermes engine not found at $Src" } + +Write-Host "==> forking engine: $Src -> $Eng" +if (Test-Path $Eng) { Remove-Item -Recurse -Force $Eng } +New-Item -ItemType Directory -Force -Path $Eng | Out-Null +# Copy the source tree, excluding the venv (recreated below), git, caches. +robocopy $Src $Eng /E /XD venv .venv .git node_modules __pycache__ /XF *.pyc /NFL /NDL /NJH /NJS /NP /R:1 /W:1 | Out-Null +if ($LASTEXITCODE -ge 8) { throw "robocopy failed copying the engine ($LASTEXITCODE)" } +$global:LASTEXITCODE = 0 # robocopy uses 0-7 for success + +Write-Host "==> recreating the venv with uv (Windows can't relocate a copied venv)" +Push-Location $Eng +try { + & uv venv venv --python $PyVer + $env:UV_PROJECT_ENVIRONMENT = "$Eng\venv" + # Mirror Hermes's own install: sync the project (+ all extras) into the fresh venv. + & uv sync --extra all --locked 2>$null + if (-not (Test-Path "$Eng\venv\Scripts\hermes.exe")) { & uv sync --extra all } + if (-not (Test-Path "$Eng\venv\Scripts\hermes.exe")) { & uv pip install -e ".[all]" } +} finally { Pop-Location } +if (-not (Test-Path "$Eng\venv\Scripts\hermes.exe")) { throw "venv recreate failed: $Eng\venv\Scripts\hermes.exe missing" } + +Write-Host "==> debranding the fork" +& "$Eng\venv\Scripts\python.exe" "$here\debrand.py" "$Eng" +if ($LASTEXITCODE -ne 0) { throw "debrand.py failed" } + +Write-Host "==> isolating ghost skills -> skills-ghost (separate from a normal hermes)" +$skillFiles = @("tools\skills_hub.py","tools\skills_sync.py","tools\skills_tool.py","tools\skill_manager_tool.py","hermes_cli\skills_hub.py") +foreach ($rel in $skillFiles) { + $p = Join-Path $Eng $rel + if (Test-Path $p) { + (Get-Content -Raw $p) ` + -replace 'SKILLS_DIR = HERMES_HOME / "skills"', 'SKILLS_DIR = HERMES_HOME / "skills-ghost"' ` + -replace '/skills/', '/skills-ghost/' | Set-Content -NoNewline -Encoding UTF8 $p + } +} + +Write-Host "==> verifying the fork launches (expect 'Ghost vX.Y')" +& "$Eng\venv\Scripts\hermes.exe" --version +if ($LASTEXITCODE -ne 0) { throw "fork failed to launch" } +Write-Host "==> fork ready: $Eng"