feat(desktop): content-hash build stamp with --build-only and --force-build flags

Add a SHA-256 content-hash based build stamp to `hermes desktop` so
unchanged source trees skip the npm install + build step. Uses pathspec
for .gitignore-aware file matching instead of a hardcoded skip-list.

New CLI flags:
- --build-only: run the build but don't launch the app
- --force-build: rebuild even when the stamp matches

`hermes update` now calls `hermes desktop --build-only` so the
desktop app is rebuilt (if needed) as part of the update flow.

16/16 tests passing.
This commit is contained in:
ethernet
2026-06-02 15:30:00 -04:00
parent b34ee80741
commit c2050183a5
5 changed files with 396 additions and 42 deletions

View File

@ -201,6 +201,7 @@ if _try_termux_ultrafast_version():
raise SystemExit(0)
import argparse
import hashlib
import json
import shutil
import subprocess
@ -6869,6 +6870,147 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool:
return (desktop_dir / "dist" / "index.html").exists()
# ---------------------------------------------------------------------------
# Desktop build stamp — content-hash based skip logic
# ---------------------------------------------------------------------------
# The desktop Electron build is expensive.
# Unlike the web UI (which uses mtime comparison), the desktop uses a
# SHA-256 content hash of the source tree so that:
# - ``git checkout`` / ``git pull`` that touch mtimes but not content
# don't trigger a rebuild
# - ``hermes update`` can unconditionally call ``hermes desktop --build-only``
# and it will skip if nothing actually changed
# - ``hermes desktop`` (interactive launch) skips the build when the
# stamp matches, making repeated launches fast
#
# Stamp file: $HERMES_HOME/desktop-build-stamp.json
# Schema:
# {
# "contentHash": "<sha256 hex of source files>",
# "sourceMode": true | false,
# "builtAt": "<ISO 8601>"
# }
def _compute_desktop_content_hash(project_root: Path) -> str:
"""Return a SHA-256 hex digest of all source files that feed the desktop build.
Covers ``apps/desktop/`` (excluding anything matched by .gitignore)
plus the root ``package.json`` / ``package-lock.json`` (workspace config
that determines dependency resolution for the desktop workspace).
Parses the repo-root ``.gitignore`` via *pathspec* so we automatically
skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining
a hardcoded skip-list.
"""
h = hashlib.sha256()
def _hash_file(path: Path) -> None:
rel = str(path.relative_to(project_root))
h.update(rel.encode())
h.update(b"\0")
try:
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
except (OSError, IOError):
pass
h.update(b"\0")
from pathspec import PathSpec
gitignore = project_root / ".gitignore"
lines: list[str] = []
if gitignore.is_file():
lines = gitignore.read_text(encoding="utf-8").splitlines()
spec = PathSpec.from_lines("gitignore", lines)
# Root workspace config
for name in ("package.json", "package-lock.json"):
p = project_root / name
if p.is_file():
rel = str(p.relative_to(project_root))
if not spec.match_file(rel):
_hash_file(p)
# Walk apps/desktop/ — prune ignored directories in-place
desktop_dir = project_root / "apps" / "desktop"
for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True):
# Prune ignored directories so we never descend into them
dirnames[:] = [
d for d in dirnames
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
]
for fn in sorted(filenames):
fp = Path(dirpath) / fn
rel = str(fp.relative_to(project_root))
if not spec.match_file(rel):
_hash_file(fp)
return h.hexdigest()
def _desktop_stamp_path() -> Path:
"""Return the path to the desktop build stamp file under $HERMES_HOME."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "desktop-build-stamp.json"
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
"""Return True when the desktop build output is stale or missing.
Compares the current content hash against the saved stamp. Also returns
True if the expected build artifact doesn't exist (e.g. first run after
``hermes update`` that pulled new source but hasn't built yet).
"""
# If there's no build output at all, we definitely need to build
if source_mode:
if not _desktop_dist_exists(desktop_dir):
return True
else:
if _desktop_packaged_executable(desktop_dir) is None:
return True
stamp_file = _desktop_stamp_path()
if not stamp_file.is_file():
return True
try:
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, KeyError):
return True
# If the mode changed (source vs packaged), force a rebuild
if stamp_data.get("sourceMode") != source_mode:
return True
saved_hash = stamp_data.get("contentHash")
if not saved_hash:
return True
current_hash = _compute_desktop_content_hash(project_root)
return current_hash != saved_hash
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
"""Write the desktop build stamp after a successful build."""
stamp_file = _desktop_stamp_path()
try:
stamp_file.parent.mkdir(parents=True, exist_ok=True)
content_hash = _compute_desktop_content_hash(project_root)
from datetime import datetime, timezone
stamp_data = {
"contentHash": content_hash,
"sourceMode": source_mode,
"builtAt": datetime.now(timezone.utc).isoformat(),
}
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
except Exception as exc:
# Never let stamp-writing block or fail a build
logger.debug("Failed to write desktop build stamp: %s", exc)
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
"""Return the current platform's unpacked Electron app executable."""
release_dir = desktop_dir / "release"
@ -6928,8 +7070,7 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
except Exception as exc:
print(f" (warning: macOS relaunch fixup skipped: {exc})")
def cmd_gui(args):
def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
if not (desktop_dir / "package.json").exists():
@ -6954,6 +7095,8 @@ def cmd_gui(args):
source_mode = getattr(args, "source", False)
skip_build = getattr(args, "skip_build", False)
force_build = getattr(args, "force_build", False)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if source_mode or not skip_build:
@ -6965,7 +7108,7 @@ def cmd_gui(args):
else:
npm = None
if getattr(args, "skip_build", False):
if skip_build:
if source_mode:
if not _desktop_dist_exists(desktop_dir):
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
@ -6986,27 +7129,41 @@ def cmd_gui(args):
else:
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
else:
print("→ Installing desktop workspace dependencies...")
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
if install_result.returncode != 0:
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
# Check the content-hash stamp before doing any build work.
# If the source tree hasn't changed since the last successful build,
# skip the npm install + build entirely (saves a ton of useless work).
# --force-build overrides the stamp and always rebuilds.
build_needed = force_build or _desktop_build_needed(
desktop_dir, PROJECT_ROOT, source_mode=source_mode
)
if not build_needed:
build_label = "source build" if source_mode else "packaged app"
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
else:
print("→ Installing desktop workspace dependencies...")
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
if install_result.returncode != 0:
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
# Locally-built apps are ad-hoc signed; make them relaunchable after
# an in-place self-update (otherwise macOS reports "Hermes is
# damaged"). No-op on non-macOS and on real-identity builds.
_desktop_macos_relaunchable_fixup(desktop_dir)
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
build_script = "build" if source_mode else "pack"
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
# Locally-built apps are ad-hoc signed; make them relaunchable after
# an in-place self-update (otherwise macOS reports "Hermes is
# damaged"). No-op on non-macOS and on real-identity builds.
_desktop_macos_relaunchable_fixup(desktop_dir)
# Build succeeded — write the stamp so next run can skip
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
# --build-only: produce the artifact but do NOT launch. The installer's
# --update flow drives the rebuild headlessly and then launches the desktop
@ -9697,6 +9854,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "web")
# Rebuild the desktop app if the source tree changed since the last
# build. ``hermes desktop --build-only`` uses the content-hash stamp
# internally, so this is effectively a no-op when nothing changed.
# Only bother if the user has a desktop app installed (indicated by
# an existing packaged executable or desktop dist); people who have
# never run ``hermes desktop`` shouldn't be forced into a full
# Electron build by ``hermes update``.
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
print("→ Checking if desktop app needs rebuilding...")
build_result = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
cwd=PROJECT_ROOT,
check=False,
)
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
print()
print("✓ Code updated!")
@ -11364,7 +11540,7 @@ def cmd_dashboard(args):
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
sys.exit(1)
elif getattr(args, "skip_build", False):
# --skip-build trusts the caller to have pre-built the web UI.
# --build-mode skip trusts the caller to have pre-built the web UI.
# Verify the dist actually exists; otherwise the server will start
# and serve 404s with no obvious cause (issue #23817).
_dist_root = (
@ -14733,11 +14909,6 @@ Examples:
"Electron app, then launches that packaged artifact."
),
)
gui_parser.add_argument(
"--skip-build",
action="store_true",
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
)
gui_parser.add_argument(
"--source",
action="store_true",
@ -14766,6 +14937,16 @@ Examples:
"--cwd",
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
)
gui_parser.add_argument(
"--skip-build",
action="store_true",
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
)
gui_parser.add_argument(
"--force-build",
action="store_true",
help="Force a full rebuild even if the content stamp matches",
)
gui_parser.set_defaults(func=cmd_gui)
# =========================================================================

