feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) (#40355)

* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI)

Adds a GUI-only uninstall path so people can remove the desktop Chat GUI
while keeping the Hermes agent + their config/sessions/.env, and surfaces
the three CLI uninstall modes inside the desktop app's Settings → About.

CLI:
- New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the
  desktop GUI's artifacts (source-built dist/release/node_modules + build
  stamp, the packaged app bundle, and the Electron userData dir) on Linux,
  macOS, and Windows. Never touches the agent source, venv, or user data.
- `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a
  JSON install snapshot (used by the desktop UI to gate options + detect a
  missing agent for a future lite client).
- `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing
  the destructive sequence via a new _perform_uninstall() helper. The keep-data
  and full flows also sweep the GUI artifacts.

Desktop:
- electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full)
  to CLI flags, resolving the running app bundle per OS, and building the
  detached cleanup script that waits for the app to exit, runs the Python
  uninstall, and removes the bundle.
- IPC hermes:uninstall:summary / :run, preload bridge, and types.
- Settings → About "Danger zone" with the three options; agent-removing
  options hide when no local agent is detected.

Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing
uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into
test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md.

* fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety)

The desktop uninstall cleanup script waited only on the desktop app's own
PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can
outlive it and keep hermes.exe + venv files mandatory-locked on Windows —
making the script's rmdir half-fail and leaving a partial install, the same
failure class as the self-update path's #37532.

- main.cjs: runDesktopUninstall now awaits releaseBackendLock() before
  spawning the cleanup script — tree-kills every backend PID the desktop owns
  (primary + pool) via taskkill /T /F and polls the venv shim until unlocked.
  Extracted the shared core out of releaseBackendLockForUpdate so both the
  update hand-off and the uninstaller use the identical, incident-hardened
  teardown. No-op on macOS/Linux (no mandatory locks).
- desktop-uninstall.cjs: Windows cleanup script removes the bundle via a
  bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows
  releases directory handles lazily even after the holding process exits.
- Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side
  tree-kill-by-PID is the reliable mechanism.

Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass).

* fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop)

Resolves @OutThisLife's review on #40355:

1. full mode now gated on agent presence (needsAgent: true). It removes the
   agent + user data, so on a lite client with no local agent it's hidden
   like lite — no more offering to remove an agent that isn't there.

2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the
   venv's OWN python. On Windows a running python.exe is mandatory-locked, so
   that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X'
   entrypoint (stdlib-only imports) lets the desktop run agent-removing modes
   under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so
   import hermes_cli resolves from source while the venv is torn down. Falls
   back to venv python + logs when no system python (gui-only unaffected).

3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the
   PID as a whole space-delimited token via findstr (no substring 99->990
   trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted.

4. Renamed the misleading 'returns null for dev run' test — the dev-run safety
   is shouldRemoveAppBundle(isPackaged=false), which the test now asserts.

Docs: note that --gui on a source checkout also sweeps node_modules/build
output. Tests: 18 python + 19 desktop pass.
This commit is contained in:
Teknium 2026-06-06 18:22:38 -07:00 committed by GitHub
parent f2e8234307
commit 5b43bf7d02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1784 additions and 10 deletions

View file

@ -0,0 +1,232 @@
/**
* desktop-uninstall.cjs
*
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - 'gui' remove ONLY the Chat GUI, keep the agent + all user data.
* `hermes uninstall --gui --yes`
* - 'lite' remove the GUI + agent code, KEEP user data (config / sessions
* / .env) for a future reinstall. `hermes uninstall --yes`
* - 'full' remove everything: GUI + agent + all user data.
* `hermes uninstall --full --yes`
*
* Why a detached cleanup script: 'lite'/'full' delete the very venv the
* `hermes` command runs from, and every mode may need to delete the running
* app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.cjs.
*/
const path = require('node:path')
const UNINSTALL_MODES = ['gui', 'lite', 'full']
/**
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
* python executable). Uses the dedicated lightweight module entrypoint (not
* `hermes_cli.main`) so it can run under a system Python OUTSIDE the venv that
* lite/full delete see the Finding-3 note in buildWindowsCleanupScript.
* Throws on an unknown mode so a typo can't silently become a full wipe.
*/
function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
/** True when `mode` removes the agent (lite/full), false for gui-only. */
function modeRemovesAgent(mode) {
return mode === 'lite' || mode === 'full'
}
/** True when `mode` removes user data (full only). */
function modeRemovesUserData(mode) {
return mode === 'full'
}
/**
* Resolve the on-disk app bundle/dir to remove for the running desktop app,
* given the path to the running executable (`process.execPath`) and platform.
*
* macOS: /Hermes.app/Contents/MacOS/Hermes /Hermes.app
* Windows: \Hermes\Hermes.exe \Hermes (install dir)
* Linux: AppImage the APPIMAGE env path; unpacked the *-unpacked dir
*
* Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree).
*/
function resolveRemovableAppPath(execPath, platform, env = {}) {
const exe = String(execPath || '')
if (!exe) return null
// Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even
// when these pure helpers are unit-tested on Linux/macOS CI.
const p = platform === 'win32' ? path.win32 : path.posix
if (platform === 'darwin') {
// …/Hermes.app/Contents/MacOS/Hermes → strip 3 segments to the .app
const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
return null
}
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) return env.APPIMAGE
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
return null
}
/**
* Should we even try to remove the running app bundle from a cleanup script?
* Only when packaged AND we resolved a concrete removable path. Dev runs
* (electron from node_modules) and system-package installs return null above
* and are left to the OS package manager.
*/
function shouldRemoveAppBundle(isPackaged, appPath) {
return Boolean(isPackaged) && Boolean(appPath)
}
/**
* Build a POSIX cleanup shell script (macOS / Linux). It:
* 1. waits (bounded ~30s) for the desktop PID to exit (venv/bundle unlock),
* 2. runs the Python uninstall module with the mode,
* 3. removes the app bundle if one was resolved.
*
* `pythonExe` should be a Python OUTSIDE the venv for lite/full (the venv is
* being deleted); `pythonPath` is prepended to PYTHONPATH so `import hermes_cli`
* resolves from the agent source. `q()` single-quote-escapes for the shell
* (closes-escapes-reopens any embedded apostrophe), defending against spaces.
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
'# and the app bundle are no longer in use.',
`pid=${Number(desktopPid) || 0}`,
'if [ "$pid" -gt 0 ]; then',
' for _ in $(seq 1 60); do',
' kill -0 "$pid" 2>/dev/null || break',
' sleep 0.5',
' done',
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(
`cd ${q(agentRoot)} 2>/dev/null || true`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
/**
* Build a Windows cleanup batch script. Same three steps, cmd.exe flavored.
*
* Finding 3 (venv self-deletion): for lite/full the agent uninstall rmtree's
* the venv that contains `python.exe`. A running .exe is mandatory-locked on
* Windows, so running the uninstall from the venv's OWN python half-fails. The
* desktop passes a system Python (findSystemPython) as `pythonExe` for those
* modes + `pythonPath`=agentRoot so `import hermes_cli` resolves from source
* while the venv is torn down. gui-only doesn't touch the venv, so it can use
* either interpreter.
*
* Wait-loop: bounded (matches POSIX's ~30s cap) so a never-exiting / mismatched
* PID can't wedge the cleanup forever. The `/FI "PID eq"` filter is an EXACT
* match, so no redundant `| find` (which would substring-match 99990).
*
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail retry up to 10x.
*/
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
'rem /FI "PID eq %PID%" is an EXACT filter — tasklist outputs the one task',
'rem row for that PID, or "INFO: No tasks..." otherwise. /NH drops the',
'rem header; findstr matches the PID as a whole space-delimited token so',
'rem PID 99 cannot match 990 (the substring trap of a bare `find`).',
'tasklist /NH /FI "PID eq %PID%" 2>nul | findstr /r /c:" %PID% " >nul',
'if %ERRORLEVEL% neq 0 goto waited_done',
'set /a waited+=1',
'if %waited% geq 60 goto waited_done',
'timeout /t 1 /nobreak >nul',
'goto waitloop',
':waited_done',
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
':rmloop',
`if not exist ${q(appPath)} goto rmdone`,
`rmdir /s /q ${q(appPath)} >nul 2>&1`,
`if not exist ${q(appPath)} goto rmdone`,
'set /a tries+=1',
'if %tries% geq 10 goto rmdone',
'timeout /t 1 /nobreak >nul',
'goto rmloop',
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
module.exports = {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
}

