Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
+231
-51
@@ -124,7 +124,7 @@ def _apply_profile_override() -> None:
|
||||
|
||||
# 1. Check for explicit -p / --profile flag
|
||||
for i, arg in enumerate(argv):
|
||||
if arg in ("--profile", "-p") and i + 1 < len(argv):
|
||||
if arg in {"--profile", "-p"} and i + 1 < len(argv):
|
||||
profile_name = argv[i + 1]
|
||||
consume = 2
|
||||
break
|
||||
@@ -192,7 +192,7 @@ def _apply_profile_override() -> None:
|
||||
# Strip the flag from argv so argparse doesn't choke
|
||||
if consume > 0:
|
||||
for i, arg in enumerate(argv):
|
||||
if arg in ("--profile", "-p"):
|
||||
if arg in {"--profile", "-p"}:
|
||||
start = i + 1 # +1 because argv is sys.argv[1:]
|
||||
sys.argv = sys.argv[:start] + sys.argv[start + consume :]
|
||||
break
|
||||
@@ -505,8 +505,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
|
||||
# Compute visible area
|
||||
visible_rows = max_y - 4 # header + col header + blank + footer
|
||||
if visible_rows < 1:
|
||||
visible_rows = 1
|
||||
visible_rows = max(visible_rows, 1)
|
||||
|
||||
# Clamp cursor and scroll
|
||||
if not filtered:
|
||||
@@ -518,8 +517,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
else:
|
||||
if cursor >= len(filtered):
|
||||
cursor = len(filtered) - 1
|
||||
if cursor < 0:
|
||||
cursor = 0
|
||||
cursor = max(cursor, 0)
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
@@ -569,13 +567,13 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (curses.KEY_UP,):
|
||||
if key in {curses.KEY_UP,}:
|
||||
if filtered:
|
||||
cursor = (cursor - 1) % len(filtered)
|
||||
elif key in (curses.KEY_DOWN,):
|
||||
elif key in {curses.KEY_DOWN,}:
|
||||
if filtered:
|
||||
cursor = (cursor + 1) % len(filtered)
|
||||
elif key in (curses.KEY_ENTER, 10, 13):
|
||||
elif key in {curses.KEY_ENTER, 10, 13}:
|
||||
if filtered:
|
||||
result_holder[0] = filtered[cursor]["id"]
|
||||
return
|
||||
@@ -589,7 +587,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
else:
|
||||
# Second Esc exits
|
||||
return
|
||||
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
||||
elif key in {curses.KEY_BACKSPACE, 127, 8}:
|
||||
if search_text:
|
||||
search_text = search_text[:-1]
|
||||
if search_text:
|
||||
@@ -628,7 +626,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
while True:
|
||||
try:
|
||||
val = input(f"\n Select [1-{len(sessions)}]: ").strip()
|
||||
if not val or val.lower() in ("q", "quit", "exit"):
|
||||
if not val or val.lower() in {"q", "quit", "exit"}:
|
||||
return None
|
||||
idx = int(val) - 1
|
||||
if 0 <= idx < len(sessions):
|
||||
@@ -1305,7 +1303,7 @@ def _launch_tui(
|
||||
except KeyboardInterrupt:
|
||||
code = 130
|
||||
|
||||
if code in (0, 130):
|
||||
if code in {0, 130}:
|
||||
_print_tui_exit_summary(resume_session_id, active_session_file)
|
||||
finally:
|
||||
try:
|
||||
@@ -1425,7 +1423,7 @@ def cmd_chat(args):
|
||||
reply = input("Run setup now? [Y/n] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
reply = "n"
|
||||
if reply in ("", "y", "yes"):
|
||||
if reply in {"", "y", "yes"}:
|
||||
cmd_setup(args)
|
||||
return
|
||||
print()
|
||||
@@ -1602,7 +1600,7 @@ def cmd_whatsapp(args):
|
||||
response = input("\n Update allowed users? [y/N] ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
response = "n"
|
||||
if response.lower() in ("y", "yes"):
|
||||
if response.lower() in {"y", "yes"}:
|
||||
if wa_mode == "bot":
|
||||
phone = input(
|
||||
" Phone numbers that can message the bot (comma-separated): "
|
||||
@@ -1677,7 +1675,7 @@ def cmd_whatsapp(args):
|
||||
).strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
response = "n"
|
||||
if response.lower() in ("y", "yes"):
|
||||
if response.lower() in {"y", "yes"}:
|
||||
shutil.rmtree(session_dir, ignore_errors=True)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(" ✓ Session cleared")
|
||||
@@ -2031,7 +2029,7 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_bedrock(config, current_model)
|
||||
elif selected_provider == "azure-foundry":
|
||||
_model_flow_azure_foundry(config, current_model)
|
||||
elif selected_provider in (
|
||||
elif selected_provider in {
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"xai",
|
||||
@@ -2051,18 +2049,18 @@ def select_provider_and_model(args=None):
|
||||
"ollama-cloud",
|
||||
"tencent-tokenhub",
|
||||
"lmstudio",
|
||||
) or _is_profile_api_key_provider(selected_provider):
|
||||
} or _is_profile_api_key_provider(selected_provider):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
|
||||
# When the user switches to a named provider (anything except "custom"),
|
||||
# a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary
|
||||
# clients that use provider:auto. Clear it proactively. (#5161)
|
||||
if selected_provider not in (
|
||||
if selected_provider not in {
|
||||
"custom",
|
||||
"cancel",
|
||||
"remove-custom",
|
||||
) and not selected_provider.startswith("custom:"):
|
||||
} and not selected_provider.startswith("custom:"):
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
|
||||
@@ -2188,7 +2186,7 @@ def _reset_aux_to_auto() -> int:
|
||||
entry = {}
|
||||
aux[task] = entry
|
||||
changed = False
|
||||
if entry.get("provider") not in (None, "", "auto"):
|
||||
if entry.get("provider") not in {None, "", "auto"}:
|
||||
entry["provider"] = "auto"
|
||||
changed = True
|
||||
for field in ("model", "base_url", "api_key"):
|
||||
@@ -3099,7 +3097,7 @@ def _model_flow_custom(config):
|
||||
_add_v1 = input(" Add /v1? [Y/n]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
_add_v1 = "n"
|
||||
if _add_v1 in ("", "y", "yes"):
|
||||
if _add_v1 in {"", "y", "yes"}:
|
||||
effective_url = effective_url.rstrip("/") + "/v1"
|
||||
if base_url:
|
||||
base_url = effective_url
|
||||
@@ -3143,7 +3141,7 @@ def _model_flow_custom(config):
|
||||
if len(detected_models) == 1:
|
||||
print(f" Detected model: {detected_models[0]}")
|
||||
confirm = input(" Use this model? [Y/n]: ").strip().lower()
|
||||
if confirm in ("", "y", "yes"):
|
||||
if confirm in {"", "y", "yes"}:
|
||||
model_name = detected_models[0]
|
||||
else:
|
||||
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
|
||||
@@ -3976,7 +3974,7 @@ def _model_flow_copilot(config, current_model=""):
|
||||
api_key = creds.get("api_key", "")
|
||||
source = creds.get("source", "")
|
||||
else:
|
||||
if source in ("GITHUB_TOKEN", "GH_TOKEN"):
|
||||
if source in {"GITHUB_TOKEN", "GH_TOKEN"}:
|
||||
print(f" GitHub token: {api_key[:8]}... ✓ ({source})")
|
||||
elif source == "gh auth token":
|
||||
print(" GitHub token: ✓ (from `gh auth token`)")
|
||||
@@ -5296,7 +5294,7 @@ def cmd_slack(args):
|
||||
command registered as a first-class slash.
|
||||
"""
|
||||
sub = getattr(args, "slack_command", None)
|
||||
if sub in (None, ""):
|
||||
if sub in {None, ""}:
|
||||
# No subcommand — print usage hint.
|
||||
print(
|
||||
"usage: hermes slack <subcommand>\n"
|
||||
@@ -5443,7 +5441,7 @@ def _clear_bytecode_cache(root: Path) -> int:
|
||||
dirnames[:] = [
|
||||
d
|
||||
for d in dirnames
|
||||
if d not in ("venv", ".venv", "node_modules", ".git", ".worktrees")
|
||||
if d not in {"venv", ".venv", "node_modules", ".git", ".worktrees"}
|
||||
]
|
||||
if os.path.basename(dirpath) == "__pycache__":
|
||||
try:
|
||||
@@ -5568,6 +5566,8 @@ def _run_npm_install_deterministic(
|
||||
cwd=cwd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
if ci_result.returncode == 0:
|
||||
@@ -5580,6 +5580,8 @@ def _run_npm_install_deterministic(
|
||||
cwd=cwd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
|
||||
@@ -5616,12 +5618,50 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if fatal:
|
||||
print(" Run manually: cd apps/dashboard && npm install && npm run build")
|
||||
return False
|
||||
r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True)
|
||||
# First attempt
|
||||
r2 = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=web_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if r2.returncode != 0:
|
||||
# Retry once after a short delay — covers boot-time races on Windows
|
||||
# (antivirus scanning Node.js binaries, npm cache not ready, transient
|
||||
# I/O when launched via Scheduled Task at logon). See issue #23817.
|
||||
_time.sleep(3)
|
||||
r2 = subprocess.run(
|
||||
[npm, "run", "build"],
|
||||
cwd=web_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
if r2.returncode != 0:
|
||||
stderr_preview = (r2.stderr or "").strip()
|
||||
stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else ""
|
||||
dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
|
||||
dist_index = dist_dir / "index.html"
|
||||
|
||||
# If a stale dist exists, serve it as a fallback instead of failing.
|
||||
# A stale UI is far better than no UI for non-interactive callers
|
||||
# (Windows Scheduled Tasks, CI) — issue #23817.
|
||||
if dist_index.exists():
|
||||
print(" ⚠ Web UI build failed — serving stale dist as fallback")
|
||||
if stderr_tail:
|
||||
print(f" Build error:\n {stderr_tail}")
|
||||
return True
|
||||
|
||||
print(
|
||||
f" {'✗' if fatal else '⚠'} Web UI build failed"
|
||||
+ ("" if fatal else " (hermes web will not be available)")
|
||||
)
|
||||
if stderr_tail:
|
||||
print(f" Build error:\n {stderr_tail}")
|
||||
if fatal:
|
||||
print(" Run manually: cd apps/dashboard && npm install && npm run build")
|
||||
return False
|
||||
@@ -5940,8 +5980,8 @@ def _kill_stale_dashboard_processes(
|
||||
|
||||
for pid in killed:
|
||||
print(f" ✓ stopped PID {pid}")
|
||||
for pid, reason in failed:
|
||||
print(f" ✗ failed to stop PID {pid}: {reason}")
|
||||
for pid, err_msg in failed:
|
||||
print(f" ✗ failed to stop PID {pid}: {err_msg}")
|
||||
|
||||
if killed:
|
||||
print(" Restart the dashboard when you're ready:")
|
||||
@@ -6198,7 +6238,7 @@ def _restore_stashed_changes(
|
||||
response = input_fn("Restore local changes now? [Y/n]", "y")
|
||||
else:
|
||||
response = input().strip().lower()
|
||||
if response not in ("", "y", "yes"):
|
||||
if response not in {"", "y", "yes"}:
|
||||
print("Skipped restoring local changes.")
|
||||
print("Your changes are still preserved in git stash.")
|
||||
print(f"Restore manually with: git stash apply {stash_ref}")
|
||||
@@ -6441,7 +6481,7 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None:
|
||||
print()
|
||||
response = "n"
|
||||
|
||||
if response in ("", "y", "yes"):
|
||||
if response in {"", "y", "yes"}:
|
||||
print("→ Adding upstream remote...")
|
||||
if _add_upstream_remote(git_cmd, cwd):
|
||||
print(
|
||||
@@ -6622,6 +6662,103 @@ def _run_install_with_heartbeat(
|
||||
t.join(timeout=0.2)
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def _venv_scripts_dir() -> Path | None:
|
||||
"""Return the venv Scripts directory if we're running inside the project venv."""
|
||||
venv_dir = PROJECT_ROOT / "venv"
|
||||
if not venv_dir.is_dir():
|
||||
return None
|
||||
scripts = venv_dir / ("Scripts" if _is_windows() else "bin")
|
||||
return scripts if scripts.is_dir() else None
|
||||
|
||||
|
||||
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
|
||||
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
|
||||
|
||||
On Windows these are .exe launchers generated by setuptools/uv. On POSIX
|
||||
they're regular Python scripts which can be replaced atomically — no
|
||||
self-replacement hazard exists outside Windows.
|
||||
"""
|
||||
if not _is_windows():
|
||||
return []
|
||||
return [
|
||||
scripts_dir / "hermes.exe",
|
||||
scripts_dir / "hermes-gateway.exe",
|
||||
]
|
||||
|
||||
|
||||
def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]]:
|
||||
"""Pre-empt Windows file lock on the running ``hermes.exe``.
|
||||
|
||||
Windows allows RENAMING a mapped/running executable (the kernel tracks the
|
||||
file by handle, not path), but blocks DELETE/REPLACE while it's loaded. uv
|
||||
needs to overwrite the entry-point shims during ``pip install -e .``;
|
||||
when ``hermes update`` runs, ``hermes.exe`` IS the live process, and uv
|
||||
fails with ``Access is denied. (os error 5)``.
|
||||
|
||||
We rename live shims to ``hermes.exe.old.<unix-ms>`` first. uv then writes
|
||||
fresh shims at the original paths. The ``.old`` files are cleaned up on
|
||||
the next hermes invocation by ``_cleanup_quarantined_exes``.
|
||||
|
||||
Returns the list of (original, quarantined) pairs so the caller can roll
|
||||
back if the install itself fails before uv writes a replacement.
|
||||
"""
|
||||
moved: list[tuple[Path, Path]] = []
|
||||
if not _is_windows():
|
||||
return moved
|
||||
|
||||
import time
|
||||
stamp = int(time.time() * 1000)
|
||||
for shim in _hermes_exe_shims(scripts_dir):
|
||||
if not shim.exists():
|
||||
continue
|
||||
target = shim.with_suffix(shim.suffix + f".old.{stamp}")
|
||||
try:
|
||||
shim.rename(target)
|
||||
moved.append((shim, target))
|
||||
except OSError as e:
|
||||
# Best-effort: keep going. uv's failure later will surface the
|
||||
# real error; this is a heuristic, not a hard guarantee.
|
||||
print(f" ⚠ Could not quarantine {shim.name}: {e}")
|
||||
return moved
|
||||
|
||||
|
||||
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
|
||||
"""Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements."""
|
||||
for original, quarantined in moved:
|
||||
try:
|
||||
if not original.exists() and quarantined.exists():
|
||||
quarantined.rename(original)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
|
||||
"""Sweep ``hermes.exe.old.*`` left by prior updates.
|
||||
|
||||
Called early on every hermes invocation. The .old files are unlocked once
|
||||
their owning process exited, so deletion succeeds the next run. Silent
|
||||
no-op when nothing's there or on file-locked / permission errors.
|
||||
"""
|
||||
if not _is_windows():
|
||||
return
|
||||
if scripts_dir is None:
|
||||
scripts_dir = _venv_scripts_dir()
|
||||
if scripts_dir is None:
|
||||
return
|
||||
try:
|
||||
for stale in scripts_dir.glob("*.exe.old.*"):
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
pass # still locked or in use — try again next run
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
@@ -6632,31 +6769,42 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
|
||||
By default this targets ``.[all]``; Termux callers can pass
|
||||
``group='termux-all'`` to use the curated Android-compatible profile.
|
||||
|
||||
On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims
|
||||
in the venv Scripts dir before each install attempt so uv can write fresh
|
||||
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
|
||||
``_quarantine_running_hermes_exe`` for the rationale.
|
||||
"""
|
||||
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
||||
|
||||
def _install(args: list[str]) -> None:
|
||||
moved: list[tuple[Path, Path]] = []
|
||||
if scripts_dir is not None:
|
||||
moved = _quarantine_running_hermes_exe(scripts_dir)
|
||||
try:
|
||||
_run_install_with_heartbeat(install_cmd_prefix + args, env=env)
|
||||
except BaseException:
|
||||
# Restore shims if uv didn't write replacements (e.g. install
|
||||
# failed before the entry-points step). Don't swallow the error.
|
||||
if scripts_dir is not None:
|
||||
_restore_quarantined_exes(moved)
|
||||
raise
|
||||
|
||||
try:
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", f".[{group}]"],
|
||||
env=env,
|
||||
)
|
||||
_install(["install", "-e", f".[{group}]"])
|
||||
return
|
||||
except subprocess.CalledProcessError:
|
||||
print(
|
||||
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
|
||||
)
|
||||
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", "."],
|
||||
env=env,
|
||||
)
|
||||
_install(["install", "-e", "."])
|
||||
|
||||
failed_extras: list[str] = []
|
||||
installed_extras: list[str] = []
|
||||
for extra in _load_installable_optional_extras(group=group):
|
||||
try:
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
|
||||
env=env,
|
||||
)
|
||||
_install(["install", "-e", f".[{extra}]"])
|
||||
installed_extras.append(extra)
|
||||
except subprocess.CalledProcessError:
|
||||
failed_extras.append(extra)
|
||||
@@ -7396,7 +7544,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
prompt_user=prompt_for_restore,
|
||||
input_fn=gw_input_fn,
|
||||
)
|
||||
if current_branch not in ("main", "HEAD"):
|
||||
if current_branch not in {"main", "HEAD"}:
|
||||
subprocess.run(
|
||||
git_cmd + ["checkout", current_branch],
|
||||
cwd=PROJECT_ROOT,
|
||||
@@ -7680,7 +7828,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except EOFError:
|
||||
response = "n"
|
||||
|
||||
if response in ("", "y", "yes", "auto"):
|
||||
if response in {"", "y", "yes", "auto"}:
|
||||
print()
|
||||
# Gateway mode, --yes, and non-interactive update contexts
|
||||
# (dashboard / web server actions) cannot prompt for API keys.
|
||||
@@ -8741,7 +8889,7 @@ def cmd_profile(args):
|
||||
answer = input("\nProceed with install? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = ""
|
||||
if answer not in ("y", "yes"):
|
||||
if answer not in {"y", "yes"}:
|
||||
print("Install cancelled.")
|
||||
return
|
||||
|
||||
@@ -8800,7 +8948,7 @@ def cmd_profile(args):
|
||||
answer = input("\nProceed? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = ""
|
||||
if answer not in ("y", "yes"):
|
||||
if answer not in {"y", "yes"}:
|
||||
print("Update cancelled.")
|
||||
return
|
||||
|
||||
@@ -8996,9 +9144,24 @@ def cmd_dashboard(args):
|
||||
# backend is the desktop's primary entrypoint and needs the same.
|
||||
_sync_bundled_skills_quietly()
|
||||
|
||||
if "HERMES_WEB_DIST" not in os.environ:
|
||||
if "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False):
|
||||
if not _build_web_ui(PROJECT_ROOT / "apps" / "dashboard", fatal=True):
|
||||
sys.exit(1)
|
||||
elif getattr(args, "skip_build", False):
|
||||
# --skip-build 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 = (
|
||||
Path(os.environ["HERMES_WEB_DIST"])
|
||||
if "HERMES_WEB_DIST" in os.environ
|
||||
else PROJECT_ROOT / "hermes_cli" / "web_dist"
|
||||
)
|
||||
if not (_dist_root / "index.html").exists():
|
||||
print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}")
|
||||
print(" Pre-build first: cd apps/dashboard && npm install && npm run build")
|
||||
print(" Or drop --skip-build to build automatically.")
|
||||
sys.exit(1)
|
||||
print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}")
|
||||
|
||||
from hermes_cli.web_server import start_server
|
||||
|
||||
@@ -9179,6 +9342,14 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sweep stale ``hermes.exe.old.*`` quarantine files left by previous
|
||||
# ``hermes update`` runs on Windows. Silent no-op on non-Windows or when
|
||||
# there's nothing to clean. See ``_quarantine_running_hermes_exe``.
|
||||
try:
|
||||
_cleanup_quarantined_exes()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
@@ -10571,9 +10742,9 @@ Examples:
|
||||
mem_dir = get_hermes_home() / "memories"
|
||||
target = getattr(args, "target", "all")
|
||||
files_to_reset = []
|
||||
if target in ("all", "memory"):
|
||||
if target in {"all", "memory"}:
|
||||
files_to_reset.append(("MEMORY.md", "agent notes"))
|
||||
if target in ("all", "user"):
|
||||
if target in {"all", "user"}:
|
||||
files_to_reset.append(("USER.md", "user profile"))
|
||||
|
||||
# Check what exists
|
||||
@@ -10684,7 +10855,7 @@ Examples:
|
||||
|
||||
def cmd_tools(args):
|
||||
action = getattr(args, "tools_action", None)
|
||||
if action in ("list", "disable", "enable"):
|
||||
if action in {"list", "disable", "enable"}:
|
||||
from hermes_cli.tools_config import tools_disable_enable_command
|
||||
|
||||
tools_disable_enable_command(args)
|
||||
@@ -10893,7 +11064,7 @@ Examples:
|
||||
def _confirm_prompt(prompt: str) -> bool:
|
||||
"""Prompt for y/N confirmation, safe against non-TTY environments."""
|
||||
try:
|
||||
return input(prompt).strip().lower() in ("y", "yes")
|
||||
return input(prompt).strip().lower() in {"y", "yes"}
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
|
||||
@@ -11467,6 +11638,15 @@ Examples:
|
||||
"Alternatively set HERMES_DASHBOARD_TUI=1."
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip the web UI build step and serve the existing dist directly. "
|
||||
"Useful for non-interactive contexts (Windows Scheduled Tasks, CI) "
|
||||
"where npm may not be available. Pre-build with: cd web && npm run build"
|
||||
),
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
|
||||
Reference in New Issue
Block a user