View File

@ -64,6 +64,8 @@ dependencies = [
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
# `os.killpg` (which doesn't exist on Windows).
"psutil==7.2.2",
# .gitignore-aware file matching for desktop build stamp.
"pathspec==1.1.1",
"fastapi>=0.104.0,<1",
"uvicorn[standard]>=0.24.0,<1",
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",

View File

@ -16,6 +16,8 @@ from hermes_cli import main as cli_main
def _ns(**kw):
defaults = dict(
skip_build=False,
build_only=False,
force_build=False,
source=False,
fake_boot=False,
ignore_existing=False,
@ -60,6 +62,8 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
@ -86,6 +90,8 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \
pytest.raises(SystemExit):
@ -158,6 +164,8 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(source=True))
@ -179,3 +187,158 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
def test_gui_is_known_builtin_for_plugin_gating(argv):
with patch.object(sys, "argv", argv):
assert cli_main._plugin_cli_discovery_needed() is False
# ── Content-hash stamp tests ──────────────────────────────────────────
def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch):
"""When the stamp matches and the artifact exists, build is skipped entirely."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch)
launch_ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 0
mock_install.assert_not_called()
mock_run.assert_called_once() # only the launch call, no build
def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch):
"""--force-build forces a rebuild even when the stamp says up-to-date."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
launch_ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(force_build=True))
assert exc.value.code == 0
mock_install.assert_called_once()
mock_stamp.assert_called_once()
# pack + launch = 2 calls
assert mock_run.call_count == 2
def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch):
"""_compute_desktop_content_hash returns the same digest for identical trees."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8")
(root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8")
(root / "package-lock.json").write_text('{}', encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
h1 = cli_main._compute_desktop_content_hash(root)
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 == h2
assert len(h1) == 64 # sha256 hex
def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch):
"""Editing a file under apps/desktop/ changes the hash."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
h1 = cli_main._compute_desktop_content_hash(root)
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 != h2
def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch):
"""Even with a valid stamp, missing artifact means build is needed."""
root = _make_desktop_tree(tmp_path)
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Write a stamp that matches current content
cli_main._write_desktop_build_stamp(root, source_mode=False)
# No packaged executable exists → build needed
assert cli_main._desktop_build_needed(
root / "apps" / "desktop", root, source_mode=False
) is True
def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch):
"""Write stamp, then _desktop_build_needed returns False when artifact exists."""
root = _make_desktop_tree(tmp_path)
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Create the artifact so the "artifact exists" check passes
_make_packaged_executable(root, monkeypatch)
# Write stamp
cli_main._write_desktop_build_stamp(root, source_mode=False)
# Build should NOT be needed
assert cli_main._desktop_build_needed(
root / "apps" / "desktop", root, source_mode=False
) is False
def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch):
"""When no .gitignore exists, _compute_desktop_content_hash still works (matches everything)."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# No .gitignore → pathspec matches nothing → all files hashed
h = cli_main._compute_desktop_content_hash(root)
assert len(h) == 64 # valid sha256 hex
# Edit a file → hash changes
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
h2 = cli_main._compute_desktop_content_hash(root)
assert h != h2
def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
"""Files matched by .gitignore are excluded from the hash."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8")
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
(root / ".gitignore").write_text("*.env\n", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Reset cached spec
cli_main._DESKTOP_STAMP_SPEC = None
h1 = cli_main._compute_desktop_content_hash(root)
# Change the .env file (ignored) — hash should NOT change
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8")
cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 == h2, "changing an ignored file should not change the hash"
# Change the .js file (not ignored) — hash SHOULD change
(root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8")
cli_main._DESKTOP_STAMP_SPEC = None
h3 = cli_main._compute_desktop_content_hash(root)
assert h1 != h3, "changing a tracked file should change the hash"

31
uv.lock generated
View File

@ -1602,21 +1602,26 @@ version = "0.15.1"
source = { editable = "." }
dependencies = [
{ name = "croniter" },
{ name = "fastapi" },
{ name = "fire" },
{ name = "httpx", extra = ["socks"] },
{ name = "jinja2" },
{ name = "openai" },
{ name = "pathspec" },
{ name = "prompt-toolkit" },
{ name = "psutil" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "rich" },
{ name = "ruamel-yaml" },
{ name = "tenacity" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
@ -1632,11 +1637,9 @@ all = [
{ name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" },
{ name = "mcp" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-timeout" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "simple-term-menu" },
@ -1739,10 +1742,6 @@ modal = [
parallel-web = [
{ name = "parallel-web" },
]
pty = [
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
]
slack = [
{ name = "aiohttp" },
{ name = "slack-bolt" },
@ -1755,9 +1754,7 @@ termux = [
{ name = "agent-client-protocol" },
{ name = "honcho-ai" },
{ name = "mcp" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "simple-term-menu" },
{ name = "starlette" },
]
@ -1770,9 +1767,7 @@ termux-all = [
{ name = "google-auth-oauthlib" },
{ name = "honcho-ai" },
{ name = "mcp" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
{ name = "simple-term-menu" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
@ -1822,6 +1817,7 @@ requires-dist = [
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" },
{ name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" },
{ name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" },
{ name = "fastapi", specifier = ">=0.104.0,<1" },
{ name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" },
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" },
{ name = "fire", specifier = "==0.7.1" },
@ -1866,9 +1862,10 @@ requires-dist = [
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
{ name = "pathspec", specifier = "==1.1.1" },
{ name = "prompt-toolkit", specifier = "==3.0.52" },
{ name = "psutil", specifier = "==7.2.2" },
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
@ -1877,7 +1874,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" },
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
{ name = "pyyaml", specifier = "==6.0.3" },
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
@ -1900,6 +1897,7 @@ requires-dist = [
{ name = "tenacity", specifier = "==9.1.4" },
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
@ -3057,6 +3055,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"

View File

@ -92,8 +92,9 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works
| Flag | Description |
| -------------------- | ----------------------------------------------------------------------------------------- |
| `--skip-build` | Skip npm install/package and launch the existing unpacked app from `apps/desktop/release` |
| `--force-build` | Force a full rebuild even if the content stamp matches |
| `--build-only` | Build the desktop app but do not launch it (used by `hermes update`) |
| `--source` | Launch via `electron .` against `apps/desktop/dist` instead of the packaged app |
| `--build-only` | Build the desktop app but do not launch it (used by the installer's `--update` flow) |
| `--cwd PATH` | Initial project directory for desktop chat sessions (sets `HERMES_DESKTOP_CWD`) |
| `--hermes-root PATH` | Override the Hermes source root the app uses (sets `HERMES_DESKTOP_HERMES_ROOT`) |
| `--ignore-existing` | Force the app to ignore any `hermes` CLI already on `PATH` during backend resolution |