View file

@ -0,0 +1,246 @@
/**
* Tests for electron/desktop-uninstall.cjs.
*
* Run with: node --test electron/desktop-uninstall.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
* mode CLI-flag mapping, the running-app-bundle resolution per OS, and the
* cleanup-script builders (POSIX + Windows).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
// --- uninstallArgsForMode ---
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
})
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
})
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
})
// --- modeRemovesAgent / modeRemovesUserData ---
test('mode predicates classify what each mode removes', () => {
assert.equal(modeRemovesAgent('gui'), false)
assert.equal(modeRemovesAgent('lite'), true)
assert.equal(modeRemovesAgent('full'), true)
assert.equal(modeRemovesUserData('gui'), false)
assert.equal(modeRemovesUserData('lite'), false)
assert.equal(modeRemovesUserData('full'), true)
})
// --- resolveRemovableAppPath ---
test('resolveRemovableAppPath finds the .app bundle on macOS', () => {
assert.equal(
resolveRemovableAppPath('/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
'/Applications/Hermes.app'
)
assert.equal(
resolveRemovableAppPath('/Users/x/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
'/Users/x/Applications/Hermes.app'
)
})
test('resolveRemovableAppPath: dev-run .app resolves (safety is shouldRemoveAppBundle, not null)', () => {
// A dev run from node_modules' Electron DOES resolve to a .app — the real
// dev-run safety gate is shouldRemoveAppBundle(isPackaged=false,...), not a
// null return here. This test documents that contract.
assert.equal(
resolveRemovableAppPath('/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', 'darwin'),
'/repo/node_modules/electron/dist/Electron.app'
)
assert.equal(shouldRemoveAppBundle(false, '/repo/node_modules/electron/dist/Electron.app'), false)
// A bare path with no .app ancestor → null.
assert.equal(resolveRemovableAppPath('/usr/bin/electron', 'darwin'), null)
})
test('resolveRemovableAppPath finds the install dir on Windows', () => {
assert.equal(
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\Programs\\Hermes\\Hermes.exe', 'win32'),
'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes'
)
assert.equal(
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\hermes-desktop\\Hermes.exe', 'win32'),
'C:\\Users\\x\\AppData\\Local\\hermes-desktop'
)
})
test('resolveRemovableAppPath returns null for an unrecognized Windows dir', () => {
assert.equal(resolveRemovableAppPath('C:\\Temp\\foo\\Hermes.exe', 'win32'), null)
})
test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
assert.equal(
resolveRemovableAppPath('/tmp/.mount_HermesXXXX/hermes', 'linux', { APPIMAGE: '/home/x/Apps/Hermes.AppImage' }),
'/home/x/Apps/Hermes.AppImage'
)
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
assert.equal(
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
'/opt/hermes/linux-unpacked'
)
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})
test('resolveRemovableAppPath returns null for an empty exe path', () => {
assert.equal(resolveRemovableAppPath('', 'darwin'), null)
assert.equal(resolveRemovableAppPath(null, 'win32'), null)
})
// --- shouldRemoveAppBundle ---
test('shouldRemoveAppBundle requires packaged AND a resolved path', () => {
assert.equal(shouldRemoveAppBundle(true, '/Applications/Hermes.app'), true)
assert.equal(shouldRemoveAppBundle(false, '/Applications/Hermes.app'), false)
assert.equal(shouldRemoveAppBundle(true, null), false)
assert.equal(shouldRemoveAppBundle(false, null), false)
})
// --- buildPosixCleanupScript ---
test('buildPosixCleanupScript waits for the PID, runs the uninstall module, removes bundle', () => {
const script = buildPosixCleanupScript({
desktopPid: 4321,
pythonExe: '/home/x/.hermes/hermes-agent/venv/bin/python',
pythonPath: null,
agentRoot: '/home/x/.hermes/hermes-agent',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
// bounded wait (~30s), not unbounded
assert.match(script, /seq 1 60/)
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'gui'/)
assert.match(script, /rm -rf '\/opt\/hermes\/linux-unpacked'/)
assert.match(script, /export HERMES_HOME='\/home\/x\/\.hermes'/)
})
test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/full)', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/usr/bin/python3',
pythonPath: '/home/x/.hermes/hermes-agent',
agentRoot: '/home/x/.hermes/hermes-agent',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
assert.match(script, /'\/usr\/bin\/python3' '-m' 'hermes_cli\.uninstall' '--mode' 'full'/)
})
test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/p/python',
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/p/python',
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'lite'],
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
})
test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: "/home/o'brien/python",
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/)
})
// --- buildWindowsCleanupScript ---
test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir bundle', () => {
const script = buildWindowsCleanupScript({
desktopPid: 9988,
pythonExe: 'C:\\Python313\\python.exe',
pythonPath: 'C:\\hermes',
agentRoot: 'C:\\hermes',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source.
assert.match(script, /set "PYTHONPATH=C:\\hermes;%PYTHONPATH%"/)
assert.match(script, /"C:\\Python313\\python.exe" "-m" "hermes_cli\.uninstall" "--mode" "full"/)
// Bounded wait-loop (no infinite loop), whole-token PID match (no substring).
assert.match(script, /if %waited% geq 60 goto waited_done/)
assert.match(script, /findstr \/r \/c:" %PID% "/)
assert.doesNotMatch(script, /find "%PID%"/) // the old substring-prone form is gone
// Removal is a retry loop (Windows releases dir handles lazily).
assert.match(script, /:rmloop/)
assert.match(script, /rmdir \/s \/q "C:\\Users\\x\\AppData\\Local\\Programs\\Hermes" >nul 2>&1/)
assert.match(script, /if %tries% geq 10 goto rmdone/)
assert.match(script, /del "%~f0"/)
})
test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, no bundle)', () => {
const script = buildWindowsCleanupScript({
desktopPid: 2,
pythonExe: 'C:\\h\\venv\\Scripts\\python.exe',
pythonPath: null,
agentRoot: 'C:\\h',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/)
})

View file

@ -29,6 +29,15 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@ -1488,6 +1497,20 @@ function forceKillProcessTree(pid) {
// aggressively SIGKILL-ing the backend here would be an untested behavior change
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
async function releaseBackendLockForUpdate(updateRoot) {
return releaseBackendLock(updateRoot, 'updates')
}
// Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update
// hand-off and the desktop uninstaller — they have the identical Windows
// problem: the desktop's backend (and the grandchildren IT spawned — a hermes
// REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the
// venv mandatory-locked, so any in-place replace/delete of the install tree
// races a live handle and half-fails (#37532). We tree-kill every backend PID
// the desktop owns, then poll the shim until it's genuinely writable.
//
// `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory
// locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice).
async function releaseBackendLock(updateRoot, tag) {
if (!IS_WINDOWS) return { unlocked: true }
// Collect every backend PID the desktop owns: primary window backend + pool.
@ -1512,14 +1535,12 @@ async function releaseBackendLockForUpdate(updateRoot) {
const deadlineMs = Date.now() + 15000
while (Date.now() < deadlineMs) {
if (!isShimLocked(shim)) {
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
return { unlocked: true }
}
await new Promise(r => setTimeout(r, 300))
}
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
// line of defense, and we pass --force so the guard won't dead-end. Log it.
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
return { unlocked: false }
}
@ -5473,6 +5494,199 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ===========================================================================
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
// ===========================================================================
//
// The renderer's About → Danger Zone surfaces three options that mirror the
// CLI exactly: GUI only, Lite (keep user data), Full. We ask the agent to do
// the actual removal via `hermes uninstall …` so the cross-platform PATH /
// registry / service / node-symlink cleanup all lives in one place
// (hermes_cli/uninstall.py + hermes_cli/gui_uninstall.py).
//
// getUninstallSummary() shells out to `--gui-summary` (a fast, no-side-effect
// JSON probe) so the UI can gate options on what's actually installed — and
// detect a missing agent (a future "lite client" that ships without the
// bundled agent), hiding the agent/full options when there's nothing to remove.
function uninstallVenvPython() {
return getVenvPython(VENV_ROOT)
}
async function getUninstallSummary() {
const py = uninstallVenvPython()
const agentRoot = ACTIVE_HERMES_ROOT
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
// probe fails — the renderer still needs *something* to render options from.
const fallback = () => ({
hermes_home: HERMES_HOME,
agent_installed: isHermesSourceRoot(agentRoot) && fileExists(py),
gui_installed: true,
source_built_artifacts: [],
packaged_app_paths: [],
userdata_dir: app.getPath('userData'),
userdata_exists: true,
platform: process.platform,
probe: 'fallback'
})
if (!fileExists(py)) {
return fallback()
}
return new Promise(resolve => {
let stdout = ''
let settled = false
const done = value => {
if (settled) return
settled = true
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
child.on('error', () => done(fallback()))
child.on('exit', code => {
if (code !== 0) return done(fallback())
try {
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'
const parsed = JSON.parse(line)
// The app bundle the renderer would be removing on *this* machine,
// resolved from the running exe (the Python probe only knows the
// standard locations, not where THIS build actually runs from).
parsed.running_app_path = resolveRemovableAppPath(process.execPath, process.platform, process.env)
done(parsed)
} catch {
done(fallback())
}
})
setTimeout(() => done(fallback()), 8000)
} catch {
done(fallback())
}
})
}
async function runDesktopUninstall(mode) {
let uninstallArgs
try {
uninstallArgs = uninstallArgsForMode(mode)
} catch (error) {
return { ok: false, error: 'invalid-mode', message: error.message }
}
const venvPy = uninstallVenvPython()
if (!fileExists(venvPy)) {
return {
ok: false,
error: 'agent-missing',
message: `Can't run the uninstaller: no Hermes agent venv at ${VENV_ROOT}.`
}
}
// Interpreter choice (Finding 3): lite/full rmtree the venv that holds the
// running python.exe. On Windows a running .exe is mandatory-locked, so the
// rmtree must NOT be driven by the venv's own interpreter — use a system
// Python with PYTHONPATH=<agentRoot> so `import hermes_cli` resolves from
// source while the venv is torn down. gui-only doesn't touch the venv, so the
// venv python is fine there. If no system Python exists (the Windows edge
// case), fall back to the venv python — gui-only is unaffected; lite/full may
// leave venv remnants the user can delete, which we log.
let py = venvPy
let pythonPath = null
if (modeRemovesAgent(mode)) {
const sysPy = findSystemPython()
if (sysPy) {
py = sysPy
pythonPath = ACTIVE_HERMES_ROOT
} else if (IS_WINDOWS) {
rememberLog(
'[uninstall] no system Python found for lite/full on Windows; falling back ' +
'to the venv python — venv files locked by the running interpreter may ' +
'remain and need manual deletion.'
)
}
}
const appPath = resolveRemovableAppPath(process.execPath, process.platform, process.env)
const removeBundle = shouldRemoveAppBundle(IS_PACKAGED, appPath) ? appPath : null
// CRITICAL (Windows): tear down every backend the desktop owns and wait for
// the venv shim to unlock BEFORE the cleanup script runs. lite/full delete
// the venv, and even gui-only removes the install tree's GUI artifacts — a
// live backend grandchild (gateway / pty / REPL) holding a mandatory file
// lock would make the script's rmdir half-fail (#37532 for the update path).
// Reuses the incident-hardened update teardown; no-op on macOS/Linux.
try {
await releaseBackendLock(ACTIVE_HERMES_ROOT, 'uninstall')
} catch (error) {
rememberLog(`[uninstall] backend teardown errored (continuing): ${error.message}`)
}
const scriptArgs = {
desktopPid: process.pid,
pythonExe: py,
pythonPath,
agentRoot: ACTIVE_HERMES_ROOT,
uninstallArgs,
appPath: removeBundle,
hermesHome: HERMES_HOME
}
let scriptPath
let runner
let runnerArgs
try {
if (IS_WINDOWS) {
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`)
fs.writeFileSync(scriptPath, buildWindowsCleanupScript(scriptArgs))
runner = process.env.ComSpec || 'cmd.exe'
runnerArgs = ['/c', scriptPath]
} else {
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.sh`)
fs.writeFileSync(scriptPath, buildPosixCleanupScript(scriptArgs), { mode: 0o755 })
runner = '/bin/bash'
runnerArgs = [scriptPath]
}
} catch (error) {
return { ok: false, error: 'script-write-failed', message: error.message }
}
try {
const child = spawn(runner, runnerArgs, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
} catch (error) {
return { ok: false, error: 'spawn-failed', message: error.message }
}
rememberLog(
`[uninstall] launched detached cleanup (${mode}): ${scriptPath} ` +
`(removesAgent=${modeRemovesAgent(mode)} removesUserData=${modeRemovesUserData(mode)} bundle=${removeBundle || 'none'})`
)
// Give the renderer a beat to show its "uninstalling…" state, then quit so
// the venv python shim + app bundle unlock and the cleanup script can run.
setTimeout(() => app.quit(), 800)
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
}
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())

View file

@ -117,6 +117,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
uninstall: {
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
},
updates: {
check: () => ipcRenderer.invoke('hermes:updates:check'),
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),

View file

@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View file

@ -17,6 +17,7 @@ import {
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { UninstallSection } from './uninstall-section'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
@ -167,6 +168,8 @@ export function AboutSettings() {
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<UninstallSection />
</div>
</SettingsContent>
)

View file

@ -0,0 +1,185 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Loader2, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { DesktopUninstallMode, DesktopUninstallSummary } from '@/global'
import { SectionHeading } from './primitives'
interface ModeOption {
mode: DesktopUninstallMode
title: string
description: string
/** Shown in the confirm step so people know exactly what disappears. */
consequence: string
/** True when the option removes the Python agent (hidden if no agent). */
needsAgent: boolean
}
const OPTIONS: ModeOption[] = [
{
mode: 'gui',
title: 'Uninstall Chat GUI only',
description: 'Remove this desktop app. The Hermes agent, your config, and chats all stay.',
consequence: 'the desktop Chat GUI (this app and its data)',
needsAgent: false
},
{
mode: 'lite',
title: 'Uninstall GUI + agent, keep my data',
description: 'Remove the app and the Hermes agent, but keep config, chats, and secrets for a future reinstall.',
consequence: 'the Chat GUI and the Hermes agent (config, chats, and secrets are kept)',
needsAgent: true
},
{
mode: 'full',
title: 'Uninstall everything',
description: 'Remove the app, the agent, and all user data — config, chats, scheduled jobs, secrets, logs.',
consequence: 'EVERYTHING — the Chat GUI, the Hermes agent, and all of your config, chats, secrets, and logs',
// full removes the agent (and user data), so it's an agent-removing option:
// hide it on a lite client with no local agent, same as lite. A lite client
// connecting to a remote backend has no local agent OR local user data the
// GUI installed, so gui-only is the correct (and only) option there.
needsAgent: true
}
]
export function UninstallSection() {
const [summary, setSummary] = useState<DesktopUninstallSummary | null>(null)
const [loading, setLoading] = useState(true)
const [pending, setPending] = useState<DesktopUninstallMode | null>(null)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
setLoading(false)
return
}
void bridge
.summary()
.then(result => {
if (alive) {
setSummary(result)
}
})
.catch(() => {
// Non-fatal — we degrade to offering the GUI-only option.
})
.finally(() => {
if (alive) {
setLoading(false)
}
})
return () => {
alive = false
}
}, [])
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
return null
}
// Gate the agent-removing options on whether an agent is actually present.
// A future lite client that ships without the bundled agent shows GUI-only.
const agentInstalled = summary?.agent_installed ?? false
const visibleOptions = OPTIONS.filter(opt => agentInstalled || !opt.needsAgent)
const handleConfirm = async () => {
if (!pending) {
return
}
setRunning(true)
setError(null)
try {
const result = await bridge.run(pending)
if (!result.ok) {
setError(result.message || result.error || 'Uninstall could not start.')
setRunning(false)
setPending(null)
}
// On success the app quits shortly; keep the spinner up until it does.
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setRunning(false)
setPending(null)
}
}
const pendingOption = OPTIONS.find(opt => opt.mode === pending) ?? null
return (
<div className="mx-auto mt-8 w-full max-w-2xl">
<SectionHeading icon={AlertTriangle} title="Danger zone" />
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
{loading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking what&apos;s installed
</div>
) : pendingOption ? (
<div>
<p className="text-sm font-medium text-destructive">Confirm uninstall</p>
<p className="mt-1 text-xs text-muted-foreground">
This removes {pendingOption.consequence}. This can&apos;t be undone.
</p>
{summary?.running_app_path && (
<p className="mt-1 font-mono text-[0.68rem] text-muted-foreground/60">
App: {summary.running_app_path}
</p>
)}
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
disabled={running}
onClick={() => void handleConfirm()}
size="sm"
variant="destructive"
>
{running && <Loader2 className="size-3 animate-spin" />}
{running ? 'Uninstalling…' : 'Yes, uninstall'}
</Button>
<Button disabled={running} onClick={() => setPending(null)} size="sm" variant="text">
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Uninstall Hermes</p>
<p className="text-xs text-muted-foreground">
Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back.
</p>
<div className="mt-1 flex flex-col gap-2">
{visibleOptions.map(opt => (
<button
className={cn(
'flex items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 text-left transition',
'hover:border-destructive/40 hover:bg-destructive/5'
)}
key={opt.mode}
onClick={() => {
setError(null)
setPending(opt.mode)
}}
type="button"
>
<Trash2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{opt.title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{opt.description}</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -81,6 +81,10 @@ declare global {
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
uninstall: {
summary: () => Promise<DesktopUninstallSummary>
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
}
}
}
}
@ -104,6 +108,30 @@ export interface DesktopVersionInfo {
hermesRoot: string
}
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
export interface DesktopUninstallSummary {
hermes_home: string
agent_installed: boolean
gui_installed: boolean
source_built_artifacts: string[]
packaged_app_paths: string[]
userdata_dir: string
userdata_exists: boolean
platform: string
running_app_path?: null | string
probe?: string
}
export interface DesktopUninstallResult {
ok: boolean
mode?: DesktopUninstallMode
willRemoveAppBundle?: boolean
scriptPath?: string
error?: string
message?: string
}
export interface DesktopUpdateCommit {
sha: string
summary: string

285
hermes_cli/gui_uninstall.py Normal file
View file

@ -0,0 +1,285 @@
"""
Hermes Desktop (Chat GUI) uninstaller.
The desktop GUI ships in two shapes and this module knows how to find and
remove the artifacts of both, on Linux, macOS, and Windows, WITHOUT touching
the Python agent or the user's config/data:
1. Source-built GUI (``hermes desktop`` / ``hermes gui``)
Built inside the agent checkout under ``$HERMES_HOME/hermes-agent/``:
- ``apps/desktop/dist`` (compiled renderer)
- ``apps/desktop/release`` (electron-builder unpacked app + installers)
- ``apps/desktop/node_modules`` and the workspace-root ``node_modules``
(Electron itself, ~200MB) only removed on a GUI uninstall because
the agent does not need them.
- ``$HERMES_HOME/desktop-build-stamp.json`` (the build freshness stamp)
2. Packaged distributable (DMG / NSIS / AppImage / deb / rpm)
Installed by the OS to a standard application location and carrying its
own bundled Electron + a per-user Electron ``userData`` directory:
- macOS: ``/Applications/Hermes.app`` or ``~/Applications/Hermes.app``
- Windows: ``%LOCALAPPDATA%\\Programs\\Hermes`` (NSIS per-user)
- Linux: ``~/.local/share/applications`` .desktop entry + AppImage
In both shapes the Electron runtime keeps a ``userData`` directory keyed on
the app name ("Hermes"), separate from ``$HERMES_HOME``:
- macOS: ``~/Library/Application Support/Hermes``
- Windows: ``%APPDATA%\\Hermes``
- Linux: ``$XDG_CONFIG_HOME/Hermes`` (default ``~/.config/Hermes``)
This holds the desktop's own ``connection.json`` / ``updates.json`` and
Chromium cache pure GUI state, safe to remove on a GUI uninstall.
The functions here are deliberately import-light and side-effect-free at
import time so the Electron main process can shell out to
``hermes uninstall --gui`` (and friends) without paying for the full CLI.
"""
import os
import shutil
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.colors import Colors, color
def log_info(msg: str):
print(f"{color('', Colors.CYAN)} {msg}")
def log_success(msg: str):
print(f"{color('', Colors.GREEN)} {msg}")
def log_warn(msg: str):
print(f"{color('', Colors.YELLOW)} {msg}")
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def _agent_root(hermes_home: Path) -> Path:
"""The agent checkout root — same layout install.sh / install.ps1 use."""
return hermes_home / "hermes-agent"
def desktop_userdata_dir() -> Path:
"""Return the Electron ``userData`` directory for the desktop app.
Mirrors Electron's ``app.getPath('userData')`` for an app named "Hermes"
on each platform. This is GUI-only state (connection.json, updates.json,
Chromium cache) and never holds agent config or sessions.
"""
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Application Support" / "Hermes"
if sys.platform == "win32":
appdata = os.environ.get("APPDATA")
base = Path(appdata) if appdata else (home / "AppData" / "Roaming")
return base / "Hermes"
# Linux / other POSIX — XDG config home.
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else (home / ".config")
return base / "Hermes"
def source_built_gui_artifacts(hermes_home: Path) -> "list[Path]":
"""GUI build artifacts produced by ``hermes desktop`` inside the checkout.
These are removable on a GUI uninstall without harming the agent: the
Python agent runs from ``hermes-agent/`` source + ``venv/`` and never
needs the Electron build output or node_modules.
"""
agent_root = _agent_root(hermes_home)
desktop_dir = agent_root / "apps" / "desktop"
return [
desktop_dir / "dist",
desktop_dir / "release",
desktop_dir / "node_modules",
# Workspace-root node_modules carries Electron (devDependency of the
# desktop workspace, ~200MB). The agent does not use any npm package,
# so this is GUI tooling — safe to drop on a GUI uninstall.
agent_root / "node_modules",
hermes_home / "desktop-build-stamp.json",
]
def packaged_gui_app_paths() -> "list[Path]":
"""Standard install locations of the packaged desktop distributable.
Returns every candidate for the current OS; the caller filters to those
that actually exist. We never glob system-wide only the well-known
electron-builder output locations for the "Hermes" product.
"""
home = Path.home()
paths: list[Path] = []
if sys.platform == "darwin":
paths += [
Path("/Applications/Hermes.app"),
home / "Applications" / "Hermes.app",
]
elif sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
local_base = Path(local) if local else (home / "AppData" / "Local")
paths += [
# NSIS per-user install (perMachine=false → Programs\Hermes).
local_base / "Programs" / "Hermes",
# Older / alternate layout some builds used.
local_base / "hermes-desktop",
]
program_files = os.environ.get("ProgramFiles")
if program_files:
# NSIS per-machine fallback (needs admin to remove).
paths.append(Path(program_files) / "Hermes")
else:
# Linux: AppImage is a single file the user placed somewhere; we can
# only reliably clean the desktop entry + icon we know the name of.
# The AppImage itself lives wherever the user put it, so we surface a
# hint rather than guessing. deb/rpm installs are owned by the system
# package manager and must be removed via apt/dnf — see the message in
# ``uninstall_gui``.
data = os.environ.get("XDG_DATA_HOME")
data_base = Path(data) if data else (home / ".local" / "share")
paths += [
data_base / "applications" / "hermes.desktop",
data_base / "applications" / "Hermes.desktop",
]
return paths
def agent_is_installed(hermes_home: Path) -> bool:
"""Return True when a usable Python agent install exists under HERMES_HOME.
Used by the desktop UI to decide which uninstall options to offer: if the
agent isn't present (a future "lite" GUI-only client), the "remove agent"
options are hidden.
"""
agent_root = _agent_root(hermes_home)
# A real install has the package source + a venv. Either signal alone is
# enough — a source checkout without a venv is still "the agent is here".
if (agent_root / "hermes_cli").is_dir():
return True
if (agent_root / "venv").is_dir() or (agent_root / ".venv").is_dir():
return True
return False
def gui_is_installed(hermes_home: Path) -> bool:
"""Return True when any desktop GUI artifact exists (built or packaged)."""
for p in source_built_gui_artifacts(hermes_home):
if p.exists():
return True
for p in packaged_gui_app_paths():
if p.exists():
return True
if desktop_userdata_dir().exists():
return True
return False
def gui_install_summary(hermes_home: "Path | None" = None) -> dict:
"""Structured snapshot of what's installed, for the desktop UI to render.
Returns JSON-serializable primitives so the Electron main process can
forward it to the renderer via IPC (paths as strings, booleans for the
high-level questions the UI gates options on).
"""
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
source_artifacts = [p for p in source_built_gui_artifacts(home) if p.exists()]
packaged = [p for p in packaged_gui_app_paths() if p.exists()]
userdata = desktop_userdata_dir()
return {
"hermes_home": str(home),
"agent_installed": agent_is_installed(home),
"gui_installed": gui_is_installed(home),
"source_built_artifacts": [str(p) for p in source_artifacts],
"packaged_app_paths": [str(p) for p in packaged],
"userdata_dir": str(userdata),
"userdata_exists": userdata.exists(),
"platform": sys.platform,
}
# ---------------------------------------------------------------------------
# Removal
# ---------------------------------------------------------------------------
def _remove_path(path: Path) -> bool:
"""Remove a file or directory tree. Returns True when something was removed."""
try:
if path.is_symlink() or path.is_file():
path.unlink()
return True
if path.is_dir():
shutil.rmtree(path)
return True
except Exception as e:
log_warn(f"Could not remove {path}: {e}")
return False
def uninstall_gui(hermes_home: "Path | None" = None, *, remove_userdata: bool = True) -> "list[Path]":
"""Remove the desktop GUI's artifacts, leaving the agent + user data intact.
Removes:
- source-built GUI artifacts (dist/release/node_modules/build-stamp)
- the packaged app bundle / install dir (best-effort; deb/rpm need the
system package manager and are reported, not force-removed)
- the Electron ``userData`` directory (unless ``remove_userdata=False``)
Never touches ``hermes-agent/hermes_cli`` (agent source), ``venv/``, or any
config / sessions / .env under ``$HERMES_HOME``.
Returns the list of paths actually removed.
"""
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
removed: list[Path] = []
log_info("Removing built GUI artifacts (renderer, release, node_modules)...")
for path in source_built_gui_artifacts(home):
if path.exists() and _remove_path(path):
log_success(f"Removed {path}")
removed.append(path)
log_info("Removing installed desktop app...")
found_packaged = False
for path in packaged_gui_app_paths():
if path.exists():
found_packaged = True
if _remove_path(path):
log_success(f"Removed {path}")
removed.append(path)
if not found_packaged:
log_info("No packaged desktop app found in standard locations")
if remove_userdata:
userdata = desktop_userdata_dir()
if userdata.exists():
log_info("Removing desktop app data (Electron userData)...")
if _remove_path(userdata):
log_success(f"Removed {userdata}")
removed.append(userdata)
if not removed:
log_info("No desktop GUI artifacts found to remove")
# Linux deb/rpm installs are owned by the package manager; we can't (and
# shouldn't) rmtree files under /usr. Surface the hint so the user can
# finish the job. AppImages live wherever the user dropped them.
if sys.platform.startswith("linux"):
log_info(
"If you installed the desktop via a .deb / .rpm package, remove it "
"with your package manager (e.g. 'sudo apt remove hermes' or "
"'sudo dnf remove hermes'). AppImage builds are a single file you "
"can delete from wherever you saved it."
)
return removed

View file

@ -6697,8 +6697,30 @@ def cmd_version(args):
def cmd_uninstall(args):
"""Uninstall Hermes Agent."""
_require_tty("uninstall")
"""Uninstall Hermes Agent (or just the Chat GUI with --gui)."""
# Machine-readable install snapshot for the desktop app's uninstall UI.
# Must run before any TTY gate — it's called from a non-interactive child.
if getattr(args, "gui_summary", False):
from hermes_cli.gui_uninstall import gui_install_summary
print(json.dumps(gui_install_summary()))
return
# GUI-only uninstall. The desktop app shells out to this non-interactively
# with --yes, so only gate on a TTY when we actually need to prompt.
if getattr(args, "gui", False):
if not getattr(args, "yes", False):
_require_tty("uninstall --gui")
from hermes_cli.uninstall import run_gui_uninstall
run_gui_uninstall(args)
return
# Full/keep-data uninstall. ``--yes`` runs non-interactively (the desktop
# app's lite/full modes drive this from a detached cleanup script), so only
# gate on a TTY when we actually need to prompt for the option + confirm.
if not getattr(args, "yes", False):
_require_tty("uninstall")
from hermes_cli.uninstall import run_uninstall
run_uninstall(args)
@ -15454,6 +15476,17 @@ Examples:
action="store_true",
help="Full uninstall - remove everything including configs and data",
)
uninstall_parser.add_argument(
"--gui",
action="store_true",
help="Uninstall only the desktop Chat GUI, leaving the agent intact",
)
uninstall_parser.add_argument(
"--gui-summary",
action="store_true",
help="Print a JSON summary of installed GUI/agent artifacts and exit "
"(used by the desktop app to gate uninstall options)",
)
uninstall_parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)

View file

@ -492,6 +492,78 @@ def _uninstall_profile(profile) -> None:
log_warn(f" Could not remove {profile_home}: {e}")
def run_gui_uninstall(args):
"""GUI-only uninstall: remove the Chat GUI, leave the agent + data intact.
Mirrors ``hermes uninstall --gui``. Removes the desktop app's built
artifacts, the packaged app bundle (best-effort), and the Electron
userData dir nothing under ``$HERMES_HOME`` config/sessions/.env, and
never the Python agent or its venv.
"""
from hermes_cli.gui_uninstall import (
agent_is_installed,
gui_install_summary,
uninstall_gui,
)
hermes_home = get_hermes_home()
summary = gui_install_summary(hermes_home)
skip_confirm = bool(getattr(args, "yes", False))
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
print(color("│ ⚕ Hermes Chat GUI Uninstaller │", Colors.MAGENTA, Colors.BOLD))
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA, Colors.BOLD))
print()
if not summary["gui_installed"]:
print("No Hermes Chat GUI installation was found.")
print(f" Checked: {hermes_home}, and the standard app locations for this OS.")
return
print(color("This removes the Chat GUI only. The Hermes agent stays installed.", Colors.CYAN))
print()
print(color("Will remove:", Colors.YELLOW, Colors.BOLD))
for p in summary["source_built_artifacts"]:
print(f"{p}")
for p in summary["packaged_app_paths"]:
print(f"{p}")
if summary["userdata_exists"]:
print(f"{summary['userdata_dir']} (desktop app data)")
print()
if agent_is_installed(hermes_home):
print(color("Kept intact:", Colors.GREEN, Colors.BOLD))
print(f" • The Hermes agent at {hermes_home / 'hermes-agent'}")
print(f" • Your config, sessions, and secrets under {hermes_home}")
print()
if not skip_confirm:
try:
confirm = input(f"Type '{color('yes', Colors.YELLOW)}' to remove the Chat GUI: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
print("Cancelled.")
return
if confirm != "yes":
print()
print("Uninstall cancelled.")
return
print()
print(color("Uninstalling Chat GUI...", Colors.CYAN, Colors.BOLD))
print()
uninstall_gui(hermes_home)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN, Colors.BOLD))
print(color("│ ✓ Chat GUI Uninstalled! │", Colors.GREEN, Colors.BOLD))
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN, Colors.BOLD))
print()
print("The Hermes agent is still installed. Run 'hermes' to use the CLI,")
print("or 'hermes uninstall' to remove the agent too.")
print()
def run_uninstall(args):
"""
Run the uninstall process.
@ -509,6 +581,24 @@ def run_uninstall(args):
is_default_profile = _is_default_hermes_home(hermes_home)
named_profiles = _discover_named_profiles() if is_default_profile else []
# Non-interactive fast path (``--yes``): no prompts. ``--full`` selects a
# full wipe (code + ~/.hermes data); otherwise keep-data. Named profiles
# are NOT auto-removed here — that's a destructive, surprising default for
# an unattended run, so it stays opt-in to the interactive flow. This is
# the path the desktop app's detached cleanup script uses for its
# lite/full modes.
skip_confirm = bool(getattr(args, "yes", False))
if skip_confirm:
full_uninstall = bool(getattr(args, "full", False))
_perform_uninstall(
project_root=project_root,
hermes_home=hermes_home,
full_uninstall=full_uninstall,
remove_profiles=False,
named_profiles=named_profiles,
)
return
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD))
@ -604,7 +694,32 @@ def run_uninstall(args):
print()
print("Uninstall cancelled.")
return
_perform_uninstall(
project_root=project_root,
hermes_home=hermes_home,
full_uninstall=full_uninstall,
remove_profiles=remove_profiles,
named_profiles=named_profiles,
)
def _perform_uninstall(
*,
project_root: Path,
hermes_home: Path,
full_uninstall: bool,
remove_profiles: bool,
named_profiles: list,
) -> None:
"""Execute the uninstall steps. Shared by the interactive and ``--yes``
paths so the destructive sequence lives in exactly one place.
Steps: stop gateway strip PATH (rc files + Windows registry) remove the
``hermes`` wrapper + node symlinks remove the desktop Chat GUI artifacts
delete the code checkout (Windows) remove PortableGit/Node optionally
wipe ``$HERMES_HOME`` data and named profiles on full uninstall.
"""
print()
print(color("Uninstalling...", Colors.CYAN, Colors.BOLD))
print()
@ -664,7 +779,25 @@ def run_uninstall(args):
log_success(f"Removed {link}")
else:
log_info("No Hermes-managed node/npm/npx symlinks found")
# 3c. Remove the desktop Chat GUI's artifacts too (built renderer/release,
# node_modules, the packaged app bundle, and the Electron userData
# dir). Both the "keep data" and "full" CLI flows remove the agent
# code, so the GUI — which is just another consumer of the same
# checkout — should go with it. uninstall_gui() never touches config /
# sessions / .env, so it's safe in keep-data mode; on full uninstall the
# step-5 rmtree(hermes_home) would sweep the in-tree artifacts anyway,
# but the packaged app + Electron userData live OUTSIDE HERMES_HOME and
# must be cleaned explicitly here.
log_info("Removing desktop Chat GUI artifacts...")
try:
from hermes_cli.gui_uninstall import uninstall_gui
gui_removed = uninstall_gui(hermes_home)
if not gui_removed:
log_info("No desktop GUI artifacts found")
except Exception as e:
log_warn(f"Could not remove desktop GUI artifacts: {e}")
# 4. Remove installation directory (code)
log_info("Removing installation directory...")
@ -748,3 +881,50 @@ def run_uninstall(args):
print()
print("Thank you for using Hermes Agent! ⚕")
print()
class _UninstallArgs:
"""Lightweight args namespace for the module entrypoint below."""
def __init__(self, *, mode: str):
self.gui = mode == "gui"
self.gui_summary = False
self.full = mode == "full"
self.yes = True # the module entrypoint is always non-interactive
def main(argv=None) -> int:
"""Module entrypoint: ``python -m hermes_cli.uninstall --mode <gui|lite|full>``.
Exists so the desktop app can run the uninstall under a Python interpreter
OUTSIDE the venv being deleted. On Windows, ``lite``/``full`` rmtree the
venv that contains the running ``python.exe`` and a running .exe is
mandatory-locked, so doing that from the venv's own interpreter half-fails.
The desktop launches this with the system Python + ``PYTHONPATH=<agentRoot>``
so ``import hermes_cli`` resolves from source while the venv is torn down.
This module imports only stdlib + ``hermes_constants`` + ``hermes_cli.colors``
(and lazily ``hermes_cli.gui_uninstall``), so it runs fine under a bare
system Python with no site-packages from the venv.
"""
import argparse
parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall")
parser.add_argument(
"--mode",
choices=["gui", "lite", "full"],
required=True,
help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything",
)
ns = parser.parse_args(argv)
args = _UninstallArgs(mode=ns.mode)
if args.gui:
run_gui_uninstall(args)
else:
run_uninstall(args)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,348 @@
"""Tests for hermes_cli.gui_uninstall — GUI-only uninstall + install discovery.
Covers the cross-platform artifact discovery, the agent/GUI detection the
desktop UI gates options on, and that ``uninstall_gui`` removes only GUI
artifacts (built renderer/release/node_modules, packaged bundle, Electron
userData) while leaving the Python agent + config/sessions/.env intact.
"""
import sys
from pathlib import Path
import pytest
import hermes_cli.gui_uninstall as gu
def _make_agent(hermes_home: Path) -> Path:
"""Create a fake agent install: source package + venv."""
agent_root = hermes_home / "hermes-agent"
(agent_root / "hermes_cli").mkdir(parents=True)
(agent_root / "hermes_cli" / "__init__.py").write_text("")
(agent_root / "venv" / "bin").mkdir(parents=True)
return agent_root
def _make_gui_build(hermes_home: Path) -> None:
"""Create the source-built GUI artifacts a `hermes desktop` run produces."""
desktop = hermes_home / "hermes-agent" / "apps" / "desktop"
(desktop / "dist").mkdir(parents=True)
(desktop / "dist" / "index.html").write_text("<html>")
(desktop / "release" / "linux-unpacked").mkdir(parents=True)
(desktop / "node_modules").mkdir(parents=True)
(hermes_home / "hermes-agent" / "node_modules").mkdir(parents=True)
(hermes_home / "desktop-build-stamp.json").write_text("{}")
def _make_user_data(hermes_home: Path) -> None:
(hermes_home / "config.yaml").write_text("x: 1\n")
(hermes_home / ".env").write_text("KEY=secret\n")
(hermes_home / "sessions").mkdir()
def test_agent_is_installed_detects_source_and_venv(tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
assert gu.agent_is_installed(hermes_home) is False
_make_agent(hermes_home)
assert gu.agent_is_installed(hermes_home) is True
def test_agent_is_installed_venv_only(tmp_path):
"""A checkout with only a venv (no package dir yet) still counts."""
hermes_home = tmp_path / ".hermes"
(hermes_home / "hermes-agent" / "venv").mkdir(parents=True)
assert gu.agent_is_installed(hermes_home) is True
def test_source_built_artifacts_lists_known_paths(tmp_path):
hermes_home = tmp_path / ".hermes"
_make_gui_build(hermes_home)
artifacts = gu.source_built_gui_artifacts(hermes_home)
names = {p.name for p in artifacts}
assert "dist" in names
assert "release" in names
assert "node_modules" in names
assert "desktop-build-stamp.json" in names
def test_gui_is_installed_true_when_built(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
_make_gui_build(hermes_home)
# Make sure packaged-app + userdata probes don't false-positive on the box
# running the test.
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope")
assert gu.gui_is_installed(hermes_home) is True
def test_gui_is_installed_false_when_nothing(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope")
assert gu.gui_is_installed(hermes_home) is False
def test_uninstall_gui_removes_only_gui_artifacts(tmp_path, monkeypatch):
"""The core invariant: GUI gone, agent + user data untouched."""
hermes_home = tmp_path / ".hermes"
agent_root = _make_agent(hermes_home)
_make_gui_build(hermes_home)
_make_user_data(hermes_home)
# Isolate the packaged-app + userdata probes from the test machine.
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "userdata-none")
removed = gu.uninstall_gui(hermes_home)
removed_names = {p.name for p in removed}
# GUI artifacts removed.
desktop = agent_root / "apps" / "desktop"
assert not (desktop / "dist").exists()
assert not (desktop / "release").exists()
assert not (desktop / "node_modules").exists()
assert not (agent_root / "node_modules").exists()
assert not (hermes_home / "desktop-build-stamp.json").exists()
assert "dist" in removed_names
# Agent + user data preserved.
assert (agent_root / "hermes_cli" / "__init__.py").exists()
assert (agent_root / "venv").exists()
assert (hermes_home / "config.yaml").exists()
assert (hermes_home / ".env").exists()
assert (hermes_home / "sessions").exists()
# The desktop source dir itself survives (only its build output is gone).
assert desktop.exists()
def test_uninstall_gui_removes_userdata(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
_make_agent(hermes_home)
userdata = tmp_path / "Hermes-userdata"
userdata.mkdir()
(userdata / "connection.json").write_text("{}")
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
gu.uninstall_gui(hermes_home)
assert not userdata.exists()
def test_uninstall_gui_keeps_userdata_when_requested(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
_make_agent(hermes_home)
userdata = tmp_path / "Hermes-userdata"
userdata.mkdir()
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
gu.uninstall_gui(hermes_home, remove_userdata=False)
assert userdata.exists()
def test_uninstall_gui_removes_packaged_bundle(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
_make_agent(hermes_home)
bundle = tmp_path / "Hermes.app"
(bundle / "Contents").mkdir(parents=True)
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [bundle])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
removed = gu.uninstall_gui(hermes_home)
assert not bundle.exists()
assert bundle in removed
def test_gui_install_summary_shape(tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
_make_agent(hermes_home)
_make_gui_build(hermes_home)
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
summary = gu.gui_install_summary(hermes_home)
# JSON-serializable primitives the desktop UI gates on.
assert summary["agent_installed"] is True
assert summary["gui_installed"] is True
assert isinstance(summary["source_built_artifacts"], list)
assert all(isinstance(p, str) for p in summary["source_built_artifacts"])
assert summary["hermes_home"] == str(hermes_home)
assert summary["platform"] == sys.platform
def test_userdata_dir_per_platform(monkeypatch):
"""userData path matches Electron's app.getPath('userData') for "Hermes"."""
home = Path("/home/tester")
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
monkeypatch.setattr(gu.sys, "platform", "darwin")
assert gu.desktop_userdata_dir() == home / "Library" / "Application Support" / "Hermes"
monkeypatch.setattr(gu.sys, "platform", "linux")
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
assert gu.desktop_userdata_dir() == home / ".config" / "Hermes"
def test_userdata_dir_windows(monkeypatch):
home = Path("/home/tester")
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
monkeypatch.setattr(gu.sys, "platform", "win32")
monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming")
assert gu.desktop_userdata_dir() == Path(r"C:\Users\tester\AppData\Roaming") / "Hermes"
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics")
def test_remove_path_handles_symlink(tmp_path):
target = tmp_path / "real"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target)
assert gu._remove_path(link) is True
assert not link.exists()
# The symlink is gone but its target is untouched.
assert target.exists()
class _Args:
"""Minimal argparse-Namespace stand-in for run_uninstall."""
def __init__(self, *, yes=False, full=False, gui=False, gui_summary=False):
self.yes = yes
self.full = full
self.gui = gui
self.gui_summary = gui_summary
def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch):
"""``--yes`` (no ``--full``) runs with no prompt, sweeps the GUI, keeps data.
We DO NOT spawn the real CLI here (its project_root removal would delete the
test checkout) we call run_uninstall in-process against a throwaway
HERMES_HOME with all the destructive externals stubbed out.
"""
import hermes_cli.uninstall as uninstall
hermes_home = tmp_path / ".hermes"
agent_root = hermes_home / "hermes-agent"
(agent_root / "hermes_cli").mkdir(parents=True)
(hermes_home / "config.yaml").write_text("x: 1\n")
desktop = agent_root / "apps" / "desktop"
(desktop / "release").mkdir(parents=True)
(hermes_home / "desktop-build-stamp.json").write_text("{}")
fake_code = tmp_path / "checkout"
fake_code.mkdir()
# Stub every destructive external so the test only exercises the control
# flow + the real GUI sweep (which is safe inside tmp_path).
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: [])
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
# Make input() blow up so a regression that reaches a prompt fails loudly.
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode"))
from hermes_cli import gui_uninstall as gu_mod
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
uninstall.run_uninstall(_Args(yes=True, full=False))
# Code checkout removed, GUI artifacts swept, but user data preserved.
assert not fake_code.exists()
assert not (hermes_home / "desktop-build-stamp.json").exists()
assert not (desktop / "release").exists()
assert (hermes_home / "config.yaml").exists()
assert hermes_home.exists()
def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch):
"""``--yes --full`` removes the whole HERMES_HOME non-interactively."""
import hermes_cli.uninstall as uninstall
hermes_home = tmp_path / ".hermes"
(hermes_home / "hermes-agent" / "hermes_cli").mkdir(parents=True)
(hermes_home / "config.yaml").write_text("x: 1\n")
fake_code = tmp_path / "checkout"
fake_code.mkdir()
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: [])
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode"))
from hermes_cli import gui_uninstall as gu_mod
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
uninstall.run_uninstall(_Args(yes=True, full=True))
assert not hermes_home.exists()
def test_uninstall_module_main_gui_mode(tmp_path, monkeypatch):
"""`python -m hermes_cli.uninstall --mode gui` runs the GUI-only path.
This is the lightweight, venv-independent entrypoint the desktop launches
with a system Python (so lite/full don't rmtree their own running venv on
Windows). Verify it dispatches by mode without prompting.
"""
import hermes_cli.uninstall as uninstall
hermes_home = tmp_path / ".hermes"
agent_root = hermes_home / "hermes-agent"
(agent_root / "hermes_cli").mkdir(parents=True)
desktop = agent_root / "apps" / "desktop"
(desktop / "release").mkdir(parents=True)
(hermes_home / "desktop-build-stamp.json").write_text("{}")
(hermes_home / "config.yaml").write_text("x: 1\n")
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
from hermes_cli import gui_uninstall as gu_mod
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
monkeypatch.setattr(gu_mod, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in module main"))
rc = uninstall.main(["--mode", "gui"])
assert rc == 0
# GUI swept, agent + config kept (gui-only contract).
assert not (desktop / "release").exists()
assert not (hermes_home / "desktop-build-stamp.json").exists()
assert (agent_root / "hermes_cli").exists()
assert (hermes_home / "config.yaml").exists()
def test_uninstall_module_main_rejects_bad_mode():
"""An invalid --mode exits non-zero (argparse), never silently full-wipes."""
import hermes_cli.uninstall as uninstall
with pytest.raises(SystemExit) as exc:
uninstall.main(["--mode", "nuke"])
assert exc.value.code != 0
def test_uninstall_args_namespace_mode_mapping():
"""_UninstallArgs maps mode → the gui/full flags run_uninstall reads."""
import hermes_cli.uninstall as uninstall
gui = uninstall._UninstallArgs(mode="gui")
assert gui.gui is True and gui.full is False and gui.yes is True
lite = uninstall._UninstallArgs(mode="lite")
assert lite.gui is False and lite.full is False and lite.yes is True
full = uninstall._UninstallArgs(mode="full")
assert full.gui is False and full.full is True and full.yes is True

View file

@ -1467,7 +1467,7 @@ Additional behavior:
| `hermes version` | Print version information. |
| `hermes update` | Pull latest changes and reinstall dependencies. |
| `hermes postinstall` | Internal bootstrap. Runs once after `pip install hermes-agent` (or `hermes update` on pip installs) to install non-Python dependencies that pip cannot provide — Node.js runtime, headless browser, ripgrep, ffmpeg — and then trigger `hermes setup` if the profile has not been configured yet. Safe to re-run idempotently. |
| `hermes uninstall [--full] [--yes]` | Remove Hermes, optionally deleting all config/data. |
| `hermes uninstall [--full] [--gui] [--yes]` | Remove Hermes, optionally deleting all config/data. `--gui` removes only the desktop Chat GUI, leaving the agent intact; `--full` also deletes config/data; `--yes` skips prompts. |
## See also

View file

@ -75,6 +75,22 @@ The app checks for updates in the background and offers a one-click update when
The [manual update process](https://hermes-agent.nousresearch.com/docs/getting-started/updating) also works with the GUI.
## Uninstalling
Open **Settings → About → Danger zone** and pick how much to remove:
- **Uninstall Chat GUI only** — removes the desktop app and its data; the Hermes agent, your config, and your chats stay. (Same as `hermes uninstall --gui`.)
- **Uninstall GUI + agent, keep my data** — removes the app and the agent but keeps config, chats, and secrets for a future reinstall. (Same as `hermes uninstall`.)
- **Uninstall everything** — removes the app, the agent, and all user data. (Same as `hermes uninstall --full`.)
The app closes to finish the job (the cleanup runs after it exits so it can remove the running app bundle and its own venv). The agent-removing options are hidden automatically when no local agent is installed (for example, a GUI-only "lite" client connected to a remote backend).
You can do the same from the terminal — `hermes uninstall --gui` for the GUI alone, or `hermes uninstall` / `hermes uninstall --full` for the agent too.
:::note
Running `hermes uninstall --gui` from a **source checkout** (a `hermes desktop` dev build) also removes the workspace `node_modules` and `apps/desktop/{dist,release}` build output, since those are GUI build artifacts. They're recoverable with `hermes desktop` (or `npm install` + a rebuild) — but if you're actively hacking on the desktop app, expect to reinstall dependencies afterward.
:::
## CLI reference: `hermes desktop`
To launch via the CLI, simply run `hermes desktop`. By default it installs workspace Node dependencies, builds the current OS's unpacked Electron app, then launches that packaged artifact.