From cfa327e5dcad303339bce218a1daa72b87930c76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:38:33 -0700 Subject: [PATCH] =?UTF-8?q?refactor(hclib):=20remaining=20hermes=5Fcli=20l?= =?UTF-8?q?ibrary=20modules=20=E2=80=94=20dead=20code,=20unified=20helpers?= =?UTF-8?q?,=20flattened=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/__init__.py | 43 +- hermes_cli/_install_repair.py | 4 +- hermes_cli/_parser.py | 43 +- hermes_cli/_secrets_common.py | 225 ++ hermes_cli/_subprocess_compat.py | 2 +- hermes_cli/agent_import.py | 599 ++--- hermes_cli/agent_plugins.py | 375 +-- hermes_cli/approval_mode.py | 8 +- hermes_cli/approval_transport.py | 83 +- hermes_cli/approvals_suggest.py | 131 +- hermes_cli/approvals_test.py | 39 +- hermes_cli/archive_safe.py | 52 +- hermes_cli/backup.py | 2037 ++++++--------- hermes_cli/bang_shell.py | 125 +- hermes_cli/blueprint_cmd.py | 75 +- hermes_cli/browser_connect.py | 679 ++--- hermes_cli/bundles.py | 19 +- hermes_cli/callbacks.py | 193 +- hermes_cli/checkpoints.py | 95 +- hermes_cli/claw.py | 465 ++-- hermes_cli/clipboard.py | 271 +- hermes_cli/colors.py | 14 +- hermes_cli/completion.py | 53 +- hermes_cli/console_engine.py | 1139 ++------- hermes_cli/context_switch_guard.py | 12 +- hermes_cli/curator.py | 684 ++--- hermes_cli/curses_ui.py | 901 +++---- hermes_cli/debug.py | 428 +--- hermes_cli/default_soul.py | 10 +- hermes_cli/diagnostics_upload.py | 71 +- hermes_cli/doctor_live.py | 107 +- hermes_cli/dump.py | 287 +-- hermes_cli/focus_view.py | 82 +- hermes_cli/foreign_sessions.py | 254 +- hermes_cli/gateway_windows.py | 749 ++---- hermes_cli/goals.py | 380 +-- hermes_cli/hooks.py | 140 +- hermes_cli/init_command.py | 44 +- hermes_cli/input_sanitize.py | 9 +- hermes_cli/journey.py | 81 +- hermes_cli/logs.py | 185 +- hermes_cli/loops.py | 514 ++-- hermes_cli/managed_scope.py | 115 +- hermes_cli/mcp_catalog.py | 626 ++--- hermes_cli/mcp_picker.py | 191 +- hermes_cli/mcp_security.py | 42 +- hermes_cli/mcp_startup.py | 148 +- hermes_cli/mem_trim.py | 120 +- hermes_cli/memory_setup.py | 68 +- hermes_cli/middleware.py | 160 +- hermes_cli/moa_cmd.py | 146 +- hermes_cli/observability/relay_runtime.py | 7 +- .../observability/relay_shared_metrics.py | 500 ++-- hermes_cli/observability/shared_metrics.py | 43 +- .../observability/shared_metrics_contract.py | 843 ++---- .../shared_metrics_send_config.py | 16 +- .../observability/shared_metrics_sender.py | 168 +- .../shared_metrics_subscriber.py | 39 +- hermes_cli/oneshot.py | 51 +- hermes_cli/partial_compress.py | 159 +- hermes_cli/personality.py | 50 +- hermes_cli/pets.py | 288 +-- hermes_cli/platform_actions.py | 48 +- hermes_cli/platforms.py | 30 +- hermes_cli/plugin_capabilities.py | 314 +-- hermes_cli/plugin_dev.py | 30 +- hermes_cli/plugin_index.py | 115 +- hermes_cli/plugin_packs.py | 80 +- hermes_cli/plugins_cmd.py | 2261 +++++++---------- hermes_cli/process_identity.py | 273 +- hermes_cli/profile_describer.py | 100 +- hermes_cli/profile_distribution.py | 427 ++-- hermes_cli/profiles.py | 1561 +++++------- hermes_cli/projects_cmd.py | 239 +- hermes_cli/projects_db.py | 252 +- hermes_cli/prompt_size.py | 187 +- hermes_cli/prompt_stash.py | 81 +- hermes_cli/proxy/__init__.py | 17 +- hermes_cli/proxy/adapters/__init__.py | 12 +- hermes_cli/proxy/adapters/base.py | 33 +- hermes_cli/proxy/adapters/nous_portal.py | 41 +- hermes_cli/proxy/adapters/xai.py | 32 +- hermes_cli/proxy/cli.py | 5 +- hermes_cli/proxy/server.py | 89 +- hermes_cli/proxy/sse_done.py | 25 +- hermes_cli/proxy_cli.py | 405 ++- hermes_cli/psutil_android.py | 13 +- hermes_cli/pt_input_extras.py | 441 +--- hermes_cli/pty_bridge.py | 101 +- hermes_cli/pty_session.py | 44 +- hermes_cli/resource_limits.py | 60 +- hermes_cli/route_identity.py | 11 +- hermes_cli/security_advisories.py | 195 +- hermes_cli/security_audit.py | 345 +-- hermes_cli/security_audit_startup.py | 200 +- hermes_cli/send_cmd.py | 284 +-- hermes_cli/session_export.py | 222 +- hermes_cli/session_export_html.py | 144 +- hermes_cli/session_export_md.py | 20 +- hermes_cli/session_filters.py | 248 +- hermes_cli/session_listing.py | 33 +- hermes_cli/session_lost_and_found.py | 167 +- hermes_cli/session_recap.py | 133 +- hermes_cli/session_recovery.py | 1144 ++++----- hermes_cli/setup_whatsapp_cloud.py | 207 +- hermes_cli/sizefmt.py | 18 +- hermes_cli/skills_hub.py | 1002 ++++---- hermes_cli/skin_cmd.py | 9 +- hermes_cli/skin_engine.py | 389 +-- hermes_cli/slack_cli.py | 57 +- hermes_cli/slash_exec.py | 68 +- hermes_cli/sqlite_runtime.py | 47 +- hermes_cli/sqlite_safe_read.py | 213 +- hermes_cli/sqlite_util.py | 20 +- hermes_cli/status.py | 443 ++-- hermes_cli/stderr_timestamp.py | 88 +- hermes_cli/stdio.py | 98 +- hermes_cli/suggestions_cmd.py | 173 +- hermes_cli/telegram_managed_bot.py | 195 +- hermes_cli/terminal_breadcrumbs.py | 33 +- hermes_cli/terminal_notify.py | 24 +- hermes_cli/timefmt.py | 8 +- hermes_cli/timeouts.py | 81 +- hermes_cli/tips.py | 7 +- hermes_cli/urllib_security.py | 69 +- hermes_cli/verify_cmd.py | 32 +- hermes_cli/voice.py | 614 ++--- hermes_cli/webhook.py | 56 +- hermes_cli/win_pty_bridge.py | 27 +- hermes_cli/write_approval_commands.py | 84 +- hermes_constants.py | 2 +- 131 files changed, 10763 insertions(+), 19250 deletions(-) create mode 100644 hermes_cli/_secrets_common.py diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index a8f6070ee2..3f4ed78585 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -1,15 +1,4 @@ -""" -Hermes CLI - Unified command-line interface for Hermes Agent. - -Provides subcommands for: -- hermes chat - Interactive chat (same as ./hermes) -- hermes gateway - Run gateway in foreground -- hermes gateway start - Start gateway service -- hermes gateway stop - Stop gateway service -- hermes setup - Interactive setup wizard -- hermes status - Show status of all components -- hermes cron - Manage cron jobs -""" +"""Hermes CLI - Unified command-line interface for Hermes Agent.""" import os import sys @@ -21,33 +10,9 @@ __release_date__ = "2026.8.31" def _ensure_utf8(): """Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes. - Several environments select a legacy, non-UTF-8 encoding for the standard - streams: - - - Windows services and terminals default to cp1252. - - Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian - installs and Raspberry Pi) select latin-1 or ASCII. - - The CLI prints box-drawing characters (┌│├└─) and the ⚕ glyph in the setup - wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec - raises an unhandled UnicodeEncodeError that crashes the command before it - can even start — e.g. `hermes setup` on a fresh Pi. - - This runs at import time so it protects every CLI subcommand, on any - platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not - already UTF-8, preferring TextIOWrapper.reconfigure() so the existing - stream object is fixed in place (cached `sys.stdout` references keep - working) and falling back to reopening the file descriptor with - closefd=False (the CPython-recommended safe variant). - - No-op when the streams are already UTF-8: a healthy UTF-8 system sees no - stream change and no environment mutation. - - Note: this is intentionally the earliest, platform-agnostic guard. - hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry - points and layers on the Windows-only extras (console code-page flip, - EDITOR default, PATH augmentation); its stream reconfiguration is a - harmless idempotent no-op once we have already repaired the streams here. + The CLI prints box-drawing characters (┌│├└─) and the ⚕ glyph in the setup wizard, doctor, and + status banners. Encoding those under a non-UTF-8 codec raises an unhandled UnicodeEncodeError + that crashes the command before it can even start — e.g. `hermes setup` on a fresh Pi. """ repaired = False diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 61a5879533..49aaa2600c 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -126,7 +126,7 @@ def _default_hermes_root() -> Path | None: def _venv_is_relocatable(venv_dir: Path) -> bool: - """True when the venv's pyvenv.cfg declares ``relocatable = true``. + r"""True when the venv's pyvenv.cfg declares ``relocatable = true``. uv writes the flag; ``managed_uv`` builds replacement venvs ``--relocatable``. A relocatable venv's console-script trampolines embed a RELATIVE interpreter reference, so a copy placed @@ -179,7 +179,7 @@ def ensure_windows_bin_launchers( windows: bool | None = None, user_path_entries: list[str] | None = None, ) -> list[str]: - """Re-stage the Windows ``hermes`` launchers when they vanish. + r"""Re-stage the Windows ``hermes`` launchers when they vanish. On Windows, ``hermes`` resolves through launchers derived from the venv console scripts — never ``venv\Scripts`` itself on PATH, which would shadow the user's ``python`` (#83797). diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 57b2616fc4..b3c3bd273b 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -1,13 +1,8 @@ -""" -Top-level argparse construction for the hermes CLI. +"""Top-level argparse construction for the hermes CLI. -Lives in its own module so other modules (e.g. ``relaunch.py``) can -introspect the parser to discover which flags exist without running the -``main`` fn. - -Only the top-level parser and the ``chat`` subparser live here. Every other -subparser (model, gateway, sessions, …) is built inline in ``main.py`` -because its dispatch is tightly coupled to module-level ``cmd_*`` functions. +Only the top-level parser and the ``chat`` subparser live here. Every other subparser (model, +gateway, sessions, …) is built inline in ``main.py`` because its dispatch is tightly coupled to +module-level ``cmd_*`` functions. """ import argparse @@ -46,17 +41,12 @@ _OPTIONAL_VALUE_FLAGS_FALLBACK: frozenset[str] = frozenset({"-c", "--continue"}) @lru_cache(maxsize=1) def top_level_value_flag_sets() -> tuple[frozenset[str], frozenset[str]]: - """(required-value, optional-value) top-level flags, derived from the - REAL parser. + """(required-value, optional-value) top-level flags, derived from the REAL parser. - Introspects ``build_top_level_parser()`` (every option with nargs != 0) - so the argv scanners in ``main.py`` (``_first_positional_argv``, - ``_apply_profile_override``) can never drift from the argparse surface — - the exact drift that made ``hermes --reasoning high chat …`` misread - ``high`` as the subcommand and forced eager plugin discovery (#93530). - Mirrors the ``update_cmd._holder_value_flags`` precedent, including the - handwritten-snapshot fallback for a broken parser import. Cached per - process. + Introspects ``build_top_level_parser()`` (every option with nargs != 0) so the argv scanners in + ``main.py`` (``_first_positional_argv``, ``_apply_profile_override``) can never drift from the + argparse surface — the exact drift that made ``hermes --reasoning high chat …`` misread ``high`` + as the subcommand and forced eager plugin discovery (#93530). """ try: parser = build_top_level_parser()[0] @@ -73,13 +63,10 @@ def top_level_value_flag_sets() -> tuple[frozenset[str], frozenset[str]]: def _inherited_flag(parser, *args, **kwargs): - """Register a flag that ``hermes_cli.relaunch`` should carry over when - the CLI re-execs itself (e.g. after ``sessions browse`` picks a session, - or after the setup wizard launches chat). + """Register a flag that ``hermes_cli.relaunch`` carries over when the CLI re-execs itself. - Equivalent to ``parser.add_argument(...)`` plus tagging the resulting - Action with ``inherit_on_relaunch = True`` so the relaunch table builder - can find it via introspection. + ``parser.add_argument(...)`` plus tagging the Action with ``inherit_on_relaunch = True`` so + the relaunch table builder can find it via introspection. """ action = parser.add_argument(*args, **kwargs) action.inherit_on_relaunch = True @@ -136,9 +123,9 @@ For more help on a command: def build_top_level_parser(): """Build the top-level parser, the subparsers action, and the ``chat`` subparser. - Returns ``(parser, subparsers, chat_parser)``. The caller wires - ``chat_parser.set_defaults(func=cmd_chat)`` and continues registering - other subparsers via ``subparsers.add_parser(...)``. + Returns ``(parser, subparsers, chat_parser)``; the caller wires + ``chat_parser.set_defaults(func= cmd_chat)`` and registers further subparsers via + ``subparsers.add_parser(...)``. """ parser = argparse.ArgumentParser( prog="hermes", diff --git a/hermes_cli/_secrets_common.py b/hermes_cli/_secrets_common.py new file mode 100644 index 0000000000..3c38545b90 --- /dev/null +++ b/hermes_cli/_secrets_common.py @@ -0,0 +1,225 @@ +"""Helpers shared by the Bitwarden and 1Password ``hermes secrets`` CLIs. + +Import-light on purpose: ``hermes_cli.secrets_cli`` must stay free of the Bitwarden backend +(``cryptography``) at import time, so nothing here touches a secret-source backend. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path +from typing import Callable, Iterable, Optional, Sequence + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from hermes_cli.config import load_config, save_config +from hermes_cli.secret_prompt import masked_secret_prompt + + +def yn(b: bool) -> str: + return "[green]yes[/green]" if b else "[dim]no[/dim]" + + +def section_cfg(cfg: dict, section: str) -> dict: + """``cfg["secrets"][section]`` or ``{}``.""" + return (cfg.get("secrets") or {}).get(section) or {} + + +def cfg_str(cfg: dict, key: str) -> str: + """A stripped string config value; ``""`` for missing/None.""" + return str(cfg.get(key, "") or "").strip() + + +def arg(name: str, help_text: str, **kwargs) -> tuple: + """One ``add_argument`` spec for :func:`register_subcommands`.""" + return name, dict(help=help_text, **kwargs) + + +def flag(name: str, help_text: str) -> tuple: + """A boolean ``store_true`` spec for :func:`register_subcommands`.""" + return arg(name, help_text, action="store_true") + + +def register_subcommands(parent: argparse.ArgumentParser, dest: str, commands: Iterable) -> None: + """Attach ``(name, help, handler, [arg(...), ...])`` subcommands to ``parent``.""" + sub = parent.add_subparsers(dest=dest) + for name, help_text, func, arguments in commands: + parser = sub.add_parser(name, help=help_text) + for arg_name, kwargs in arguments: + parser.add_argument(arg_name, **kwargs) + parser.set_defaults(func=func) + + +def require_enabled(console: Console, cfg: dict, product: str, command: str) -> bool: + """Print the "integration is disabled" hint and return False unless ``cfg["enabled"]``.""" + if cfg.get("enabled"): + return True + console.print( + f"[yellow]{product} integration is disabled. Run " + f"`hermes secrets {command} setup` first.[/yellow]" + ) + return False + + +def print_status_panel(console: Console, title: str, rows: Iterable) -> None: + """Two-column key/value table inside a cyan panel (the ``status`` layout).""" + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("", style="bold") + table.add_column("") + for label, value in rows: + table.add_row(label, value) + console.print(Panel(table, title=title, border_style="cyan")) + + +def print_table(console: Console, columns: Sequence, rows: Iterable, + warnings: Iterable[str] = (), indent: str = "") -> None: + """Headed table; ``columns`` entries are ``header`` or ``(header, add_column kwargs)``. + + ``warnings`` are echoed after the table, one ``warning:`` line each. + """ + table = Table(show_header=True, header_style="bold") + for col in columns: + header, kwargs = (col, {}) if isinstance(col, str) else col + table.add_column(header, **kwargs) + for row in rows: + table.add_row(*row) + console.print(table) + for w in warnings: + console.print(f"{indent}[yellow]warning:[/yellow] {w}") + + +def cli_version(binary: Path) -> str: + """Return the first line of `` --version`` or ``"version unknown"``.""" + try: + res = subprocess.run( + [str(binary), "--version"], + capture_output=True, + text=True, encoding='utf-8', errors='replace', + timeout=5, + ) + if res.returncode == 0: + return (res.stdout or res.stderr).strip().splitlines()[0] + except (OSError, subprocess.TimeoutExpired): + pass + return "version unknown" + + +def secret_cli_env() -> dict: + """Env for a secret-manager CLI child (``bws`` / ``op``). + + Intentionally receives tokens — no scrub, no HOME rewrite (both CLIs store + state under the real user home). + """ + from tools.environments.local import build_subprocess_env + + env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) + env.setdefault("NO_COLOR", "1") + return env + + +def prompt_new_token( + console: Console, + given: Optional[str], + token_env: str, + *, + flag: str, + intro: str, + prompt: str, +) -> Optional[str]: + """Return a non-empty token from ``given`` or a masked prompt; None (after printing) on failure.""" + token = (given or "").strip() + if not token: + if not sys.stdin.isatty(): + console.print(f"[red]No TTY — pass the token with {flag}.[/red]") + return None + console.print(intro) + token = masked_secret_prompt(prompt).strip() + if not token: + console.print("[red]Empty token, aborting.[/red]") + return None + return token + + +def rotate_token( + console: Console, + given: Optional[str], + token_env: str, + *, + flag: str, + intro: str, + prompt: str, + verify: Optional[Callable[[str], bool]], + save: Callable[[str, str], object], + env_path: Callable[[], object], + clear_caches: Callable[[], object], + disabled_note: Optional[str], +) -> int: + """Shared ``token`` subcommand: prompt, optionally verify, then persist. Returns the exit code. + + ``verify(token)`` prints its own diagnostics and returns False to abort without touching + .env — so a bad paste never bricks the working token; None skips verification + (``--no-verify``). ``save``/``env_path`` are passed in (not imported here) so each CLI + module's own ``save_env_value``/``get_env_path`` bindings — which tests monkeypatch — stay + in effect. Old cached pulls are keyed on the previous token's fingerprint; clearing them + makes the next startup fetch fresh with the new credential. + """ + token = prompt_new_token(console, given, token_env, flag=flag, intro=intro, prompt=prompt) + if token is None: + return 1 + if verify is not None and not verify(token): + return 1 + save(token_env, token) + os.environ[token_env] = token + clear_caches() + console.print( + f"[green]✓[/green] stored in {env_path()} as {token_env}. " + "Takes effect on the next Hermes invocation." + ) + if disabled_note: + console.print(disabled_note) + return 0 + + +def prompt_index( + console: Console, + prompt: str, + count: int, + *, + allow_empty: bool = False, + empty_message: Optional[str] = None, +) -> int: + """Loop until the user enters an integer in ``1..count``; return it. + + Blank input returns 0 when ``allow_empty``; otherwise ``empty_message`` (if any) + is printed and the prompt repeats. + """ + while True: + choice = console.input(prompt).strip() + if not choice: + if allow_empty: + return 0 + if empty_message: + console.print(empty_message) + continue + try: + idx = int(choice) + except ValueError: + console.print(" [red]Enter a number.[/red]") + continue + if 1 <= idx <= count: + return idx + console.print(f" [red]Out of range — pick 1-{count}.[/red]") + + +def disable_secret_source(section: str, message: str) -> int: + """Set ``secrets.
.enabled = False`` in config.yaml and print ``message``.""" + cfg = load_config() + cfg.setdefault("secrets", {}).setdefault(section, {})["enabled"] = False + save_config(cfg) + Console().print(message) + return 0 diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 8240c83ae7..af3ac81560 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -340,7 +340,7 @@ def _process_start_time(pid: int) -> int | None: def _text_names_hermes(text: str) -> bool: - """True when *text* names Hermes at a path-segment / token boundary. + r"""True when *text* names Hermes at a path-segment / token boundary. A bare ``"hermes" in text`` substring test would also match unrelated processes whose paths merely contain the letters (``...\shermesa\...``), which is exactly the false-positive class diff --git a/hermes_cli/agent_import.py b/hermes_cli/agent_import.py index 7439133a80..3e6d61907a 100644 --- a/hermes_cli/agent_import.py +++ b/hermes_cli/agent_import.py @@ -1,39 +1,8 @@ """hermes import-agent — import Claude Code / Codex CLI setups into Hermes. -Usage: - hermes import-agent # auto-detect ~/.claude or ~/.codex - hermes import-agent claude-code # import from ~/.claude - hermes import-agent codex # import from ~/.codex - hermes import-agent claude-code --dry-run # preview only, no changes - hermes import-agent codex --source /path/to/.codex - -Follows the OpenClaw migration pattern (``hermes claw migrate`` / -``optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py``): -detect → parse → map → apply, with a mandatory preview phase, per-item -imported/skipped/conflict/error records, and a ``--dry-run`` that writes -nothing. The memory-entry merge and allowlist-merge primitives here are -self-contained ports of the openclaw script's equivalents so this command -works even when the optional migration skill is not installed. - -Mappings --------- -claude-code (~/.claude): - CLAUDE.md → memory entries in HERMES_HOME/memories/MEMORY.md - settings.json permissions.allow → config.yaml command_allowlist (Bash(...) rules) - settings.json permissions.deny → config.yaml approvals.deny (Bash(...) rules) - mcpServers (~/.claude.json or settings.json) → config.yaml mcp_servers - skills//SKILL.md → HERMES_HOME/skills/claude-code-imports// - -codex (~/.codex): - AGENTS.md → memory entries in HERMES_HOME/memories/MEMORY.md - config.toml [mcp_servers.*] → config.yaml mcp_servers - memories/*.md → memory entries in HERMES_HOME/memories/MEMORY.md - skills//SKILL.md → HERMES_HOME/skills/codex-imports// - -Secrets are NEVER imported: credential files (.credentials.json, auth.json) -are ignored, and MCP server env vars with secret-looking names (KEY, TOKEN, -SECRET, PASSWORD, ...) are stripped and reported so the user can re-add them -deliberately via ``hermes setup`` or config.yaml. +Secrets are NEVER imported: credential files (.credentials.json, auth.json) are ignored, and MCP +server env vars with secret-looking names (KEY, TOKEN, SECRET, PASSWORD, ...) are stripped and +reported so the user can re-add them deliberately via ``hermes setup`` or config.yaml. """ from __future__ import annotations @@ -98,28 +67,24 @@ def read_text(path: Path) -> str: class ConfigReadError(RuntimeError): """An existing config file is present but cannot be read or parsed. - Signals that a read-modify-write round trip must be abandoned: the caller - has no idea what the file holds, so writing a merged result back would - replace real settings with only the keys it merged. + Signals that a read-modify-write round trip must be abandoned: the caller has no idea what the + file holds, so writing a merged result back would replace real settings with only the keys it + merged. """ def load_yaml_file(path: Path) -> Dict[str, Any]: """Load a YAML mapping, distinguishing "absent" from "unreadable". - Callers read ``config.yaml``, merge a section in, and write the whole - mapping back — so collapsing a present-but-unreadable file to ``{}`` - would replace every existing setting with just the merged keys. - - - Absent, or present but empty -> ``{}``; first-time creation still works. - - Present but unreadable, unparseable, or not a mapping -> raise - :class:`ConfigReadError` so the caller refuses and leaves the file - byte-identical. + - Absent, or present but empty -> ``{}``; first-time creation still works. - Present but + unreadable, unparseable, or not a mapping -> raise :class:`ConfigReadError` so the caller + refuses and leaves the file byte-identical. """ import yaml if not path.exists(): return {} + fix_hint = "Fix it with `hermes config edit` (or move it aside), then re-run the import." try: raw = read_text(path) except OSError as exc: @@ -131,9 +96,7 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: data = yaml.safe_load(raw) except yaml.YAMLError as exc: raise ConfigReadError( - f"Refusing to overwrite {path}: the existing file is not valid YAML " - f"({exc}). Fix it with `hermes config edit` (or move it aside), then " - f"re-run the import." + f"Refusing to overwrite {path}: the existing file is not valid YAML ({exc}). {fix_hint}" ) from exc # An empty file parses to None — a legitimate state with nothing to lose. if data is None: @@ -141,8 +104,7 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: if not isinstance(data, dict): raise ConfigReadError( f"Refusing to overwrite {path}: expected the existing file to hold a " - f"YAML mapping but found {type(data).__name__}. Fix it with " - f"`hermes config edit` (or move it aside), then re-run the import." + f"YAML mapping but found {type(data).__name__}. {fix_hint}" ) return data @@ -150,13 +112,9 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None: """Write ``data`` as YAML atomically (temp file + fsync + rename). - Only ever reached after :func:`load_yaml_file` has successfully read the - same path, so the mapping being written is the real file's content plus - the merged section — never a silently-empty stand-in. - - ``atomic_yaml_write`` keeps a symlinked config a symlink, creates the - parent directory, and preserves the previous file's mode/owner (a - ``0o600``-secured config stays ``0o600``). + Only ever reached after :func:`load_yaml_file` has successfully read the same path, so the + mapping being written is the real file's content plus the merged section — never a silently- + empty stand-in. """ atomic_yaml_write(path, data) @@ -168,9 +126,8 @@ def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None: def extract_markdown_entries(text: str) -> List[str]: """Split a markdown document into individual memory entries. - Headings become context prefixes, bullets and paragraphs become entries. - Code blocks and tables are skipped. Port of the openclaw migration - script's extractor. + Headings become context prefixes; bullets and paragraphs become entries; code blocks and + tables are skipped. """ entries: List[str] = [] headings: List[str] = [] @@ -186,16 +143,16 @@ def extract_markdown_entries(text: str) -> List[str]: ] return " > ".join(filtered) + def add_entry(content: str) -> None: + prefix = context_prefix() + entries.append(f"{prefix}: {content}" if prefix else content) + def flush_paragraph() -> None: nonlocal paragraph_lines - if not paragraph_lines: - return block = " ".join(line.strip() for line in paragraph_lines).strip() paragraph_lines = [] - if not block: - return - prefix = context_prefix() - entries.append(f"{prefix}: {block}" if prefix else block) + if block: + add_entry(block) in_code_block = False for raw_line in (text or "").splitlines(): @@ -222,9 +179,7 @@ def extract_markdown_entries(text: str) -> List[str]: bullet_match = re.match(r"^\s*(?:[-*]|\d+\.)\s+(.*\S)\s*$", line) if bullet_match: flush_paragraph() - content = bullet_match.group(1).strip() - prefix = context_prefix() - entries.append(f"{prefix}: {content}" if prefix else content) + add_entry(bullet_match.group(1).strip()) continue if not stripped: @@ -253,18 +208,14 @@ def extract_markdown_entries(text: str) -> List[str]: def parse_existing_memory_entries(path: Path) -> List[str]: """Parse the DESTINATION memory store into entries. - ``memories/MEMORY.md`` is the entry-delimited store written by - ``MemoryStore._write_file`` (tools/memory_tool.py), not a markdown - document, so this splits on ``ENTRY_DELIMITER`` only — exactly what - ``MemoryStore._parse_entries`` does. A store with no delimiter (a single - entry, or one that was hand-edited / shell-appended) is therefore ONE - intact entry. + ``memories/MEMORY.md`` is the entry-delimited store written by ``MemoryStore._write_file`` + (tools/memory_tool.py), not a markdown document, so this splits on ``ENTRY_DELIMITER`` only — + exactly what ``MemoryStore._parse_entries`` does. - Do NOT fall back to :func:`extract_markdown_entries` here. That extractor - is correct for CLAUDE.md / AGENTS.md *sources*, but it drops fenced code - blocks and table rows and splits a block into one entry per bullet — and - the merged result is written straight back over the user's store, so the - loss is permanent. + Do NOT fall back to :func:`extract_markdown_entries` here. That extractor is correct for + CLAUDE.md / AGENTS.md *sources*, but it drops fenced code blocks and table rows and splits a + block into one entry per bullet — and the merged result is written straight back over the user's + store, so the loss is permanent. """ if not path.exists(): return [] @@ -277,11 +228,10 @@ def parse_existing_memory_entries(path: Path) -> List[str]: def backup_memory_file(path: Path) -> Optional[Path]: """Snapshot ``path`` before a destructive rewrite; return the backup path. - Restores parity with the openclaw migration script this module was ported - from, which calls ``maybe_backup(destination)`` before rewriting a memory - store. Uses the same ``.bak.`` naming as - ``MemoryStore._backup_drifted_file``. Returns None when there is nothing - to back up. + Restores parity with the openclaw migration script this module was ported from, which calls + ``maybe_backup(destination)`` before rewriting a memory store. Uses the same + ``.bak.`` naming as ``MemoryStore._backup_drifted_file``. Returns None when there + is nothing to back up. """ if not path.exists(): return None @@ -290,7 +240,6 @@ def backup_memory_file(path: Path) -> Optional[Path]: return backup - def merge_entries( existing: Sequence[str], incoming: Sequence[str], @@ -332,12 +281,9 @@ _BASH_RULE_RE = re.compile(r"^Bash\((?P.*)\)$") def claude_rule_to_command_pattern(rule: str) -> Optional[str]: """Convert a Claude Code ``Bash(...)`` permission rule into a Hermes glob. - ``Bash(npm run build)`` → ``npm run build`` - ``Bash(npm run test:*)`` → ``npm run test*`` (Claude ':*' prefix match) - ``Bash(git diff *)`` → ``git diff *`` - ``Bash`` → None (blanket rule, too broad to import) - Non-Bash rules (``Read(...)``, ``WebFetch(...)``, ...) → None: they gate - Claude-specific tools with no command-allowlist equivalent. + ``Bash(npm run test:*)`` -> ``npm run test*`` (Claude ':*' is a prefix match). Bare ``Bash`` + and non-Bash rules (``Read(...)``, ``WebFetch(...)``) return None: the former is too broad to + import, the latter gate Claude-specific tools with no command-allowlist equivalent. """ rule = (rule or "").strip() m = _BASH_RULE_RE.match(rule) @@ -366,16 +312,45 @@ def detect_agents() -> List[str]: def sanitize_mcp_env(env: Any) -> Tuple[Dict[str, str], List[str]]: """Split an MCP server env dict into (kept, stripped-secret-names).""" - kept: Dict[str, str] = {} - stripped: List[str] = [] if not isinstance(env, dict): - return kept, stripped - for key, value in env.items(): - if is_secret_key(str(key)): - stripped.append(str(key)) - else: - kept[str(key)] = value - return kept, stripped + return {}, [] + kept = {str(k): v for k, v in env.items() if not is_secret_key(str(k))} + return kept, [str(k) for k in env if str(k) not in kept] + + +def _copy_skill_dir(skill_dir: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(skill_dir, destination) + + +def _translate_mcp_server(name: str, srv: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]: + """Map one Claude/Codex MCP server entry to Hermes shape; returns (server, stripped secret paths).""" + hermes_srv: Dict[str, Any] = {} + stripped: List[str] = [] + if srv.get("command"): + hermes_srv["command"] = srv["command"] + if srv.get("args"): + hermes_srv["args"] = srv["args"] + env_kept, env_stripped = sanitize_mcp_env(srv.get("env")) + if env_kept: + hermes_srv["env"] = env_kept + stripped.extend(f"mcp_servers.{name}.env.{k}" for k in env_stripped) + if srv.get("cwd"): + hermes_srv["cwd"] = srv["cwd"] + if srv.get("url"): + hermes_srv["url"] = srv["url"] + headers = srv.get("headers") + if isinstance(headers, dict): + kept_headers = { + k: v for k, v in headers.items() + if not is_secret_key(str(k)) and "authorization" not in str(k).lower() + } + if kept_headers: + hermes_srv["headers"] = kept_headers + stripped.extend(f"mcp_servers.{name}.headers.{k}" for k in headers if k not in kept_headers) + return hermes_srv, stripped # --------------------------------------------------------------------------- @@ -385,10 +360,8 @@ def sanitize_mcp_env(env: Any) -> Tuple[Dict[str, str], List[str]]: class AgentImporter: """Detect/parse/map/apply importer for a single agent source tree. - ``execute=False`` runs the full plan without touching disk (dry run); - ``execute=True`` applies it. Every item is recorded as - imported/skipped/conflict/error with a human-readable reason so the CLI - can print a per-item report. + ``execute=False`` runs the full plan without touching disk (dry run). Every item is recorded + as imported/skipped/conflict/error with a reason so the CLI can print a per-item report. """ def __init__( @@ -420,22 +393,15 @@ class AgentImporter: "status": status, "reason": reason, } - if details: - item.update(details) + item.update(details) self.items.append(item) def load_target_config(self, kind: str, source, destination: Path ) -> Optional[Dict[str, Any]]: """Read the destination config.yaml, or record a refusal and return None. - The single chokepoint for the three importers that read config.yaml, - merge a section into it and write it back. When the existing file is - present but unreadable there is nothing safe to merge into, so the - item is recorded as an ``error`` and the file is left untouched — - rather than being replaced by the merged section alone. - - Deliberately runs in dry-run too: ``--dry-run`` must report the - refusal, not preview an ``imported`` that would destroy the config. + Deliberately runs in dry-run too: ``--dry-run`` must report the refusal, not preview an + ``imported`` that would destroy the config. """ try: return load_yaml_file(destination) @@ -443,11 +409,24 @@ class AgentImporter: self.record(kind, source, destination, "error", str(exc)) return None + def apply(self, kind: str, source, destination, would: str, action, + details: Optional[Dict[str, Any]] = None) -> None: + """Record ``imported`` (reason ``would`` in dry-run); in execute mode run ``action`` first. + + ``action`` may add keys to ``details`` (shared by reference) and returns an error string + to record ``error`` instead, or None on success. + """ + details = details or {} + if not self.execute: + self.record(kind, source, destination, "imported", would, **details) + return + error = action() + self.record(kind, source, destination, "error" if error else "imported", error or "", **details) + def build_report(self) -> Dict[str, Any]: summary = {"imported": 0, "skipped": 0, "conflict": 0, "error": 0} for item in self.items: - status = item.get("status", "skipped") - summary[status] = summary.get(status, 0) + 1 + summary[item["status"]] = summary.get(item["status"], 0) + 1 report: Dict[str, Any] = { "agent": self.agent, "source": str(self.source_root), @@ -467,10 +446,7 @@ class AgentImporter: self.record("source", self.source_root, None, "error", "Source directory does not exist") return self.build_report() - if self.agent == "claude-code": - self._run_claude_code() - else: - self._run_codex() + {"claude-code": self._run_claude_code, "codex": self._run_codex}[self.agent]() return self.build_report() def _run_claude_code(self) -> None: @@ -491,106 +467,101 @@ class AgentImporter: def _run_codex(self) -> None: config = self._load_codex_config() self.import_context_file(self.source_root / "AGENTS.md", kind="agents-md") - mcp = config.get("mcp_servers") if isinstance(config, dict) else None - self.import_mcp_servers(mcp if isinstance(mcp, dict) else {}, - kind="mcp-servers") + mcp = config.get("mcp_servers") + self.import_mcp_servers(mcp if isinstance(mcp, dict) else {}, kind="mcp-servers") self.import_memories_dir(self.source_root / "memories") self.import_skills(self.source_root / "skills") # -- parsers (fail soft: bad files become per-item error records) ------- + _JSON_ERRORS = (json.JSONDecodeError, OSError) + def _load_claude_settings(self) -> Dict[str, Any]: - path = self.source_root / "settings.json" + return self._load_source_mapping( + "settings", self.source_root / "settings.json", json.loads, self._JSON_ERRORS, + record_missing=True, non_mapping_error="settings.json is not a JSON object", + ) + + def _load_codex_config(self) -> Dict[str, Any]: + import tomllib + + return self._load_source_mapping( + "config", self.source_root / "config.toml", tomllib.loads, Exception, + record_missing=True, + ) + + def _load_source_mapping(self, kind: str, path: Path, parse, errors, *, + record_missing: bool = False, + non_mapping_error: str = "") -> Dict[str, Any]: + """Parse ``path`` into a mapping; problems become per-item error records, result ``{}``.""" if not path.exists(): - self.record("settings", None, None, "skipped", - "No settings.json found") + if record_missing: + self.record(kind, None, None, "skipped", f"No {path.name} found") return {} try: - data = json.loads(read_text(path)) - except (json.JSONDecodeError, OSError) as exc: - self.record("settings", path, None, "error", - f"Could not parse settings.json: {exc}") + data = parse(read_text(path)) + except errors as exc: + self.record(kind, path, None, "error", f"Could not parse {path.name}: {exc}") return {} - if not isinstance(data, dict): - self.record("settings", path, None, "error", - "settings.json is not a JSON object") - return {} - return data + if isinstance(data, dict): + return data + if non_mapping_error: + self.record(kind, path, None, "error", non_mapping_error) + return {} def _claude_mcp_servers(self, settings: Dict[str, Any]) -> Dict[str, Any]: """Collect mcpServers from ~/.claude.json (preferred) and settings.json.""" - servers: Dict[str, Any] = {} # ~/.claude.json lives NEXT TO ~/.claude/, not inside it - claude_json = self.source_root.parent / ".claude.json" - if claude_json.exists(): - try: - data = json.loads(read_text(claude_json)) - if isinstance(data, dict) and isinstance(data.get("mcpServers"), dict): - servers.update(data["mcpServers"]) - except (json.JSONDecodeError, OSError) as exc: - self.record("mcp-servers", claude_json, None, "error", - f"Could not parse .claude.json: {exc}") + data = self._load_source_mapping( + "mcp-servers", self.source_root.parent / ".claude.json", json.loads, self._JSON_ERRORS) + servers: Dict[str, Any] = {} + if isinstance(data.get("mcpServers"), dict): + servers.update(data["mcpServers"]) from_settings = settings.get("mcpServers") if isinstance(from_settings, dict): for name, srv in from_settings.items(): servers.setdefault(name, srv) return servers - def _load_codex_config(self) -> Dict[str, Any]: - path = self.source_root / "config.toml" - if not path.exists(): - self.record("config", None, None, "skipped", - "No config.toml found") - return {} - try: - import tomllib - data = tomllib.loads(read_text(path)) - except Exception as exc: - self.record("config", path, None, "error", - f"Could not parse config.toml: {exc}") - return {} - return data if isinstance(data, dict) else {} - # -- mappers ------------------------------------------------------------- def import_context_file(self, source: Path, kind: str) -> None: """CLAUDE.md / AGENTS.md → memory entries in memories/MEMORY.md.""" - destination = self.target_root / "memories" / "MEMORY.md" if not source.exists(): - self.record(kind, None, destination, "skipped", - f"No {source.name} found") + self.record(kind, None, self.target_root / "memories" / "MEMORY.md", + "skipped", f"No {source.name} found") return - try: - incoming = extract_markdown_entries(read_text(source)) - except OSError as exc: - self.record(kind, source, destination, "error", - f"Could not read file: {exc}") - return - if not incoming: - self.record(kind, source, destination, "skipped", - "No importable entries found") - return - self._merge_memory_entries(kind, source, destination, incoming) + self._import_markdown_files(kind, source, [source], single_file=True) def import_memories_dir(self, memories_dir: Path) -> None: """codex memories/*.md → memory entries in memories/MEMORY.md.""" - destination = self.target_root / "memories" / "MEMORY.md" if not memories_dir.is_dir(): - self.record("memories", None, destination, "skipped", - "No memories directory found") + self.record("memories", None, self.target_root / "memories" / "MEMORY.md", + "skipped", "No memories directory found") return + self._import_markdown_files("memories", memories_dir, sorted(memories_dir.glob("*.md"))) + + def _import_markdown_files(self, kind: str, source: Path, files: List[Path], + single_file: bool = False) -> None: + """Extract entries from ``files`` and merge them into memories/MEMORY.md. + + An unreadable file records an error; a directory import then still reports "no entries" + when nothing was extracted, while a single-file import stops at the error. + """ + destination = self.target_root / "memories" / "MEMORY.md" incoming: List[str] = [] - for md_file in sorted(memories_dir.glob("*.md")): + failed = False + for md_file in files: try: incoming.extend(extract_markdown_entries(read_text(md_file))) except OSError as exc: - self.record("memories", md_file, destination, "error", - f"Could not read file: {exc}") + failed = True + self.record(kind, md_file, destination, "error", f"Could not read file: {exc}") if not incoming: - self.record("memories", memories_dir, destination, "skipped", - "No importable entries found") + if not (failed and single_file): + self.record(kind, source, destination, "skipped", "No importable entries found") return - self._merge_memory_entries("memories", memories_dir, destination, incoming) + self._merge_memory_entries(kind, source, destination, incoming) def _merge_memory_entries(self, kind: str, source: Path, destination: Path, incoming: List[str]) -> None: @@ -603,49 +574,53 @@ class AgentImporter: "overflowed_entries": stats["overflowed"], } if stats["added"] == 0: - self.record(kind, source, destination, "skipped", - "No new entries to import", **details) + self.record(kind, source, destination, "skipped", "No new entries to import", **details) return - if self.execute: + + def write() -> Optional[str]: destination.parent.mkdir(parents=True, exist_ok=True) try: backup = backup_memory_file(destination) except OSError as exc: # Never rewrite the store when the safety net failed. - self.record(kind, source, destination, "error", - f"Could not back up existing memory file: {exc}", - **details) - return + return f"Could not back up existing memory file: {exc}" if backup is not None: details["backup"] = str(backup) try: - atomic_write_text( - destination, - ENTRY_DELIMITER.join(merged) + ("\n" if merged else ""), - ) + atomic_write_text(destination, ENTRY_DELIMITER.join(merged) + ("\n" if merged else "")) except OSError as exc: - self.record(kind, source, destination, "error", - f"Could not write merged memory file: {exc}", - **details) - return - self.record(kind, source, destination, "imported", **details) - else: - self.record(kind, source, destination, "imported", - "Would merge entries", **details) + return f"Could not write merged memory file: {exc}" + return None + + self.apply(kind, source, destination, "Would merge entries", write, details) + + # (settings key, item kind, config path, dry-run tracks unmapped rules) + _PERMISSION_RULES = { + "allow": ("command-allowlist", ("command_allowlist",), True), + "deny": ("command-denylist", ("approvals", "deny"), False), + } def import_permission_allowlist(self, settings: Dict[str, Any]) -> None: """settings.json permissions.allow → config.yaml command_allowlist.""" + self._import_permission_rules(settings, "allow") + + def import_permission_denylist(self, settings: Dict[str, Any]) -> None: + """settings.json permissions.deny → config.yaml approvals.deny.""" + self._import_permission_rules(settings, "deny") + + def _import_permission_rules(self, settings: Dict[str, Any], key: str) -> None: + kind, config_path, track_unmapped = self._PERMISSION_RULES[key] + label = f"settings.json permissions.{key}" destination = self.target_root / "config.yaml" permissions = settings.get("permissions") - allow = permissions.get("allow") if isinstance(permissions, dict) else None - if not isinstance(allow, list) or not allow: - self.record("command-allowlist", None, destination, "skipped", - "No permissions.allow rules found") + rules = permissions.get(key) if isinstance(permissions, dict) else None + if not isinstance(rules, list) or not rules: + self.record(kind, None, destination, "skipped", f"No permissions.{key} rules found") return patterns: List[str] = [] skipped_rules: List[str] = [] - for rule in allow: + for rule in rules: if not isinstance(rule, str): continue pattern = claude_rule_to_command_pattern(rule) @@ -654,93 +629,45 @@ class AgentImporter: else: skipped_rules.append(rule) patterns = sorted(dict.fromkeys(patterns)) + unmapped: Dict[str, Any] = {"unmapped_rules": skipped_rules} if track_unmapped else {} if not patterns: - self.record("command-allowlist", None, destination, "skipped", - "No Bash(...) allow rules to import", - unmapped_rules=skipped_rules) + self.record(kind, None, destination, "skipped", + f"No Bash(...) {key} rules to import", **unmapped) return + if not skipped_rules: + unmapped = {} - config = self.load_target_config( - "command-allowlist", "settings.json permissions.allow", destination) + config = self.load_target_config(kind, label, destination) if config is None: return - current = config.get("command_allowlist", []) + # Walk to the list's parent mapping, materializing missing/invalid levels. + parent: Dict[str, Any] = config + for part in config_path[:-1]: + child = parent.get(part) + if not isinstance(child, dict): + child = {} + parent[part] = child + parent = child + current = parent.get(config_path[-1], []) if not isinstance(current, list): current = [] merged = sorted(dict.fromkeys(list(current) + patterns)) added = [p for p in merged if p not in current] if not added: - self.record("command-allowlist", "settings.json permissions.allow", - destination, "skipped", "All patterns already present") + self.record(kind, label, destination, "skipped", "All patterns already present") return - details: Dict[str, Any] = {"added_patterns": added} - if skipped_rules: - details["unmapped_rules"] = skipped_rules - if self.execute: - config["command_allowlist"] = merged + def write() -> None: + parent[config_path[-1]] = merged dump_yaml_file(destination, config) - self.record("command-allowlist", "settings.json permissions.allow", - destination, "imported", **details) - else: - self.record("command-allowlist", "settings.json permissions.allow", - destination, "imported", "Would merge patterns", **details) - def import_permission_denylist(self, settings: Dict[str, Any]) -> None: - """settings.json permissions.deny → config.yaml approvals.deny.""" - destination = self.target_root / "config.yaml" - permissions = settings.get("permissions") - deny = permissions.get("deny") if isinstance(permissions, dict) else None - if not isinstance(deny, list) or not deny: - self.record("command-denylist", None, destination, "skipped", - "No permissions.deny rules found") - return - - patterns: List[str] = [] - for rule in deny: - if not isinstance(rule, str): - continue - pattern = claude_rule_to_command_pattern(rule) - if pattern: - patterns.append(pattern) - patterns = sorted(dict.fromkeys(patterns)) - if not patterns: - self.record("command-denylist", None, destination, "skipped", - "No Bash(...) deny rules to import") - return - - config = self.load_target_config( - "command-denylist", "settings.json permissions.deny", destination) - if config is None: - return - approvals = config.get("approvals") - if not isinstance(approvals, dict): - approvals = {} - current = approvals.get("deny", []) - if not isinstance(current, list): - current = [] - merged = sorted(dict.fromkeys(list(current) + patterns)) - added = [p for p in merged if p not in current] - if not added: - self.record("command-denylist", "settings.json permissions.deny", - destination, "skipped", "All patterns already present") - return - if self.execute: - approvals["deny"] = merged - config["approvals"] = approvals - dump_yaml_file(destination, config) - self.record("command-denylist", "settings.json permissions.deny", - destination, "imported", added_patterns=added) - else: - self.record("command-denylist", "settings.json permissions.deny", - destination, "imported", "Would merge patterns", - added_patterns=added) + self.apply(kind, label, destination, "Would merge patterns", write, + {"added_patterns": added, **unmapped}) def import_mcp_servers(self, servers: Dict[str, Any], kind: str) -> None: """mcpServers / [mcp_servers.*] → config.yaml mcp_servers.""" destination = self.target_root / "config.yaml" if not servers: - self.record(kind, None, destination, "skipped", - "No MCP servers found") + self.record(kind, None, destination, "skipped", "No MCP servers found") return config = self.load_target_config(kind, None, destination) @@ -752,53 +679,22 @@ class AgentImporter: added = 0 for name, srv in servers.items(): if not isinstance(srv, dict): - self.record(kind, name, None, "skipped", - "Server entry is not a mapping") + self.record(kind, name, None, "skipped", "Server entry is not a mapping") continue if name in existing and not self.overwrite: self.record(kind, name, f"mcp_servers.{name}", "conflict", "MCP server already exists in Hermes config") continue - hermes_srv: Dict[str, Any] = {} - if srv.get("command"): - hermes_srv["command"] = srv["command"] - if srv.get("args"): - hermes_srv["args"] = srv["args"] - env_kept, env_stripped = sanitize_mcp_env(srv.get("env")) - if env_kept: - hermes_srv["env"] = env_kept - if env_stripped: - self.stripped_secrets.extend( - f"mcp_servers.{name}.env.{k}" for k in env_stripped - ) - if srv.get("cwd"): - hermes_srv["cwd"] = srv["cwd"] - if srv.get("url"): - hermes_srv["url"] = srv["url"] - headers = srv.get("headers") - if isinstance(headers, dict): - kept_headers = { - k: v for k, v in headers.items() - if not is_secret_key(str(k)) - and "authorization" not in str(k).lower() - } - if kept_headers: - hermes_srv["headers"] = kept_headers - for k in headers: - if k not in kept_headers: - self.stripped_secrets.append( - f"mcp_servers.{name}.headers.{k}" - ) + hermes_srv, stripped = _translate_mcp_server(name, srv) + self.stripped_secrets.extend(stripped) if not hermes_srv: - self.record(kind, name, None, "skipped", - "Server has neither a command nor a url") + self.record(kind, name, None, "skipped", "Server has neither a command nor a url") continue existing[name] = hermes_srv added += 1 - self.record(kind, name, f"config.yaml mcp_servers.{name}", - "imported") + self.record(kind, name, f"config.yaml mcp_servers.{name}", "imported") if added > 0 and self.execute: config["mcp_servers"] = existing @@ -809,8 +705,7 @@ class AgentImporter: category = _SKILL_CATEGORY[self.agent] destination_root = self.target_root / "skills" / category if not source_root.is_dir(): - self.record("skills", None, destination_root, "skipped", - "No skills directory found") + self.record("skills", None, destination_root, "skipped", "No skills directory found") return skill_dirs = [ p for p in sorted(source_root.iterdir()) @@ -826,15 +721,8 @@ class AgentImporter: self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists") continue - if self.execute: - destination.parent.mkdir(parents=True, exist_ok=True) - if destination.exists(): - shutil.rmtree(destination) - shutil.copytree(skill_dir, destination) - self.record("skill", skill_dir, destination, "imported") - else: - self.record("skill", skill_dir, destination, "imported", - "Would copy skill directory") + self.apply("skill", skill_dir, destination, "Would copy skill directory", + lambda: _copy_skill_dir(skill_dir, destination)) # --------------------------------------------------------------------------- @@ -846,20 +734,11 @@ def import_agent_command(args) -> None: from hermes_cli.config import get_config_path, load_config, save_config from hermes_constants import get_hermes_home from hermes_cli.setup import ( - Colors, - color, - print_header, - print_info, - print_success, - print_error, - prompt_yes_no, + Colors, color, print_header, print_info, print_success, print_error, prompt_yes_no, ) - agent = getattr(args, "agent", None) - explicit_source = getattr(args, "source", None) - dry_run = getattr(args, "dry_run", False) - overwrite = getattr(args, "overwrite", False) - auto_yes = getattr(args, "yes", False) + agent, explicit_source = args.agent, args.source + dry_run, overwrite, auto_yes = args.dry_run, args.overwrite, args.yes # -- detect -------------------------------------------------------------- if agent is None: @@ -886,8 +765,7 @@ def import_agent_command(args) -> None: if not source_dir.is_dir(): print() print_error(f"Agent directory not found: {source_dir}") - print_info("Specify a custom path: hermes import-agent " - f"{agent} --source /path/to/{_AGENT_DEFAULT_DIRS[agent]}") + print_info(f"Specify a custom path: hermes import-agent {agent} --source /path/to/{_AGENT_DEFAULT_DIRS[agent]}") return hermes_home = get_hermes_home() @@ -905,14 +783,17 @@ def import_agent_command(args) -> None: save_config(load_config()) # -- Phase 1: preview (always) -------------------------------------------- - try: - preview = AgentImporter( + def run_import(execute: bool) -> Dict[str, Any]: + return AgentImporter( agent=agent, source_root=source_dir.resolve(), target_root=hermes_home.resolve(), - execute=False, + execute=execute, overwrite=overwrite, ).run() + + try: + preview = run_import(execute=False) except Exception as e: print() print_error(f"Import preview failed: {e}") @@ -946,13 +827,7 @@ def import_agent_command(args) -> None: return try: - report = AgentImporter( - agent=agent, - source_root=source_dir.resolve(), - target_root=hermes_home.resolve(), - execute=True, - overwrite=overwrite, - ).run() + report = run_import(execute=True) except Exception as e: print() print_error(f"Import failed: {e}") @@ -992,14 +867,11 @@ def print_import_report(report: Dict[str, Any], dry_run: bool) -> None: continue print(color(f" {label}:", col)) for item in group_items: - kind = item.get("kind", "unknown") - if status == "imported": - dest = item.get("destination") or "" - dest_short = str(dest).replace(str(Path.home()), "~") - print(f" {kind:<22s} → {dest_short}") - else: - reason = item.get("reason", "") - print(f" {kind:<22s} {reason}") + tail = ( + "→ " + str(item.get("destination") or "").replace(str(Path.home()), "~") + if status == "imported" else f" {item.get('reason', '')}" + ) + print(f" {item.get('kind', 'unknown'):<22s} {tail}") print() stripped = report.get("stripped_secrets") or [] @@ -1010,15 +882,10 @@ def print_import_report(report: Dict[str, Any], dry_run: bool) -> None: print_info("Re-add credentials deliberately via 'hermes setup' or ~/.hermes/.env.") print() - parts = [] - if summary.get("imported"): - action = "would import" if dry_run else "imported" - parts.append(f"{summary['imported']} {action}") - if summary.get("conflict"): - parts.append(f"{summary['conflict']} conflict(s)") - if summary.get("skipped"): - parts.append(f"{summary['skipped']} skipped") - if summary.get("error"): - parts.append(f"{summary['error']} error(s)") + labels = ( + ("imported", "would import" if dry_run else "imported"), + ("conflict", "conflict(s)"), ("skipped", "skipped"), ("error", "error(s)"), + ) + parts = [f"{summary[k]} {label}" for k, label in labels if summary.get(k)] if parts: print_info(f"Summary: {', '.join(parts)}") diff --git a/hermes_cli/agent_plugins.py b/hermes_cli/agent_plugins.py index af48727b08..6428f80315 100644 --- a/hermes_cli/agent_plugins.py +++ b/hermes_cli/agent_plugins.py @@ -1,10 +1,4 @@ -"""Compatibility helpers for Agent Plugins v1 portable directory packages. - -This module validates the versioned portable format locally and translates its -supported components into records consumed by Hermes' existing skill and MCP -runtimes. It deliberately performs no schema fetching and imports no plugin -Python code. -""" +"""Compatibility helpers for Agent Plugins v1 portable directory packages.""" from __future__ import annotations @@ -22,23 +16,13 @@ PLUGIN_SCHEMA_V1 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" MCP_SCHEMA_V1 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" _PLUGIN_FIELDS = { - "$schema", - "name", - "version", - "description", - "author", - "homepage", - "repository", - "license", - "keywords", - "extensions", + "$schema", "name", "version", "description", "author", "homepage", "repository", "license", + "keywords", "extensions", } _AUTHOR_FIELDS = {"name", "email", "url"} _STDIO_FIELDS = {"type", "command", "args", "env", "cwd"} _REMOTE_FIELDS = {"type", "url", "headers"} -_PLUGIN_NAME_RE = re.compile( - r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" -) +_PLUGIN_NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") _SKILL_NAME_RE = re.compile(r"^(?!.*--)[a-z0-9]+(?:-[a-z0-9]+)*$") _PLACEHOLDER_RE = re.compile(r"\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}") _HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") @@ -84,6 +68,14 @@ def _inside(path: Path, root: Path) -> bool: return False +def _all_str(values) -> bool: + return all(isinstance(v, str) for v in values) + + +def _str_map(value: object) -> bool: + return isinstance(value, dict) and _all_str(value) and _all_str(value.values()) + + def _read_json_object(path: Path, *, label: str) -> dict: try: value = json.loads(path.read_text(encoding="utf-8")) @@ -102,21 +94,13 @@ def _validate_manifest(root: Path) -> tuple[dict, list[AgentPluginDiagnostic]]: diagnostics: list[AgentPluginDiagnostic] = [] for field in sorted(set(manifest) - _PLUGIN_FIELDS): - diagnostics.append( - AgentPluginDiagnostic("manifest", f"ignored unknown top-level field: {field}") - ) + diagnostics.append(AgentPluginDiagnostic("manifest", f"ignored unknown top-level field: {field}")) manifest.pop(field) if manifest.get("$schema") != PLUGIN_SCHEMA_V1: - raise AgentPluginError( - "plugin.json declares an unsupported or missing Agent Plugins schema" - ) + raise AgentPluginError("plugin.json declares an unsupported or missing Agent Plugins schema") name = manifest.get("name") - if ( - not isinstance(name, str) - or not 1 <= len(name) <= 64 - or _PLUGIN_NAME_RE.fullmatch(name) is None - ): + if not isinstance(name, str) or not 1 <= len(name) <= 64 or _PLUGIN_NAME_RE.fullmatch(name) is None: raise AgentPluginError("plugin.json name does not satisfy v1 constraints") for field in ("version", "description", "homepage", "repository", "license"): @@ -125,29 +109,20 @@ def _validate_manifest(root: Path) -> tuple[dict, list[AgentPluginDiagnostic]]: if "keywords" in manifest: keywords = manifest["keywords"] - if not isinstance(keywords, list) or any( - not isinstance(value, str) for value in keywords - ): + if not isinstance(keywords, list) or not _all_str(keywords): raise AgentPluginError("plugin.json keywords must be an array of strings") if "author" in manifest: author = manifest["author"] if not isinstance(author, dict): raise AgentPluginError("plugin.json author must be an object") - unknown = set(author) - _AUTHOR_FIELDS - if unknown or any(not isinstance(value, str) for value in author.values()): - raise AgentPluginError( - "plugin.json author may contain only string name, email, and url fields" - ) + if set(author) - _AUTHOR_FIELDS or not _all_str(author.values()): + raise AgentPluginError("plugin.json author may contain only string name, email, and url fields") if "extensions" in manifest: extensions = manifest["extensions"] if not isinstance(extensions, dict): - diagnostics.append( - AgentPluginDiagnostic( - "manifest", "ignored non-object extensions field" - ) - ) + diagnostics.append(AgentPluginDiagnostic("manifest", "ignored non-object extensions field")) manifest.pop("extensions") elif any(not isinstance(value, dict) for value in extensions.values()): raise AgentPluginError("plugin.json extension namespace values must be objects") @@ -155,9 +130,7 @@ def _validate_manifest(root: Path) -> tuple[dict, list[AgentPluginDiagnostic]]: return manifest, diagnostics -def _valid_skill_frontmatter( - frontmatter: Mapping[str, Any], directory_name: str -) -> str | None: +def _valid_skill_frontmatter(frontmatter: Mapping[str, Any], directory_name: str) -> str | None: name = frontmatter.get("name") if ( not isinstance(name, str) @@ -175,68 +148,60 @@ def _valid_skill_frontmatter( compatibility = frontmatter["compatibility"] if not isinstance(compatibility, str) or not 1 <= len(compatibility) <= 500: return "compatibility must be a string of 1 to 500 characters" - if "metadata" in frontmatter: - metadata = frontmatter["metadata"] - if not isinstance(metadata, dict) or any( - not isinstance(key, str) or not isinstance(value, str) - for key, value in metadata.items() - ): - return "metadata must map string keys to string values" - if "allowed-tools" in frontmatter and not isinstance( - frontmatter["allowed-tools"], str - ): + if "metadata" in frontmatter and not _str_map(frontmatter["metadata"]): + return "metadata must map string keys to string values" + if "allowed-tools" in frontmatter and not isinstance(frontmatter["allowed-tools"], str): return "allowed-tools must be a string" return None -def _discover_skills( - root: Path, diagnostics: list[AgentPluginDiagnostic] -) -> tuple[AgentPluginSkill, ...]: +def _parse_skill_frontmatter(skill_md: Path) -> dict: + """Read SKILL.md and return its YAML frontmatter object; raises ValueError/OSError/UnicodeError.""" + content = skill_md.read_text(encoding="utf-8").lstrip("\ufeff") + if not content.startswith("---"): + raise ValueError("missing YAML frontmatter") + end_match = re.search(r"\n---\s*\n", content[3:]) + if end_match is None: + raise ValueError("unterminated YAML frontmatter") + try: + parsed = yaml_load(content[3 : end_match.start() + 3]) + except Exception as exc: + raise ValueError(f"invalid YAML frontmatter: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError("YAML frontmatter must be an object") + return parsed + + +def _discover_skills(root: Path, diagnostics: list[AgentPluginDiagnostic]) -> tuple[AgentPluginSkill, ...]: skills_root = root / "skills" if not skills_root.exists() and not skills_root.is_symlink(): return () if not _inside(skills_root, root) or not skills_root.is_dir(): - diagnostics.append( - AgentPluginDiagnostic("skills", "skills must be an in-root directory") - ) + diagnostics.append(AgentPluginDiagnostic("skills", "skills must be an in-root directory")) return () - - skills: list[AgentPluginSkill] = [] try: children = sorted(skills_root.iterdir(), key=lambda path: path.name) except OSError as exc: diagnostics.append(AgentPluginDiagnostic("skills", f"cannot list skills: {exc}")) return () + skills: list[AgentPluginSkill] = [] for child in children: skill_md = child / "SKILL.md" if not child.is_dir() or not skill_md.exists(): continue scope = f"skill:{child.name}" + error: str | None = None + frontmatter: dict = {} if not _inside(skill_md, root) or not skill_md.is_file(): - diagnostics.append( - AgentPluginDiagnostic(scope, "SKILL.md must be a regular in-root file") - ) - continue - try: - content = skill_md.read_text(encoding="utf-8") - content = content.lstrip("\ufeff") - if not content.startswith("---"): - raise ValueError("missing YAML frontmatter") - end_match = re.search(r"\n---\s*\n", content[3:]) - if end_match is None: - raise ValueError("unterminated YAML frontmatter") + error = "SKILL.md must be a regular in-root file" + else: try: - parsed = yaml_load(content[3 : end_match.start() + 3]) - except Exception as exc: - raise ValueError(f"invalid YAML frontmatter: {exc}") from exc - if not isinstance(parsed, dict): - raise ValueError("YAML frontmatter must be an object") - frontmatter = parsed - except (OSError, UnicodeError, ValueError) as exc: - diagnostics.append(AgentPluginDiagnostic(scope, f"invalid SKILL.md: {exc}")) - continue - error = _valid_skill_frontmatter(frontmatter, child.name) + frontmatter = _parse_skill_frontmatter(skill_md) + except (OSError, UnicodeError, ValueError) as exc: + error = f"invalid SKILL.md: {exc}" + else: + error = _valid_skill_frontmatter(frontmatter, child.name) if error: diagnostics.append(AgentPluginDiagnostic(scope, error)) continue @@ -253,30 +218,20 @@ def _discover_skills( def _expand(value: str, plugin_root: Path, data_root: Path) -> str: - replacements = { - "PLUGIN_ROOT": str(plugin_root), - "PLUGIN_DATA": str(data_root), - } + replacements = {"PLUGIN_ROOT": str(plugin_root), "PLUGIN_DATA": str(data_root)} return _PLACEHOLDER_RE.sub(lambda match: replacements[match.group(1)], value) def _resolve_scoped_path( - value: str, - plugin_root: Path, - data_root: Path, - *, - expand_placeholders: bool = True, + value: str, plugin_root: Path, data_root: Path, *, expand_placeholders: bool = True ) -> Path: expanded = _expand(value, plugin_root, data_root) if expand_placeholders else value if value.startswith("./"): - base = plugin_root - candidate = base / expanded[2:] + base, candidate = plugin_root, plugin_root / expanded[2:] elif value == "${PLUGIN_ROOT}" or value.startswith("${PLUGIN_ROOT}/"): - base = plugin_root - candidate = Path(expanded) + base, candidate = plugin_root, Path(expanded) elif value == "${PLUGIN_DATA}" or value.startswith("${PLUGIN_DATA}/"): - base = data_root - candidate = Path(expanded) + base, candidate = data_root, Path(expanded) else: raise ValueError("path must start with ./, ${PLUGIN_ROOT}, or ${PLUGIN_DATA}") resolved = candidate.resolve(strict=False) @@ -292,28 +247,20 @@ def _validate_headers(headers: object) -> bool: return True if not isinstance(headers, dict): return False - seen: set[str] = set() - for name, value in headers.items(): - if ( - not isinstance(name, str) - or _HEADER_NAME_RE.fullmatch(name) is None - or not isinstance(value, str) - or "\r" in value - or "\n" in value - or name.lower() in seen - ): - return False - seen.add(name.lower()) - return True + names = [name for name in headers if isinstance(name, str) and _HEADER_NAME_RE.fullmatch(name)] + return ( + len(names) == len(headers) + and len({name.lower() for name in names}) == len(names) + and all(isinstance(v, str) and "\r" not in v and "\n" not in v for v in headers.values()) + ) def _validate_remote_url(url: object) -> str: """Validate a portable remote MCP URL per the v1 spec and return it. - Rules (Agent Plugins v1 §7.2.1): absolute http(s) URL, no user - information, no fragment; non-loopback endpoints must use HTTPS. HTTP is - allowed only when the host is exactly ``localhost`` or an IP literal in a - loopback range. No placeholder or environment expansion is performed. + Rules (Agent Plugins v1 §7.2.1): absolute http(s) URL, no user information, no fragment; non- + loopback endpoints must use HTTPS. HTTP is allowed only when the host is exactly ``localhost`` + or an IP literal in a loopback range. No placeholder or environment expansion is performed. """ from urllib.parse import urlsplit @@ -334,17 +281,13 @@ def _validate_remote_url(url: object) -> str: host = parsed.hostname if not host: raise ValueError("url must have a host") - if scheme == "http": - loopback = False - if host == "localhost": - loopback = True - else: - import ipaddress + if scheme == "http" and host != "localhost": + import ipaddress - try: - loopback = ipaddress.ip_address(host).is_loopback - except ValueError: - loopback = False + try: + loopback = ipaddress.ip_address(host).is_loopback + except ValueError: + loopback = False if not loopback: raise ValueError("non-loopback url must use https") return url @@ -353,11 +296,9 @@ def _validate_remote_url(url: object) -> str: def _translate_remote(config: Mapping[str, Any]) -> Dict[str, Any]: """Translate a portable ``streamable-http`` entry into native MCP config. - The returned record targets Hermes' existing URL-based MCP runtime. - ``strict_redirect_headers`` instructs the runtime to drop the configured - headers on any cross-origin redirect, which the v1 spec requires for - portable packages (configured headers must not be forwarded to a - different origin without explicit user authorization). + ``strict_redirect_headers`` tells the runtime to drop configured headers on any cross-origin + redirect, as the v1 spec requires for portable packages (no forwarding to another origin + without explicit user authorization). """ if set(config) - _REMOTE_FIELDS: @@ -365,33 +306,20 @@ def _translate_remote(config: Mapping[str, Any]) -> Dict[str, Any]: url = _validate_remote_url(config.get("url")) if not _validate_headers(config.get("headers")): raise ValueError("invalid headers") - translated: Dict[str, Any] = { - "url": url, - "strict_redirect_headers": True, - } - headers = config.get("headers") - if headers: - translated["headers"] = dict(headers) + translated: Dict[str, Any] = {"url": url, "strict_redirect_headers": True} + if config.get("headers"): + translated["headers"] = dict(config["headers"]) return translated -def _translate_stdio( - config: Mapping[str, Any], plugin_root: Path, data_root: Path -) -> Dict[str, Any]: +def _translate_stdio(config: Mapping[str, Any], plugin_root: Path, data_root: Path) -> Dict[str, Any]: if set(config) - _STDIO_FIELDS: raise ValueError("unknown stdio field") command = config.get("command") if not isinstance(command, str) or not command or "\x00" in command: raise ValueError("command must be a non-empty executable token") if command.startswith("./"): - command_value = str( - _resolve_scoped_path( - command, - plugin_root, - data_root, - expand_placeholders=False, - ) - ) + command_value = str(_resolve_scoped_path(command, plugin_root, data_root, expand_placeholders=False)) elif any(character.isspace() for character in command): raise ValueError("command must contain one executable token") elif "/" in command or "\\" in command or command in {".", ".."}: @@ -400,13 +328,10 @@ def _translate_stdio( command_value = command args = config.get("args", []) - if not isinstance(args, list) or any(not isinstance(value, str) for value in args): + if not isinstance(args, list) or not _all_str(args): raise ValueError("args must be an array of strings") env = config.get("env", {}) - if not isinstance(env, dict) or any( - not isinstance(key, str) or not isinstance(value, str) - for key, value in env.items() - ): + if not _str_map(env): raise ValueError("env must map string keys to string values") env_keys = {key.upper() if os.name == "nt" else key for key in env} if "PLUGIN_ROOT" in env_keys or "PLUGIN_DATA" in env_keys: @@ -420,9 +345,7 @@ def _translate_stdio( else: cwd_value = _resolve_scoped_path(cwd, plugin_root, data_root) - translated_env = { - key: _expand(value, plugin_root, data_root) for key, value in env.items() - } + translated_env = {key: _expand(value, plugin_root, data_root) for key, value in env.items()} translated_env["PLUGIN_ROOT"] = str(plugin_root) translated_env["PLUGIN_DATA"] = str(data_root) return { @@ -433,6 +356,30 @@ def _translate_stdio( } +def _translate_stdio_server(server: Mapping[str, Any], root: Path, data_root: Path, create_data: bool) -> Dict[str, Any]: + translated_server = _translate_stdio(server, root, data_root) + if create_data: + data_root.mkdir(parents=True, exist_ok=True) + cwd_path = Path(translated_server["cwd"]) + # The MCP client starts stdio servers with this cwd. Create only data-root + # descendants; plugin-root paths remain package-owned and are never made + # writable as a side effect of discovery. + if cwd_path.is_relative_to(data_root): + cwd_path.mkdir(parents=True, exist_ok=True) + return translated_server + + +def _reject_sse(server: Mapping[str, Any]) -> None: + if ( + set(server) - _REMOTE_FIELDS + or not isinstance(server.get("url"), str) + or not server.get("url") + or not _validate_headers(server.get("headers")) + ): + raise ValueError("invalid remote entry") + raise ValueError("portable sse transport is not supported") + + def _discover_mcp( root: Path, data_root: Path, @@ -443,95 +390,58 @@ def _discover_mcp( mcp_path = root / "mcp.json" if not mcp_path.exists() and not mcp_path.is_symlink(): return {} - if not _inside(mcp_path, root) or not mcp_path.is_file(): - diagnostics.append( - AgentPluginDiagnostic("mcp", "mcp.json must be a regular in-root file") - ) + + def _fail(message: str) -> Dict[str, Dict[str, Any]]: + diagnostics.append(AgentPluginDiagnostic("mcp", message)) return {} + + if not _inside(mcp_path, root) or not mcp_path.is_file(): + return _fail("mcp.json must be a regular in-root file") try: config = _read_json_object(mcp_path, label="mcp.json") except AgentPluginError as exc: - diagnostics.append(AgentPluginDiagnostic("mcp", str(exc))) - return {} + return _fail(str(exc)) if set(config) != {"$schema", "mcpServers"}: - diagnostics.append( - AgentPluginDiagnostic("mcp", "mcp.json has an invalid top-level shape") - ) - return {} + return _fail("mcp.json has an invalid top-level shape") if config.get("$schema") != MCP_SCHEMA_V1: - diagnostics.append( - AgentPluginDiagnostic("mcp", "mcp.json declares an unsupported schema") - ) - return {} + return _fail("mcp.json declares an unsupported schema") servers = config.get("mcpServers") if not isinstance(servers, dict): - diagnostics.append( - AgentPluginDiagnostic("mcp", "mcpServers must be an object") - ) - return {} + return _fail("mcpServers must be an object") + translators = { + "stdio": lambda server: _translate_stdio_server(server, root, data_root, create_data), + "streamable-http": _translate_remote, + "sse": _reject_sse, + } translated: Dict[str, Dict[str, Any]] = {} for name, server in servers.items(): scope = f"mcp:{name}" if not isinstance(name, str) or not name or not isinstance(server, dict): diagnostics.append(AgentPluginDiagnostic(scope, "invalid server entry")) continue - server_type = server.get("type") - if server_type == "stdio": - try: - translated_server = _translate_stdio(server, root, data_root) - if create_data: - data_root.mkdir(parents=True, exist_ok=True) - cwd_path = Path(translated_server["cwd"]) - try: - cwd_path.relative_to(data_root) - except ValueError: - pass - else: - # The MCP client starts stdio servers with this cwd. - # Create only data-root descendants; plugin-root paths - # remain package-owned and are never made writable as a - # side effect of discovery. - cwd_path.mkdir(parents=True, exist_ok=True) - translated[name] = translated_server - except (OSError, ValueError) as exc: - diagnostics.append(AgentPluginDiagnostic(scope, str(exc))) - elif server_type == "streamable-http": - try: - translated[name] = _translate_remote(server) - except ValueError as exc: - diagnostics.append(AgentPluginDiagnostic(scope, str(exc))) - elif server_type == "sse": - if ( - set(server) - _REMOTE_FIELDS - or not isinstance(server.get("url"), str) - or not server.get("url") - or not _validate_headers(server.get("headers")) - ): - diagnostics.append(AgentPluginDiagnostic(scope, "invalid remote entry")) - else: - diagnostics.append( - AgentPluginDiagnostic( - scope, - f"portable {server_type} transport is not supported", - ) - ) - else: + translate = translators.get(server.get("type")) + if translate is None: diagnostics.append(AgentPluginDiagnostic(scope, "unknown MCP server type")) + continue + try: + translated[name] = translate(server) + except (OSError, ValueError) as exc: + diagnostics.append(AgentPluginDiagnostic(scope, str(exc))) return translated -def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage: - """Validate and translate one installed Agent Plugins v1 package. - - Fatal manifest errors raise :class:`AgentPluginError`. Component and entry - failures are returned as diagnostics and isolated to their owning scope. - """ - +def _validate_root(plugin_root: Path) -> tuple[Path, dict, list[AgentPluginDiagnostic]]: root = Path(plugin_root).resolve(strict=True) if not root.is_dir(): raise AgentPluginError("plugin root must be a directory") - manifest, diagnostics = _validate_manifest(root) + return root, *_validate_manifest(root) + + +def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage: + """Validate and translate one installed Agent Plugins v1 package.""" + + root, manifest, diagnostics = _validate_root(plugin_root) resolved_data = Path(data_root).resolve(strict=False) skills = _discover_skills(root, diagnostics) mcp_servers = _discover_mcp(root, resolved_data, diagnostics) @@ -551,19 +461,16 @@ def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage: def read_agent_plugin_manifest(plugin_root: Path) -> tuple[dict, tuple[AgentPluginDiagnostic, ...]]: """Validate only root ``plugin.json`` without discovering components.""" - root = Path(plugin_root).resolve(strict=True) - if not root.is_dir(): - raise AgentPluginError("plugin root must be a directory") - manifest, diagnostics = _validate_manifest(root) + _root, manifest, diagnostics = _validate_root(plugin_root) return manifest, tuple(diagnostics) def has_enabled_agent_plugin_mcp(raw_config: Mapping[str, Any]) -> bool: """Compatibility wrapper for the shared PluginManager MCP probe. - Directory scanning belongs to :mod:`hermes_cli.plugins` so startup gating - and full plugin discovery cannot drift apart. Keep this import-compatible - entry point for callers that used the original helper. + Directory scanning belongs to :mod:`hermes_cli.plugins` so startup gating and full plugin + discovery cannot drift apart. Keep this import-compatible entry point for callers that used the + original helper. """ from hermes_cli.plugins import has_enabled_agent_plugin_mcp as _probe diff --git a/hermes_cli/approval_mode.py b/hermes_cli/approval_mode.py index 0bd7d3a314..fe5d5c24fe 100644 --- a/hermes_cli/approval_mode.py +++ b/hermes_cli/approval_mode.py @@ -1,9 +1,9 @@ """Shared persistent approval-mode command logic. -Approval mode is profile-scoped configuration, not conversation state. Changing -it affects subsequent terminal guard checks immediately because approval.py -loads config on each check; it must not rebuild a live agent or mutate its -system prompt/tool schema, preserving the prompt-cache prefix. +Approval mode is profile-scoped configuration, not conversation state. Changing it affects +subsequent terminal guard checks immediately because approval.py loads config on each check; it must +not rebuild a live agent or mutate its system prompt/tool schema, preserving the prompt-cache +prefix. """ from __future__ import annotations diff --git a/hermes_cli/approval_transport.py b/hermes_cli/approval_transport.py index 1bd81aa3ad..eafa6ee873 100644 --- a/hermes_cli/approval_transport.py +++ b/hermes_cli/approval_transport.py @@ -1,8 +1,8 @@ """Host-owned contract for plugin-provided human approval transports. -Transports only present an immutable, redacted request and return a correlated -human decision. They do not participate in command detection or authorization -policy. The host validates scope, request binding, and timeout fail-closed. +Transports only present an immutable, redacted request and return a correlated human decision. They +do not participate in command detection or authorization policy. The host validates scope, request +binding, and timeout fail-closed. """ from __future__ import annotations @@ -68,48 +68,26 @@ class ApprovalRequest: allow_permanent: bool, timeout_seconds: float = 300, ) -> "ApprovalRequest": - request_id = uuid.uuid4().hex choices: list[ApprovalChoice] = ["once"] if allow_session: choices.append("session") if allow_permanent: choices.append("always") choices.append("deny") - canonical = { - "schema_version": 1, - "request_id": request_id, - "command": command, - "description": description, - "pattern_key": pattern_key, - "pattern_keys": list(pattern_keys), - "session_key": session_key, - "surface": surface, - "timeout_seconds": timeout_seconds, - "allowed_choices": choices, - } - digest = hashlib.sha256( - json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest() + fields = dict( + schema_version=1, request_id=uuid.uuid4().hex, command=command, + description=description, pattern_key=pattern_key, pattern_keys=list(pattern_keys), + surface=surface, timeout_seconds=timeout_seconds, allowed_choices=choices, + ) + canonical = json.dumps({**fields, "session_key": session_key}, sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() return cls( - schema_version=1, - request_id=request_id, - digest=digest, - command=command, - description=description, - pattern_key=pattern_key, - pattern_keys=pattern_keys, - surface=surface, - timeout_seconds=timeout_seconds, - allowed_choices=tuple(choices), + **{**fields, "pattern_keys": pattern_keys, "allowed_choices": tuple(choices)}, digest=digest ) def respond(self, choice: ApprovalChoice | str) -> ApprovalDecision: """Build the correlated response a transport should return.""" - return ApprovalDecision( - request_id=self.request_id, - request_digest=self.digest, - choice=choice, - ) + return ApprovalDecision(self.request_id, self.digest, choice) @dataclass(frozen=True) @@ -141,9 +119,9 @@ def invoke_approval_transport( ) -> ApprovalTransportResult: """Run a sync or async transport on a bounded daemon worker. - Async callbacks are awaited with ``asyncio.run`` on that worker, never on a - gateway or TUI event loop. A callback must return before the host timeout; - late results are discarded and cannot authorize another request. + Async callbacks are awaited with ``asyncio.run`` on that worker, never on a gateway or TUI event + loop. A callback must return before the host timeout; late results are discarded and cannot + authorize another request. """ if not _transport_worker_slots.acquire(blocking=False): @@ -171,9 +149,7 @@ def invoke_approval_transport( _transport_worker_slots.release() worker = threading.Thread( - target=_run, - name=f"approval-transport-{request.request_id[:8]}", - daemon=True, + target=_run, name=f"approval-transport-{request.request_id[:8]}", daemon=True ) try: worker.start() @@ -201,19 +177,28 @@ def invoke_approval_transport( except Exception: logger.debug("Approval transport poll callback failed", exc_info=True) + failure = _validate_decision(kind, value, completed_at, deadline, request) + if failure is not None: + return ApprovalTransportResult("deny", failure) + return ApprovalTransportResult(value.choice) + + +def _validate_decision(kind, value, completed_at, deadline, request) -> str | None: + """Return the failure code for a worker result, or ``None`` when the decision is valid.""" + rid = request.request_id if completed_at > deadline: - logger.warning("Approval transport timed out for request %s", request.request_id) - return ApprovalTransportResult("deny", "timeout") + logger.warning("Approval transport timed out for request %s", rid) + return "timeout" if kind == "error": - logger.warning("Approval transport failed for request %s", request.request_id) - return ApprovalTransportResult("deny", "error") + logger.warning("Approval transport failed for request %s", rid) + return "error" if not isinstance(value, ApprovalDecision): logger.warning("Approval transport returned an invalid decision type") - return ApprovalTransportResult("deny", "invalid") - if value.request_id != request.request_id or value.request_digest != request.digest: + return "invalid" + if value.request_id != rid or value.request_digest != request.digest: logger.warning("Approval transport returned a stale or mismatched decision") - return ApprovalTransportResult("deny", "stale") + return "stale" if value.choice not in request.allowed_choices: logger.warning("Approval transport returned a disallowed choice") - return ApprovalTransportResult("deny", "invalid") - return ApprovalTransportResult(value.choice) + return "invalid" + return None diff --git a/hermes_cli/approvals_suggest.py b/hermes_cli/approvals_suggest.py index 1288d79e2b..af92627eef 100644 --- a/hermes_cli/approvals_suggest.py +++ b/hermes_cli/approvals_suggest.py @@ -1,34 +1,13 @@ """``hermes approvals suggest`` — mine approval history into allowlist proposals. -Hermes has no dedicated approval-decision ledger: ``always`` answers land in -``command_allowlist`` (config.yaml) via :func:`tools.approval.save_permanent_allowlist`, -while ``once``/``session`` approvals are in-memory only. What *does* persist -is the session DB (``~/.hermes/state.db``): every assistant ``terminal`` tool -call is stored with its arguments, and the paired ``role='tool'`` result -records whether the command was blocked/denied ("BLOCKED: User denied …", -"Asking the user for approval") or actually executed. +Hermes has no dedicated approval-decision ledger: ``always`` answers land in ``command_allowlist`` +(config.yaml) via :func:`tools.approval.save_permanent_allowlist`, while ``once``/``session`` +approvals are in-memory only. -So this module mines *implied approvals*: a command that matches a -dangerous-command class (the same :func:`tools.approval.detect_dangerous_command` -classifier that triggers the prompt) AND whose tool result is not a -block/denial marker must have been approved by the user (once, session, -always, smart-approve, or yolo) before it ran. Frequently re-approved -patterns are exactly the prompts worth turning into one-time allowlist -policy — the port of Claude Code's ``/fewer-permission-prompts``. - -Safety posture: - -* **Never auto-applies.** The default run is a dry proposal; only an explicit - ``--apply N[,M...]`` merges the chosen patterns into ``command_allowlist`` - via the existing :func:`tools.approval.save_permanent_allowlist` path. -* **Hardline commands are never proposed** — anything matched by - :func:`tools.approval.detect_hardline_command` is dropped outright. -* **Destructive / privilege / credential / obfuscation classes are never - proposed**, no matter how often they were approved. ``rm -rf build/`` - approved 100 times still never yields an ``rm`` allowlist entry. Only - benign, recoverable classes (container lifecycle, git force push, service - restarts, hermes self-management, …) are eligible. -* **Dangerous root binaries never become globs** (``rm *``, ``sudo *`` …). +So this module mines *implied approvals*: a command that matches a dangerous-command class (the same +:func:`tools.approval.detect_dangerous_command` classifier that triggers the prompt) AND whose tool +result is not a block/denial marker must have been approved by the user (once, session, always, +smart-approve, or yolo) before it ran. """ from __future__ import annotations @@ -153,6 +132,15 @@ def _connect_readonly(db_path: Path) -> sqlite3.Connection: return sqlite3.connect(uri, uri=True) +def _fetch_rows(cur) -> Iterator[tuple]: + """Stream cursor rows in 2000-row batches.""" + while True: + rows = cur.fetchmany(2000) + if not rows: + return + yield from rows + + def _iter_terminal_calls( con: sqlite3.Connection, since_ts: float ) -> Iterator[tuple[str, str]]: @@ -163,59 +151,49 @@ def _iter_terminal_calls( "AND tool_calls LIKE '%terminal%' AND timestamp >= ?", (since_ts,), ) - while True: - rows = cur.fetchmany(2000) - if not rows: - break - for (raw,) in rows: + for (raw,) in _fetch_rows(cur): + try: + calls = json.loads(raw) + except (TypeError, ValueError): + continue + if not isinstance(calls, list): + continue + for call in calls: + if not isinstance(call, dict): + continue + fn = call.get("function") or {} + if fn.get("name") != "terminal": + continue try: - calls = json.loads(raw) + args = json.loads(fn.get("arguments") or "{}") except (TypeError, ValueError): continue - if not isinstance(calls, list): - continue - for call in calls: - if not isinstance(call, dict): - continue - fn = call.get("function") or {} - if fn.get("name") != "terminal": - continue - try: - args = json.loads(fn.get("arguments") or "{}") - except (TypeError, ValueError): - continue - command = args.get("command") - if isinstance(command, str) and command.strip(): - yield (call.get("id") or "", command) + command = args.get("command") + if isinstance(command, str) and command.strip(): + yield (call.get("id") or "", command) def _blocked_tool_call_ids(con: sqlite3.Connection, since_ts: float) -> set: """Collect tool_call_ids whose result shows the command never ran freely.""" - blocked: set = set() cur = con.execute( "SELECT tool_call_id, content FROM messages " "WHERE role='tool' AND tool_call_id IS NOT NULL AND timestamp >= ? " "AND (content LIKE '%BLOCKED%' OR content LIKE '%approval%')", (since_ts,), ) - while True: - rows = cur.fetchmany(2000) - if not rows: - break - for tool_call_id, content in rows: - if not content: - continue - if any(marker in content for marker in _BLOCK_MARKERS): - blocked.add(tool_call_id) - return blocked + return { + tool_call_id + for tool_call_id, content in _fetch_rows(cur) + if content and any(marker in content for marker in _BLOCK_MARKERS) + } def scan_approval_history( db_path: Optional[Path] = None, days: int = 90 ) -> list[tuple[str, str]]: - """Return ``(command, dangerous_class_description)`` records mined from - the session DB — dangerous-classified terminal commands that actually - executed (i.e. carried an implied user approval). + """Return ``(command, dangerous_class_description)`` records mined from the session DB — + dangerous-classified terminal commands that actually executed (i.e. carried an implied user + approval). """ from tools.approval import detect_dangerous_command, detect_hardline_command @@ -251,11 +229,7 @@ def scan_approval_history( # --------------------------------------------------------------------------- def normalize_command(command: str) -> str: - """Fold user/hermes home prefixes and collapse whitespace. - - Reuses tools.approval's home-folding machinery so proposals are portable - across machines/users (``/home/alice/x`` -> ``~/x``). - """ + """Fold user/hermes home prefixes and collapse whitespace.""" from tools.approval import ( _rewrite_resolved_hermes_home, _rewrite_resolved_user_home, @@ -272,17 +246,14 @@ def is_unsafe_class(description: str) -> bool: def _unsafe_root_binary(token: str) -> bool: tok = token.lower().rsplit("/", 1)[-1] - if tok in _UNSAFE_ROOT_BINARIES: - return True - return any(tok.startswith(p) for p in _UNSAFE_ROOT_PREFIXES) + return tok in _UNSAFE_ROOT_BINARIES or tok.startswith(_UNSAFE_ROOT_PREFIXES) def derive_glob(normalized: str) -> Optional[str]: """Derive a narrow command glob (``git push *``) from a simple command. - Returns None for compound commands (shell operators — the runtime - allowlist matcher refuses those anyway) and for commands anchored on an - unsafe root binary. + Returns None for compound commands (shell operators — the runtime allowlist matcher refuses + those anyway) and for commands anchored on an unsafe root binary. """ from tools.approval import _has_allowlist_shell_operator @@ -309,10 +280,9 @@ def build_proposals( ) -> list[Proposal]: """Aggregate scan records into a ranked, safety-filtered proposal list. - Grain: a command glob (``git push *``) for simple commands; the - dangerous-class description itself (the same key an interactive - ``[a]lways`` answer persists) for compound commands where no safe glob - can be derived. + Grain: a command glob (``git push *``) for simple commands; the dangerous-class description + itself (the same key an interactive ``[a]lways`` answer persists) for compound commands where no + safe glob can be derived. """ existing = existing or set() by_pattern: dict[tuple[str, str], Proposal] = {} @@ -322,10 +292,7 @@ def build_proposals( continue normalized = normalize_command(command) glob = derive_glob(normalized) - if glob is not None: - key = (glob, "glob") - else: - key = (description, "class") + key = (glob, "glob") if glob is not None else (description, "class") pattern, kind = key if pattern in existing: continue diff --git a/hermes_cli/approvals_test.py b/hermes_cli/approvals_test.py index 2fb9aec94b..c9bca6d57a 100644 --- a/hermes_cli/approvals_test.py +++ b/hermes_cli/approvals_test.py @@ -1,32 +1,13 @@ """``hermes approvals test`` — dry-run approval verdict for a command. -Answers "what would the approval system do with this command?" WITHOUT -running it, prompting anyone, or persisting anything. It composes the REAL -runtime evaluators from ``tools.approval`` in the same order the runtime -guard (``check_all_command_guards``) applies them: +Answers "what would the approval system do with this command?" WITHOUT running it, prompting anyone, +or persisting anything. It composes the REAL runtime evaluators from ``tools.approval`` in the same +order the runtime guard (``check_all_command_guards``) applies them: - 1. container-skip gate (isolated backends bypass all guards), - 2. hardline blocklist (never bypassable, fires before yolo/off), - 3. sudo-stdin guard (unconditional), - 4. user ``approvals.deny`` rules (fire before yolo/off), - 5. yolo / ``approvals.mode: off`` bypass, - 6. permanent ``command_allowlist``, - 7. dangerous-pattern detection → would ask for approval. - -Because the same functions run — including ``_command_detection_variants``'s -normalization/de-obfuscation path — an obfuscated command (``r\\m -rf /``) -gets exactly the verdict its plain form would get at runtime, and the trace -shows the normalized variants that were actually evaluated. - -Read-only invariants: the command is never executed, no approval prompt is -raised, nothing is written to config or approval history, no gateway -notification fires. - -Exit codes (script-friendly): - 0 allow (would run without a prompt) - 1 usage error - 2 ask-approval (would raise an interactive approval prompt) - 3 deny (hardline blocklist, sudo-stdin guard, or user deny rule) +1. container-skip gate (isolated backends bypass all guards), 2. hardline blocklist (never +bypassable, fires before yolo/off), 3. sudo-stdin guard (unconditional), 4. user ``approvals.deny`` +rules (fire before yolo/off), 5. yolo / ``approvals.mode: off`` bypass, 6. permanent +``command_allowlist``, 7. """ from __future__ import annotations @@ -49,10 +30,8 @@ _VERDICT_EXIT = { def evaluate_command(command: str, env_type: str = "local") -> dict: """Return the dry-run verdict for *command* on *env_type*. - Pure composition of the runtime evaluators — no execution, no prompt, - no persistence. Returns a dict with ``verdict``, ``exit_code``, - ``rule`` (matching guard/pattern name or None), ``detail`` (human - explanation), and ``normalized_variants`` (the trace of normalized / + Pure composition of the runtime evaluators — no execution, prompt or persistence. The dict + carries ``verdict``, ``exit_code``, ``rule``, ``detail`` and ``normalized_variants`` (the de-obfuscated forms the detectors actually evaluated). """ import tools.approval as approval diff --git a/hermes_cli/archive_safe.py b/hermes_cli/archive_safe.py index b73a057081..dbf1b3486f 100644 --- a/hermes_cli/archive_safe.py +++ b/hermes_cli/archive_safe.py @@ -1,20 +1,4 @@ -"""Safe ``tar.gz`` primitives shared by the profile and kanban transfer paths. - -Both ``hermes profile export|import`` and ``hermes kanban export|import`` -ship a directory to another machine and unpack whatever comes back. The -unpack side is the dangerous half: a hand-crafted archive can carry -``../`` members, absolute paths, symlinks, or device nodes, any of which -turn an import into an arbitrary-write primitive. These helpers are the -one place that logic lives so a second transfer surface can't ship a -second, subtly weaker extractor. - -The writer is deliberately not :func:`shutil.make_archive`: that emits -PAX (Python's tarfile default since 3.8), whose fractional-mtime records -macOS Archive Utility rejects — double-clicking an exported profile threw -"Error 94 - Bad message." GNU format keeps long paths working (longlink -extensions) and stays integer-mtime, so Finder, bsdtar, and gnutar all -extract it. -""" +"""Safe ``tar.gz`` primitives shared by the profile and kanban transfer paths.""" from __future__ import annotations @@ -28,10 +12,9 @@ from pathlib import Path, PurePosixPath, PureWindowsPath def normalize_archive_parts(member_name: str) -> list[str]: """Return safe path parts for an archive member, or raise. - Rejects absolute paths (POSIX and Windows, including drive letters), - empty names, and any ``..`` component. Backslashes are folded to - ``/`` first so a Windows-authored archive can't smuggle a separator - past the POSIX parse. + Rejects absolute paths (POSIX and Windows, including drive letters), empty names, and any ``..`` + component. Backslashes are folded to ``/`` first so a Windows-authored archive can't smuggle a + separator past the POSIX parse. """ normalized_name = member_name.replace("\\", "/") posix_path = PurePosixPath(normalized_name) @@ -54,12 +37,8 @@ def normalize_archive_parts(member_name: str) -> list[str]: def make_targz(base: str, root_dir: str, base_dir: str) -> str: """Create ``.tar.gz`` of ``root_dir/base_dir`` in GNU tar format. - Writes to a sibling temp file and renames onto ``archive_path`` only - after the archive is fully written. ``tarfile.open`` on a path truncates - the destination the instant it opens, so writing there directly means a - failure partway through ``tf.add`` (disk full, permission loss, - interruption) destroys whatever was already at that path — including an - existing export the caller chose to overwrite. + Writes to a sibling temp file and renames onto ``archive_path`` only after the archive is fully + written. """ archive_path = f"{base}.tar.gz" dest_dir = os.path.dirname(archive_path) or "." @@ -83,9 +62,9 @@ def make_targz(base: str, root_dir: str, base_dir: str) -> str: def safe_extract_targz(archive: Path, destination: Path) -> None: """Extract ``archive`` into ``destination`` without path escapes or links. - Only directories and regular files are extracted; symlinks, hardlinks, - and device nodes raise rather than being silently skipped, so a - tampered archive fails the import instead of landing a partial tree. + Only directories and regular files are extracted; symlinks, hardlinks, and device nodes raise + rather than being silently skipped, so a tampered archive fails the import instead of landing a + partial tree. """ with tarfile.open(archive, "r:gz") as tf: for member in tf.getmembers(): @@ -118,10 +97,9 @@ def safe_extract_targz(archive: Path, destination: Path) -> None: def archive_root_dirs(archive: Path) -> set[str]: """Return the archive's top-level directory names. - Transfer archives carry exactly one root directory, which names the - thing being imported. Inspecting the archive before extraction lets - the caller resolve the target name (and refuse a malformed archive) - without first mutating a live tree. + Transfer archives carry exactly one root directory, which names the thing being imported. + Inspecting the archive before extraction lets the caller resolve the target name (and refuse a + malformed archive) without first mutating a live tree. """ with tarfile.open(archive, "r:gz") as tf: return { @@ -135,9 +113,9 @@ def archive_root_dirs(archive: Path) -> set[str]: def copy_regular_files(src: Path, dst: Path) -> int: """Copy the regular files under ``src`` into ``dst``, skipping symlinks. - Used on the *export* side so a symlink planted in an attachments or - logs tree can't pull an arbitrary file into the archive. Returns the - number of files copied; a missing ``src`` copies nothing. + Used on the *export* side so a symlink planted in an attachments or logs tree can't pull an + arbitrary file into the archive. Returns the number of files copied; a missing ``src`` copies + nothing. """ if not src.is_dir(): return 0 diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 1f49582a2e..98982bdfb2 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -1,12 +1,4 @@ -""" -Backup and import commands for hermes CLI. - -`hermes backup` creates a zip archive of the entire ~/.hermes/ directory -(excluding the hermes-agent repo and transient files). - -`hermes import` restores from a backup zip, overlaying onto the current -HERMES_HOME root. -""" +"""Backup and import commands for hermes CLI.""" import json import logging @@ -19,7 +11,7 @@ import tempfile import threading import time import zipfile -from contextlib import contextmanager +from contextlib import contextmanager, suppress from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -116,23 +108,18 @@ _EXCLUDED_DIRS = { # ONLY at the root of HERMES_HOME and at ``profiles//`` — a deeper # directory that happens to share one of these names (a skill's ``models/``, # a user checkout) is user data and stays in the backup. -_EXCLUDED_ROOT_DIRS = { - "models", - "runtimes", - "node", -} +_EXCLUDED_ROOT_DIRS = {"models", "runtimes", "node"} def _in_excluded_root_dir(rel_path: Path) -> bool: """True when *rel_path* (relative to HERMES_HOME) is, or sits inside, a Hermes-managed runtime tree at the top of a profile home.""" parts = rel_path.parts - if not parts: - return False - if parts[0] in _EXCLUDED_ROOT_DIRS: - return True # Named profiles are profile homes too: profiles//models etc. - return len(parts) >= 3 and parts[0] == "profiles" and parts[2] in _EXCLUDED_ROOT_DIRS + return bool(parts) and ( + parts[0] in _EXCLUDED_ROOT_DIRS + or (len(parts) >= 3 and parts[0] == "profiles" and parts[2] in _EXCLUDED_ROOT_DIRS) + ) # File-name suffixes to skip @@ -150,11 +137,7 @@ _EXCLUDED_SUFFIXES = ( ) # File names to skip (runtime state that's meaningless on another machine) -_EXCLUDED_NAMES = { - ".backup.lock", - "gateway.pid", - "cron.pid", -} +_EXCLUDED_NAMES = {".backup.lock", "gateway.pid", "cron.pid"} # File-name prefixes to skip. The desktop updater's pre-flight drops # ``state.db.pre-update-emergency-.bak`` at the HERMES_HOME root @@ -191,13 +174,7 @@ _EXCLUDED_PREFIXES = ( # # Older backups predate the backup-side exclusions, so we filter on import too # rather than trusting the archive's contents. -_IMPORT_SKIP_NAMES = { - "gateway_state.json", - "gateway.pid", - "cron.pid", - "gateway.lock", - "processes.json", -} +_IMPORT_SKIP_NAMES = {"gateway_state.json", "gateway.pid", "cron.pid", "gateway.lock", "processes.json"} # zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600. _SECRET_FILE_NAMES = {".env", "auth.json", "state.db"} @@ -237,53 +214,42 @@ def _backup_operation_lock(hermes_home: Path, timeout_seconds: float = 0.25): if lock_path.stat().st_size == 0: handle.write(b" ") handle.flush() - while True: - try: - handle.seek(0) - msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) - acquired = True - break - except (OSError, PermissionError): - if time.monotonic() >= deadline: - raise BackupInProgressError("another Hermes backup is already running") - time.sleep(0.05) + + def _lock_op(flag: int) -> None: + handle.seek(0) + msvcrt.locking(handle.fileno(), flag, 1) + + lock_flag, unlock_flag = msvcrt.LK_NBLCK, msvcrt.LK_UNLCK else: import fcntl - while True: - try: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - acquired = True - break - except (BlockingIOError, OSError): - if time.monotonic() >= deadline: - raise BackupInProgressError("another Hermes backup is already running") - time.sleep(0.05) + def _lock_op(flag: int) -> None: + fcntl.flock(handle.fileno(), flag) + + lock_flag, unlock_flag = fcntl.LOCK_EX | fcntl.LOCK_NB, fcntl.LOCK_UN + + while True: + try: + _lock_op(lock_flag) + acquired = True + break + except OSError: + if time.monotonic() >= deadline: + raise BackupInProgressError("another Hermes backup is already running") + time.sleep(0.05) yield finally: if acquired: - try: - if os.name == "nt": - import msvcrt - - handle.seek(0) - msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl - - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) - except (OSError, PermissionError): - pass + with suppress(OSError): + _lock_op(unlock_flag) handle.close() @contextmanager def _atomic_output_path(final_path: Path): """Yield a hidden sibling path and publish it only after a clean close.""" - partial_path = final_path.with_name( - f".{final_path.name}.{os.getpid()}-{threading.get_ident()}.partial" - ) + partial_path = final_path.with_name(f".{final_path.name}.{os.getpid()}-{threading.get_ident()}.partial") partial_path.unlink(missing_ok=True) try: yield partial_path @@ -295,30 +261,19 @@ def _atomic_output_path(final_path: Path): def _collect_memory_provider_external_paths() -> List[Path]: """Return existing absolute paths the active memory provider stores - outside HERMES_HOME, resolved from config only (no network, no init). - Reads ``memory.provider`` from config, loads just that provider, and asks - it for ``backup_paths()``. Returns an empty list when no external provider - is active or the provider can't be loaded — backup must never fail because - of a flaky plugin. + Reads ``memory.provider``, loads just that provider, and asks it for ``backup_paths()``. + Returns ``[]`` when no external provider is active or it can't be loaded: backup must never + fail because of a flaky plugin. """ try: from plugins.memory import _get_active_memory_provider, load_memory_provider - except Exception: - return [] - try: active = _get_active_memory_provider() + provider = load_memory_provider(active) if active else None except Exception: - active = None - if not active: return [] - - try: - provider = load_memory_provider(active) - except Exception: - provider = None - if provider is None: + if not active or provider is None: return [] try: @@ -332,28 +287,21 @@ def _collect_memory_provider_external_paths() -> List[Path]: for raw in declared: try: p = Path(raw).expanduser() + resolved = p.resolve() if p.exists() else None except Exception: continue - if not p.exists(): - continue - try: - resolved = p.resolve() - except (OSError, ValueError): - continue - if resolved in seen: - continue - seen.add(resolved) - out.append(p) + if resolved is not None and resolved not in seen: + seen.add(resolved) + out.append(p) return out def _iter_external_files(base: Path) -> List[Path]: """Yield regular files under *base* (a file or a directory), skipping symlinks, caches, and pyc files. *base* itself may be a file.""" - files: List[Path] = [] if base.is_file() and not base.is_symlink(): - files.append(base) - return files + return [base] + files: List[Path] = [] if not base.is_dir(): return files for dirpath, dirnames, filenames in os.walk(base, followlinks=False): @@ -361,9 +309,7 @@ def _iter_external_files(base: Path) -> List[Path]: dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS] for fname in filenames: fpath = dp / fname - if fpath.is_symlink(): - continue - if fpath.name in _EXCLUDED_NAMES or fpath.name.endswith(_EXCLUDED_SUFFIXES): + if fpath.is_symlink() or fname in _EXCLUDED_NAMES or fname.endswith(_EXCLUDED_SUFFIXES): continue files.append(fpath) return files @@ -376,28 +322,18 @@ def _should_exclude(rel_path: Path) -> bool: if _in_excluded_root_dir(rel_path): return True - for part in parts: - if part not in _EXCLUDED_DIRS: - continue - # ``hermes-agent`` only matches at the root level (first component). - # Nested directories with the same name — e.g. - # ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved. - if part == "hermes-agent" and part != parts[0]: - continue + # ``hermes-agent`` only matches at the root level (first component). + # Nested directories with the same name — e.g. + # ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved. + if any(p in _EXCLUDED_DIRS and (p != "hermes-agent" or p == parts[0]) for p in parts): return True name = rel_path.name - - if name in _EXCLUDED_NAMES: - return True - - if name.startswith(_EXCLUDED_PREFIXES): - return True - - if name.endswith(_EXCLUDED_SUFFIXES): - return True - - return False + return ( + name in _EXCLUDED_NAMES + or name.startswith(_EXCLUDED_PREFIXES) + or name.endswith(_EXCLUDED_SUFFIXES) + ) def _should_skip_backup_file(abs_path: Path, rel_path: Path, out_path: Path) -> bool: @@ -416,21 +352,13 @@ def _should_skip_backup_file(abs_path: Path, rel_path: Path, out_path: Path) -> return False -def _iter_backup_files( - hermes_root: Path, - out_path: Path, - skipped_dirs: Optional[set] = None, -): +def _iter_backup_files(hermes_root: Path, out_path: Path, skipped_dirs: Optional[set] = None): """Yield ``(abs_path, rel_path)`` for every file a full backup should hold. - The one owner of the backup walk policy: directory pruning (so os.walk - never descends a multi-GB excluded tree), the root-only ``hermes-agent`` - carve-out, profile-home-root runtime trees, and the per-file exclusion - rules — shared by the manual ``hermes backup`` path and the automatic + The one owner of the backup walk policy: directory pruning (so os.walk never descends a multi-GB + excluded tree), the root-only ``hermes-agent`` carve-out, profile-home-root runtime trees, and + the per-file exclusion rules — shared by the manual ``hermes backup`` path and the automatic pre-update/pre-migration path so the two can never drift. - - ``skipped_dirs``, when given, collects pruned directories (root-relative, - as strings) for the end-of-run summary. """ for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False): rel_dir = Path(dirpath).relative_to(hermes_root) @@ -462,17 +390,30 @@ def _iter_backup_files( # SQLite safe copy # --------------------------------------------------------------------------- -def _safe_copy_db( - src: Path, - dst: Path, - *, - timeout_seconds: float = 10.0, -) -> bool: +def _close_quietly(conn: Optional[sqlite3.Connection]) -> None: + if conn is not None: + with suppress(Exception): + conn.close() + + +def _query_ro_sqlite(path: Path, fn): + """Run ``fn(conn)`` on a read-only connection to *path*; return ``(value, None)`` or ``(None, exc)``.""" + conn = None + try: + conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=1.0) + return fn(conn), None + except Exception as exc: + return None, exc + finally: + _close_quietly(conn) + + +def _safe_copy_db(src: Path, dst: Path, *, timeout_seconds: float = 10.0) -> bool: """Copy a SQLite database safely using the backup() API. - Handles WAL mode — produces a consistent snapshot even while - the DB is being written to. Fail closed if a consistent snapshot cannot - be created: copying only the live main file can omit committed WAL data. + Handles WAL mode — produces a consistent snapshot even while the DB is being written to. Fail + closed if a consistent snapshot cannot be created: copying only the live main file can omit + committed WAL data. """ conn = None backup_conn = None @@ -489,82 +430,47 @@ def _safe_copy_db( now = time.monotonic() if status in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED): if now >= busy_deadline: - raise _SQLiteBackupTimeout( - f"database remained locked for {timeout_seconds:g} seconds" - ) + raise _SQLiteBackupTimeout(f"database remained locked for {timeout_seconds:g} seconds") else: busy_deadline = now + max(0.0, timeout_seconds) - conn.backup( - backup_conn, - pages=256, - progress=_check_backup_progress, - sleep=0.1, - ) + conn.backup(backup_conn, pages=256, progress=_check_backup_progress, sleep=0.1) return True except Exception as exc: logger.warning("SQLite safe copy failed for %s: %s", src, exc) # Windows will not remove the partial destination while SQLite still # has it open. Close it before fail-closed cleanup; the finally block # still owns the source and any close failure. - if backup_conn is not None: - try: - backup_conn.close() - except Exception: - pass - backup_conn = None - try: + _close_quietly(backup_conn) + backup_conn = None + with suppress(OSError): dst.unlink(missing_ok=True) - except OSError: - pass return False finally: - for connection in (backup_conn, conn): - if connection is not None: - try: - connection.close() - except Exception: - pass + _close_quietly(backup_conn) + _close_quietly(conn) -def is_zeroed_sqlite_file( - path: Path, *, probe_bytes: int = 100, force: bool = False -) -> bool: +def is_zeroed_sqlite_file(path: Path, *, probe_bytes: int = 100, force: bool = False) -> bool: """True when *path* looks like the #68474 zeroed-state.db signature. - Signature: no ``SQLite format 3`` header and no data — either empty - (size 0, the total-loss case, #97568) or first *probe_bytes* all NUL. - Used at SessionDB open and for snapshot diagnostics so a silent - all-zero file becomes a guided recovery instead of a generic failure. - - Only regular files qualify: a special file at the path (FIFO, device, - socket) is never "zeroed" — and probing one could block indefinitely - (opening a FIFO for read waits for a writer), so refuse before any I/O. + Only regular files qualify: a special file at the path (FIFO, device, socket) is never "zeroed" + — and probing one could block indefinitely (opening a FIFO for read waits for a writer), so + refuse before any I/O. """ try: if not path.is_file(): return False - size = path.stat().st_size except OSError: return False - if size < 0: - return False from hermes_cli.sqlite_safe_read import has_live_connection, read_header_bytes_preopen if not force and has_live_connection(path): return False - head = read_header_bytes_preopen( - path, length=max(16, probe_bytes), force=force - ) - if head is None: - return False - if len(head) == 0: - return True - if head.startswith(b"SQLite format 3"): - return False - return all(byte == 0 for byte in head) - + head = read_header_bytes_preopen(path, length=max(16, probe_bytes), force=force) + # Empty or all-NUL header => zeroed; a real header (or unreadable) => not. + return head is not None and not head.startswith(b"SQLite format 3") and not any(head) # --------------------------------------------------------------------------- @@ -592,48 +498,28 @@ def verify_sqlite_integrity( ) -> dict: """Verify that a SQLite database at *path* is intact. - Checks, in order: - 1. File exists and has an expected minimum size. - 2. SQLite header magic bytes are present. - 3. For files at or under ``max_bytes``, a read-only - ``PRAGMA integrity_check``. For larger files, a cheap structural - probe (schema read) instead — see ``max_bytes``. - - Args: - path: Path to the database file. - check_header: When true (default), verify the SQLite header magic. - run_pragma: When true (default), run ``PRAGMA integrity_check`` via - a read-only connection and verify the result is ``"ok"``. - max_bytes: Size ceiling for the full ``PRAGMA integrity_check``. - Files larger than this fall back to the header check plus a - cheap structural probe, because ``integrity_check`` pages - through the ENTIRE file — minutes of silent pegged CPU on a - multi-GB database. Defaults to - :data:`DEFAULT_INTEGRITY_CHECK_MAX_BYTES` (2 GiB); pass ``0`` - to force the full check regardless of size. - - Returns: - A dict with keys: - - ``valid`` (bool): true when all requested checks passed. - - ``message`` (str): human-readable outcome or error detail. - - ``size`` (int | None): file size in bytes, or None if stat failed. + Checks, in order: 1. File exists and has an expected minimum size. 2. SQLite header magic bytes + are present. 3. For files at or under ``max_bytes``, a read-only ``PRAGMA integrity_check``. For + larger files, a cheap structural probe (schema read) instead — see ``max_bytes``. """ result: dict = {"valid": False, "message": "", "size": None} + def _done(message: str, valid: bool = False) -> dict: + result["valid"] = valid + result["message"] = message + return result + try: st = path.stat() except FileNotFoundError: - result["message"] = f"not found: {path}" - return result + return _done(f"not found: {path}") except OSError as exc: - result["message"] = f"cannot stat: {exc}" - return result + return _done(f"cannot stat: {exc}") result["size"] = st.st_size if st.st_size < 100: # SQLite minimum viable size (header + 1 page) - result["message"] = f"too small ({st.st_size} bytes) to be a valid SQLite database" - return result + return _done(f"too small ({st.st_size} bytes) to be a valid SQLite database") oversized = max_bytes > 0 and st.st_size > max_bytes @@ -646,15 +532,9 @@ def verify_sqlite_integrity( head = read_header_bytes_preopen(path, length=len(_SQLITE_HEADER)) if head is None: - result["valid"] = False - result["message"] = "cannot read header" - return result + return _done("cannot read header") if head != _SQLITE_HEADER: - result["valid"] = False - result["message"] = ( - f"missing SQLite header magic (got {head[:16].hex()!r})" - ) - return result + return _done(f"missing SQLite header magic (got {head[:16].hex()!r})") if oversized: # Too large to page through PRAGMA integrity_check (which is O(file @@ -664,102 +544,59 @@ def verify_sqlite_integrity( # plus reading sqlite_master + the page geometry catches the # malformed-schema and truncated-header-page classes. Both are # constant-time — they parse the schema, they do not walk the data. - run_pragma = False - probe = None - try: - probe = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=1.0) - probe.execute("PRAGMA schema_version").fetchone() - probe.execute("SELECT count(*) FROM sqlite_master").fetchone() - result["valid"] = True - result["message"] = ( - f"size {st.st_size:,} bytes exceeds max_bytes {max_bytes:,}; " - "skipped PRAGMA integrity_check (header + schema probe passed)" - ) - except sqlite3.DatabaseError as exc: - result["valid"] = False - result["message"] = f"schema probe failed: {exc}" - return result - except Exception as exc: - result["valid"] = False - result["message"] = f"schema probe error: {exc}" - return result - finally: - if probe is not None: - try: - probe.close() - except Exception: - pass + _, exc = _query_ro_sqlite( + path, + lambda c: ( + c.execute("PRAGMA schema_version").fetchone(), + c.execute("SELECT count(*) FROM sqlite_master").fetchone(), + ), + ) + if exc is not None: + kind = "failed" if isinstance(exc, sqlite3.DatabaseError) else "error" + return _done(f"schema probe {kind}: {exc}") + return _done( + f"size {st.st_size:,} bytes exceeds max_bytes {max_bytes:,}; " + "skipped PRAGMA integrity_check (header + schema probe passed)", + valid=True, + ) if run_pragma: - conn = None - try: - conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=1.0) - cursor = conn.execute("PRAGMA integrity_check") - rows = cursor.fetchall() - if len(rows) == 1 and rows[0][0] == "ok": - result["valid"] = True - result["message"] = "integrity check passed" - return result - errors = [str(r[0]) for r in rows] - result["message"] = f"integrity check failed: {'; '.join(errors[:5])}" - return result - except sqlite3.DatabaseError as exc: - result["message"] = f"cannot open database: {exc}" - return result - except Exception as exc: - result["message"] = f"integrity check error: {exc}" - return result - finally: - if conn is not None: - try: - conn.close() - except Exception: - pass + rows, exc = _query_ro_sqlite( + path, + lambda c: [str(r[0]) for r in c.execute("PRAGMA integrity_check").fetchall()], + ) + if exc is not None: + kind = "cannot open database" if isinstance(exc, sqlite3.DatabaseError) else "integrity check error" + return _done(f"{kind}: {exc}") + if rows == ["ok"]: + return _done("integrity check passed", valid=True) + return _done(f"integrity check failed: {'; '.join(rows[:5])}") - result["valid"] = True - if not result["message"]: - result["message"] = "header check passed" - return result - - -def copy_db_and_verify(src: Path, dst: Path) -> bool: - """Like :func:`_safe_copy_db` but verifies the destination after copy. - - Returns True only when the copy succeeded AND the destination is valid - SQLite (header + integrity check). Verification honours the default - size ceiling — a multi-GB destination gets the header + schema probe - rather than a full ``PRAGMA integrity_check`` that would page through - the whole file. - """ - if not _safe_copy_db(src, dst): - return False - integrity = verify_sqlite_integrity(dst, run_pragma=True) - if not integrity.get("valid"): - try: - dst.unlink(missing_ok=True) - except OSError: - pass - logger.warning("Backup of %s failed integrity verification: %s", src, integrity.get("message")) - return False - return True + return _done("header check passed", valid=True) def _foreign_db_holder_pids(db_path: Path) -> Optional[List[int]]: """PIDs of OTHER processes holding *db_path* or its WAL/SHM open. - Linux-only ``/proc//fd`` scan (no psutil dependency), preserving the - kernel's ``(deleted)`` suffix so an already-unlinked sidecar generation — - the #90950 split-brain fingerprint — still counts as held. Returns - ``None`` when the scan is unavailable (non-Linux, or /proc unreadable); - callers must treat ``None`` as "unknown", not as "no holders". + Linux-only ``/proc//fd`` scan (no psutil dependency), preserving the kernel's ``(deleted)`` + suffix so an already-unlinked sidecar generation — the #90950 split-brain fingerprint — still + counts as held. """ if not sys.platform.startswith("linux"): return None def _canonical(path: str) -> str: - return os.path.normcase( - os.path.abspath(path.removesuffix(" (deleted)")) - ) + return os.path.normcase(os.path.abspath(path.removesuffix(" (deleted)"))) + + def _holds_watched(fd_dir: str) -> bool: + for fd in os.listdir(fd_dir): + try: + target = os.readlink(f"{fd_dir}/{fd}") + except OSError: + continue + if _canonical(target) in watched: + return True + return False canonical_db = _canonical(os.fspath(db_path)) watched = {canonical_db, canonical_db + "-wal", canonical_db + "-shm"} @@ -767,24 +604,13 @@ def _foreign_db_holder_pids(db_path: Path) -> Optional[List[int]]: try: own_pid = os.getpid() for pid_str in os.listdir("/proc"): - if not pid_str.isdigit(): + if not pid_str.isdigit() or int(pid_str) == own_pid: continue - pid = int(pid_str) - if pid == own_pid: - continue - fd_dir = f"/proc/{pid}/fd" try: - fds = os.listdir(fd_dir) + if _holds_watched(f"/proc/{pid_str}/fd"): + pids.append(int(pid_str)) except OSError: continue - for fd in fds: - try: - target = os.readlink(f"{fd_dir}/{fd}") - except OSError: - continue - if _canonical(target) in watched: - pids.append(pid) - break except OSError: return None return pids @@ -793,36 +619,19 @@ def _foreign_db_holder_pids(db_path: Path) -> Optional[List[int]]: def _safe_restore_db(src: Path, dst: Path) -> bool: """Restore a SQLite database from snapshot *src* into live *dst*. - Uses SQLite's backup() API to write snapshot pages into the live - database file, preserving the file's inode and WAL state so that - any other process still holding the DB open (gateway, dashboard, - another CLI session) sees the restored data on the next read — - instead of continuing to serve stale cached pages from a replaced - inode. + Uses SQLite's backup() API to write snapshot pages into the live database file, preserving the + file's inode and WAL state so that any other process still holding the DB open (gateway, + dashboard, another CLI session) sees the restored data on the next read — instead of continuing + to serve stale cached pages from a replaced inode. - The old approach was ``unlink() + move()``, which replaced the file - under any live connection. SQLite connections cache pages in - per-connection page caches keyed by inode; after an unlink+move the - old inode still existed (the live connection held a reference), so - that connection continued serving the pre-restore data while new - connections saw the restored snapshot — a partial/inconsistent - state (issue #65942). - - By writing pages through the backup API the file inode is preserved, - the WAL journal is updated correctly, and all connections (old and - new) converge on the restored data. - - Falls back to the unlink+move approach on failure so restore never - blocks on a transient error. + Falls back to the unlink+move approach on failure so restore never blocks on a transient error. """ try: dst_conn = sqlite3.connect(str(dst)) - try: - # Force a WAL checkpoint so the backup starts from a clean - # state rather than writing on top of a deep WAL. + # Force a WAL checkpoint so the backup starts from a clean + # state rather than writing on top of a deep WAL. + with suppress(Exception): dst_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") - except Exception: - pass src_conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True) try: src_conn.backup(dst_conn) @@ -830,82 +639,192 @@ def _safe_restore_db(src: Path, dst: Path) -> bool: src_conn.close() dst_conn.close() # Restore original file permissions from the snapshot - try: - mode = src.stat().st_mode - dst.chmod(mode) - except Exception: - pass + with suppress(Exception): + dst.chmod(src.stat().st_mode) return True except Exception as exc: logger.warning("SQLite safe restore failed for %s -> %s: %s", src, dst, exc) - # Fallback: unlink+move (the old approach). This still works for - # the common case where no other process holds the DB open. - from hermes_cli.sqlite_safe_read import ( - LiveConnectionError, - offline_file_access, - ) + return _unlink_move_restore_db(src, dst) - try: - holders = _foreign_db_holder_pids(dst) - if holders: - # Replacing the inode under a live holder is the #90950 - # corruption class: the holder keeps writing through a - # deleted-inode fd (split brain), and removing its sidecars - # detaches the WAL index it is checkpointing through. The - # backup-API path above is the live-safe route; if it failed, - # fail closed rather than corrupt. - logger.error( - "Refusing unlink+move restore of %s: process(es) %s still " - "hold the database or its WAL open. Stop them and retry.", - dst, holders, - ) - return False - # The foreign-pid scan above deliberately excludes THIS process, - # but an in-process SessionDB (the agent's own handle during - # /snapshot restore, a second SessionDB instance, a read pool) - # is exactly as much of a live holder: unlinking the DB and its - # sidecars under it leaves this process on deleted-inode fds — - # the same #90950 split brain, produced first-party (proven live - # on main: `/proc/self/fd` shows `state.db-wal (deleted)` right - # after this fallback ran under a tracked connection). - # ``offline_file_access`` fails CLOSED when any tracked - # connection to *dst* is live and holds the connection-lifecycle - # lock across the whole swap so no new connection can appear - # mid-replace. - with offline_file_access(dst, what="unlink+move restore of"): - tmp = dst.parent / f".{dst.name}.snap_restore" - shutil.copy2(src, tmp) - dst.unlink(missing_ok=True) - # Drop the destination's sidecars before installing the - # snapshot. The snapshot is a checkpointed ``sqlite3.backup()`` - # image (see ``_safe_copy_db``) that owns no WAL, so any - # ``-wal``/``-shm`` still sitting here describes the database we - # just unlinked — an ungracefully killed gateway leaves them - # behind, which is exactly when a restore gets run. SQLite - # replays that foreign WAL over the restored file on the next - # open and the database comes up "malformed" (or silently - # resurrects post-snapshot rows). Same reasoning as - # ``_EXCLUDED_SUFFIXES``, applied to the restore destination. - for _sidecar_suffix in ("-wal", "-shm", "-journal"): - dst.with_name(dst.name + _sidecar_suffix).unlink(missing_ok=True) - shutil.move(str(tmp), str(dst)) - return True - except LiveConnectionError as exc2: + +def _unlink_move_restore_db(src: Path, dst: Path) -> bool: + """Fallback restore: unlink+move (the old approach). Works when no process holds the DB open. + + Replacing the inode under a live holder is the #90950 corruption class: the holder keeps + writing through a deleted-inode fd (split brain), and removing its sidecars detaches the WAL + index it is checkpointing through. The backup-API path is the live-safe route; if it failed, + fail closed rather than corrupt. The foreign-pid scan deliberately excludes THIS process, but + an in-process SessionDB (the agent's own handle during /snapshot restore, a second SessionDB + instance, a read pool) is exactly as much of a live holder, so ``offline_file_access`` fails + CLOSED when any tracked connection to *dst* is live and holds the connection-lifecycle lock + across the whole swap so no new connection can appear mid-replace. + """ + from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access + + try: + holders = _foreign_db_holder_pids(dst) + if holders: logger.error( - "Refusing unlink+move restore of %s: %s Close the in-process " - "database handles (or restart Hermes) and retry.", - dst, exc2, + "Refusing unlink+move restore of %s: process(es) %s still " + "hold the database or its WAL open. Stop them and retry.", + dst, holders, ) return False - except Exception as exc2: - logger.error("Fallback restore also failed for %s -> %s: %s", src, dst, exc2) - return False + with offline_file_access(dst, what="unlink+move restore of"): + tmp = dst.parent / f".{dst.name}.snap_restore" + shutil.copy2(src, tmp) + dst.unlink(missing_ok=True) + # Drop the destination's sidecars before installing the snapshot. The + # snapshot is a checkpointed ``sqlite3.backup()`` image that owns no + # WAL, so any ``-wal``/``-shm`` still here describes the database we + # just unlinked (an ungracefully killed gateway leaves them behind — + # exactly when a restore gets run). SQLite would replay that foreign + # WAL over the restored file on next open and come up "malformed" (or + # silently resurrect post-snapshot rows). Same reasoning as + # ``_EXCLUDED_SUFFIXES``, applied to the restore destination. + for _sidecar_suffix in ("-wal", "-shm", "-journal"): + dst.with_name(dst.name + _sidecar_suffix).unlink(missing_ok=True) + shutil.move(str(tmp), str(dst)) + return True + except LiveConnectionError as exc2: + logger.error( + "Refusing unlink+move restore of %s: %s Close the in-process " + "database handles (or restart Hermes) and retry.", + dst, exc2, + ) + return False + except Exception as exc2: + logger.error("Fallback restore also failed for %s -> %s: %s", src, dst, exc2) + return False + + +def _zip_sqlite_snapshot( + zf: zipfile.ZipFile, abs_path: Path, rel_path: Path, out_path: Path +) -> Optional[int]: + """Add a WAL-safe snapshot of *abs_path* to *zf*; return its byte size, or None on failure. + + The snapshot is staged alongside the output zip so the temp file lives on the same + filesystem: the system default (/tmp) may be a small tmpfs that cannot hold large databases, + causing silent backup incompleteness. + """ + with tempfile.NamedTemporaryFile( + suffix=".db", delete=False, dir=str(out_path.parent) + ) as tmp: + tmp_db = Path(tmp.name) + try: + if not _safe_copy_db(abs_path, tmp_db): + return None + zf.write(tmp_db, arcname=str(rel_path)) + return tmp_db.stat().st_size + finally: + tmp_db.unlink(missing_ok=True) + + +def _write_zip_entries( + zf: zipfile.ZipFile, + files_to_add: List[Tuple[Path, Path]], + out_path: Path, + *, + on_db_failure, + on_error, + on_progress, + track_bytes: bool, +) -> int: + """Add every ``(abs_path, rel_path)`` to *zf*, WAL-safe for ``*.db``; return bytes archived. + + ``on_db_failure(rel_path)`` runs when a SQLite snapshot fails (it may raise to abort); + ``on_error(rel_path, exc)`` records a per-file read failure; ``on_progress(index)`` fires + every 500 files. ``track_bytes`` stats each archived plain file for the size total. + """ + total_bytes = 0 + for i, (abs_path, rel_path) in enumerate(files_to_add, 1): + try: + if abs_path.suffix == ".db": + size = _zip_sqlite_snapshot(zf, abs_path, rel_path, out_path) + if size is None: + on_db_failure(rel_path) + continue + total_bytes += size + else: + zf.write(abs_path, arcname=str(rel_path)) + if track_bytes: + total_bytes += abs_path.stat().st_size + except (PermissionError, OSError, ValueError) as exc: + on_error(rel_path, exc) + continue + if i % 500 == 0: + on_progress(i) + return total_bytes + + +def _print_capped(header: str, lines: List[str], indent: str) -> None: + """Print *header*, then at most 10 of *lines* (each prefixed by *indent*) and a "... and N more" tail.""" + print(header) + for line in lines[:10]: + print(f"{indent}{line}") + if len(lines) > 10: + print(f"{indent}... and {len(lines) - 10} more") + + +def _print_skipped_warnings(errors: List[str]) -> None: + _print_capped(f"\n Warnings ({len(errors)} files skipped):", errors, " ") # --------------------------------------------------------------------------- # Backup # --------------------------------------------------------------------------- +def _resolve_backup_output_path(output: Optional[str]) -> Path: + """Turn ``--output`` (file, directory, or None) into a ``.zip`` path whose parent exists. + + A bad/unwritable output path (permission denied, unreadable parent, etc.) gives a clean + one-line error, not a raw traceback: is_dir() and mkdir() both hit the filesystem. + """ + out_path = None + default_name = f"hermes-backup-{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip" + try: + if output: + out_path = Path(output).expanduser().resolve() + # If user gave a directory, put the zip inside it + if out_path.is_dir(): + out_path = out_path / default_name + else: + out_path = Path.home() / default_name + if out_path.suffix.lower() != ".zip": + out_path = out_path.with_suffix(out_path.suffix + ".zip") + out_path.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + print(f"Error: cannot write backup to {output or out_path}: {exc}") + raise SystemExit(1) from exc + return out_path + + +def _collect_external_entries() -> tuple[list[tuple[Path, str]], list[str]]: + """``([(abs_path, arcname)], [skipped])`` for the active memory provider's external state. + + Provider state (e.g. ~/.honcho, ~/.hindsight) lives outside HERMES_HOME, so the backup walk + never sees it; it is staged under the reserved ``_external/`` arc prefix, encoded relative to + the user's home dir. Only paths under home are captured (security + portability); anything + else is returned as skipped so the caller can note it. + """ + home_dir = Path.home().resolve() + external_to_add: list[tuple[Path, str]] = [] + skipped_external: list[str] = [] + for base in _collect_memory_provider_external_paths(): + try: + base.resolve().relative_to(home_dir) + except (ValueError, OSError): + skipped_external.append(str(base)) + continue + for fpath in _iter_external_files(base): + try: + rel_to_home = fpath.resolve().relative_to(home_dir) + except (ValueError, OSError): + continue + external_to_add.append((fpath, _EXTERNAL_PREFIX + rel_to_home.as_posix())) + return external_to_add, skipped_external + + def run_backup(args) -> None: """Create a zip backup of the Hermes home directory.""" hermes_root = get_default_hermes_root() @@ -924,65 +843,15 @@ def run_backup(args) -> None: def _run_backup_locked(args, hermes_root: Path) -> None: """Write a full backup while the cross-process backup slot is held.""" - - # Determine output path - out_path = None - try: - if args.output: - out_path = Path(args.output).expanduser().resolve() - # If user gave a directory, put the zip inside it - if out_path.is_dir(): - stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - out_path = out_path / f"hermes-backup-{stamp}.zip" - else: - stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - out_path = Path.home() / f"hermes-backup-{stamp}.zip" - - # Ensure the suffix is .zip - if out_path.suffix.lower() != ".zip": - out_path = out_path.with_suffix(out_path.suffix + ".zip") - - # Ensure parent directory exists - out_path.parent.mkdir(parents=True, exist_ok=True) - except OSError as exc: - # A bad/unwritable output path (permission denied, unreadable parent, - # etc.) should give a clean one-line error, not a raw traceback - # (round-3 QA SUB-01). is_dir() and mkdir() both hit the filesystem. - print(f"Error: cannot write backup to {args.output or out_path}: {exc}") - raise SystemExit(1) from exc + out_path = _resolve_backup_output_path(args.output) # Collect files scan_started = time.monotonic() logger.info("backup phase=scan status=started") print(f"Scanning {display_hermes_home()} ...") skipped_dirs: set = set() - files_to_add: list[tuple[Path, Path]] = list( - _iter_backup_files(hermes_root, out_path, skipped_dirs) - ) - - # External memory-provider state (e.g. ~/.honcho, ~/.hindsight) lives - # outside HERMES_HOME, so the walk above never sees it. Ask the active - # provider for its declared paths and stage them under the reserved - # ``_external/`` arc prefix, encoded relative to the user's home dir. - # Only paths under home are captured (security + portability); anything - # else is skipped with a note. - home_dir = Path.home().resolve() - external_to_add: list[tuple[Path, str]] = [] # (absolute, arcname) - skipped_external: list[str] = [] - for base in _collect_memory_provider_external_paths(): - try: - base_resolved = base.resolve() - base_resolved.relative_to(home_dir) - except (ValueError, OSError): - skipped_external.append(str(base)) - continue - for fpath in _iter_external_files(base): - try: - rel_to_home = fpath.resolve().relative_to(home_dir) - except (ValueError, OSError): - continue - arcname = _EXTERNAL_PREFIX + rel_to_home.as_posix() - external_to_add.append((fpath, arcname)) + files_to_add: list[tuple[Path, Path]] = list(_iter_backup_files(hermes_root, out_path, skipped_dirs)) + external_to_add, skipped_external = _collect_external_entries() if not files_to_add and not external_to_add: logger.info( @@ -996,54 +865,28 @@ def _run_backup_locked(args, hermes_root: Path) -> None: file_count = len(files_to_add) + len(external_to_add) logger.info( "backup phase=scan status=complete duration_ms=%.1f files=%d", - (time.monotonic() - scan_started) * 1000, - file_count, + (time.monotonic() - scan_started) * 1000, file_count, ) logger.info("backup phase=archive status=started files=%d", file_count) print(f"Backing up {file_count} files ...") - total_bytes = 0 errors = [] t0 = time.monotonic() + def _progress(i: int) -> None: + print(f" {i}/{file_count} files ...") + logger.info("backup phase=archive status=progress completed=%d total=%d", i, file_count) + with _atomic_output_path(out_path) as archive_path, zipfile.ZipFile( archive_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6 ) as zf: - for i, (abs_path, rel_path) in enumerate(files_to_add, 1): - try: - # Safe copy for SQLite databases (handles WAL mode) - if abs_path.suffix == ".db": - # Stage the snapshot alongside the output zip so that the - # temp file lives on the same filesystem. The system - # default (/tmp) may be a small tmpfs that cannot hold - # large databases, causing silent backup incompleteness. - with tempfile.NamedTemporaryFile( - suffix=".db", delete=False, dir=str(out_path.parent) - ) as tmp: - tmp_db = Path(tmp.name) - if _safe_copy_db(abs_path, tmp_db): - zf.write(tmp_db, arcname=str(rel_path)) - total_bytes += tmp_db.stat().st_size - tmp_db.unlink(missing_ok=True) - else: - tmp_db.unlink(missing_ok=True) - errors.append(f" {rel_path}: SQLite safe copy failed") - continue - else: - zf.write(abs_path, arcname=str(rel_path)) - total_bytes += abs_path.stat().st_size - except (PermissionError, OSError, ValueError) as exc: - errors.append(f" {rel_path}: {exc}") - continue - - # Progress every 500 files - if i % 500 == 0: - print(f" {i}/{file_count} files ...") - logger.info( - "backup phase=archive status=progress completed=%d total=%d", - i, - file_count, - ) + total_bytes = _write_zip_entries( + zf, files_to_add, out_path, + on_db_failure=lambda rel: errors.append(f"{rel}: SQLite safe copy failed"), + on_error=lambda rel, exc: errors.append(f"{rel}: {exc}"), + on_progress=_progress, + track_bytes=True, + ) # External memory-provider state, stored under the ``_external/`` arc # prefix. These never include ``.db`` files in practice (config/env @@ -1053,25 +896,19 @@ def _run_backup_locked(args, hermes_root: Path) -> None: zf.write(abs_path, arcname=arcname) total_bytes += abs_path.stat().st_size except (PermissionError, OSError, ValueError) as exc: - errors.append(f" {arcname}: {exc}") + errors.append(f"{arcname}: {exc}") continue elapsed = time.monotonic() - t0 zip_size = out_path.stat().st_size logger.info( "backup phase=archive status=complete duration_ms=%.1f files=%d errors=%d bytes=%d", - elapsed * 1000, - file_count, - len(errors), - zip_size, + elapsed * 1000, file_count, len(errors), zip_size, ) # Summary print() - if errors: - print(f"Backup incomplete: {out_path}") - else: - print(f"Backup complete: {out_path}") + print(f"Backup {'incomplete' if errors else 'complete'}: {out_path}") print(f" Files: {file_count}") print(f" Original: {_format_size(total_bytes)}") print(f" Compressed: {_format_size(zip_size)}") @@ -1088,22 +925,15 @@ def _run_backup_locked(args, hermes_root: Path) -> None: f"\n Skipped {len(skipped_external)} memory-provider path(s) " f"outside your home directory (not portable):" ) - for p in sorted(skipped_external)[:10]: - print(f" {p}") + print("\n".join(f" {p}" for p in sorted(skipped_external)[:10])) if skipped_dirs: print("\n Excluded directories:") - for d in sorted(skipped_dirs): - print(f" {d}/") + print("\n".join(f" {d}/" for d in sorted(skipped_dirs))) if errors: - print(f"\n Warnings ({len(errors)} files skipped):") - for e in errors[:10]: - print(e) - if len(errors) > 10: - print(f" ... and {len(errors) - 10} more") - - if not errors: + _print_skipped_warnings(errors) + else: print(f"\nRestore with: hermes import {out_path.name}") @@ -1112,24 +942,14 @@ def _run_backup_locked(args, hermes_root: Path) -> None: # --------------------------------------------------------------------------- def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]: - """Check that a zip looks like a Hermes backup. - - Returns (ok, reason). - """ + """Check that a zip looks like a Hermes backup.""" names = zf.namelist() if not names: return False, "zip archive is empty" - # Look for telltale files that a hermes home would have - markers = {"config.yaml", ".env", "state.db"} - found = set() - for n in names: - # Could be at the root or one level deep (if someone zipped the directory) - basename = Path(n).name - if basename in markers: - found.add(basename) - - if not found: + # Telltale files a hermes home has — at the root or one level deep + # (if someone zipped the directory). + if not any(Path(n).name in {"config.yaml", ".env", "state.db"} for n in names): return False, ( "zip does not appear to be a Hermes backup " "(no config.yaml, .env, or state databases found)" @@ -1139,41 +959,23 @@ def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]: def _detect_prefix(zf: zipfile.ZipFile) -> str: - """Detect if the zip has a common directory prefix wrapping all entries. - - Some tools zip as `.hermes/config.yaml` instead of `config.yaml`. - Returns the prefix to strip (empty string if none). - """ + """Detect if the zip has a common directory prefix wrapping all entries.""" names = [n for n in zf.namelist() if not n.endswith("/")] if not names: return "" - - # Find common prefix - parts_list = [Path(n).parts for n in names] - - # Check if all entries share a common first directory - first_parts = {p[0] for p in parts_list if len(p) > 1} - if len(first_parts) == 1: - prefix = first_parts.pop() - # Only strip if it looks like a hermes dir name - if prefix in {".hermes", "hermes"}: - return prefix + "/" - + # All entries share one first directory that looks like a hermes dir name. + first_parts = {Path(n).parts[0] for n in names if len(Path(n).parts) > 1} + if len(first_parts) == 1 and first_parts <= {".hermes", "hermes"}: + return first_parts.pop() + "/" return "" def _default_new_file_mode() -> Optional[int]: """Return the mode ``open(path, "wb")`` gives a file it has to create. - ``tempfile.mkstemp`` always creates at 0600, so staging an import through a - temp file would tighten every *newly created* file to owner-only — the same - hazard ``utils._restore_file_mode`` documents for Docker/NAS volume mounts - that rely on broader permissions. The umask can only be read by setting it, - so this is resolved once per import rather than once per member. The probe - installs a *restrictive* mask rather than 0 so that anything another thread - creates inside the two-syscall window is owner-only, never world-writable. - Returns ``None`` if the umask cannot be read, in which case the caller - leaves mkstemp's mode alone. + ``tempfile.mkstemp`` always creates at 0600, so staging an import through a temp file would + tighten every *newly created* file to owner-only — the same hazard ``utils._restore_file_mode`` + documents for Docker/NAS volume mounts that rely on broader permissions. """ try: current = os.umask(0o077) @@ -1191,43 +993,13 @@ def _extract_member_atomically( ) -> None: """Restore one zip member onto *target* with no truncation window. - ``open(target, "wb")`` truncates the user's existing file to zero *before* - any replacement bytes exist. A Ctrl-C, an ENOSPC, a corrupt member, or a - crash between the truncate and the write therefore leaves that file empty - with nothing behind it — during ``hermes import``, which is the - disaster-recovery path a user reaches for *because* they already lost - something. Staging into the target's own directory and publishing with a - rename means the target only ever moves from its old contents to the - complete new contents. + ``open(target, "wb")`` truncates the user's existing file to zero *before* any replacement bytes + exist. - ``atomic_replace`` rather than a bare ``os.replace``: it resolves a - symlinked target first, so a deployment that links ``config.yaml`` into a - dotfiles repo keeps the link instead of having it silently swapped for a - regular file (GitHub #16743), and it falls back to copy/fsync/unlink on - ``EXDEV``/``EBUSY`` for cross-device and bind-mount installs. That - fallback uses ``shutil.copyfile``, which does truncate in place, so on the - cross-device path the guarantee above degrades to today's behaviour rather - than improving on it; closing that belongs in ``utils.atomic_replace``, - where every atomic writer in the repo would benefit, not here. - - Permission bits *and* ownership are carried across the replace so routing - through mkstemp does not change the file the caller would otherwise have - produced. ``os.replace`` swaps in a temp file owned by the *writing* user, - so without the chown a ``sudo hermes import`` would silently re-own every - restored file to root — on the disaster-recovery path, and on exactly the - Docker/NAS installs ``utils._restore_file_owner`` documents. Both concerns - delegate to the shared ``utils`` helpers rather than being re-derived here. - The temp file is removed on any failure so a partial import leaves no - residue. - - The one bit of the old file *not* carried across is setuid/setgid. The - replacement bytes come out of the zip, so preserving those would let an - archive take over the identity an existing privileged file executes as — - and unlike the other ``utils`` writers, which re-serialize content this - process produced, the trust boundary here is an untrusted archive. The - mask is applied once, before the temp file is chmod'd, so neither the - pre-replace ``fchmod`` nor the post-replace restore can re-elevate the - target. + ``atomic_replace`` rather than a bare ``os.replace``: it resolves a symlinked target first, so a + deployment that links ``config.yaml`` into a dotfiles repo keeps the link instead of having it + silently swapped for a regular file (GitHub #16743), and it falls back to copy/fsync/unlink on + ``EXDEV``/``EBUSY`` for cross-device and bind-mount installs. """ # ``_preserve_file_mode`` returns None when the target does not exist (or # cannot be stat'd), in which case the umask-derived create-mode applies — @@ -1279,13 +1051,30 @@ def _extract_member_atomically( _restore_file_owner(real_path, owner) _restore_file_mode(real_path, mode) except BaseException: - try: + with suppress(OSError): os.unlink(tmp_name) - except OSError: - pass raise +def _confirm_import_overwrite(hermes_root: Path) -> bool: + """Prompt before importing over an existing installation; True when import may proceed.""" + if not any((hermes_root / m).exists() for m in ("config.yaml", ".env")): + return True + print() + print("Warning: Target directory already has Hermes configuration.") + print("Importing will overwrite existing files with backup contents.") + print() + try: + answer = input("Continue? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(1) + if answer not in {"y", "yes"}: + print("Aborted.") + return False + return True + + def run_import(args) -> None: """Restore a Hermes backup from a zip file.""" zip_path = Path(args.zipfile).expanduser().resolve() @@ -1322,23 +1111,8 @@ def run_import(args) -> None: if prefix: print(f"Detected archive prefix: {prefix!r} (will be stripped)") - # Check for existing installation - has_config = (hermes_root / "config.yaml").exists() - has_env = (hermes_root / ".env").exists() - - if (has_config or has_env) and not args.force: - print() - print("Warning: Target directory already has Hermes configuration.") - print("Importing will overwrite existing files with backup contents.") - print() - try: - answer = input("Continue? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\nAborted.") - sys.exit(1) - if answer not in {"y", "yes"}: - print("Aborted.") - return + if not args.force and not _confirm_import_overwrite(hermes_root): + return # Extract print(f"\nImporting {file_count} files ...") @@ -1354,74 +1128,65 @@ def run_import(args) -> None: new_file_mode = _default_new_file_mode() t0 = time.monotonic() + def _restore_member( + member: str, rel: str, target: Path, root: Path, tighten: bool, *, strict_chmod: bool + ) -> bool: + """Publish one member under *root*; False when blocked or failed (recorded in errors).""" + # Security: reject absolute paths and traversals + try: + target.resolve().relative_to(root) + except ValueError: + errors.append(f"{rel}: path traversal blocked") + return False + try: + target.parent.mkdir(parents=True, exist_ok=True) + _extract_member_atomically(zf, member, target, new_file_mode) + if tighten: + try: + os.chmod(target, 0o600) + except OSError: + if strict_chmod: + raise + except (PermissionError, OSError) as exc: + errors.append(f"{rel}: {exc}") + return False + return True + for member in members: # External memory-provider state captured under the reserved # ``_external/`` arc prefix restores to its original home-relative # location (e.g. ~/.honcho/config.json), NOT under HERMES_HOME. - if member.startswith(_EXTERNAL_PREFIX): - ext_rel = member[len(_EXTERNAL_PREFIX):] - if not ext_rel: - continue - target = home_dir / ext_rel - # Security: the resolved target must stay under the home dir. - try: - target.resolve().relative_to(home_dir) - except ValueError: - errors.append(f" {member}: path traversal blocked") - continue - try: - target.parent.mkdir(parents=True, exist_ok=True) - _extract_member_atomically(zf, member, target, new_file_mode) - # External provider configs commonly hold credentials. - if target.suffix in {".json", ".env", ".conf"} or target.name in _SECRET_FILE_NAMES: - try: - os.chmod(target, 0o600) - except OSError: - pass - restored += 1 - restored_external += 1 - except (PermissionError, OSError) as exc: - errors.append(f" {member}: {exc}") - if restored % 500 == 0: - print(f" {restored}/{file_count} files ...") - continue - - # Strip prefix if detected - if prefix and member.startswith(prefix): - rel = member[len(prefix):] + # Provider configs commonly hold credentials, so they are tightened + # to 0600 best-effort. + external = member.startswith(_EXTERNAL_PREFIX) + if external: + rel = member[len(_EXTERNAL_PREFIX):] + target = home_dir / rel + root = home_dir + tighten = target.suffix in {".json", ".env", ".conf"} or target.name in _SECRET_FILE_NAMES else: - rel = member - + # Strip prefix if detected + rel = member[len(prefix):] if prefix and member.startswith(prefix) else member + # Never overwrite volatile gateway/process runtime state. These are + # namespaced to the machine/container the backup was taken on; + # clobbering them (especially gateway_state.json) breaks the gateway + # reconciler on the target and disconnects hosted instances from the + # Nous portal. Matched by basename so both the root profile and + # named profiles (profiles//gateway_state.json) are covered. + if rel and Path(rel).name in _IMPORT_SKIP_NAMES: + skipped_runtime.append(rel) + continue + target = hermes_root / rel + root = hermes_root.resolve() + tighten = target.name in _SECRET_FILE_NAMES if not rel: continue - # Never overwrite volatile gateway/process runtime state. These are - # namespaced to the machine/container the backup was taken on; - # clobbering them (especially gateway_state.json) breaks the gateway - # reconciler on the target and disconnects hosted instances from the - # Nous portal. Matched by basename so both the root profile and - # named profiles (profiles//gateway_state.json) are covered. - if Path(rel).name in _IMPORT_SKIP_NAMES: - skipped_runtime.append(rel) - continue - - target = hermes_root / rel - - # Security: reject absolute paths and traversals - try: - target.resolve().relative_to(hermes_root.resolve()) - except ValueError: - errors.append(f" {rel}: path traversal blocked") - continue - - try: - target.parent.mkdir(parents=True, exist_ok=True) - _extract_member_atomically(zf, member, target, new_file_mode) - if target.name in _SECRET_FILE_NAMES: - os.chmod(target, 0o600) + if _restore_member( + member, member if external else rel, target, root, tighten, strict_chmod=not external + ): restored += 1 - except (PermissionError, OSError) as exc: - errors.append(f" {rel}: {exc}") + restored_external += external if restored % 500 == 0: print(f" {restored}/{file_count} files ...") @@ -1440,62 +1205,17 @@ def run_import(args) -> None: ) if errors: - print(f"\n Warnings ({len(errors)} files skipped):") - for e in errors[:10]: - print(e) - if len(errors) > 10: - print(f" ... and {len(errors) - 10} more") + _print_skipped_warnings(errors) if skipped_runtime: - print( + _print_capped( f"\n Preserved {len(skipped_runtime)} runtime state " - f"file(s) (kept this machine's, not the backup's):" + f"file(s) (kept this machine's, not the backup's):", + sorted(skipped_runtime), + " ", ) - for rel in sorted(skipped_runtime)[:10]: - print(f" {rel}") - if len(skipped_runtime) > 10: - print(f" ... and {len(skipped_runtime) - 10} more") - # Post-import: restore profile wrapper scripts - profiles_dir = hermes_root / "profiles" - restored_profiles = [] - if profiles_dir.is_dir(): - try: - from hermes_cli.profiles import ( - create_wrapper_script, check_alias_collision, - _is_wrapper_dir_in_path, _get_wrapper_dir, - ) - for entry in sorted(profiles_dir.iterdir()): - if not entry.is_dir(): - continue - profile_name = entry.name - # Only create wrappers for directories with config - if not (entry / "config.yaml").exists() and not (entry / ".env").exists(): - continue - collision = check_alias_collision(profile_name) - if collision: - print(f" Skipped alias '{profile_name}': {collision}") - restored_profiles.append((profile_name, False)) - else: - wrapper = create_wrapper_script(profile_name) - restored_profiles.append((profile_name, wrapper is not None)) - - if restored_profiles: - created = [n for n, ok in restored_profiles if ok] - skipped = [n for n, ok in restored_profiles if not ok] - if created: - print(f"\n Profile aliases restored: {', '.join(created)}") - if skipped: - print(f" Profile aliases skipped: {', '.join(skipped)}") - if not _is_wrapper_dir_in_path(): - print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.") - print(' Add to your shell config (~/.bashrc or ~/.zshrc):') - print(' export PATH="$HOME/.local/bin:$PATH"') - except ImportError: - # hermes_cli.profiles might not be available (fresh install) - if any(profiles_dir.iterdir()): - print("\n Profiles detected but aliases could not be created.") - print(" Run: hermes profile list (after installing hermes)") + restored_profiles = _restore_profile_wrappers(hermes_root) # Guidance print() @@ -1504,48 +1224,90 @@ def run_import(args) -> None: print(" If this is a fresh install, run: hermes update") if restored_profiles: - gw_profiles = [n for n, _ in restored_profiles] print("\nTo re-enable gateway services for profiles:") - for pname in gw_profiles: + for pname in restored_profiles: print(f" hermes -p {pname} gateway install") - # Bring the restored install to life: the backup may contain bot - # tokens and registered cron jobs, but they're inert without a - # gateway process. Install/start the service automatically (a - # platform-less gateway is a supported mode, so this is safe even - # for backups with no messaging config). Best-effort and prompt-free; - # failures print a manual fallback and never fail the import. - native_default = _get_platform_default_hermes_home() - default_has_install = any( - (native_default / marker).exists() - for marker in ("config.yaml", ".env", "state.db") - ) - # A restore into a sandbox or profile home must not silently install - # a second gateway pointed at it — on the default service name that - # would shadow or hijack the machine's primary install. Only revive - # the service automatically when the restore landed in the default - # home, or when no other install exists on this machine. - if hermes_root != native_default and default_has_install: - print( - "\nRestored into a non-default home; leaving the gateway service " - "alone to avoid clashing with the install at " - f"{native_default}." - ) - print("To start a gateway for this home, run: hermes gateway install") - else: - try: - from hermes_cli.gateway import ensure_gateway_service, _is_service_running - - if not _is_service_running(): - print() - ensure_gateway_service(context="import") - except Exception: - print("\nStart the gateway to activate cron jobs and messaging:") - print(" hermes gateway install") - + _revive_gateway_after_import(hermes_root) print("Done. Your Hermes configuration has been restored.") +def _restore_profile_wrappers(hermes_root: Path) -> List[str]: + """Re-create shell wrapper scripts for restored named profiles; return the profile names seen.""" + profiles_dir = hermes_root / "profiles" + restored_profiles: list[tuple[str, bool]] = [] + if not profiles_dir.is_dir(): + return [] + try: + from hermes_cli.profiles import ( + create_wrapper_script, check_alias_collision, + _is_wrapper_dir_in_path, _get_wrapper_dir, + ) + for entry in sorted(profiles_dir.iterdir()): + # Only create wrappers for directories with config + if not entry.is_dir() or not any((entry / m).exists() for m in ("config.yaml", ".env")): + continue + profile_name = entry.name + collision = check_alias_collision(profile_name) + if collision: + print(f" Skipped alias '{profile_name}': {collision}") + restored_profiles.append( + (profile_name, not collision and create_wrapper_script(profile_name) is not None) + ) + + if restored_profiles: + created = [n for n, ok in restored_profiles if ok] + skipped = [n for n, ok in restored_profiles if not ok] + if created: + print(f"\n Profile aliases restored: {', '.join(created)}") + if skipped: + print(f" Profile aliases skipped: {', '.join(skipped)}") + if not _is_wrapper_dir_in_path(): + print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.") + print(' Add to your shell config (~/.bashrc or ~/.zshrc):') + print(' export PATH="$HOME/.local/bin:$PATH"') + except ImportError: + # hermes_cli.profiles might not be available (fresh install) + if any(profiles_dir.iterdir()): + print("\n Profiles detected but aliases could not be created.") + print(" Run: hermes profile list (after installing hermes)") + return [n for n, _ in restored_profiles] + + +def _revive_gateway_after_import(hermes_root: Path) -> None: + """Bring the restored install to life: install/start the gateway service, best-effort. + + The backup may contain bot tokens and registered cron jobs, but they're inert without a + gateway process. A platform-less gateway is a supported mode, so this is safe even for backups + with no messaging config; prompt-free, and failures print a manual fallback, never fail the + import. A restore into a sandbox or profile home must not silently install a second gateway + pointed at it — on the default service name that would shadow or hijack the machine's primary + install — so the service is only revived when the restore landed in the default home, or when + no other install exists on this machine. + """ + native_default = _get_platform_default_hermes_home() + default_has_install = any( + (native_default / marker).exists() for marker in ("config.yaml", ".env", "state.db") + ) + if hermes_root != native_default and default_has_install: + print( + "\nRestored into a non-default home; leaving the gateway service " + "alone to avoid clashing with the install at " + f"{native_default}." + ) + print("To start a gateway for this home, run: hermes gateway install") + return + try: + from hermes_cli.gateway import ensure_gateway_service, _is_service_running + + if not _is_service_running(): + print() + ensure_gateway_service(context="import") + except Exception: + print("\nStart the gateway to activate cron jobs and messaging:") + print(" hermes gateway install") + + # --------------------------------------------------------------------------- # Quick state snapshots (used by /snapshot slash command and hermes backup --quick) # --------------------------------------------------------------------------- @@ -1608,63 +1370,106 @@ def create_quick_snapshot( """Create one atomic quick snapshot while holding the shared backup slot.""" home = hermes_home or get_hermes_home() with _backup_operation_lock(home): - return _create_quick_snapshot_locked( - label=label, - hermes_home=home, - keep=keep, - max_file_size=max_file_size, - ) + return _create_quick_snapshot_locked(label, home, keep, max_file_size) + + +def _quick_snapshot_candidates(home: Path): + """Yield ``(src, rel_posix, in_dir)`` for every regular file a quick snapshot captures. + + Directory entries of ``_QUICK_STATE_FILES`` are walked so restore can treat every file + uniformly; empty dirs are skipped. Heavy, regenerable per-board subtrees (scratch workspaces + and task attachments) are skipped — only the board databases + metadata are needed. + """ + for rel in _QUICK_STATE_FILES: + src = home / rel + if not src.exists(): + continue + if src.is_dir(): + for sub in src.rglob("*"): + if not sub.is_file(): + continue + sub_rel = sub.relative_to(home).as_posix() + if "/workspaces/" in f"/{sub_rel}/" or "/attachments/" in f"/{sub_rel}/": + continue + yield sub, sub_rel, True + elif src.is_file(): + yield src, rel, False + + +def _copy_quick_snapshot_files( + home: Path, staging_dir: Path, max_file_size: Optional[int] +) -> tuple[Dict[str, int], list[str], list[str]]: + """Copy every quick-snapshot candidate into *staging_dir*. + + Returns ``(manifest, failed_dbs, oversized_skipped)``: ``manifest`` maps rel_path -> file size; + ``failed_dbs`` lists present ``*.db`` that could not be snapshotted; ``oversized_skipped`` lists + protected DB files skipped for size (#68805) — those are snapshot incompleteness just like a + failed copy, so the caller must suppress pruning to preserve the older complete snapshot that + may contain the only recoverable database. + """ + manifest: Dict[str, int] = {} + failed_dbs: list[str] = [] + oversized_skipped: list[str] = [] + + for src, rel, in_dir in _quick_snapshot_candidates(home): + if max_file_size is not None: + try: + size = src.stat().st_size + except OSError: + size = None + if size is not None and size > max_file_size: + print( + f" ⚠ Snapshot: skipping {rel} " + f"({_format_size(size)} exceeds {_format_size(max_file_size)} limit)" + ) + logger.warning( + "Quick snapshot skipped %s: %d bytes exceeds %d byte limit", rel, size, max_file_size + ) + if src.suffix == ".db": + oversized_skipped.append(rel) + continue + + dst = staging_dir / rel + dst.parent.mkdir(parents=True, exist_ok=True) + try: + # Route SQLite DBs through the WAL-safe backup() path so a DB with + # an open WAL (the gateway may hold it at snapshot time) is + # captured consistently. + if src.suffix == ".db": + if not _safe_copy_db(src, dst): + failed_dbs.append(rel) + print( + f" ⚠ Snapshot: SQLite safe copy FAILED for {rel} " + f"— file may be locked or corrupted" + ) + if is_zeroed_sqlite_file(src): + nuls = " of NULs?" if in_dir else "" + print( + f" ⚠ Snapshot: {rel} looks ZEROED " + f"(no SQLite header; {src.stat().st_size} bytes{nuls})" + ) + continue + else: + shutil.copy2(src, dst) + manifest[rel] = dst.stat().st_size + except (OSError, PermissionError) as exc: + logger.warning("Could not snapshot %s: %s", rel, exc) + return manifest, failed_dbs, oversized_skipped def _create_quick_snapshot_locked( - label: Optional[str] = None, - hermes_home: Optional[Path] = None, - keep: Optional[int] = None, - max_file_size: Optional[int] = None, + label: Optional[str], hermes_home: Optional[Path], keep: Optional[int], max_file_size: Optional[int] ) -> Optional[str]: """Create a quick state snapshot of critical files. - Copies STATE_FILES to a timestamped directory under state-snapshots/. - Auto-prunes old snapshots beyond the keep limit. - - Args: - max_file_size: When set, individual files larger than this many bytes - are skipped (with a printed warning) instead of copied. Used by - the pre-update safety snapshot so a multi-GB ``state.db`` can - never stall ``hermes update`` or silently eat disk — the small - pairing/cron/config files the snapshot exists to protect are - always captured. ``None`` (default) copies everything, which - preserves manual ``/snapshot`` and ``hermes backup --quick`` - behavior. - - Returns: - Snapshot ID (timestamp-based), or None if no files found. + Copies STATE_FILES to a timestamped directory under state-snapshots/ and prunes old snapshots. + ``max_file_size`` skips (with a warning) files above that many bytes; the pre-update snapshot + uses it so a multi-GB ``state.db`` can never stall ``hermes update`` while the small + pairing/cron/config files are always captured. ``None`` copies everything. """ home = hermes_home or get_hermes_home() root = _quick_snapshot_root(home) - def _too_large(path: Path, rel_name: str) -> bool: - """True (and warn) when ``path`` exceeds the max_file_size cap.""" - if max_file_size is None: - return False - try: - size = path.stat().st_size - except OSError: - return False - if size <= max_file_size: - return False - print( - f" ⚠ Snapshot: skipping {rel_name} " - f"({_format_size(size)} exceeds {_format_size(max_file_size)} limit)" - ) - logger.warning( - "Quick snapshot skipped %s: %d bytes exceeds %d byte limit", - rel_name, - size, - max_file_size, - ) - return True - ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") base_snap_id = f"{ts}-{label}" if label else ts snap_id = base_snap_id @@ -1678,118 +1483,21 @@ def _create_quick_snapshot_locked( staging_dir.mkdir(parents=True, exist_ok=False) logger.info("quick snapshot phase=copy status=started id=%s", snap_id) - manifest: Dict[str, int] = {} # rel_path -> file size - failed_dbs: list[str] = [] # present *.db that could not be snapshotted - # #68805: track protected DB files skipped for size — they are snapshot - # incompleteness just like a failed copy, so pruning must be suppressed - # to preserve the older complete snapshot that may contain the only - # recoverable database. - oversized_skipped: list[str] = [] - - for rel in _QUICK_STATE_FILES: - src = home / rel - if not src.exists(): - continue - - if src.is_dir(): - # Walk the directory and record each file individually in the - # manifest so restore can treat them uniformly. Empty dirs are - # skipped (nothing to snapshot). - for sub in src.rglob("*"): - if not sub.is_file(): - continue - sub_rel = sub.relative_to(home).as_posix() - # Skip heavy, regenerable per-board subtrees (scratch - # workspaces and task attachments can be large); we only need - # the board databases + their metadata to restore a board. - if "/workspaces/" in f"/{sub_rel}/" or "/attachments/" in f"/{sub_rel}/": - continue - if _too_large(sub, sub_rel): - if sub.suffix == ".db": - oversized_skipped.append(sub_rel) - continue - dst = staging_dir / sub_rel - dst.parent.mkdir(parents=True, exist_ok=True) - try: - # Route SQLite DBs through the WAL-safe backup() path so a - # board DB with an open WAL (the gateway may hold it at - # snapshot time) is captured consistently. - if sub.suffix == ".db": - if not _safe_copy_db(sub, dst): - failed_dbs.append(sub_rel) - print( - f" ⚠ Snapshot: SQLite safe copy FAILED for {sub_rel} " - f"— file may be locked or corrupted" - ) - if is_zeroed_sqlite_file(sub): - print( - f" ⚠ Snapshot: {sub_rel} looks ZEROED " - f"(no SQLite header; {sub.stat().st_size} bytes of NULs?)" - ) - continue - else: - shutil.copy2(sub, dst) - manifest[sub_rel] = dst.stat().st_size - except (OSError, PermissionError) as exc: - logger.warning("Could not snapshot %s: %s", sub_rel, exc) - continue - - if not src.is_file(): - continue - - if _too_large(src, rel): - if src.suffix == ".db": - oversized_skipped.append(rel) - continue - - dst = staging_dir / rel - dst.parent.mkdir(parents=True, exist_ok=True) - - try: - if src.suffix == ".db": - if not _safe_copy_db(src, dst): - failed_dbs.append(rel) - print( - f" ⚠ Snapshot: SQLite safe copy FAILED for {rel} " - f"— file may be locked or corrupted" - ) - if is_zeroed_sqlite_file(src): - print( - f" ⚠ Snapshot: {rel} looks ZEROED " - f"(no SQLite header; {src.stat().st_size} bytes)" - ) - continue - else: - shutil.copy2(src, dst) - manifest[rel] = dst.stat().st_size - except (OSError, PermissionError) as exc: - logger.warning("Could not snapshot %s: %s", rel, exc) + manifest, failed_dbs, oversized_skipped = _copy_quick_snapshot_files(home, staging_dir, max_file_size) if failed_dbs: # Critical: update path used to log-and-continue with exit 0, so a # missing state.db backup looked like a successful pre-update snapshot # (#68474). Surface this on stdout where operators actually look. - print( - " ⚠ CRITICAL: could not snapshot DB file(s): " - + ", ".join(failed_dbs) - ) - print( - " ⚠ If sessions disappear after update, check " - f"{root} and run: hermes snapshot list" - ) - logger.error( - "Quick snapshot failed to capture DB file(s): %s", - ", ".join(failed_dbs), - ) + print(" ⚠ CRITICAL: could not snapshot DB file(s): " + ", ".join(failed_dbs)) + print(f" ⚠ If sessions disappear after update, check {root} and run: hermes snapshot list") + logger.error("Quick snapshot failed to capture DB file(s): %s", ", ".join(failed_dbs)) if not manifest: shutil.rmtree(staging_dir, ignore_errors=True) if failed_dbs: # Distinguish "nothing to snapshot" from "state.db present but unreadable" - print( - " ⚠ Snapshot aborted: no files captured " - f"(failed DBs: {', '.join(failed_dbs)})" - ) + print(f" ⚠ Snapshot aborted: no files captured (failed DBs: {', '.join(failed_dbs)})") return None # Write manifest @@ -1814,8 +1522,7 @@ def _create_quick_snapshot_locked( # #68805 review: skip pruning when a present DB failed to capture OR was # skipped for size — either way the snapshot is incomplete and the older # snapshot may contain the only recoverable database. - incomplete = failed_dbs or oversized_skipped - if not incomplete: + if not (failed_dbs or oversized_skipped): _prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP if keep is None else keep) else: if oversized_skipped: @@ -1823,10 +1530,7 @@ def _create_quick_snapshot_locked( " ⚠ Skipping snapshot prune: DB file(s) skipped for size: " + ", ".join(oversized_skipped) ) - logger.warning( - "Quick snapshot skipped oversized DB file(s): %s", - ", ".join(oversized_skipped), - ) + logger.warning("Quick snapshot skipped oversized DB file(s): %s", ", ".join(oversized_skipped)) logger.warning( "Skipping snapshot prune because %d DB(s) failed to capture " "and/or %d were oversized — preserving older snapshots as " @@ -1836,26 +1540,27 @@ def _create_quick_snapshot_locked( logger.info( "quick snapshot phase=copy status=complete id=%s files=%d bytes=%d", - snap_id, - len(manifest), - sum(manifest.values()), + snap_id, len(manifest), sum(manifest.values()), ) return snap_id -def list_quick_snapshots( - limit: int = 20, - hermes_home: Optional[Path] = None, -) -> List[Dict[str, Any]]: - """List existing quick state snapshots, most recent first.""" - root = _quick_snapshot_root(hermes_home) +def _snapshot_dirs(root: Path) -> List[Path]: + """Published snapshot directories under *root*, newest (by name) first.""" if not root.exists(): return [] + return sorted( + (d for d in root.iterdir() + if d.is_dir() and not d.name.startswith(".") and not d.name.endswith(".partial")), + key=lambda d: d.name, + reverse=True, + ) + +def list_quick_snapshots(limit: int = 20, hermes_home: Optional[Path] = None) -> List[Dict[str, Any]]: + """List existing quick state snapshots, most recent first.""" results = [] - for d in sorted(root.iterdir(), reverse=True): - if not d.is_dir() or d.name.startswith(".") or d.name.endswith(".partial"): - continue + for d in _snapshot_dirs(_quick_snapshot_root(hermes_home)): manifest_path = d / "manifest.json" if manifest_path.exists(): try: @@ -1869,15 +1574,8 @@ def list_quick_snapshots( return results -def restore_quick_snapshot( - snapshot_id: str, - hermes_home: Optional[Path] = None, -) -> bool: - """Restore state from a quick snapshot. - - Overwrites current state files with the snapshot's copies. - Returns True if at least one file was restored. - """ +def restore_quick_snapshot(snapshot_id: str, hermes_home: Optional[Path] = None) -> bool: + """Restore state from a quick snapshot.""" home = hermes_home or get_hermes_home() root = _quick_snapshot_root(home) @@ -1896,11 +1594,8 @@ def restore_quick_snapshot( logger.error("Snapshot path traversal blocked for id: %s", snapshot_id) return False - if not snap_dir.is_dir(): - return False - manifest_path = snap_dir / "manifest.json" - if not manifest_path.exists(): + if not snap_dir.is_dir() or not manifest_path.exists(): return False with open(manifest_path, encoding="utf-8") as f: @@ -1910,24 +1605,16 @@ def restore_quick_snapshot( for rel in meta.get("files", {}): # Security: reject absolute paths and traversals in manifest entries src = snap_dir / rel - try: - src.resolve().relative_to(snap_dir.resolve()) - except ValueError: - logger.error("Manifest path traversal blocked: %s", rel) - continue - dst = home / rel try: + src.resolve().relative_to(snap_dir.resolve()) dst.resolve().relative_to(home.resolve()) except ValueError: logger.error("Manifest path traversal blocked: %s", rel) continue - if not src.exists(): continue - dst.parent.mkdir(parents=True, exist_ok=True) - try: if dst.suffix == ".db": # Restore through SQLite backup API so live connections @@ -1953,14 +1640,9 @@ _CRON_JOBS_REL = "cron/jobs.json" def _count_cron_jobs(path: Path) -> Optional[int]: """Return the number of cron jobs stored in ``path``. - The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``). - A legacy bare-list shape (``[...]``) is also honoured. - - Returns: - The job count for any *valid, readable* JSON document, or ``None`` if - the file is missing or cannot be parsed. ``None`` means "unknown" — - callers must not treat it as "zero jobs", because acting on an - unreadable file could mask a real corruption the user needs to see. + Accepts the canonical ``{"jobs": [...]}`` shape and the legacy bare list. Returns ``None`` if + the file is missing or unparseable; callers must treat ``None`` as "unknown", not zero, + since acting on an unreadable file could mask a real corruption the user needs to see. """ if not path.is_file(): return None @@ -1974,11 +1656,8 @@ def _count_cron_jobs(path: Path) -> Optional[int]: except (OSError, json.JSONDecodeError): return None if isinstance(data, dict): - jobs = data.get("jobs", []) - return len(jobs) if isinstance(jobs, list) else None - if isinstance(data, list): - return len(data) - return None + data = data.get("jobs", []) + return len(data) if isinstance(data, list) else None def restore_cron_jobs_if_emptied( @@ -1987,31 +1666,10 @@ def restore_cron_jobs_if_emptied( ) -> Optional[Dict[str, Any]]: """Safety net for silent cron-job loss across ``hermes update``. - Config-version migrations have been observed to leave ``cron/jobs.json`` - valid-but-empty after an update, silently dropping every scheduled job - (issue #34600). The desktop scheduler can also overwrite the file with its - own small set of internally-tracked crons, causing partial loss (issue - #52144). - - This compares the *current* job count against the pre-update snapshot. If - the live file now has **fewer** jobs than the snapshot, the snapshot copy - of ``cron/jobs.json`` is restored in place. - - The check is deliberately conservative — it only ever restores when there - is unambiguous evidence of loss (snapshot had more jobs than live file), - so a user who genuinely deleted jobs during/after the update is never - second-guessed, and an unreadable live file (count ``None``) is left - untouched so real corruption still surfaces. - - Args: - snapshot_id: The pre-update quick-snapshot id (from - :func:`create_quick_snapshot`). - hermes_home: Override for the Hermes home directory (tests). - - Returns: - ``None`` when no action was taken (the common, healthy path). On a - successful restore, a dict ``{"restored": True, "job_count": N, - "snapshot_id": ...}`` so the caller can warn the user. + The check is deliberately conservative — it only ever restores when there is unambiguous + evidence of loss (snapshot had more jobs than live file), so a user who genuinely deleted jobs + during/after the update is never second-guessed, and an unreadable live file (count ``None``) is + left untouched so real corruption still surfaces. """ if not snapshot_id: return None @@ -2041,18 +1699,13 @@ def restore_cron_jobs_if_emptied( live_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(snap_path, live_path) except (OSError, PermissionError) as exc: - logger.error( - "Cron jobs were emptied during update but auto-restore failed: %s", exc - ) + logger.error("Cron jobs were emptied during update but auto-restore failed: %s", exc) return None logger.warning( "Restored %d cron job(s) from pre-update snapshot %s " "(live file had %d job(s), snapshot had %d — jobs were lost during migration)", - snap_count, - snapshot_id, - live_count, - snap_count, + snap_count, snapshot_id, live_count, snap_count, ) return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id} @@ -2060,9 +1713,9 @@ def restore_cron_jobs_if_emptied( def _sibling_profile_homes(invoking_home: Path) -> list[tuple[str, Path]]: """(name, home) for every OTHER profile on this install. Never raises. - The update's code swap and gateway fleet restart touch every profile, - so the pre-update snapshot must too (#66140). The invoking profile is - excluded — its snapshot is taken by the existing call. + The update's code swap and gateway fleet restart touch every profile, so the pre-update snapshot + must too (#66140). The invoking profile is excluded — its snapshot is taken by the existing + call. """ homes: list[tuple[str, Path]] = [] try: @@ -2098,22 +1751,17 @@ def create_pre_update_snapshots_all_profiles( ) -> Dict[str, str]: """Pre-update quick snapshots for every SIBLING profile (#66140). - Same snapshot set, same per-file size cap, same keep policy as the - invoking profile's snapshot — identical semantics per profile, no - partial-tier coherence class. Each sibling's snapshot lands under its - OWN ``/state-snapshots/`` so per-profile restore tooling finds - it where it expects. Returns ``{profile_name: snapshot_id}`` for the - siblings that snapshotted successfully. Never raises. + Same snapshot set, same per-file size cap, same keep policy as the invoking profile's snapshot — + identical semantics per profile, no partial-tier coherence class. Each sibling's snapshot lands + under its OWN ``/state-snapshots/`` so per-profile restore tooling finds it where it + expects. """ results: Dict[str, str] = {} home = invoking_home or get_hermes_home() for name, profile_home in _sibling_profile_homes(home): try: snap_id = create_quick_snapshot( - label="pre-update", - hermes_home=profile_home, - keep=keep, - max_file_size=max_file_size, + label="pre-update", hermes_home=profile_home, keep=keep, max_file_size=max_file_size ) if snap_id: results[name] = snap_id @@ -2176,29 +1824,12 @@ def restore_config_model_settings_if_rewritten( ) -> Optional[Dict[str, Any]]: """Safety net for silent config.yaml model/MoA loss across ``hermes update``. - Desktop update/repair cycles have been observed to rewrite user-set - ``model.provider``/``model.default`` and drop the ``moa:`` section - entirely (issue #64160; the macOS repair/relaunch variant rewrote a - pinned ``model.default`` to a transient composer pick). These keys are - consumed by the gateway and unattended cron jobs too, so a rewrite - silently changes paid inference behavior machine-wide. + These keys are consumed by the gateway and unattended cron jobs too, so a rewrite silently + changes paid inference behavior machine-wide. - Mirrors :func:`restore_cron_jobs_if_emptied`: compare the *current* - config against the pre-update snapshot taken minutes earlier by this - same update run, and restore only the protected keys — never the whole - file — when a value the user had set was changed or dropped. Everything - the update legitimately wrote (version stamps, new sections) is left in - place. - - Args: - snapshot_id: The pre-update quick-snapshot id (from - :func:`create_quick_snapshot`). - hermes_home: Override for the Hermes home directory (tests/siblings). - - Returns: - ``None`` when no action was taken (the common, healthy path). On a - successful restore, ``{"restored": True, "keys": [...], - "snapshot_id": ...}`` so the caller can warn the user. + Mirrors :func:`restore_cron_jobs_if_emptied`: compare the *current* config against the pre- + update snapshot taken minutes earlier by this same update run, and restore only the protected + keys — never the whole file — when a value the user had set was changed or dropped. """ if not snapshot_id: return None @@ -2251,16 +1882,16 @@ def restore_config_model_settings_if_rewritten( return {"restored": True, "keys": restored_keys, "snapshot_id": snapshot_id} -def restore_config_model_settings_all_profiles( +def _restore_all_sibling_profiles( profile_snapshots: Dict[str, str], - invoking_home: Optional[Path] = None, + invoking_home: Optional[Path], + restore_fn, + failure_log: str, ) -> list[Dict[str, Any]]: - """Run the config model-settings safety net for every sibling profile. + """Run a per-profile safety net (``restore_fn(snap_id, hermes_home=...)``) for every sibling. - Same contract as :func:`restore_cron_jobs_all_profiles`: each profile's - live ``config.yaml`` is compared against ITS OWN same-generation - pre-update snapshot. Returns one result dict per restored profile, each - with a ``profile`` key added. Never raises. + Each profile's live file is compared against ITS OWN same-generation pre-update snapshot. + Returns one result dict per restored profile, each with a ``profile`` key added. Never raises. """ restored: list[Dict[str, Any]] = [] if not profile_snapshots: @@ -2272,15 +1903,9 @@ def restore_config_model_settings_all_profiles( if profile_home is None: continue try: - result = restore_config_model_settings_if_rewritten( - snap_id, hermes_home=profile_home - ) + result = restore_fn(snap_id, hermes_home=profile_home) except Exception as exc: - logger.debug( - "Config model-settings restore check for profile %s failed: %s", - name, - exc, - ) + logger.debug(failure_log, name, exc) continue if result: result["profile"] = name @@ -2288,69 +1913,60 @@ def restore_config_model_settings_all_profiles( return restored +def restore_config_model_settings_all_profiles( + profile_snapshots: Dict[str, str], + invoking_home: Optional[Path] = None, +) -> list[Dict[str, Any]]: + """Run the config model-settings safety net for every sibling profile. + + Same contract as :func:`restore_cron_jobs_all_profiles`: each profile's live ``config.yaml`` is + compared against ITS OWN same-generation pre-update snapshot. Returns one result dict per + restored profile, each with a ``profile`` key added. Never raises. + """ + return _restore_all_sibling_profiles( + profile_snapshots, + invoking_home, + restore_config_model_settings_if_rewritten, + "Config model-settings restore check for profile %s failed: %s", + ) + + def restore_cron_jobs_all_profiles( profile_snapshots: Dict[str, str], invoking_home: Optional[Path] = None, ) -> list[Dict[str, Any]]: """Run the cron-jobs safety net for every sibling profile (#66140). - ``profile_snapshots`` is the map returned by - :func:`create_pre_update_snapshots_all_profiles`. Each profile's live - ``cron/jobs.json`` is compared against ITS OWN snapshot — restores are - same-generation by construction (the snapshot was taken minutes ago by - this update run). Returns one result dict per restored profile, each - with a ``profile`` key added. Never raises. + ``profile_snapshots`` comes from :func:`create_pre_update_snapshots_all_profiles`; each + profile's live ``cron/jobs.json`` is compared against ITS OWN snapshot, so restores are + same-generation by construction. Returns one result dict per restored profile. Never raises. """ - restored: list[Dict[str, Any]] = [] - if not profile_snapshots: - return restored - home = invoking_home or get_hermes_home() - by_name = dict(_sibling_profile_homes(home)) - for name, snap_id in profile_snapshots.items(): - profile_home = by_name.get(name) - if profile_home is None: - continue + return _restore_all_sibling_profiles( + profile_snapshots, + invoking_home, + restore_cron_jobs_if_emptied, + "Cron restore check for profile %s failed: %s", + ) + + +def _prune_oldest(newest_first: List[Path], keep: int, remove, what: str) -> int: + """``remove(path)`` every entry past the first *keep*; return how many succeeded.""" + deleted = 0 + for p in newest_first[keep:]: try: - result = restore_cron_jobs_if_emptied(snap_id, hermes_home=profile_home) - except Exception as exc: - logger.debug("Cron restore check for profile %s failed: %s", name, exc) - continue - if result: - result["profile"] = name - restored.append(result) - return restored + remove(p) + deleted += 1 + except OSError as exc: + logger.warning("Failed to prune %s %s: %s", what, p.name, exc) + return deleted def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int: """Remove oldest quick snapshots beyond the keep limit. Returns count deleted.""" - if not root.exists(): - return 0 - - dirs = sorted( - ( - d - for d in root.iterdir() - if d.is_dir() and not d.name.startswith(".") and not d.name.endswith(".partial") - ), - key=lambda d: d.name, - reverse=True, - ) - - deleted = 0 - for d in dirs[keep:]: - try: - shutil.rmtree(d) - deleted += 1 - except OSError as exc: - logger.warning("Failed to prune snapshot %s: %s", d.name, exc) - - return deleted + return _prune_oldest(_snapshot_dirs(root), keep, shutil.rmtree, "snapshot") -def prune_quick_snapshots( - keep: int = _QUICK_DEFAULT_KEEP, - hermes_home: Optional[Path] = None, -) -> int: +def prune_quick_snapshots(keep: int = _QUICK_DEFAULT_KEEP, hermes_home: Optional[Path] = None) -> int: """Manually prune quick snapshots. Returns count deleted.""" return _prune_quick_snapshots(_quick_snapshot_root(hermes_home), keep=keep) @@ -2373,7 +1989,12 @@ def run_quick_backup(args) -> None: # --------------------------------------------------------------------------- def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]: - """Single-flight wrapper for automatic full zip backups.""" + """Write a full zip snapshot of ``hermes_root`` to ``out_path`` while holding the backup slot. + + Uses the same exclusion rules and SQLite safe-copy as :func:`run_backup`. Returns the output + path on success, None on failure (nothing to back up, another backup running, or write error — + caller should surface the outcome but not raise). + """ try: with _backup_operation_lock(hermes_root): return _write_full_zip_backup_locked(out_path, hermes_root) @@ -2383,12 +2004,6 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]: def _write_full_zip_backup_locked(out_path: Path, hermes_root: Path) -> Optional[Path]: - """Write a full zip snapshot of ``hermes_root`` to ``out_path``. - - Uses the same exclusion rules and SQLite safe-copy as :func:`run_backup`. - Returns the output path on success, None on failure (nothing to back up, - or write error — caller should surface the outcome but not raise). - """ scan_started = time.monotonic() logger.info("automatic backup phase=scan status=started") try: @@ -2407,42 +2022,25 @@ def _write_full_zip_backup_locked(out_path: Path, hermes_root: Path) -> Optional ) archive_started = time.monotonic() + + def _db_failure(rel_path: Path) -> None: + logger.warning("Full-zip backup aborted: SQLite snapshot failed for %s", rel_path) + raise _SQLiteSnapshotError(str(rel_path)) + try: with _atomic_output_path(out_path) as archive_path, zipfile.ZipFile( archive_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6 ) as zf: - for index, (abs_path, rel_path) in enumerate(files_to_add, 1): - try: - if abs_path.suffix == ".db": - # Stage the snapshot alongside the output zip so that the - # temp file lives on the same filesystem. The system - # default (/tmp) may be a small tmpfs that cannot hold - # large databases, causing silent backup incompleteness. - with tempfile.NamedTemporaryFile( - suffix=".db", delete=False, dir=str(out_path.parent) - ) as tmp: - tmp_db = Path(tmp.name) - try: - if not _safe_copy_db(abs_path, tmp_db): - logger.warning( - "Full-zip backup aborted: SQLite snapshot failed for %s", - rel_path, - ) - raise _SQLiteSnapshotError(str(rel_path)) - zf.write(tmp_db, arcname=str(rel_path)) - finally: - tmp_db.unlink(missing_ok=True) - else: - zf.write(abs_path, arcname=str(rel_path)) - except (PermissionError, OSError, ValueError) as exc: - logger.debug("Skipping %s in zip backup: %s", rel_path, exc) - continue - if index % 500 == 0: - logger.info( - "automatic backup phase=archive status=progress completed=%d total=%d", - index, - len(files_to_add), - ) + _write_zip_entries( + zf, files_to_add, out_path, + on_db_failure=_db_failure, + on_error=lambda rel, exc: logger.debug("Skipping %s in zip backup: %s", rel, exc), + on_progress=lambda i: logger.info( + "automatic backup phase=archive status=progress completed=%d total=%d", + i, len(files_to_add), + ), + track_bytes=False, + ) except (OSError, _SQLiteSnapshotError) as exc: logger.warning("Full-zip backup: zip write failed: %s", exc) # ``_atomic_output_path`` already removed the hidden partial. Do not @@ -2469,46 +2067,54 @@ _PRE_UPDATE_PREFIX = "pre-update-" _PRE_UPDATE_DEFAULT_KEEP = 5 -def _pre_update_backup_dir(hermes_home: Optional[Path] = None) -> Path: - home = hermes_home or get_hermes_home() - return home / _PRE_UPDATE_BACKUPS_DIR +def _prune_prefixed_zips(backup_dir: Path, prefix: str, keep: int, what: str) -> int: + """Remove oldest ``*.zip`` files in *backup_dir* beyond the keep limit. + Returns the number of files deleted. Only touches files matching the prefix so hand-made zips + or other backup kinds dropped in the same directory are never touched. -def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int: - """Remove oldest pre-update backups beyond the keep limit. - - Returns the number of files deleted. Only touches files matching - ``pre-update-*.zip`` so hand-made zips dropped in the same directory - are never touched. - - ``keep`` is floored to 1 because this helper is only called immediately - after a fresh backup is written: deleting that backup right after the - user paid the disk/CPU cost to create it would leave them worse off - than no backup at all (and the wrapper in ``main.py`` would still print - a misleading ``Saved: `` line for a file that no longer exists). - Operators who genuinely don't want a backup should set - ``updates.pre_update_backup: off`` in config — that gates creation. + Operators who genuinely don't want a backup should set ``updates.pre_update_backup: off`` in + config — that gates creation. """ - keep = max(keep, 1) if not backup_dir.exists(): return 0 backups = sorted( (p for p in backup_dir.iterdir() - if p.is_file() and p.name.startswith(_PRE_UPDATE_PREFIX) and p.suffix.lower() == ".zip"), + if p.is_file() and p.name.startswith(prefix) and p.suffix.lower() == ".zip"), key=lambda p: p.name, reverse=True, ) + return _prune_oldest(backups, keep, Path.unlink, what) - deleted = 0 - for p in backups[keep:]: - try: - p.unlink() - deleted += 1 - except OSError as exc: - logger.warning("Failed to prune backup %s: %s", p.name, exc) - return deleted +def _create_prefixed_full_backup( + hermes_home: Optional[Path], prefix: str, keep: int, what: str, prune_what: str +) -> Optional[Path]: + """Write ``/backups/.zip`` and prune older same-prefix zips. + + Returns the created path, or ``None`` if nothing was found to back up or the write failed. + Never raises. + """ + hermes_root = hermes_home or get_default_hermes_root() + if not hermes_root.is_dir(): + return None + + backup_dir = hermes_root / _PRE_UPDATE_BACKUPS_DIR + try: + backup_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + logger.warning("Could not create %s backup dir %s: %s", what, backup_dir, exc) + return None + + stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") + out_path = backup_dir / f"{prefix}{stamp}.zip" + + if _write_full_zip_backup(out_path, hermes_root) is None: + return None + + _prune_prefixed_zips(backup_dir, prefix, keep, prune_what) + return out_path def create_pre_update_backup( @@ -2517,34 +2123,16 @@ def create_pre_update_backup( ) -> Optional[Path]: """Create a full zip backup of HERMES_HOME under ``backups/``. - Mirrors :func:`run_backup` (same exclusion rules, same SQLite safe-copy) - but writes to ``/backups/pre-update-.zip`` and - auto-prunes old pre-update backups. + Mirrors :func:`run_backup` (same exclusion rules, same SQLite safe-copy) but writes to + ``/backups/pre-update-.zip`` and auto-prunes old pre-update backups. - Returns the path to the created zip, or ``None`` if no files were - found or the backup could not be created. Never raises — the caller - (``hermes update``) should continue even if the backup fails. + Returns the path to the created zip, or ``None`` if no files were found or the backup could not + be created. Never raises — the caller (``hermes update``) should continue even if the backup + fails. """ - hermes_root = hermes_home or get_default_hermes_root() - if not hermes_root.is_dir(): - return None - - backup_dir = _pre_update_backup_dir(hermes_root) - try: - backup_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - logger.warning("Could not create pre-update backup dir %s: %s", backup_dir, exc) - return None - - stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - out_path = backup_dir / f"{_PRE_UPDATE_PREFIX}{stamp}.zip" - - result = _write_full_zip_backup(out_path, hermes_root) - if result is None: - return None - - _prune_pre_update_backups(backup_dir, keep=keep) - return out_path + return _create_prefixed_full_backup( + hermes_home, _PRE_UPDATE_PREFIX, max(keep, 1), "pre-update", "backup" + ) # --------------------------------------------------------------------------- @@ -2555,70 +2143,21 @@ _PRE_MIGRATION_PREFIX = "pre-migration-" _PRE_MIGRATION_DEFAULT_KEEP = 5 -def _prune_pre_migration_backups(backup_dir: Path, keep: int) -> int: - """Remove oldest pre-migration backups beyond the keep limit. - - Only touches files matching ``pre-migration-*.zip`` so other backups in - the same directory are never touched. - """ - keep = max(keep, 0) - if not backup_dir.exists(): - return 0 - - backups = sorted( - (p for p in backup_dir.iterdir() - if p.is_file() and p.name.startswith(_PRE_MIGRATION_PREFIX) and p.suffix.lower() == ".zip"), - key=lambda p: p.name, - reverse=True, - ) - - deleted = 0 - for p in backups[keep:]: - try: - p.unlink() - deleted += 1 - except OSError as exc: - logger.warning("Failed to prune pre-migration backup %s: %s", p.name, exc) - - return deleted - - def create_pre_migration_backup( hermes_home: Optional[Path] = None, keep: int = _PRE_MIGRATION_DEFAULT_KEEP, ) -> Optional[Path]: - """Create a full zip backup of HERMES_HOME under ``backups/`` before a - ``hermes claw migrate`` apply. + """Create a full zip backup of HERMES_HOME under ``backups/`` before a ``hermes claw migrate`` apply. - Shares implementation with :func:`create_pre_update_backup` via - ``_write_full_zip_backup`` — same exclusions, same SQLite safe-copy, - restorable with ``hermes import ``. Writes to - ``/backups/pre-migration-.zip`` and auto-prunes - old pre-migration backups. + Shares implementation with :func:`create_pre_update_backup` via ``_write_full_zip_backup`` — + same exclusions, same SQLite safe-copy, restorable with ``hermes import ``. Writes to + ``/backups/pre-migration-.zip`` (the shared ``backups/`` directory, so + ``hermes import`` and the update-backup listing pick up pre-migration archives too) and + auto-prunes old pre-migration backups. - Returns the path to the created zip, or ``None`` if nothing was found - to back up (fresh install) or the write failed. Never raises — the - caller decides whether to abort or proceed. + Returns the path to the created zip, or ``None`` if nothing was found to back up (fresh install) + or the write failed. Never raises — the caller decides whether to abort or proceed. """ - hermes_root = hermes_home or get_default_hermes_root() - if not hermes_root.is_dir(): - return None - - # Reuses the shared backups/ directory so `hermes import` and the - # update-backup listing pick up pre-migration archives too. - backup_dir = _pre_update_backup_dir(hermes_root) - try: - backup_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - logger.warning("Could not create pre-migration backup dir %s: %s", backup_dir, exc) - return None - - stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") - out_path = backup_dir / f"{_PRE_MIGRATION_PREFIX}{stamp}.zip" - - result = _write_full_zip_backup(out_path, hermes_root) - if result is None: - return None - - _prune_pre_migration_backups(backup_dir, keep=keep) - return out_path + return _create_prefixed_full_backup( + hermes_home, _PRE_MIGRATION_PREFIX, max(keep, 0), "pre-migration", "pre-migration backup" + ) diff --git a/hermes_cli/bang_shell.py b/hermes_cli/bang_shell.py index def25e5c43..31261f3d7c 100644 --- a/hermes_cli/bang_shell.py +++ b/hermes_cli/bang_shell.py @@ -1,18 +1,12 @@ """``!`` shell mode for the interactive CLI. -Typing ``!git status`` at the composer runs the command directly in the -session's working directory. The model is never invoked: no user message, no -assistant message, no tool result enters the conversation history, so a bang -command costs zero tokens and cannot perturb role alternation or the prompt -cache. +Typing ``!git status`` at the composer runs the command directly in the session's working directory. +The model is never invoked: no user message, no assistant message, no tool result enters the +conversation history, so a bang command costs zero tokens and cannot perturb role alternation or the +prompt cache. -A user-typed command still goes through the SAME dangerous-pattern approval -gate the terminal tool uses (``tools.approval.check_all_command_guards``), -reached here through ``tools.terminal_tool._check_all_guards`` so the CLI -approval callback and Docker host-access handling behave identically. - -CLI-only by design: gateway/API/cron sessions have their own shells and no -composer, so :func:`bang_shell_enabled` gates the feature off there. +CLI-only by design: gateway/API/cron sessions have their own shells and no composer, so +:func:`bang_shell_enabled` gates the feature off there. """ from __future__ import annotations @@ -32,36 +26,30 @@ DEFAULT_TIMEOUT = 120 def is_bang_command(text: Optional[str]) -> bool: """Return True when *text* is a ``!`` shell-mode submission. - Only a leading ``!`` (after surrounding whitespace) counts. A line that - merely *contains* ``!`` mid-text (``fix the bug!``, ``echo hi!``) is an - ordinary prompt and must reach the agent untouched. + Only a leading ``!`` (after surrounding whitespace) counts. A line that merely *contains* ``!`` + mid-text (``fix the bug!``, ``echo hi!``) is an ordinary prompt and must reach the agent + untouched. """ - if not isinstance(text, str): - return False - return text.strip().startswith("!") + return isinstance(text, str) and text.strip().startswith("!") def parse_bang_command(text: str) -> str: """Return the shell command inside a bang submission (``""`` when bare). - ``!ls`` → ``ls``; ``! ls -la`` → ``ls -la``; ``!!`` → ``!`` (a literal - second bang is part of the command, e.g. history expansion the user's - shell will handle); ``!`` alone → ``""``. + ``! ls -la`` -> ``ls -la``; ``!!`` -> ``!`` -- a literal second bang is part of the command + (e.g. history expansion for the user's shell), not a Hermes token. """ - if not isinstance(text, str): + if not is_bang_command(text): return "" - stripped = text.strip() - if not stripped.startswith("!"): - return "" - return stripped[1:].strip() + return text.strip()[1:].strip() def bang_shell_enabled() -> bool: """True only for interactive local CLI sessions. - Gateway, API, and cron sessions never reach the composer and their users - already have a shell; running arbitrary commands for them would be a - remote-execution surface with no approving human at the keyboard. + Gateway, API, and cron sessions never reach the composer and their users already have a shell; + running arbitrary commands for them would be a remote-execution surface with no approving human + at the keyboard. """ try: from utils import env_var_enabled @@ -69,51 +57,38 @@ def bang_shell_enabled() -> bool: def env_var_enabled(name, default=""): # type: ignore[misc] return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"} - if env_var_enabled("HERMES_GATEWAY_SESSION"): - return False - if env_var_enabled("HERMES_CRON_SESSION"): - return False - if (os.getenv("HERMES_SESSION_PLATFORM") or "").strip(): - return False - return True + return not ( + env_var_enabled("HERMES_GATEWAY_SESSION") + or env_var_enabled("HERMES_CRON_SESSION") + or (os.getenv("HERMES_SESSION_PLATFORM") or "").strip() + ) def resolve_bang_cwd(session_key: Optional[str] = None) -> Optional[str]: """Return the directory a bang command should run in. - Mirrors the terminal tool's resolution order so ``!pwd`` matches where the - agent's own commands land: the session's recorded ``cd`` state first - (``terminal_tool.get_session_cwd``, updated after every agent command), - then the configured ``TERMINAL_CWD``/backend default. ``None`` means "let - the subprocess inherit the process cwd". + Mirrors the terminal tool's resolution order so ``!pwd`` matches where the agent's own commands + land: the session's recorded ``cd`` state first (``terminal_tool.get_session_cwd``, updated + after every agent command), then the configured ``TERMINAL_CWD``/backend default. """ try: from tools.terminal_tool import _get_env_config, get_session_cwd - recorded = get_session_cwd(session_key) - if recorded: - return recorded - configured = (_get_env_config() or {}).get("cwd") - if configured: - return configured + return get_session_cwd(session_key) or (_get_env_config() or {}).get("cwd") or None except Exception: - pass - return None + return None def check_bang_approval(command: str) -> dict: """Run *command* through the terminal tool's approval gate. - Reuses ``tools.terminal_tool._check_all_guards`` — the exact function - ``terminal_tool()`` calls before executing anything — so the hardline - blocklist, user deny rules, tirith findings, and the interactive - dangerous-command prompt all apply to user-typed bang commands too. A - command the agent would need approval for still needs approval when the - user types it; ``!`` is a latency/cost shortcut, not a security bypass. + Reuses ``tools.terminal_tool._check_all_guards`` — the exact function ``terminal_tool()`` calls + before executing anything — so the hardline blocklist, user deny rules, tirith findings, and the + interactive dangerous-command prompt all apply to user-typed bang commands too. - Returns the gate's decision dict (``{"approved": bool, "message": ...}``). - Falls back to *approved* only when the gate itself cannot be imported, - which would mean a broken install rather than a policy decision. + Returns the gate's decision dict (``{"approved": bool, "message": ...}``). Falls back to + *approved* only when the gate itself cannot be imported, which would mean a broken install + rather than a policy decision. """ try: from tools.terminal_tool import _check_all_guards @@ -128,10 +103,9 @@ def check_bang_approval(command: str) -> dict: def _bang_env() -> dict: """Environment for a bang command, with Hermes-managed secrets filtered. - The CLI process holds every configured provider API key in ``os.environ``. - A bang command is user-typed, but it can still be a third-party script, so - reuse the same sanitizer ``quick_commands`` and the local terminal backend - use rather than handing the whole keyring to an arbitrary subprocess. + The CLI process holds every provider API key in ``os.environ``; a bang command is user-typed + but may still run a third-party script, so reuse the sanitizer ``quick_commands`` and the + local terminal backend use rather than hand the whole keyring to an arbitrary subprocess. """ try: from tools.environments.local import _sanitize_subprocess_env @@ -150,16 +124,16 @@ def run_bang_command( ) -> int: """Execute *command* and stream its output, returning the exit code. - stdout and stderr are merged and written through *writer* (defaults to - ``print``) as they arrive, so long-running commands show progress instead - of buffering to the end. Nothing is returned to a caller for insertion - into conversation history — the output exists only on the user's terminal. + stdout and stderr are merged and written through *writer* (defaults to ``print``) as they + arrive, so long-running commands show progress instead of buffering to the end. Nothing is + returned to a caller for insertion into conversation history — the output exists only on the + user's terminal. """ emit = writer or (lambda line: print(line, end="" if line.endswith("\n") else "\n")) - run_cwd = cwd if (cwd and os.path.isdir(os.path.expanduser(cwd))) else None - if run_cwd: - run_cwd = os.path.expanduser(run_cwd) + run_cwd = os.path.expanduser(cwd) if cwd else None + if run_cwd and not os.path.isdir(run_cwd): + run_cwd = None try: from hermes_cli._subprocess_compat import windows_hide_flags @@ -172,16 +146,9 @@ def run_bang_command( # shell=True is intentional and matches quick_commands: this is a # command the human typed into their own composer, not model output. proc = subprocess.Popen( - command, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - cwd=run_cwd, - env=_bang_env(), - creationflags=creationflags, + command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", + cwd=run_cwd, env=_bang_env(), creationflags=creationflags, ) except Exception as exc: emit(f"!: failed to run command: {exc}") diff --git a/hermes_cli/blueprint_cmd.py b/hermes_cli/blueprint_cmd.py index c1e5fd61cf..187f2a4970 100644 --- a/hermes_cli/blueprint_cmd.py +++ b/hermes_cli/blueprint_cmd.py @@ -1,33 +1,4 @@ -"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway. - -The conversational counterpart to the dashboard's Automation Blueprints form. Where a -surface has a screen, the user fills a form (dashboard / GUI app) and the API -calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a -chat line, the user picks a blueprint by name and the agent asks for what it -needs — pick a blueprint by name and the agent asks you for what it needs, one -question at a time (the messaging-assistant model: pick a blueprint → it asks you -a couple things → done). - -Subcommand shapes: - /blueprint list the catalog - /blueprint name-match a blueprint, then SEED THE AGENT to - ask the user for each value conversationally - /blueprint slot=val … fill + create the cron job directly - (the deterministic dashboard / docs / power- - user shortcut — no agent turn) - -The ```` form is forgiving: exact key, unique prefix, or fuzzy match all -resolve; an ambiguous query lists the candidates; an unknown one suggests the -closest. When it resolves, the handler returns an ``agent_seed`` — a natural- -language instruction built from the blueprint's typed slots + schedule/prompt -templates — that the calling surface feeds to the agent as a normal user turn -(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI: -a one-shot pending seed the main loop runs). The agent then asks for each slot -and calls the existing ``cronjob`` tool. No new tool, no second job engine. - -Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``) -survive. -""" +"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway.""" from __future__ import annotations @@ -44,12 +15,9 @@ logger = logging.getLogger(__name__) class BlueprintCommandResult: """Outcome of a ``/blueprint`` invocation. - ``text`` is always shown to the user. When ``agent_seed`` is set, the - calling surface should ALSO hand that seed to the agent as the user's next - turn (the blueprint was matched and now the agent gathers the slot values - conversationally). When ``agent_seed`` is None the command is fully handled - (catalog listing, direct create, or an error) and nothing is sent to the - agent. + ``text`` is always shown to the user. When ``agent_seed`` is set, the calling surface should + ALSO hand that seed to the agent as the user's next turn (the blueprint was matched and now the + agent gathers the slot values conversationally). """ text: str @@ -94,14 +62,9 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]: def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]: """Resolve a free-typed blueprint name to a blueprint. - Returns ``(blueprint, candidates)``: - * exact key or unique prefix / fuzzy match -> ``(blueprint, [])`` - * ambiguous (2+ plausible) -> ``(None, [candidates…])`` - * no plausible match -> ``(None, [])`` - - Matching is forgiving because chat-line users type the name (unlike the - dashboard/Discord where it's picked): exact key first, then case-insensitive - prefix on key or title, then a difflib fuzzy pass. + Matching is forgiving because chat-line users type the name (unlike the dashboard/Discord where + it's picked): exact key first, then case-insensitive prefix on key or title, then a difflib + fuzzy pass. """ from cron.blueprint_catalog import CATALOG, get_blueprint @@ -157,9 +120,8 @@ def _humanize_schedule(blueprint) -> str: def build_blueprint_seed(blueprint) -> str: """Build the natural-language fill-request the agent will act on. - The agent reads this as a normal user turn, asks the user for each unfilled - slot one at a time, then calls the ``cronjob`` tool with the - cron expression it builds from the blueprint's ``schedule_template`` and the + The agent reads this as a normal user turn, asks for each unfilled slot one at a time, then + calls the ``cronjob`` tool with the cron expression built from ``schedule_template`` and the rendered prompt. Defaults are stated so the agent can offer them. """ from cron.blueprint_catalog import WEEKDAY_PRESETS @@ -235,9 +197,9 @@ def _fmt_no_match(query: str) -> str: def _manage_hint(surface: str) -> str: - """Post-create management hint. /cron is a CLI-only slash command; on - gateway platforms the user manages jobs by asking the agent (cronjob tool) - or from the dashboard.""" + """Post-create management hint. /cron is a CLI-only slash command; on gateway platforms the user + manages jobs by asking the agent (cronjob tool) or from the dashboard. + """ if surface == "cli": return "Manage it with /cron." return "Ask me to list, pause, or remove it any time." @@ -251,14 +213,13 @@ def handle_blueprint_command( ) -> BlueprintCommandResult: """Dispatch a ``/blueprint`` invocation. - Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the - caller must feed it to the agent as the next user turn; otherwise the - command is fully handled and only ``text`` is shown. + Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the caller must feed it to + the agent as the next user turn; otherwise the command is fully handled and only ``text`` is + shown. - ``args`` is everything after ``/blueprint``. ``origin`` lets a directly - created job deliver back to the chat it was set up from. ``surface`` - (``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints — - ``/cron`` only exists on the CLI. + ``args`` is everything after ``/blueprint``. ``origin`` lets a directly created job deliver back + to the chat it was set up from. ``surface`` (``"cli"`` | ``"gateway"``) picks the right wording + for follow-up hints — ``/cron`` only exists on the CLI. """ try: from cron.blueprint_catalog import fill_blueprint, BlueprintFillError diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py index 558f59a342..a2b1322584 100644 --- a/hermes_cli/browser_connect.py +++ b/hermes_cli/browser_connect.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import logging import ntpath import os @@ -23,44 +24,61 @@ logger = logging.getLogger(__name__) DEFAULT_BROWSER_CDP_PORT = 9222 DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}" -_DARWIN_APPS = ( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - "/Applications/Brave Origin.app/Contents/MacOS/Brave Origin", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", -) +@dataclass(frozen=True) +class _Browser: + """Per-browser install/profile locations for one Chromium-family product.""" -_WINDOWS_BROWSER_GROUPS = ( - (("chrome.exe", "chrome"), (("Google", "Chrome", "Application", "chrome.exe"),)), - ( - ("chromium.exe", "chromium"), - (("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")), - ), - (("brave.exe", "brave"), (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),)), - ( - ("brave-origin.exe", "brave-origin"), - ( - ("BraveSoftware", "Brave-Origin", "Application", "brave.exe"), - ("BraveSoftware", "Brave-Origin", "Application", "brave-origin.exe"), - ), - ), - (("msedge.exe", "msedge"), (("Microsoft", "Edge", "Application", "msedge.exe"),)), -) + key: str + mac_app: str + mac_support: tuple[str, ...] # under ~/Library/Application Support + win_bins: tuple[str, ...] # shutil.which names on Windows + win_install: tuple[tuple[str, ...], ...] # under Program Files / LOCALAPPDATA + win_profile: tuple[str, ...] # under LOCALAPPDATA + linux_bins: tuple[str, ...] # shutil.which names on Linux + linux_paths: tuple[str, ...] # known absolute install paths + linux_config: str # under $XDG_CONFIG_HOME + # PATH names tried by chromium_executable() when they differ from + # linux_bins (channel/alias binaries are launch candidates only). + linux_exec: tuple[str, ...] | None = None -_WINDOWS_BIN_NAMES = tuple(name for names, _ in _WINDOWS_BROWSER_GROUPS for name in names) -_WINDOWS_INSTALL_PARTS = tuple(parts for _, group in _WINDOWS_BROWSER_GROUPS for parts in group) -_LINUX_BROWSER_GROUPS = ( - ( +# Launch-candidate order (chrome, chromium, brave, brave-origin, edge) is the +# tuple order. ``brave-origin`` is Brave's standalone paid build: same Chromium +# core, but a fully distinct install identity (BraveSoftware/Brave-Origin +# product path, ``BraveOHTML`` ProgId, ``com.brave.Browser.origin`` bundle id) +# so it side-by-side installs with regular Brave — its profile is NOT under +# Brave-Browser and must never be conflated with the ``brave`` key (a "brave" +# lookup resolving to the Origin binary would drive the wrong browser's profile). +_BROWSERS = ( + _Browser( + "chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ("Google", "Chrome"), + ("chrome.exe", "chrome"), + (("Google", "Chrome", "Application", "chrome.exe"),), + ("Google", "Chrome", "User Data"), ("google-chrome", "google-chrome-stable"), ("/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"), + "google-chrome", ), - ( + _Browser( + "chromium", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ("Chromium",), + ("chromium.exe", "chromium"), + (("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")), + ("Chromium", "User Data"), ("chromium-browser", "chromium"), ("/usr/bin/chromium-browser", "/usr/bin/chromium"), + "chromium", ), - ( + _Browser( + "brave", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + ("BraveSoftware", "Brave-Browser"), + ("brave.exe", "brave"), + (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),), + ("BraveSoftware", "Brave-Browser", "User Data"), ("brave-browser", "brave-browser-stable", "brave"), ( "/usr/bin/brave-browser", @@ -71,21 +89,34 @@ _LINUX_BROWSER_GROUPS = ( "/opt/brave.com/brave/brave", "/opt/brave-bin/brave", ), + "BraveSoftware/Brave-Browser", ), - # Brave Origin is a SEPARATE product identity (side-by-side installable - # with Brave), so it gets its own group: the executable fallback in - # chromium_executable() matches by group, and mixing Origin binaries into - # the brave group would let a "brave" lookup resolve to the Origin binary - # (or vice versa) — driving the wrong browser's profile. - ( + _Browser( + "brave-origin", + "/Applications/Brave Origin.app/Contents/MacOS/Brave Origin", + ("BraveSoftware", "Brave-Origin"), + ("brave-origin.exe", "brave-origin"), + ( + ("BraveSoftware", "Brave-Origin", "Application", "brave.exe"), + ("BraveSoftware", "Brave-Origin", "Application", "brave-origin.exe"), + ), + ("BraveSoftware", "Brave-Origin", "User Data"), ("brave-origin", "brave-origin-nightly"), ( "/usr/bin/brave-origin", "/opt/brave.com/brave-origin/brave-origin", "/opt/brave.com/brave-origin-nightly/brave-origin", ), + "BraveSoftware/Brave-Origin", + linux_exec=("brave-origin",), ), - ( + _Browser( + "edge", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ("Microsoft Edge",), + ("msedge.exe", "msedge"), + (("Microsoft", "Edge", "Application", "msedge.exe"),), + ("Microsoft", "Edge", "User Data"), ("microsoft-edge", "microsoft-edge-stable", "msedge"), ( "/usr/bin/microsoft-edge", @@ -93,11 +124,11 @@ _LINUX_BROWSER_GROUPS = ( "/opt/microsoft/msedge/microsoft-edge", "/opt/microsoft/msedge/msedge", ), + "microsoft-edge", + linux_exec=("microsoft-edge", "microsoft-edge-stable"), ), ) - -_LINUX_BIN_NAMES = tuple(name for names, _ in _LINUX_BROWSER_GROUPS for name in names) -_LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path in paths) +_BROWSER_BY_KEY = {b.key: b for b in _BROWSERS} # --------------------------------------------------------------------------- @@ -111,14 +142,6 @@ _LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path # caller fails closed with a clear message. # --------------------------------------------------------------------------- -# Canonical Chromium browser keys we support for real-profile driving. -# ``brave-origin`` is Brave's standalone paid build: same Chromium core, but a -# fully distinct install identity (BraveSoftware/Brave-Origin product path, -# ``BraveOHTML`` ProgId, ``com.brave.Browser.origin`` bundle id) so it -# side-by-side installs with regular Brave — its profile is NOT under -# Brave-Browser and must never be conflated with the ``brave`` key. -_CHROMIUM_BROWSERS = ("chrome", "edge", "brave", "chromium", "brave-origin") - # Windows UserChoice ProgId prefixes → canonical browser key. Matched # case-insensitively by prefix so version suffixes (e.g. ``ChromeHTML.X``) # still resolve to STABLE. Pre-release channels have their own ProgIds and @@ -220,131 +243,81 @@ _DARWIN_CHANNEL_BUNDLES = ( UNSUPPORTED_CHANNEL = "__unsupported_channel__" -def _real_profile_relparts(browser: str) -> tuple: - """(mac_support_subdir, windows_localappdata_parts, linux_config_name).""" - return { - "chrome": ( - ("Google", "Chrome"), - ("Google", "Chrome", "User Data"), - "google-chrome", - ), - "edge": ( - ("Microsoft Edge",), - ("Microsoft", "Edge", "User Data"), - "microsoft-edge", - ), - "brave": ( - ("BraveSoftware", "Brave-Browser"), - ("BraveSoftware", "Brave-Browser", "User Data"), - "BraveSoftware/Brave-Browser", - ), - "chromium": ( - ("Chromium",), - ("Chromium", "User Data"), - "chromium", - ), - "brave-origin": ( - ("BraveSoftware", "Brave-Origin"), - ("BraveSoftware", "Brave-Origin", "User Data"), - "BraveSoftware/Brave-Origin", - ), - }[browser] - - def real_profile_data_dir(browser: str, system: str | None = None) -> str | None: """Return the default user-data-dir for a Chromium ``browser`` on ``system``. - Returns None for unknown browsers. On Linux the native ($XDG_CONFIG_HOME), - snap and Flatpak locations are tried and the first existing one wins; the - native path is returned when none exists so the caller's error names it. - Darwin/Windows paths are not stat'ed. Paths are built with the TARGET - system's separator (posix for Darwin/Linux, backslash for Windows) so an - explicit ``system`` argument resolves correctly regardless of the host OS. + Returns None for unknown browsers. On Linux the native ($XDG_CONFIG_HOME), snap and Flatpak + locations are tried and the first existing one wins; the native path is returned when none + exists so the caller's error names it. Darwin/Windows paths are not stat'ed. """ - if browser not in _CHROMIUM_BROWSERS: + b = _BROWSER_BY_KEY.get(browser) + if b is None: return None system = system or platform.system() - mac_parts, win_parts, linux_name = _real_profile_relparts(browser) home = os.path.expanduser("~") if system == "Darwin": - return posixpath.join(home, "Library", "Application Support", *mac_parts) + return posixpath.join(home, "Library", "Application Support", *b.mac_support) if system == "Windows": local = os.environ.get("LOCALAPPDATA") or ntpath.join(home, "AppData", "Local") - return ntpath.join(local, *win_parts) + return ntpath.join(local, *b.win_profile) # Linux / other POSIX config = os.environ.get("XDG_CONFIG_HOME") or posixpath.join(home, ".config") - candidates = [posixpath.join(config, *linux_name.split("/"))] + linux_parts = b.linux_config.split("/") + candidates = [posixpath.join(config, *linux_parts)] snap_parts = _LINUX_SNAP_PROFILE_PARTS.get(browser) if snap_parts: candidates.append(posixpath.join(home, *snap_parts)) flatpak_id = _LINUX_FLATPAK_IDS.get(browser) if flatpak_id: - candidates.append( - posixpath.join(home, ".var", "app", flatpak_id, "config", *linux_name.split("/")) - ) + candidates.append(posixpath.join(home, ".var", "app", flatpak_id, "config", *linux_parts)) for candidate in candidates: if os.path.isdir(candidate): return candidate return candidates[0] +def _first_present(paths) -> str | None: + for p in paths: + if p and os.path.isfile(p): + return p + return None + + def chromium_executable(browser: str, system: str | None = None) -> str | None: """Return the first present executable for a Chromium ``browser``.""" - if browser not in _CHROMIUM_BROWSERS: + b = _BROWSER_BY_KEY.get(browser) + if b is None: return None system = system or platform.system() - - def first_present(paths: tuple) -> str | None: - for p in paths: - if p and os.path.isfile(p): - return p - return None - if system == "Darwin": - app = { - "chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "chromium": "/Applications/Chromium.app/Contents/MacOS/Chromium", - "brave": "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - "brave-origin": "/Applications/Brave Origin.app/Contents/MacOS/Brave Origin", - "edge": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - }[browser] - return app if os.path.isfile(app) else None + return _first_present((b.mac_app,)) if system == "Windows": - groups = { - "chrome": (("Google", "Chrome", "Application", "chrome.exe"),), - "chromium": (("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")), - "brave": (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),), - "brave-origin": ( - ("BraveSoftware", "Brave-Origin", "Application", "brave.exe"), - ("BraveSoftware", "Brave-Origin", "Application", "brave-origin.exe"), - ), - "edge": (("Microsoft", "Edge", "Application", "msedge.exe"),), - }[browser] bases = [ os.environ.get("PROGRAMFILES", r"C:\Program Files"), os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")), ] - cands = tuple(os.path.join(base, *parts) for base in bases for parts in groups) - return first_present(cands) - # Linux - linux = { - "chrome": ("google-chrome", "google-chrome-stable"), - "chromium": ("chromium-browser", "chromium"), - "brave": ("brave-browser", "brave-browser-stable", "brave"), - "brave-origin": ("brave-origin",), - "edge": ("microsoft-edge", "microsoft-edge-stable"), - }[browser] - for name in linux: + return _first_present(os.path.join(base, *parts) for base in bases for parts in b.win_install) + # Linux: PATH lookup first, then the known absolute install paths. + for name in b.linux_exec or b.linux_bins: found = shutil.which(name) if found: return found - # fall back to the known absolute paths from the launch tables - for names, paths in _LINUX_BROWSER_GROUPS: - if any(n in linux for n in names): - hit = first_present(tuple(paths)) - if hit: - return hit + return _first_present(b.linux_paths) + + +def _classify_default(value: str, channels, table, match) -> str | None: + """Map an OS default-browser identifier to a canonical key. + + Channels are checked FIRST: a recognized Beta/Dev/Canary identifier must fail closed + (UNSUPPORTED_CHANNEL), never fall through to a stable match and drive the stable profile. + """ + for chan in channels: + if match(value, chan): + return UNSUPPORTED_CHANNEL + for frag, browser in table: + if match(value, frag): + return browser return None @@ -363,15 +336,16 @@ def _detect_default_windows() -> str | None: except Exception: return None low = str(prog_id or "").lower() - # Channels first: a recognized Beta/Dev/Canary ProgId must fail closed, not - # fall through to a stable prefix match and drive the stable profile. - for chan in _WINDOWS_CHANNEL_PROGIDS: - if low.startswith(chan): - return UNSUPPORTED_CHANNEL - for prefix, browser in _WINDOWS_PROGID_MAP: - if low.startswith(prefix): - return browser - return None + return _classify_default(low, _WINDOWS_CHANNEL_PROGIDS, _WINDOWS_PROGID_MAP, str.startswith) + + +def _run_stdout(argv: list[str]) -> str | None: + try: + return subprocess.run( + argv, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5 + ).stdout + except Exception: + return None _LS_HANDLERS_READER = ( @@ -385,12 +359,9 @@ _LS_HANDLERS_READER = ( def _launchservices_https_handler(dump: str) -> str | None: """Return the bundle id registered for the ``https`` URL scheme. - ``dump`` is the ``defaults read … LSHandlers`` output: an array of - ``{ … }`` dictionaries, one per handler. Only the entry whose - ``LSHandlerURLScheme`` is ``https`` counts — a browser registered for - another scheme or a file type must not be mistaken for the default. - Returns None when no https handler is recorded, which is what macOS - stores while Safari (the implicit default) has never been replaced. + ``dump`` is the ``defaults read … LSHandlers`` output: an array of ``{ … }`` dictionaries, one + per handler. Only the entry whose ``LSHandlerURLScheme`` is ``https`` counts — a browser + registered for another scheme or a file type must not be mistaken for the default. """ entries: list[str] = [] depth = 0 @@ -428,63 +399,27 @@ def _launchservices_https_handler(dump: str) -> str | None: def _detect_default_darwin() -> str | None: - try: - out = subprocess.run( - list(_LS_HANDLERS_READER), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - ).stdout - except Exception: + out = _run_stdout(list(_LS_HANDLERS_READER)) + if out is None: return None bundle = _launchservices_https_handler(out) if not bundle: return None - b = bundle.lower() - # Channels first (exact): a Beta/Dev/Canary bundle must fail closed. - if b in _DARWIN_CHANNEL_BUNDLES: - return UNSUPPORTED_CHANNEL - for frag, browser in _DARWIN_BUNDLE_MAP: - if b == frag: - return browser - # A non-Chromium https handler (Safari, Firefox, Arc, …) or an unknown - # channel bundle: fail closed. No "first installed Chromium wins" fallback - # — that would drive a browser the user never made their default. - return None + # Exact match. A non-Chromium https handler (Safari, Firefox, Arc, …) or an + # unknown channel bundle fails closed: no "first installed Chromium wins" + # fallback — that would drive a browser the user never made their default. + return _classify_default(bundle.lower(), _DARWIN_CHANNEL_BUNDLES, _DARWIN_BUNDLE_MAP, str.__eq__) def _detect_default_linux() -> str | None: - try: - out = subprocess.run( - ["xdg-settings", "get", "default-web-browser"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - ).stdout.strip().lower() - except Exception: - out = "" - # Channels first: ``google-chrome-beta.desktop`` contains the stable - # ``google-chrome`` fragment, so a substring match would drive stable. - # Catch recognized channels and fail closed instead. - for frag in _LINUX_CHANNEL_FRAGMENTS: - if frag in out: - return UNSUPPORTED_CHANNEL - for frag, browser in _LINUX_DESKTOP_MAP: - if frag in out: - return browser - return None + out = (_run_stdout(["xdg-settings", "get", "default-web-browser"]) or "").strip().lower() + # Substring match; channels first because ``google-chrome-beta.desktop`` + # contains the stable ``google-chrome`` fragment. + return _classify_default(out, _LINUX_CHANNEL_FRAGMENTS, _LINUX_DESKTOP_MAP, str.__contains__) def detect_default_chromium(system: str | None = None) -> str | None: - """Return the canonical key of the default Chromium browser, or None. - - None means the default browser is non-Chromium (Firefox, Safari) or could - not be determined — the caller fails closed rather than guessing. - """ + """Return the canonical key of the default Chromium browser, or None.""" system = system or platform.system() if system == "Windows": return _detect_default_windows() @@ -573,12 +508,9 @@ def real_profile_copy_dir(browser: str) -> str: def _last_used_profile(src: str) -> str: """Return the profile dir Chrome last used (``Local State`` → profile.last_used). - Chromium opens ``Default`` inside a user-data-dir unless told otherwise, but - the user's signed-in session usually lives in whichever profile they - actually browse (``Profile 6`` etc.). We read that here and mirror its auth - into the copy's ``Default`` so the launched browser is signed in. Falls back - to ``Default`` when Local State is missing/unreadable or names a profile - dir that doesn't exist. + Chromium opens ``Default`` inside a user-data-dir unless told otherwise, but the user's signed- + in session usually lives in whichever profile they actually browse (``Profile 6`` etc.). We read + that here and mirror its auth into the copy's ``Default`` so the launched browser is signed in. """ import json @@ -596,34 +528,26 @@ def _last_used_profile(src: str) -> str: def _secure_snapshot_root(path: str) -> None: """Lock down a snapshot dir through Hermes' canonical secret-store policy. - The snapshot holds copies of the user's Cookies / Login Data, so it is a - credential store and must get the same owner-only permissions (and - managed-mode / NixOS group-share carve-out, HERMES_UID/GID ownership) as - every other Hermes secret dir — via ``hermes_cli.config._secure_dir``, - not a bespoke chmod. Deferred import avoids a config↔browser import cycle. + The snapshot holds copies of the user's Cookies / Login Data, so it is a credential store and + must get the same owner-only permissions (and managed-mode / NixOS group-share carve-out, + HERMES_UID/GID ownership) as every other Hermes secret dir — via + ``hermes_cli.config._secure_dir``, not a bespoke chmod. Best-effort: never blocks a launch. """ try: from hermes_cli.config import _secure_dir _secure_dir(path) - except Exception as e: # never block a launch on a permissions best-effort + except Exception as e: logger.debug("could not secure real-profile snapshot dir %s: %s", path, e) def _secure_snapshot_contents(dst: str) -> None: """Owner-only modes for every file/dir INSIDE the snapshot (#96729). - ``_secure_snapshot_root`` covers the top-level dirs, but the copied files - inherit the umask: ``shutil.copy2`` preserves the source's mode (Chrome - keeps its own profile 0644 inside a 0700 dir) and ``sqlite3.connect`` on - the backup destination creates plain umask files — so Cookies / Login - Data / Web Data landed 0644 and any nested profile subdir 0755. The 0700 - parents contain the damage by default, but the documented - ``HERMES_HOME_MODE`` hatch (nginx traversal) makes world-readable children - a real exposure — these are the user's live session cookies. Reconciled - through the house helpers (``_secure_dir`` / ``_secure_file``) on EVERY - snapshot pass, so older snapshots heal too; both helpers already carry the - managed-mode / container carve-outs. Best-effort: never blocks a launch. + ``_secure_snapshot_root`` covers the top-level dirs, but ``copy2`` preserves Chrome's 0644 file + modes and ``sqlite3`` backups create umask files, so live session cookies landed world-readable + — a real exposure under the ``HERMES_HOME_MODE`` hatch. Reconciled via ``_secure_dir`` / + ``_secure_file`` on EVERY pass so older snapshots heal too. Best-effort: never blocks a launch. """ try: from hermes_cli.config import _secure_dir, _secure_file @@ -633,7 +557,7 @@ def _secure_snapshot_contents(dst: str) -> None: _secure_dir(os.path.join(root, d)) for f in files: _secure_file(os.path.join(root, f)) - except Exception as e: # best-effort, same policy as _secure_snapshot_root + except Exception as e: logger.debug("could not secure real-profile snapshot contents %s: %s", dst, e) @@ -650,10 +574,10 @@ _SQLITE_AUTH_DBS = frozenset({ def _copy_auth_file(src_file: str, dst_file: str) -> bool: """Copy one auth file, lock-aware. Returns True on success. - For SQLite DBs (Cookies/Login Data/…), use the online-backup API so the - copy works even while the browser holds the file's write lock (Windows). - Everything else is a plain copy. A DB whose backup fails falls through to a - raw copy attempt; only if BOTH fail do we report failure to the caller. + For SQLite DBs (Cookies/Login Data/…), use the online-backup API so the copy works even while + the browser holds the file's write lock (Windows). Everything else is a plain copy. A DB whose + backup fails falls through to a raw copy attempt; only if BOTH fail do we report failure to the + caller. """ os.makedirs(os.path.dirname(dst_file), exist_ok=True) if os.path.basename(src_file) in _SQLITE_AUTH_DBS: @@ -699,13 +623,10 @@ def _copy_auth_file(src_file: str, dst_file: str) -> bool: def _mirror_profile_auth(src: str, dst: str, source_profile: str) -> int: """Copy ``source_profile``'s auth files into the copy's ``Default`` slot. - agent-browser launches ``Default`` in the copied user-data-dir; mirroring - the active source profile's cookies/logins/prefs there is what makes the - session actually signed in (the LinkedIn/Gmail "logged out" bug when the - real session lives in a non-Default profile). Lock-aware (Windows), so a - running Chrome doesn't block the cookie DBs. - - Returns the number of DB auth files that could NOT be copied (0 = clean). + agent-browser launches ``Default`` in the copied user-data-dir, so mirroring the active + profile's cookies/logins/prefs there is what makes the session actually signed in when the real + session lives in a non-Default profile. Lock-aware on Windows. Returns the number of DB auth + files that could NOT be copied (0 = clean). """ dst_default = os.path.join(dst, "Default") failed_dbs = 0 @@ -729,22 +650,17 @@ _PROFILE_LOCKED_PREFIX = "[profile-locked] " def _profile_cookie_db(src: str, source_profile: str) -> str | None: """Path to the active profile's cookie DB (modern Network/ first).""" - for rel in (os.path.join("Network", "Cookies"), "Cookies"): - cand = os.path.join(src, source_profile, rel) - if os.path.isfile(cand): - return cand - return None + return _first_present( + os.path.join(src, source_profile, rel) for rel in (os.path.join("Network", "Cookies"), "Cookies") + ) def _profile_is_locked(src: str, source_profile: str) -> bool: """True when the active profile's cookie DB can't be opened (browser running). - A running browser holds Cookies with a deny-all share mode on Windows - (proven live: even CreateFile with all share flags fails), so a plain open - raises PermissionError. This is a FAST probe — one open attempt, no copy — - used to fail closed BEFORE the heavy snapshot so a locked profile can never - hang the launch on a blocking file op. POSIX has no mandatory locking, so - the open succeeds and this returns False (copy proceeds normally). + On Windows a running browser holds Cookies with a deny-all share mode, so a plain open raises + PermissionError. This is a FAST one-open probe used to fail closed BEFORE the heavy snapshot so + a locked profile can never hang the launch. POSIX has no mandatory locking, so it returns False. """ db = _profile_cookie_db(src, source_profile) if not db: @@ -759,37 +675,38 @@ def _profile_is_locked(src: str, source_profile: str) -> bool: return False -def _real_profile_pin() -> str | None: - """Pinned source profile dir name from ``browser.real_profile_pin``. - - Natively the snapshot follows Chrome's - ``profile.last_used`` — whichever profile the user touched last. On a - machine with a work profile (HM) and a personal profile, that roulette - can silently give the agent the wrong identity. When set (e.g. - ``"Profile 2"``), the snapshot ALWAYS copies that profile regardless of - last_used. Unset → native last_used behavior, unchanged. - """ +def _browser_setting(key: str): + """Read ``browser.`` from raw config; None when unset/unreadable.""" try: from hermes_cli.config import read_raw_config - cfg = read_raw_config() - browser_cfg = cfg.get("browser", {}) + browser_cfg = read_raw_config().get("browser", {}) if isinstance(browser_cfg, dict): - pin = browser_cfg.get("real_profile_pin") - if isinstance(pin, str) and pin.strip(): - return pin.strip() + return browser_cfg.get(key) except Exception as e: - logger.debug("could not read real_profile_pin: %s", e) + logger.debug("could not read %s: %s", key, e) + return None + + +def _real_profile_pin() -> str | None: + """Pinned source profile dir name from ``browser.real_profile_pin``. + + Natively the snapshot follows Chrome's ``profile.last_used``, which on a machine with work and + personal profiles can silently hand the agent the wrong identity. When set (e.g. ``"Profile + 2"``) that profile is ALWAYS copied; unset keeps the native last_used behavior. + """ + pin = _browser_setting("real_profile_pin") + if isinstance(pin, str) and pin.strip(): + return pin.strip() return None def _resolve_source_profile(src: str) -> tuple[str | None, str | None]: """Resolve which source profile to copy: pin first, else last_used. - Returns ``(profile_dir_name, error)``. A configured pin that does not - exist under ``src`` FAILS CLOSED with a fixable message — falling back - to last_used would silently browse as the wrong identity, which is the - exact wrong-principal bug this pin exists to prevent. + Returns ``(profile_dir_name, error)``. A configured pin that does not exist under ``src`` FAILS + CLOSED with a fixable message — falling back to last_used would silently browse as the wrong + identity, which is the exact wrong-principal bug this pin exists to prevent. """ pin = _real_profile_pin() if pin: @@ -806,30 +723,16 @@ def _resolve_source_profile(src: str) -> tuple[str | None, str | None]: def _real_profile_autoclose() -> bool: - """Whether browser.real_profile_autoclose consent is on (config read). - - When true, snapshot_real_profile may terminate a running browser that locks - the profile. Destructive → default False; the agent gates it on user OK. - """ - try: - from hermes_cli.config import read_raw_config - - cfg = read_raw_config() - browser_cfg = cfg.get("browser", {}) - if isinstance(browser_cfg, dict): - return bool(browser_cfg.get("real_profile_autoclose", False)) - except Exception as e: - logger.debug("could not read real_profile_autoclose: %s", e) - return False + """Whether browser.real_profile_autoclose consent is on (config read).""" + return bool(_browser_setting("real_profile_autoclose") or False) def _processes_holding_profile(src: str): """Yield (psutil.Process) instances holding the user-data-dir ``src`` open. - Identity discipline mirrors the daemon reaper: a process qualifies only when - it's a Chromium-family binary AND its command line references THIS - user-data-dir — so we never terminate an unrelated same-PID process. Any - ambiguity (unreadable cmdline) is skipped, fail-closed. + Identity discipline mirrors the daemon reaper: a process qualifies only when it's a Chromium- + family binary AND its command line references THIS user-data-dir — so we never terminate an + unrelated same-PID process. Any ambiguity (unreadable cmdline) is skipped, fail-closed. """ try: import psutil @@ -864,12 +767,9 @@ def _processes_holding_profile(src: str): def close_browser_holding_profile(src: str, timeout: float = 15.0) -> tuple[bool, str]: """Terminate the browser process tree holding ``src`` and wait for release. - CONSENTED, DESTRUCTIVE. Only call after the user has agreed to close their - browser — it terminates every Chromium-family process bound to this exact - user-data-dir (graceful terminate, then kill), so unsaved tab/form state in - that browser is lost. Returns ``(True, msg)`` once the profile lock actually - releases, ``(False, msg)`` if processes couldn't be found/killed or the lock - never released within ``timeout``. + CONSENTED, DESTRUCTIVE. Only call after the user has agreed to close their browser — it + terminates every Chromium-family process bound to this exact user-data-dir (graceful terminate, + then kill), so unsaved tab/form state in that browser is lost. """ try: import psutil @@ -883,35 +783,28 @@ def close_browser_holding_profile(src: str, timeout: float = 15.0) -> tuple[bool return False, "no matching browser process found holding the profile." # Include child processes (renderers, GPU, crashpad) for a full tree kill. + gone_errs = (psutil.NoSuchProcess, psutil.AccessDenied) targets = [] for p in procs: targets.append(p) - try: + with contextlib.suppress(*gone_errs): targets.extend(p.children(recursive=True)) - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass # Graceful terminate first. for p in targets: - try: + with contextlib.suppress(*gone_errs): p.terminate() - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - gone, alive = psutil.wait_procs(targets, timeout=min(timeout, 8.0)) + _gone, alive = psutil.wait_procs(targets, timeout=min(timeout, 8.0)) for p in alive: - try: + with contextlib.suppress(*gone_errs): p.kill() - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass psutil.wait_procs(alive, timeout=3.0) # The lock releases slightly after the process exits on Windows; poll. - source_profile, _resolve_err = _resolve_source_profile(src) - if not source_profile: - source_profile = _last_used_profile(src) + source_profile = _resolve_source_profile(src)[0] or _last_used_profile(src) deadline = time.monotonic() + timeout while time.monotonic() < deadline: if not _profile_is_locked(src, source_profile): - return True, f"closed the browser and the profile lock released." + return True, "closed the browser and the profile lock released." time.sleep(0.5) return False, ( "closed the browser processes but the profile is still locked — " @@ -919,24 +812,53 @@ def close_browser_holding_profile(src: str, timeout: float = 15.0) -> tuple[bool ) +def _sync_local_state(src: str, dst: str, source_profile: str) -> None: + """Copy ``Local State`` into the snapshot and rewrite it for the single ``Default`` profile. + + Cheap; always re-synced so last_used etc. stay current. The copy contains ONLY the mirrored + Default dir, but a verbatim Local State still names the SOURCE profile (last_used="Profile 2", + info_cache listing Profile 2/4/7), so Chrome would open a missing profile dir and start SIGNED + OUT. CRITICAL: Default's identity entry must be the SOURCE profile's entry (name + Google + account), not the source's own "Default" entry — the Default DIR holds the source profile's + cookies. A mismatch makes Chrome demand a "Continue as " reconciliation on every launch. + """ + ls_src = os.path.join(src, "Local State") + ls_dst = os.path.join(dst, "Local State") + if os.path.isfile(ls_src): + try: + shutil.copy2(ls_src, ls_dst) + except OSError as e: + logger.debug("real-profile snapshot: skipped Local State: %s", e) + try: + import json as _json + + with open(ls_dst, encoding="utf-8") as fh: + state = _json.load(fh) + prof = state.get("profile") + if isinstance(prof, dict): + cache = prof.get("info_cache") + if isinstance(cache, dict): + src_entry = cache.get(source_profile) or cache.get("Default") + if src_entry: + prof["info_cache"] = {"Default": src_entry} + prof["last_used"] = "Default" + prof["last_active_profiles"] = ["Default"] + with open(ls_dst, "w", encoding="utf-8") as fh: + _json.dump(state, fh) + except (OSError, ValueError) as e: + logger.debug("real-profile snapshot: could not normalize Local State: %s", e) + + def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | None, str | None]: """Snapshot ``browser``'s real ACTIVE profile into the hermes copy dir. - Copies only what the launched browser needs: the user-data-dir's - ``Local State`` plus the auth-bearing files of the profile the user - actually browses (``Local State → profile.last_used``, e.g. ``Profile 6``), - mirrored into the copy's ``Default`` — which is what agent-browser opens. - We deliberately do NOT copy every profile dir: non-active profiles are - unused here and would just be stale credential copies sitting on disk. + Copies only what the launched browser needs: the user-data-dir's ``Local State`` plus the auth- + bearing files of the profile the user actually browses (``Local State → profile.last_used``, + e.g. ``Profile 6``), mirrored into the copy's ``Default`` — which is what agent-browser opens. - A ``.hermes-snapshot-complete`` marker is written only after a copy fully - succeeds; a torn/interrupted first copy (disk full, Ctrl+C) therefore never - looks "already populated" on the next run — it is redone from scratch. - - Auth files are re-synced on every call so fresh logins from the user's own - browsing show up. Locked-file copy errors are tolerated best-effort. - - Returns ``(copy_dir, None)`` on success, ``(None, error)`` on failure. + A ``.hermes-snapshot-complete`` marker is written only after a copy fully succeeds; a + torn/interrupted first copy (disk full, Ctrl+C) therefore never looks "already populated" on the + next run — it is redone from scratch. """ src = src or real_profile_data_dir(browser) if not src or not os.path.isdir(src): @@ -994,46 +916,7 @@ def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | N _secure_snapshot_root(parent) _secure_snapshot_root(dst) - # Base user-data-dir file the browser reads at startup. Cheap; always - # re-synced so last_used etc. stay current. - ls_src = os.path.join(src, "Local State") - ls_dst = os.path.join(dst, "Local State") - if os.path.isfile(ls_src): - try: - shutil.copy2(ls_src, ls_dst) - except OSError as e: - logger.debug("real-profile snapshot: skipped Local State: %s", e) - - # The copy contains ONLY the mirrored Default dir (that is where the - # pinned/active profile's auth was mirrored into), but a verbatim - # Local State still names the SOURCE profile (e.g. last_used="Profile - # 2", info_cache listing Profile 2/4/7). Chrome therefore opens a - # missing profile dir and starts SIGNED OUT. Rewrite Local State so - # the copy's only profile is Default and it is the last-used one. - # CRITICAL: Default's identity entry must be the SOURCE profile's - # entry (name + Google account), not the source's own "Default" - # entry — the Default DIR holds the source profile's cookies. A - # mismatch (cookies belong to profile B, info_cache names profile A) makes Chrome - # demand a "Continue as " profile-sign-in reconciliation on - # every launch and treat the profile as mid-sign-in. - try: - import json as _json - - with open(ls_dst, encoding="utf-8") as fh: - state = _json.load(fh) - prof = state.get("profile") - if isinstance(prof, dict): - cache = prof.get("info_cache") - if isinstance(cache, dict): - src_entry = cache.get(source_profile) or cache.get("Default") - if src_entry: - prof["info_cache"] = {"Default": src_entry} - prof["last_used"] = "Default" - prof["last_active_profiles"] = ["Default"] - with open(ls_dst, "w", encoding="utf-8") as fh: - _json.dump(state, fh) - except (OSError, ValueError) as e: - logger.debug("real-profile snapshot: could not normalize Local State: %s", e) + _sync_local_state(src, dst, source_profile) if not populated: # Fresh (or torn-and-rebuilding): drop any partial Default and copy @@ -1076,10 +959,8 @@ def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | N # Never carry live-instance leftovers into the copy. for leftover in ("SingletonLock", "SingletonSocket", "SingletonCookie"): - try: + with contextlib.suppress(OSError): os.unlink(os.path.join(dst, leftover)) - except OSError: - pass # Mark complete only after everything above succeeded. try: with open(marker, "w", encoding="utf-8") as fh: @@ -1100,8 +981,8 @@ def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | N def cleanup_real_profile_snapshots() -> None: """Delete the whole real-profile snapshot store (all copied credentials). - Called when consent is OFF: the copied Cookies / Login Data must not - outlive the toggle. Best-effort and idempotent — missing dir is fine. + Called when consent is OFF: the copied Cookies / Login Data must not outlive the toggle. Best- + effort and idempotent — missing dir is fine. """ root = str(get_hermes_home() / "browser-profile") try: @@ -1125,21 +1006,9 @@ def get_chrome_debug_candidates(system: str) -> list[str]: candidates.append(path) seen.add(normalized) - def add_windows_install_paths( - bases: tuple[str | None, ...], - install_groups: tuple[tuple[tuple[str, ...], tuple[tuple[str, ...], ...]], ...], - ) -> None: - for _, group in install_groups: - for base in filter(None, bases): - for parts in group: - # Only called with WSL ``/mnt/c/...`` bases — those are - # POSIX paths regardless of the host OS, so join with - # posixpath (os.path.join would emit backslashes on nt). - add(posixpath.join(base, *parts)) - if system == "Darwin": - for app in _DARWIN_APPS: - add(app) + for b in _BROWSERS: + add(b.mac_app) return candidates if system == "Windows": @@ -1148,20 +1017,25 @@ def get_chrome_debug_candidates(system: str) -> list[str]: os.environ.get("ProgramFiles(x86)"), os.environ.get("LOCALAPPDATA"), ) - for names, install_parts in _WINDOWS_BROWSER_GROUPS: - for name in names: + for b in _BROWSERS: + for name in b.win_bins: add(shutil.which(name)) for base in filter(None, install_bases): - for parts in install_parts: + for parts in b.win_install: add(os.path.join(base, *parts)) return candidates - for names, paths in _LINUX_BROWSER_GROUPS: - for name in names: + for b in _BROWSERS: + for name in b.linux_bins: add(shutil.which(name)) - for path in paths: + for path in b.linux_paths: add(path) - add_windows_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"), _WINDOWS_BROWSER_GROUPS) + # WSL: Windows installs under ``/mnt/c/...`` are POSIX paths regardless of + # the host OS, so join with posixpath (os.path.join would emit backslashes on nt). + for b in _BROWSERS: + for base in ("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"): + for parts in b.win_install: + add(posixpath.join(base, *parts)) return candidates @@ -1225,11 +1099,9 @@ _LOOPBACK_SOCKET_HOSTS = ("127.0.0.1", "::1") def discover_local_cdp_url(port: int, timeout: float = 1.0) -> str | None: """Return the first loopback URL (IPv4 first, then IPv6) speaking CDP. - Dual-stack discovery: when another application squats the IPv4 - loopback on ``port``, a debug browser launched with - ``--remote-debugging-port`` may bind only ``[::1]``. Probing both - literals finds it either way. Returns ``None`` when neither - loopback exposes a CDP discovery endpoint. + Dual-stack discovery: when another application squats the IPv4 loopback on ``port``, a debug + browser launched with ``--remote-debugging-port`` may bind only ``[::1]``. Probing both literals + finds it either way. Returns ``None`` when neither loopback exposes a CDP discovery endpoint. """ for host in _LOOPBACK_PROBE_HOSTS: url = f"http://{host}:{port}" @@ -1241,9 +1113,8 @@ def discover_local_cdp_url(port: int, timeout: float = 1.0) -> str | None: def local_port_in_use(port: int, timeout: float = 0.5) -> bool: """Return True when either loopback accepts TCP on ``port``. - Callers use this AFTER a failed CDP probe to distinguish "port is - free, we can launch a browser on it" from "another application - (IDE debugger, dev server) is squatting the port and a launch + Callers use this AFTER a failed CDP probe to distinguish "port is free, we can launch a browser + on it" from "another application (IDE debugger, dev server) is squatting the port and a launch would fight it". """ import socket @@ -1260,11 +1131,9 @@ def local_port_in_use(port: int, timeout: float = 0.5) -> bool: def find_free_debug_port(preferred: int = DEFAULT_BROWSER_CDP_PORT, attempts: int = 10) -> int: """Return the first port after ``preferred`` bindable on both loopbacks. - Used when ``preferred`` is occupied by a non-CDP application: rather - than launching a browser into a bind conflict, pick a nearby free - port. Falls back to ``preferred + 1`` if nothing binds (the launch - will then fail with a clear browser-side error instead of silently - doing nothing). + Used when ``preferred`` is occupied by a non-CDP application: rather than launching a browser + into a bind conflict, pick a nearby free port. Falls back to ``preferred + 1`` if nothing binds + (the launch will then fail with a clear browser-side error instead of silently doing nothing). """ import socket @@ -1318,9 +1187,9 @@ def _wait_for_browser_debug_ready_or_exit( ) -> str: """Classify a launched browser as ready, exited, or still starting. - We only need to wait long enough to catch the common failure mode where a - candidate binary exists but exits immediately before exposing the CDP port. - Slower browsers can still finish starting after this grace window. + We only need to wait long enough to catch the common failure mode where a candidate binary + exists but exits immediately before exposing the CDP port. Slower browsers can still finish + starting after this grace window. """ deadline = time.monotonic() + timeout @@ -1354,10 +1223,9 @@ class LaunchAttempt: class ChromeDebugLaunch: """Structured result of ``launch_chrome_debug``. - ``launched`` mirrors the legacy boolean contract: a launch command was - executed and the browser is ready or still starting (it does NOT - guarantee the CDP port ever opens). ``attempts`` carries per-candidate - diagnostics so callers can explain *why* nothing came up. + ``launched`` mirrors the legacy boolean contract: a launch command was executed and the browser + is ready or still starting (it does NOT guarantee the CDP port ever opens). ``attempts`` carries + per-candidate diagnostics so callers can explain *why* nothing came up. """ launched: bool = False @@ -1398,10 +1266,9 @@ def launch_chrome_debug( ) -> ChromeDebugLaunch: """Launch a Chromium-family browser with remote debugging, with diagnostics. - Tries each detected candidate binary in turn. A candidate that exits - before the CDP port opens (crash, singleton forward to an existing - instance, bad profile dir) is logged — with exit code and a stderr tail — - and the next candidate is tried. + Tries each detected candidate binary in turn. A candidate that exits before the CDP port opens + (crash, singleton forward to an existing instance, bad profile dir) is logged — with exit code + and a stderr tail — and the next candidate is tried. """ system = system or platform.system() result = ChromeDebugLaunch() diff --git a/hermes_cli/bundles.py b/hermes_cli/bundles.py index a9d8469049..c211c1cf20 100644 --- a/hermes_cli/bundles.py +++ b/hermes_cli/bundles.py @@ -1,16 +1,4 @@ -"""Implementation of the ``hermes bundles`` CLI subcommand. - -Mirrors the structure of ``hermes_cli/skills_hub.py`` but for skill -bundles. Bundles are tiny YAML files that name a set of skills to load -together via a single ``/`` slash command. - -Subcommands: -- list: show all bundles -- show: dump one bundle's contents -- create: build a new bundle from arguments or interactively -- delete: remove a bundle -- reload: re-scan the bundles directory -""" +"""Implementation of the ``hermes bundles`` CLI subcommand.""" from __future__ import annotations from hermes_cli.cli_output import line_input @@ -167,9 +155,8 @@ def _cmd_reload(args) -> None: def register_cli(subparser) -> None: """Build the ``hermes bundles`` argparse tree. - Called from ``hermes_cli/main.py`` where it owns the top-level - ``bundles`` subparser. Keeping registration here means the bundles - subcommand's argparse tree lives next to its handlers. + Called from ``hermes_cli/main.py``, which owns the top-level subparser; registering here keeps + the argparse tree next to its handlers. """ subs = subparser.add_subparsers(dest="bundles_action") diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index 903bc6709b..4e37e62191 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -1,10 +1,4 @@ -"""Interactive prompt callbacks for terminal_tool integration. - -These bridge terminal_tool's interactive prompts (clarify, sudo, approval) -into prompt_toolkit's event loop. Each function takes the HermesCLI instance -as its first argument and uses its state (queues, app reference) to coordinate -with the TUI. -""" +"""Interactive prompt callbacks for terminal_tool integration.""" import queue import time as _time @@ -15,14 +9,51 @@ from hermes_cli.secret_prompt import masked_secret_prompt from hermes_constants import display_hermes_home +def _invalidate(cli) -> None: + if hasattr(cli, "_app") and cli._app: + cli._app.invalidate() + + +def _clear_secret_input(cli) -> None: + """Drop stale draft input so Enter never stores it as the secret.""" + if hasattr(cli, "_clear_secret_input_buffer"): + try: + cli._clear_secret_input_buffer() + except Exception: + pass + elif hasattr(cli, "_app") and cli._app: + try: + cli._app.current_buffer.reset() + except Exception: + pass + + +def _secret_result(var_name: str, value: str) -> dict: + """Store ``value`` (or report a skip when empty) and build the callback result dict.""" + if not value: + cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}") + return { + "success": True, + "reason": "cancelled", + "stored_as": var_name, + "validated": False, + "skipped": True, + "message": "Secret setup was skipped.", + } + stored = save_env_value_secure(var_name, value) + cprint(f"\n{_DIM} ✓ Stored secret in {display_hermes_home()}/.env as {var_name}{_RST}") + return { + **stored, + "skipped": False, + "message": "Secret stored securely. The secret value was not exposed to the model.", + } + + def clarify_callback(cli, question, choices, multi_select=False): """Prompt for clarifying question through the TUI. - Sets up the interactive selection UI, then blocks until the user - responds. Returns the user's choice or a timeout message. - - When ``multi_select`` is True, shows checkboxes and the user can - select multiple options with Space, confirming with Enter. + Blocks until the user responds; returns the choice or a timeout message. ``multi_select`` + shows checkboxes (Space to toggle, Enter to confirm). """ from cli import CLI_CONFIG from tools.clarify_gateway import resolve_clarify_timeout @@ -44,9 +75,7 @@ def clarify_callback(cli, question, choices, multi_select=False): } cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout cli._clarify_freetext = is_open_ended - - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _invalidate(cli) while True: try: @@ -55,18 +84,14 @@ def clarify_callback(cli, question, choices, multi_select=False): return result except queue.Empty: # None deadline = unlimited: never auto-skip, just keep polling. - if cli._clarify_deadline is not None: - remaining = cli._clarify_deadline - _time.monotonic() - if remaining <= 0: - break - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + if cli._clarify_deadline is not None and cli._clarify_deadline - _time.monotonic() <= 0: + break + _invalidate(cli) cli._clarify_state = None cli._clarify_freetext = False cli._clarify_deadline = None - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _invalidate(cli) cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}") return ( "The user did not provide a response within the time limit. " @@ -77,8 +102,8 @@ def clarify_callback(cli, question, choices, multi_select=False): def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: """Prompt for a secret value through the TUI (e.g. API keys for skills). - Returns a dict with keys: success, stored_as, validated, skipped, message. - The secret is stored in ~/.hermes/.env and never exposed to the model. + Returns a dict with keys: success, stored_as, validated, skipped, message. The secret is stored + in ~/.hermes/.env and never exposed to the model. """ if not getattr(cli, "_app", None): if not hasattr(cli, "_secret_state"): @@ -89,26 +114,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: value = masked_secret_prompt(f"{prompt} (hidden, ESC or empty Enter to skip): ") except (EOFError, KeyboardInterrupt): value = "" - - if not value: - cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}") - return { - "success": True, - "reason": "cancelled", - "stored_as": var_name, - "validated": False, - "skipped": True, - "message": "Secret setup was skipped.", - } - - stored = save_env_value_secure(var_name, value) - _dhh = display_hermes_home() - cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}") - return { - **stored, - "skipped": False, - "message": "Secret stored securely. The secret value was not exposed to the model.", - } + return _secret_result(var_name, value) timeout = 120 response_queue = queue.Queue() @@ -122,69 +128,26 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: cli._secret_deadline = _time.monotonic() + timeout if hasattr(cli, "_ring_bell"): cli._ring_bell(prompt=True, context=f"secret needed ({var_name})") - # Avoid storing stale draft input as the secret when Enter is pressed. - if hasattr(cli, "_clear_secret_input_buffer"): - try: - cli._clear_secret_input_buffer() - except Exception: - pass - elif hasattr(cli, "_app") and cli._app: - try: - cli._app.current_buffer.reset() - except Exception: - pass - - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _clear_secret_input(cli) + _invalidate(cli) while True: try: value = response_queue.get(timeout=1) - cli._secret_state = None - cli._secret_deadline = 0 - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() - - if not value: - cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}") - return { - "success": True, - "reason": "cancelled", - "stored_as": var_name, - "validated": False, - "skipped": True, - "message": "Secret setup was skipped.", - } - - stored = save_env_value_secure(var_name, value) - _dhh = display_hermes_home() - cprint(f"\n{_DIM} ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}") - return { - **stored, - "skipped": False, - "message": "Secret stored securely. The secret value was not exposed to the model.", - } except queue.Empty: - remaining = cli._secret_deadline - _time.monotonic() - if remaining <= 0: + if cli._secret_deadline - _time.monotonic() <= 0: break - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _invalidate(cli) + continue + cli._secret_state = None + cli._secret_deadline = 0 + _invalidate(cli) + return _secret_result(var_name, value) cli._secret_state = None cli._secret_deadline = 0 - if hasattr(cli, "_clear_secret_input_buffer"): - try: - cli._clear_secret_input_buffer() - except Exception: - pass - elif hasattr(cli, "_app") and cli._app: - try: - cli._app.current_buffer.reset() - except Exception: - pass - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _clear_secret_input(cli) + _invalidate(cli) cprint(f"\n{_DIM} ⏱ Timeout — secret capture cancelled{_RST}") return { "success": True, @@ -199,12 +162,9 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: def approval_callback(cli, command: str, description: str) -> str: """Prompt for dangerous command approval through the TUI. - Shows a selection UI with choices: once / session / always / deny. - When the command is longer than 70 characters, a "view" option is - included so the user can reveal the full text before deciding. - - Uses cli._approval_lock to serialize concurrent requests (e.g. from - parallel delegation subtasks) so each prompt gets its own turn. + Shows a selection UI with choices: once / session / always / deny. When the command is longer + than 70 characters, a "view" option is included so the user can reveal the full text before + deciding. """ lock = getattr(cli, "_approval_lock", None) if lock is None: @@ -228,28 +188,23 @@ def approval_callback(cli, command: str, description: str) -> str: "response_queue": response_queue, } cli._approval_deadline = _time.monotonic() + timeout - - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _invalidate(cli) while True: try: result = response_queue.get(timeout=1) - cli._approval_state = None - cli._approval_deadline = 0 - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() - return result except queue.Empty: - remaining = cli._approval_deadline - _time.monotonic() - if remaining <= 0: + if cli._approval_deadline - _time.monotonic() <= 0: break - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _invalidate(cli) + continue + cli._approval_state = None + cli._approval_deadline = 0 + _invalidate(cli) + return result cli._approval_state = None cli._approval_deadline = 0 - if hasattr(cli, "_app") and cli._app: - cli._app.invalidate() + _invalidate(cli) cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}") return "timeout" diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py index 0d7c866b40..9796b20d6f 100644 --- a/hermes_cli/checkpoints.py +++ b/hermes_cli/checkpoints.py @@ -1,42 +1,18 @@ """`hermes checkpoints` CLI subcommand. -Gives users direct visibility and control over the filesystem checkpoint -store at ``~/.hermes/checkpoints/``. Actions: - - hermes checkpoints # same as `status` - hermes checkpoints status # total size, project count, breakdown - hermes checkpoints list # per-project checkpoint counts + workdir - hermes checkpoints prune [opts] # force a sweep (ignores the 24h marker) - hermes checkpoints clear [-f] # nuke the entire base (asks first) - hermes checkpoints clear-legacy # delete just the legacy-* archives - -Examples:: - - hermes checkpoints - hermes checkpoints prune --retention-days 3 --max-size-mb 200 - hermes checkpoints clear -f - -None of these require the agent to be running. Safe to call any time. +None of these require the agent to be running. Safe to call any time. """ from __future__ import annotations import argparse import time -from datetime import datetime from pathlib import Path from typing import Any, Optional from hermes_cli.sizefmt import format_bytes as _fmt_bytes -def _fmt_ts(ts: Any) -> str: - try: - return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M") - except (TypeError, ValueError): - return "—" - - def _fmt_age(ts: Any) -> str: try: age = time.time() - float(ts) @@ -44,12 +20,9 @@ def _fmt_age(ts: Any) -> str: return "—" if age < 0: return "now" - if age < 60: - return f"{int(age)}s ago" - if age < 3600: - return f"{int(age / 60)}m ago" - if age < 86400: - return f"{int(age / 3600)}h ago" + for bound, div, unit in ((60, 1, "s"), (3600, 60, "m"), (86400, 3600, "h")): + if age < bound: + return f"{int(age / div)}{unit} ago" return f"{int(age / 86400)}d ago" @@ -76,8 +49,7 @@ def cmd_status(args: argparse.Namespace) -> int: wd = p.get("workdir") or "(unknown)" if len(wd) > 60: wd = "…" + wd[-59:] - exists = p.get("exists") - state = "live" if exists else "orphan" + state = "live" if p.get("exists") else "orphan" commits = p.get("commits", 0) last = _fmt_age(p.get("last_touch")) print(f" {wd:<60} {commits:>7} {last:>12} {state}") @@ -86,16 +58,15 @@ def cmd_status(args: argparse.Namespace) -> int: if legacy: print() print(f"Legacy archives ({len(legacy)}):") - for arch in sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True): - print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}") + _print_archives(sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True)) print() print("Clear with: hermes checkpoints clear-legacy") return 0 -def cmd_list(args: argparse.Namespace) -> int: - # `list` is just a terser status — already covered. - return cmd_status(args) +def _print_archives(archives) -> None: + for arch in archives: + print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}") def cmd_prune(args: argparse.Namespace) -> int: @@ -126,11 +97,9 @@ def cmd_prune(args: argparse.Namespace) -> int: "orphan checkpoint project(s) whose workdir is not currently reachable:") print() for p in orphans: - wd = p.get("workdir") or "(unknown)" - print(f" {wd} ({p.get('commits', 0)} commit(s))") + print(f" {p.get('workdir') or '(unknown)'} ({p.get('commits', 0)} commit(s))") for p in pre_v2_orphans: - wd = p.get("workdir") or "(unknown)" - print(f" {wd} (pre-v2 shadow repo)") + print(f" {p.get('workdir') or '(unknown)'} (pre-v2 shadow repo)") print() print("A workdir can be unreachable because the project was deleted,") print("or because an external volume / network share / VPN is down.") @@ -177,6 +146,14 @@ def _confirm(prompt: str) -> bool: return resp in {"y", "yes"} +def _confirmed(args: argparse.Namespace, prompt: str) -> bool: + """``--force`` or an interactive yes; prints ``Aborted.`` otherwise.""" + if args.force or _confirm(prompt): + return True + print("Aborted.") + return False + + def cmd_clear(args: argparse.Namespace) -> int: from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status @@ -191,8 +168,7 @@ def cmd_clear(args: argparse.Namespace) -> int: print(f" legacy dirs: {len(info.get('legacy_archives', []))}") print() print("All /rollback history for every working directory will be lost.") - if not args.force and not _confirm("Proceed?"): - print("Aborted.") + if not _confirmed(args, "Proceed?"): return 1 result = clear_all() @@ -214,14 +190,12 @@ def cmd_clear_legacy(args: argparse.Namespace) -> int: total = sum(a.get("size_bytes", 0) for a in legacy) print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:") - for arch in legacy: - print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}") + _print_archives(legacy) print() print("Legacy archives hold pre-v2 per-project shadow repos, moved aside") print("during the single-store migration. Delete when you're confident") print("you don't need the old /rollback history.") - if not args.force and not _confirm("Delete all legacy archives?"): - print("Aborted.") + if not _confirmed(args, "Delete all legacy archives?"): return 1 result = clear_legacy() @@ -247,7 +221,7 @@ def register_cli(parser: argparse.ArgumentParser) -> None: help="Alias for 'status'", ) p_list.add_argument("--limit", type=int, default=20) - p_list.set_defaults(func=cmd_list) + p_list.set_defaults(func=cmd_status) p_prune = subs.add_parser( "prune", @@ -264,18 +238,11 @@ def register_cli(parser: argparse.ArgumentParser) -> None: help="Skip the orphan-deletion confirmation prompt") p_prune.set_defaults(func=cmd_prune) - p_clear = subs.add_parser( - "clear", - help="Delete the entire checkpoint base (all /rollback history)", - ) - p_clear.add_argument("-f", "--force", action="store_true", - help="Skip confirmation prompt") - p_clear.set_defaults(func=cmd_clear) - - p_legacy = subs.add_parser( - "clear-legacy", - help="Delete only the legacy-/ archives from v1 migration", - ) - p_legacy.add_argument("-f", "--force", action="store_true", - help="Skip confirmation prompt") - p_legacy.set_defaults(func=cmd_clear_legacy) + for name, help_text, func in ( + ("clear", "Delete the entire checkpoint base (all /rollback history)", cmd_clear), + ("clear-legacy", "Delete only the legacy-/ archives from v1 migration", cmd_clear_legacy), + ): + p_clear = subs.add_parser(name, help=help_text) + p_clear.add_argument("-f", "--force", action="store_true", + help="Skip confirmation prompt") + p_clear.set_defaults(func=func) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 8b78902a19..22e14a8c9a 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -1,14 +1,4 @@ -"""hermes claw — OpenClaw migration commands. - -Usage: - hermes claw migrate # Preview then migrate (always shows preview first) - hermes claw migrate --dry-run # Preview only, no changes - hermes claw migrate --yes # Skip confirmation prompt - hermes claw migrate --preset full --overwrite --migrate-secrets # Full run w/ secrets - hermes claw migrate --no-backup # Skip pre-migration snapshot - hermes claw cleanup # Archive leftover OpenClaw directories - hermes claw cleanup --dry-run # Preview what would be archived -""" +"""hermes claw — OpenClaw migration commands.""" import importlib.util import logging @@ -34,46 +24,53 @@ logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() -_OPENCLAW_SCRIPT = ( - get_optional_skills_dir(PROJECT_ROOT / "optional-skills") - / "migration" - / "openclaw-migration" - / "scripts" - / "openclaw_to_hermes.py" -) - +_SCRIPT_REL = Path("migration", "openclaw-migration", "scripts", "openclaw_to_hermes.py") +_OPENCLAW_SCRIPT = get_optional_skills_dir(PROJECT_ROOT / "optional-skills") / _SCRIPT_REL # Fallback: user may have installed the skill from the Hub -_OPENCLAW_SCRIPT_INSTALLED = ( - get_hermes_home() - / "skills" - / "migration" - / "openclaw-migration" - / "scripts" - / "openclaw_to_hermes.py" -) +_OPENCLAW_SCRIPT_INSTALLED = get_hermes_home() / "skills" / _SCRIPT_REL # Known OpenClaw directory names (current + legacy) _OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") -def _detect_openclaw_processes() -> list[str]: - """Detect running OpenClaw processes and services. - Returns a list of human-readable descriptions of what was found. - An empty list means nothing was detected. - """ +def _print_banner(title: str) -> None: + """Print the magenta boxed banner shared by the claw subcommands.""" + print() + for line in ( + "┌─────────────────────────────────────────────────────────┐", + f"│ ⚕ Hermes — {title:<35s}│", + "└─────────────────────────────────────────────────────────┘", + ): + print(color(line, Colors.MAGENTA)) + + +def _warn_running(running: list[str], headline: str, *lines: str) -> None: + """Print the 'OpenClaw is running' warning block.""" + print() + print_error(headline) + for detail in running: + print_info(f" * {detail}") + for line in lines: + print_info(line) + print() + +def _detect_openclaw_processes() -> list[str]: + """Detect running OpenClaw processes and services.""" found: list[str] = [] + def _posix_probe(cmd: list[str], timeout: int): + try: + return subprocess.run( + cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + # -- systemd service (Linux) ------------------------------------------ if sys.platform != "win32": - try: - result = subprocess.run( - ["systemctl", "--user", "is-active", "openclaw-gateway.service"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, - ) - if result.stdout.strip() == "active": - found.append("systemd service: openclaw-gateway.service") - except (FileNotFoundError, subprocess.TimeoutExpired): - pass + result = _posix_probe(["systemctl", "--user", "is-active", "openclaw-gateway.service"], 5) + if result is not None and result.stdout.strip() == "active": + found.append("systemd service: openclaw-gateway.service") # -- process scan ------------------------------------------------------ if sys.platform == "win32": @@ -108,16 +105,9 @@ def _detect_openclaw_processes() -> list[str]: except Exception: pass else: - try: - result = subprocess.run( - ["pgrep", "-f", "openclaw"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3, - ) - if result.returncode == 0: - pids = result.stdout.strip().split() - found.append(f"openclaw process(es) (PIDs: {', '.join(pids)})") - except (FileNotFoundError, subprocess.TimeoutExpired): - pass + result = _posix_probe(["pgrep", "-f", "openclaw"], 3) + if result is not None and result.returncode == 0: + found.append(f"openclaw process(es) (PIDs: {', '.join(result.stdout.strip().split())})") return found @@ -125,25 +115,21 @@ def _detect_openclaw_processes() -> list[str]: def _warn_if_openclaw_running(auto_yes: bool) -> None: """Warn if OpenClaw is still running before migration. - Telegram, Discord, and Slack only allow one active connection per bot - token. Migrating while OpenClaw is running causes both to fight for the - same token. + Telegram, Discord, and Slack only allow one active connection per bot token. Migrating while + OpenClaw is running causes both to fight for the same token. """ running = _detect_openclaw_processes() if not running: return - print() - print_error("OpenClaw appears to be running:") - for detail in running: - print_info(f" * {detail}") - print_info( + _warn_running( + running, + "OpenClaw appears to be running:", "Messaging platforms (Telegram, Discord, Slack) only allow one " "active session per bot token. If you continue, both OpenClaw and " - "Hermes may try to use the same token, causing disconnects." + "Hermes may try to use the same token, causing disconnects.", + "Recommendation: stop OpenClaw before migrating.", ) - print_info("Recommendation: stop OpenClaw before migrating.") - print() if auto_yes: return if not sys.stdin.isatty(): @@ -157,9 +143,8 @@ def _warn_if_openclaw_running(auto_yes: bool) -> None: def _warn_if_gateway_running(auto_yes: bool) -> None: """Check if a Hermes gateway is running with connected platforms. - Migrating bot tokens while the gateway is polling will cause conflicts - (e.g. Telegram 409 "terminated by other getUpdates request"). Warn the - user and let them decide whether to continue. + Migrating bot tokens while the gateway is polling causes conflicts (e.g. Telegram 409 + "terminated by other getUpdates request"); warn and let the user decide. """ from gateway.status import get_running_pid, read_runtime_status @@ -189,22 +174,10 @@ def _warn_if_gateway_running(auto_yes: bool) -> None: print_info("Migration cancelled. Stop the gateway and try again.") sys.exit(0) -# State files commonly found in OpenClaw workspace directories — listed -# during cleanup to help the user decide whether to archive -_WORKSPACE_STATE_GLOBS = ( - "*/todo.json", - "*/sessions/*", - "*/memory/*.json", - "*/logs/*", -) - def _find_migration_script() -> Path | None: """Find the openclaw_to_hermes.py script in known locations.""" - for candidate in [_OPENCLAW_SCRIPT, _OPENCLAW_SCRIPT_INSTALLED]: - if candidate.exists(): - return candidate - return None + return next((c for c in (_OPENCLAW_SCRIPT, _OPENCLAW_SCRIPT_INSTALLED) if c.exists()), None) def _load_migration_module(script_path: Path): @@ -226,71 +199,53 @@ def _load_migration_module(script_path: Path): def _find_openclaw_dirs() -> list[Path]: """Find all OpenClaw directories on disk.""" - found = [] - for name in _OPENCLAW_DIR_NAMES: - candidate = Path.home() / name - if candidate.is_dir(): - found.append(candidate) - return found + return [d for d in (Path.home() / name for name in _OPENCLAW_DIR_NAMES) if d.is_dir()] def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: - """Scan an OpenClaw directory for workspace state files. - - Returns a list of (path, description) tuples. - """ + """Scan an OpenClaw directory for workspace state files.""" findings: list[tuple[Path, str]] = [] if not source_dir.exists(): return findings + def _add(path: Path, scope: str) -> None: + if path.exists(): + kind = "directory" if path.is_dir() else "file" + findings.append((path, f"{scope} {kind}: {path.relative_to(source_dir).as_posix()}")) + # Direct state files in the root for name in ("todo.json", "sessions", "logs"): - candidate = source_dir / name - if candidate.exists(): - kind = "directory" if candidate.is_dir() else "file" - findings.append((candidate, f"Root {kind}: {name}")) + _add(source_dir / name, "Root") - # State files inside workspace directories + # State files inside workspace-like subdirectories try: children = sorted(source_dir.iterdir()) except OSError: return findings for child in children: - if not child.is_dir() or child.name.startswith("."): - continue - # Check for workspace-like subdirectories - for state_name in ("todo.json", "sessions", "logs", "memory"): - state_path = child / state_name - if state_path.exists(): - kind = "directory" if state_path.is_dir() else "file" - rel = state_path.relative_to(source_dir).as_posix() - findings.append((state_path, f"Workspace {kind}: {rel}")) + if child.is_dir() and not child.name.startswith("."): + for state_name in ("todo.json", "sessions", "logs", "memory"): + _add(child / state_name, "Workspace") return findings def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path: - """Rename an OpenClaw directory to .pre-migration. + """Rename an OpenClaw directory to .pre-migration.""" + base = f"{source_dir.name}.pre-migration" + archive_path = source_dir.parent / base - Returns the archive path. - """ - timestamp = datetime.now().strftime("%Y%m%d") - archive_name = f"{source_dir.name}.pre-migration" - archive_path = source_dir.parent / archive_name - - # If archive already exists, add timestamp + # If archive already exists, add timestamp; if it still exists (multiple + # runs same day), add a counter. if archive_path.exists(): - archive_name = f"{source_dir.name}.pre-migration-{timestamp}" - archive_path = source_dir.parent / archive_name - - # If still exists (multiple runs same day), add counter - counter = 2 - while archive_path.exists(): - archive_name = f"{source_dir.name}.pre-migration-{timestamp}-{counter}" - archive_path = source_dir.parent / archive_name - counter += 1 + timestamp = datetime.now().strftime("%Y%m%d") + archive_path = source_dir.parent / f"{base}-{timestamp}" + counter = 2 + while archive_path.exists(): + archive_path = source_dir.parent / f"{base}-{timestamp}-{counter}" + counter += 1 if not dry_run: source_dir.rename(archive_path) @@ -318,19 +273,9 @@ def claw_command(args): def _cmd_migrate(args): """Run the OpenClaw → Hermes migration.""" - # Check current and legacy OpenClaw directories + # Explicit --source, else first existing of current + legacy names; default to ~/.openclaw. explicit_source = getattr(args, "source", None) - if explicit_source: - source_dir = Path(explicit_source) - else: - source_dir = Path.home() / ".openclaw" - if not source_dir.is_dir(): - # Try legacy directory names - for legacy in (".clawdbot", ".moltbot"): - candidate = Path.home() / legacy - if candidate.is_dir(): - source_dir = candidate - break + source_dir = Path(explicit_source) if explicit_source else next(iter(_find_openclaw_dirs()), Path.home() / ".openclaw") dry_run = getattr(args, "dry_run", False) preset = getattr(args, "preset", "full") overwrite = getattr(args, "overwrite", False) @@ -345,25 +290,7 @@ def _cmd_migrate(args): # --include-secrets) and prevents a --preset full invocation from silently # importing API keys that the user may not have intended to copy. - print() - print( - color( - "┌─────────────────────────────────────────────────────────┐", - Colors.MAGENTA, - ) - ) - print( - color( - "│ ⚕ Hermes — OpenClaw Migration │", - Colors.MAGENTA, - ) - ) - print( - color( - "└─────────────────────────────────────────────────────────┘", - Colors.MAGENTA, - ) - ) + _print_banner("OpenClaw Migration") # Check source directory if not source_dir.is_dir(): @@ -415,24 +342,23 @@ def _cmd_migrate(args): # Load the migration module try: mod = _load_migration_module(script_path) - if mod is None: - print_error("Could not load migration script.") - return except Exception as e: print() print_error(f"Could not load migration script: {e}") logger.debug("OpenClaw migration error", exc_info=True) return + if mod is None: + print_error("Could not load migration script.") + return selected = mod.resolve_selected_options(None, None, preset=preset) ws_target = Path(workspace_target).resolve() if workspace_target else None - # ── Phase 1: Always preview first ────────────────────────── - try: - preview = mod.Migrator( + def _run_migrator(execute: bool) -> dict: + return mod.Migrator( source_root=source_dir.resolve(), target_root=hermes_home.resolve(), - execute=False, + execute=execute, workspace_target=ws_target, overwrite=overwrite, migrate_secrets=migrate_secrets, @@ -440,8 +366,11 @@ def _cmd_migrate(args): selected_options=selected, preset_name=preset, skill_conflict_mode=skill_conflict, - ) - preview_report = preview.migrate() + ).migrate() + + # ── Phase 1: Always preview first ────────────────────────── + try: + preview_report = _run_migrator(execute=False) except Exception as e: print() print_error(f"Migration preview failed: {e}") @@ -462,12 +391,11 @@ def _cmd_migrate(args): return print() - if preview_count > 0: - print_header(f"Migration Preview — {preview_count} item(s) would be imported") - else: - print_header( - f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported" - ) + print_header( + f"Migration Preview — {preview_count} item(s) would be imported" + if preview_count > 0 + else f"Migration Preview — {preview_conflicts} conflict(s), nothing would be imported" + ) print_info("No changes have been made yet. Review the list below:") _print_migration_report(preview_report, dry_run=True) @@ -531,19 +459,7 @@ def _cmd_migrate(args): return try: - migrator = mod.Migrator( - source_root=source_dir.resolve(), - target_root=hermes_home.resolve(), - execute=True, - workspace_target=ws_target, - overwrite=overwrite, - migrate_secrets=migrate_secrets, - output_dir=None, - selected_options=selected, - preset_name=preset, - skill_conflict_mode=skill_conflict, - ) - report = migrator.migrate() + report = _run_migrator(execute=True) except Exception as e: print() print_error(f"Migration failed: {e}") @@ -564,38 +480,16 @@ def _cmd_migrate(args): def _cmd_cleanup(args): """Archive leftover OpenClaw directories after migration. - Scans for OpenClaw directories that still exist after migration and offers - to rename them to .pre-migration to free disk space. + Scans for OpenClaw directories that still exist after migration and offers to rename them to + .pre-migration to free disk space. """ dry_run = getattr(args, "dry_run", False) auto_yes = getattr(args, "yes", False) explicit_source = getattr(args, "source", None) - print() - print( - color( - "┌─────────────────────────────────────────────────────────┐", - Colors.MAGENTA, - ) - ) - print( - color( - "│ ⚕ Hermes — OpenClaw Cleanup │", - Colors.MAGENTA, - ) - ) - print( - color( - "└─────────────────────────────────────────────────────────┘", - Colors.MAGENTA, - ) - ) + _print_banner("OpenClaw Cleanup") - # Find OpenClaw directories - if explicit_source: - dirs_to_check = [Path(explicit_source)] - else: - dirs_to_check = _find_openclaw_dirs() + dirs_to_check = [Path(explicit_source)] if explicit_source else _find_openclaw_dirs() if not dirs_to_check: print() @@ -606,16 +500,13 @@ def _cmd_cleanup(args): # active causes it to recreate an empty skeleton directory (#8502). running = _detect_openclaw_processes() if running: - print() - print_error("OpenClaw appears to be still running:") - for detail in running: - print_info(f" * {detail}") - print_info( + _warn_running( + running, + "OpenClaw appears to be still running:", "Archiving .openclaw/ while the service is active may cause it to " - "immediately recreate an empty skeleton directory, destroying your config." + "immediately recreate an empty skeleton directory, destroying your config.", + "Stop OpenClaw first: systemctl --user stop openclaw-gateway.service", ) - print_info("Stop OpenClaw first: systemctl --user stop openclaw-gateway.service") - print() if not auto_yes: if not sys.stdin.isatty(): print_info("Non-interactive session — aborting. Stop OpenClaw and re-run.") @@ -646,27 +537,16 @@ def _cmd_cleanup(args): if workspace_dirs: print_info(f"Workspace directories: {len(workspace_dirs)}") for ws in workspace_dirs[:5]: - items = [] - if (ws / "todo.json").exists(): - items.append("todo.json") - if (ws / "sessions").is_dir(): - items.append("sessions/") - if (ws / "SOUL.md").exists(): - items.append("SOUL.md") - if (ws / "MEMORY.md").exists(): - items.append("MEMORY.md") - detail = ", ".join(items) if items else "empty" - print(f" {ws.name}/ ({detail})") - if len(workspace_dirs) > 5: - print(f" ... and {len(workspace_dirs) - 5} more") + items = [label for name, label, check in _WORKSPACE_ITEM_LABELS if check(ws / name)] + print(f" {ws.name}/ ({', '.join(items) or 'empty'})") + _print_more(workspace_dirs, 5) if state_files: print() print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW)) - for path, desc in state_files[:8]: + for _path, desc in state_files[:8]: print(f" {desc}") - if len(state_files) > 8: - print(f" ... and {len(state_files) - 8} more") + _print_more(state_files, 8) print() @@ -691,28 +571,44 @@ def _cmd_cleanup(args): print() if dry_run: _n_dirs = len(dirs_to_check) - print_info( - f"Dry run complete. {_n_dirs} " - f"{'directory' if _n_dirs == 1 else 'directories'} would be archived." - ) + print_info(f"Dry run complete. {_n_dirs} {_dirs_word(_n_dirs)} would be archived.") print_info("Run without --dry-run to archive them.") elif total_archived: - print_success( - f"Cleaned up {total_archived} OpenClaw " - f"{'directory' if total_archived == 1 else 'directories'}." - ) + print_success(f"Cleaned up {total_archived} OpenClaw {_dirs_word(total_archived)}.") print_info("Directories were renamed, not deleted. You can undo by renaming them back.") else: print_info("No directories were archived.") +def _dirs_word(n: int) -> str: + return "directory" if n == 1 else "directories" + + +def _print_more(seq, shown: int) -> None: + if len(seq) > shown: + print(f" ... and {len(seq) - shown} more") + + +# (status, heading, color, default reason) — printed in this order after migrated items. +_REPORT_REASON_GROUPS = ( + ("conflict", " ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW, "already exists"), + ("skipped", " ─ Skipped:", Colors.DIM, ""), + ("error", " ✗ Errors:", Colors.RED, "unknown error"), +) + +# Workspace marker files listed by `hermes claw cleanup` (name, display label, presence check). +_WORKSPACE_ITEM_LABELS = ( + ("todo.json", "todo.json", Path.exists), + ("sessions", "sessions/", Path.is_dir), + ("SOUL.md", "SOUL.md", Path.exists), + ("MEMORY.md", "MEMORY.md", Path.exists), +) + + def _print_migration_report(report: dict, dry_run: bool): """Print a formatted migration report.""" summary = report.get("summary", {}) migrated = summary.get("migrated", 0) - skipped = summary.get("skipped", 0) - conflicts = summary.get("conflict", 0) - errors = summary.get("error", 0) print() if dry_run: @@ -725,66 +621,37 @@ def _print_migration_report(report: dict, dry_run: bool): # Detailed items items = report.get("items", []) - if items: - # Group by status - migrated_items = [i for i in items if i.get("status") == "migrated"] - skipped_items = [i for i in items if i.get("status") == "skipped"] - conflict_items = [i for i in items if i.get("status") == "conflict"] - error_items = [i for i in items if i.get("status") == "error"] + migrated_items = [i for i in items if i.get("status") == "migrated"] + if migrated_items: + label = "Would migrate" if dry_run else "Migrated" + print(color(f" ✓ {label}:", Colors.GREEN)) + for item in migrated_items: + kind = item.get("kind", "unknown") + dest = item.get("destination", "") + print(f" {kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" if dest else f" {kind}") + print() - if migrated_items: - label = "Would migrate" if dry_run else "Migrated" - print(color(f" ✓ {label}:", Colors.GREEN)) - for item in migrated_items: - kind = item.get("kind", "unknown") - dest = item.get("destination", "") - if dest: - dest_short = str(dest).replace(str(Path.home()), "~") - print(f" {kind:<22s} → {dest_short}") - else: - print(f" {kind}") - print() - - if conflict_items: - print(color(" ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW)) - for item in conflict_items: - kind = item.get("kind", "unknown") - reason = item.get("reason", "already exists") - print(f" {kind:<22s} {reason}") - print() - - if skipped_items: - print(color(" ─ Skipped:", Colors.DIM)) - for item in skipped_items: - kind = item.get("kind", "unknown") - reason = item.get("reason", "") - print(f" {kind:<22s} {reason}") - print() - - if error_items: - print(color(" ✗ Errors:", Colors.RED)) - for item in error_items: - kind = item.get("kind", "unknown") - reason = item.get("reason", "unknown error") - print(f" {kind:<22s} {reason}") - print() + for status, heading, heading_color, default_reason in _REPORT_REASON_GROUPS: + group = [i for i in items if i.get("status") == status] + if not group: + continue + print(color(heading, heading_color)) + for item in group: + print(f" {item.get('kind', 'unknown'):<22s} {item.get('reason', default_reason)}") + print() # Summary line - parts = [] - if migrated: - action = "would migrate" if dry_run else "migrated" - parts.append(f"{migrated} {action}") - if conflicts: - parts.append(f"{conflicts} conflict(s)") - if skipped: - parts.append(f"{skipped} skipped") - if errors: - parts.append(f"{errors} error(s)") - - if parts: - print_info(f"Summary: {', '.join(parts)}") - else: - print_info("Nothing to migrate.") + parts = [ + f"{count} {label}" + for count, label in ( + (migrated, "would migrate" if dry_run else "migrated"), + (summary.get("conflict", 0), "conflict(s)"), + (summary.get("skipped", 0), "skipped"), + (summary.get("error", 0), "error(s)"), + ) + if count + ] + print_info(f"Summary: {', '.join(parts)}" if parts else "Nothing to migrate.") # Output directory output_dir = report.get("output_dir") @@ -799,11 +666,7 @@ def _print_migration_report(report: dict, dry_run: bool): print() print_success("Migration complete!") # Warn if API keys were skipped (migrate_secrets not enabled) - skipped_keys = [ - i for i in report.get("items", []) - if i.get("kind") == "provider-keys" and i.get("status") == "skipped" - ] - if skipped_keys: + if any(i.get("kind") == "provider-keys" and i.get("status") == "skipped" for i in items): print() print(color(" ⚠ API keys were NOT migrated (secrets migration is disabled by default).", Colors.YELLOW)) print(color(" Your OPENROUTER_API_KEY and other provider keys must be added manually.", Colors.YELLOW)) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index 3881502e5d..307a5a8fe9 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -1,15 +1,12 @@ """Clipboard image extraction for macOS, Windows, Linux, and WSL2. -Provides a single function `save_clipboard_image(dest)` that checks the -system clipboard for image data, saves it to *dest* as PNG, and returns -True on success. No external Python dependencies — uses only OS-level -CLI tools that ship with the platform (or are commonly installed). +Provides a single function `save_clipboard_image(dest)` that checks the system clipboard for image +data, saves it to *dest* as PNG, and returns True on success. No external Python dependencies — uses +only OS-level CLI tools that ship with the platform (or are commonly installed). -Platform support: - macOS — osascript (always available), pngpaste (if installed) - Windows — PowerShell via WinForms, Get-Clipboard, file-drop fallback - WSL2 — powershell.exe via WinForms, Get-Clipboard, file-drop fallback - Linux — wl-paste (Wayland), xclip (X11) +Platform support: macOS — osascript (always available), pngpaste (if installed) Windows — PowerShell +via WinForms, Get-Clipboard, file-drop fallback WSL2 — powershell.exe via WinForms, Get-Clipboard, +file-drop fallback Linux — wl-paste (Wayland), xclip (X11) """ import base64 @@ -23,6 +20,33 @@ from hermes_constants import is_wsl as _is_wsl logger = logging.getLogger(__name__) _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_TEXT = dict(capture_output=True, text=True, encoding='utf-8', errors='replace') + + +def _nonempty(path: Path) -> bool: + return path.exists() and path.stat().st_size > 0 + + +def _probe(argv: list, timeout: int, ok, *, missing: str | None = None) -> bool: + """Run a text-mode probe; True when it ran and ``ok(result)`` holds. + A missing executable logs *missing* (when given); every other failure is silent.""" + try: + return bool(ok(subprocess.run(argv, timeout=timeout, **_TEXT))) + except FileNotFoundError: + if missing: + logger.debug(missing) + except Exception: + pass + return False + + +def _linux_backends(): + """(enabled, has_image, save) in Linux fallthrough order: WSL → Wayland → X11.""" + return ( + (_is_wsl(), _wsl_has_image, _wsl_save), + (bool(os.environ.get("WAYLAND_DISPLAY")), _wayland_has_image, _wayland_save), + (True, _xclip_has_image, _xclip_save), + ) def save_clipboard_image(dest: Path) -> bool: @@ -39,20 +63,12 @@ def save_clipboard_image(dest: Path) -> bool: def has_clipboard_image() -> bool: - """Quick check: does the clipboard currently contain an image? - - Lighter than save_clipboard_image — doesn't extract or write anything. - """ + """Quick check: does the clipboard currently contain an image?""" if sys.platform == "darwin": return _macos_has_image() if sys.platform == "win32": return _windows_has_image() - # Match _linux_save fallthrough order: WSL → Wayland → X11 - if _is_wsl() and _wsl_has_image(): - return True - if os.environ.get("WAYLAND_DISPLAY") and _wayland_has_image(): - return True - return _xclip_has_image() + return any(enabled and has() for enabled, has, _ in _linux_backends()) # ── Text write (native tools, mirrors ui-tui/src/lib/clipboard.ts) ────── @@ -87,10 +103,9 @@ def _write_clipboard_commands() -> list: def is_remote_shell_session(env=None) -> bool: """True when running inside an SSH session. - Mirrors ui-tui/src/lib/terminalSetup.ts isRemoteShellSession(). Over - SSH, native clipboard tools write the REMOTE machine's clipboard (or - an X-forwarded one), which is almost never what the user wants — - OSC 52 reaches the LOCAL terminal emulator instead. + Mirrors ui-tui/src/lib/terminalSetup.ts isRemoteShellSession(). Over SSH, native clipboard tools + write the REMOTE machine's clipboard (or an X-forwarded one), which is almost never what the + user wants — OSC 52 reaches the LOCAL terminal emulator instead. """ e = os.environ if env is None else env return bool( @@ -101,26 +116,21 @@ def is_remote_shell_session(env=None) -> bool: def write_clipboard_text(text: str) -> bool: """Write *text* to the system clipboard via native platform tools. - Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts): - macOS pbcopy → Windows/WSL PowerShell Set-Clipboard → wl-copy → - xclip → xsel. Returns True if any backend succeeded; callers should - fall back to OSC 52 on False. + Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts): macOS pbcopy → Windows/WSL + PowerShell Set-Clipboard → wl-copy → xclip → xsel. Returns True if any backend succeeded; + callers should fall back to OSC 52 on False. """ + data = text.encode("utf-8") + quiet = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for argv, use_stdin in _write_clipboard_commands(): try: if use_stdin: - proc = subprocess.run( - argv, input=text.encode("utf-8"), - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=10, - ) + proc = subprocess.run(argv, input=data, timeout=10, **quiet) else: - b64 = base64.b64encode(text.encode("utf-8")).decode("ascii") + b64 = base64.b64encode(data).decode("ascii") proc = subprocess.run( argv + ["-Command", _powershell_write_script(b64)], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=10, + stdin=subprocess.DEVNULL, timeout=10, **quiet, ) if proc.returncode == 0: return True @@ -138,24 +148,15 @@ def _macos_save(dest: Path) -> bool: def _macos_has_image() -> bool: """Check if macOS clipboard contains image data.""" - try: - info = subprocess.run( - ["osascript", "-e", "clipboard info"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3, - ) - return "«class PNGf»" in info.stdout or "«class TIFF»" in info.stdout - except Exception: - return False + return _probe(["osascript", "-e", "clipboard info"], 3, + lambda r: "«class PNGf»" in r.stdout or "«class TIFF»" in r.stdout) def _macos_pngpaste(dest: Path) -> bool: """Use pngpaste (brew install pngpaste) — fastest, cleanest.""" try: - r = subprocess.run( - ["pngpaste", str(dest)], - capture_output=True, timeout=3, - ) - if r.returncode == 0 and dest.exists() and dest.stat().st_size > 0: + r = subprocess.run(["pngpaste", str(dest)], capture_output=True, timeout=3) + if r.returncode == 0 and _nonempty(dest): return True except FileNotFoundError: pass # pngpaste not installed @@ -181,11 +182,8 @@ def _macos_osascript(dest: Path) -> bool: 'end try\n' ) try: - r = subprocess.run( - ["osascript", "-e", script], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, - ) - if r.returncode == 0 and "fail" not in r.stdout and dest.exists() and dest.stat().st_size > 0: + r = subprocess.run(["osascript", "-e", script], timeout=5, **_TEXT) + if r.returncode == 0 and "fail" not in r.stdout and _nonempty(dest): return True except Exception as e: logger.debug("osascript clipboard extract failed: %s", e) @@ -238,22 +236,22 @@ _PS_EXTRACT_IMAGE_GET_CLIPBOARD = ( ) _FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'" - -_PS_CHECK_FILEDROP_IMAGE = ( +_PS_FILEDROP_HIT = ( "try { " "$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;" f"$exts = @({_FILEDROP_IMAGE_EXTS});" "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" - "if ($null -ne $hit) { 'True' } else { 'False' }" +) + +_PS_CHECK_FILEDROP_IMAGE = ( + _PS_FILEDROP_HIT + + "if ($null -ne $hit) { 'True' } else { 'False' }" "} catch { 'False' }" ) _PS_EXTRACT_FILEDROP_IMAGE = ( - "try { " - "$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;" - f"$exts = @({_FILEDROP_IMAGE_EXTS});" - "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" - "if ($null -eq $hit) { exit 1 }" + _PS_FILEDROP_HIT + + "if ($null -eq $hit) { exit 1 }" "[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))" "} catch { exit 1 }" ) @@ -273,15 +271,13 @@ _POWERSHELL_EXTRACT_IMAGE_SCRIPTS = ( def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess: return subprocess.run( - [exe, "-NoProfile", "-NonInteractive", "-Command", script], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, + [exe, "-NoProfile", "-NonInteractive", "-Command", script], timeout=timeout, **_TEXT, ) def _write_base64_image(dest: Path, b64_data: str) -> bool: - image_bytes = base64.b64decode(b64_data, validate=True) - dest.write_bytes(image_bytes) - return dest.exists() and dest.stat().st_size > 0 + dest.write_bytes(base64.b64decode(b64_data, validate=True)) + return _nonempty(dest) def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool: @@ -304,12 +300,8 @@ def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> r = _run_powershell(exe, script, timeout=timeout) if r.returncode != 0: continue - b64_data = r.stdout.strip() - if not b64_data: - continue - - if _write_base64_image(dest, b64_data): + if b64_data and _write_base64_image(dest, b64_data): return True except FileNotFoundError: logger.debug("%s not found — clipboard unavailable", exe) @@ -330,14 +322,9 @@ def _find_powershell() -> str | None: """Return the first available PowerShell executable, or None.""" for name in ("powershell", "pwsh"): try: - r = subprocess.run( - [name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, - ) + r = _run_powershell(name, "echo ok", timeout=5) if r.returncode == 0 and "ok" in r.stdout: return name - except FileNotFoundError: - continue except Exception: continue return None @@ -374,17 +361,9 @@ def _windows_save(dest: Path) -> bool: # ── Linux ──────────────────────────────────────────────────────────────── def _linux_save(dest: Path) -> bool: - """Try clipboard backends in priority order: WSL → Wayland → X11.""" - if _is_wsl(): - if _wsl_save(dest): - return True - # Fall through — WSLg might have wl-paste or xclip working - - if os.environ.get("WAYLAND_DISPLAY"): - if _wayland_save(dest): - return True - - return _xclip_save(dest) + """Try clipboard backends in priority order: WSL → Wayland → X11 + (a failed WSL probe falls through — WSLg might have wl-paste or xclip working).""" + return any(enabled and save(dest) for enabled, _, save in _linux_backends()) # ── WSL2 (powershell.exe) ──────────────────────────────────────────────── @@ -402,69 +381,50 @@ def _wsl_save(dest: Path) -> bool: # ── Wayland (wl-paste) ────────────────────────────────────────────────── +_WAYLAND_MIME_PREFERENCE = ("image/png", "image/jpeg", "image/bmp", "image/gif", "image/webp") +_WL_LIST_TYPES = ["wl-paste", "--list-types"] +_WL_MISSING = "wl-paste not installed — Wayland clipboard unavailable" + + def _wayland_has_image() -> bool: """Check if Wayland clipboard has image content.""" - try: - r = subprocess.run( - ["wl-paste", "--list-types"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3, - ) - return r.returncode == 0 and any( - t.startswith("image/") for t in r.stdout.splitlines() - ) - except FileNotFoundError: - logger.debug("wl-paste not installed — Wayland clipboard unavailable") - except Exception: - pass - return False + return _probe(_WL_LIST_TYPES, 3, lambda r: r.returncode == 0 and any( + t.startswith("image/") for t in r.stdout.splitlines()), missing=_WL_MISSING) + + +def _pipe_to_file(argv: list, dest: Path) -> bool: + """Run *argv* with stdout redirected into *dest*; True when a non-empty file resulted.""" + with open(dest, "wb") as f: + subprocess.run(argv, stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True) + return _nonempty(dest) def _wayland_save(dest: Path) -> bool: """Use wl-paste to extract clipboard image (Wayland sessions).""" try: - # Check available MIME types - types_r = subprocess.run( - ["wl-paste", "--list-types"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3, - ) + types_r = subprocess.run(_WL_LIST_TYPES, timeout=3, **_TEXT) if types_r.returncode != 0: return False types = types_r.stdout.splitlines() - # Prefer PNG, fall back to other image formats - mime = None - for preferred in ("image/png", "image/jpeg", "image/bmp", - "image/gif", "image/webp"): - if preferred in types: - mime = preferred - break - + mime = next((m for m in _WAYLAND_MIME_PREFERENCE if m in types), None) if not mime: return False - # Extract the image data - with open(dest, "wb") as f: - subprocess.run( - ["wl-paste", "--type", mime], - stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True, - ) - - if not dest.exists() or dest.stat().st_size == 0: + if not _pipe_to_file(["wl-paste", "--type", mime], dest): dest.unlink(missing_ok=True) return False # save_clipboard_image() promises a PNG output path. Wayland can offer # JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before # returning success. - if mime != "image/png": - if not _convert_to_png(dest) or not _is_png_file(dest): - dest.unlink(missing_ok=True) - return False - + if mime != "image/png" and (not _convert_to_png(dest) or not _is_png_file(dest)): + dest.unlink(missing_ok=True) + return False return True except FileNotFoundError: - logger.debug("wl-paste not installed — Wayland clipboard unavailable") + logger.debug(_WL_MISSING) except Exception as e: logger.debug("wl-paste clipboard extraction failed: %s", e) dest.unlink(missing_ok=True) @@ -492,23 +452,21 @@ def _convert_to_png(path: Path) -> bool: ["convert", str(tmp), "png:" + str(path)], capture_output=True, timeout=5, ) - if r.returncode == 0 and path.exists() and path.stat().st_size > 0: + if r.returncode == 0 and _nonempty(path): tmp.unlink(missing_ok=True) return True - else: - # Convert failed — restore the original file - tmp.rename(path) - except FileNotFoundError: - logger.debug("ImageMagick not installed — cannot convert BMP to PNG") - if tmp.exists() and not path.exists(): - tmp.rename(path) + # Convert failed — restore the original file + tmp.rename(path) except Exception as e: - logger.debug("ImageMagick BMP→PNG conversion failed: %s", e) + if isinstance(e, FileNotFoundError): + logger.debug("ImageMagick not installed — cannot convert BMP to PNG") + else: + logger.debug("ImageMagick BMP→PNG conversion failed: %s", e) if tmp.exists() and not path.exists(): tmp.rename(path) # Can't convert — BMP is still usable as-is for most APIs - return path.exists() and path.stat().st_size > 0 + return _nonempty(path) def _is_png_file(path: Path) -> bool: @@ -522,45 +480,24 @@ def _is_png_file(path: Path) -> bool: # ── X11 (xclip) ───────────────────────────────────────────────────────── +_XCLIP_TARGETS = ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"] + + def _xclip_has_image() -> bool: """Check if X11 clipboard has image content.""" - try: - r = subprocess.run( - ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3, - ) - return r.returncode == 0 and "image/png" in r.stdout - except FileNotFoundError: - pass - except Exception: - pass - return False + return _probe(_XCLIP_TARGETS, 3, lambda r: r.returncode == 0 and "image/png" in r.stdout) def _xclip_save(dest: Path) -> bool: """Use xclip to extract clipboard image (X11 sessions).""" # Check if clipboard has image content - try: - targets = subprocess.run( - ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3, - ) - if "image/png" not in targets.stdout: - return False - except FileNotFoundError: - logger.debug("xclip not installed — X11 clipboard image paste unavailable") - return False - except Exception: + if not _probe(_XCLIP_TARGETS, 3, lambda r: "image/png" in r.stdout, + missing="xclip not installed — X11 clipboard image paste unavailable"): return False # Extract PNG data try: - with open(dest, "wb") as f: - subprocess.run( - ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], - stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True, - ) - if dest.exists() and dest.stat().st_size > 0: + if _pipe_to_file(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], dest): return True except Exception as e: logger.debug("xclip image extraction failed: %s", e) diff --git a/hermes_cli/colors.py b/hermes_cli/colors.py index 8c85b4c0b0..51186e37fe 100644 --- a/hermes_cli/colors.py +++ b/hermes_cli/colors.py @@ -5,18 +5,10 @@ import sys def should_use_color() -> bool: - """Return True when colored output is appropriate. - - Respects the NO_COLOR environment variable (https://no-color.org/) - and TERM=dumb, in addition to the existing TTY check. - """ - if os.environ.get("NO_COLOR") is not None: + """Return True when colored output is appropriate.""" + if os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb": return False - if os.environ.get("TERM") == "dumb": - return False - if not sys.stdout.isatty(): - return False - return True + return bool(sys.stdout.isatty()) class Colors: diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index cd4815e808..c74892216a 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -1,9 +1,7 @@ """Shell completion script generation for hermes CLI. -Walks the live argparse parser tree to generate accurate, always-up-to-date -completion scripts — no hardcoded subcommand lists, no extra dependencies. - -Supports bash, zsh, and fish. +Walks the live argparse parser tree to generate accurate, always-up-to-date completion scripts — no +hardcoded subcommand lists, no extra dependencies. """ from __future__ import annotations @@ -13,11 +11,7 @@ from typing import Any def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]: - """Recursively extract subcommands and flags from a parser. - - Uses _SubParsersAction._choices_actions to get canonical names (no aliases) - along with their help text. - """ + """Recursively extract subcommands and flags from a parser.""" flags: list[str] = [] subcommands: dict[str, Any] = {} @@ -78,19 +72,11 @@ def generate_bash(parser: argparse.ArgumentParser) -> str: f" esac\n" f" ;;" ) - elif info["subcommands"]: - subcmds = " ".join(sorted(info["subcommands"])) + elif info["subcommands"] or info["flags"]: + words = " ".join(sorted(info["subcommands"]) if info["subcommands"] else info["flags"]) cases.append( f" {cmd})\n" - f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n" - f" return\n" - f" ;;" - ) - elif info["flags"]: - flags = " ".join(info["flags"]) - cases.append( - f" {cmd})\n" - f" COMPREPLY=($(compgen -W \"{flags}\" -- \"$cur\"))\n" + f" COMPREPLY=($(compgen -W \"{words}\" -- \"$cur\"))\n" f" return\n" f" ;;" ) @@ -143,14 +129,17 @@ complete -F _hermes_completion hermes # Zsh # --------------------------------------------------------------------------- +def _zsh_describe_lines(subcommands: dict[str, Any], indent: str) -> str: + """One ``'name:help'`` line per subcommand, sorted, at the given indent.""" + return "\n".join( + f"{indent}'{sc}:{_clean(subcommands[sc].get('help', ''))}'" + for sc in sorted(subcommands) + ) + + def generate_zsh(parser: argparse.ArgumentParser) -> str: tree = _walk(parser) - - top_cmds_lines: list[str] = [] - for cmd in sorted(tree["subcommands"]): - help_text = _clean(tree["subcommands"][cmd].get("help", "")) - top_cmds_lines.append(f" '{cmd}:{help_text}'") - top_cmds_str = "\n".join(top_cmds_lines) + top_cmds_str = _zsh_describe_lines(tree["subcommands"], " " * 16) sub_cases: list[str] = [] for cmd in sorted(tree["subcommands"]): @@ -160,11 +149,7 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str: if cmd == "profile": # Profile subcommand: complete actions, then profile names for # actions that accept a profile argument. - sub_lines: list[str] = [] - for sc in sorted(info["subcommands"]): - sh = _clean(info["subcommands"][sc].get("help", "")) - sub_lines.append(f" '{sc}:{sh}'") - sub_str = "\n".join(sub_lines) + sub_str = _zsh_describe_lines(info["subcommands"], " " * 24) sub_cases.append( f" profile)\n" f" case ${{line[2]}} in\n" @@ -182,11 +167,7 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str: f" ;;" ) else: - sub_lines = [] - for sc in sorted(info["subcommands"]): - sh = _clean(info["subcommands"][sc].get("help", "")) - sub_lines.append(f" '{sc}:{sh}'") - sub_str = "\n".join(sub_lines) + sub_str = _zsh_describe_lines(info["subcommands"], " " * 20) safe = cmd.replace("-", "_") sub_cases.append( f" {cmd})\n" diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index a2cf380c71..ae892f2eb6 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -1,9 +1,4 @@ -"""Safe Hermes Console command engine. - -This module backs ``hermes console`` and is intentionally narrower than the -full Hermes CLI. It exposes a curated set of native adapters that can later be -shared by the dashboard console websocket without becoming a raw shell. -""" +"""Safe Hermes Console command engine.""" from __future__ import annotations @@ -14,7 +9,6 @@ import functools import importlib import io import json -import shlex import sys from dataclasses import dataclass from pathlib import Path @@ -194,14 +188,10 @@ def _choice_help(action: argparse._SubParsersAction, name: str) -> str: def _clean_summary(text: str | None) -> str: - if not text: - return "" - if text is argparse.SUPPRESS: + if not text or text is argparse.SUPPRESS: return "" summary = " ".join(str(text).split()) - if not summary: - return "" - if summary.startswith("Run `hermes "): + if not summary or summary.startswith("Run `hermes "): return "" return summary @@ -228,66 +218,58 @@ def _noop_console_command(_args: argparse.Namespace) -> None: return None +@dataclass(frozen=True) +class _CliSurface: + """How a CLI subcommand module hangs its argparse tree off a root subparsers action. + + ``kind`` selects the wiring convention: + * ``extracted`` — ``builder(subparsers, =fn)`` (hermes_cli.subcommands.*; fn from hermes_cli.main) + * ``registered`` — ``register(subparsers.add_parser(root))``; optional module-level ``handler`` as func + * ``builder`` — ``top = builder(subparsers)``; func = ``handler`` from hermes_cli.main + * ``adder`` — ``add(subparsers)`` wires its own func + """ + + kind: Literal["extracted", "registered", "builder", "adder"] + module: str + builder: str + handler: str | None = None + + def build(self, root: str, *, live: bool) -> _ArgumentParser: + """Build a throwaway parser. ``live=False`` wires no-op handlers (summary extraction only).""" + parser, subparsers = _parser_root() + module = importlib.import_module(self.module) + entry = getattr(module, self.builder) + if self.kind == "extracted": + fn = ( + getattr(importlib.import_module("hermes_cli.main"), self.handler) + if live + else _noop_console_command + ) + entry(subparsers, **{self.handler: fn}) + elif self.kind == "registered": + top_parser = subparsers.add_parser(root) + entry(top_parser) + if live and self.handler: + top_parser.set_defaults(func=getattr(module, self.handler)) + elif self.kind == "builder": + main_module = importlib.import_module("hermes_cli.main") if live else None + top_parser = entry(subparsers) + if live: + top_parser.set_defaults(func=getattr(main_module, self.handler)) + else: + entry(subparsers) + return parser + + # The CLI surface these helpers reflect is process-static: they import a # subcommand module and build a throwaway argparse tree purely to extract help # summaries. Nothing about the result changes across engine instances, but the # dashboard opens a fresh HermesConsoleEngine per /api/console connection, so # without memoization every reconnect re-imports + re-parses the whole surface. -# Cache by args (all hashable strings); callers only read the returned map. @functools.lru_cache(maxsize=None) -def _extracted_summaries( - module_name: str, - builder_name: str, - main_handler_name: str, -) -> dict[tuple[str, ...], str]: +def _surface_summaries(surface: _CliSurface, root: str) -> dict[tuple[str, ...], str]: try: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - builder = getattr(module, builder_name) - builder(subparsers, **{main_handler_name: _noop_console_command}) - return _summaries_from_parser(parser) - except Exception: - return {} - - -@functools.lru_cache(maxsize=None) -def _registered_summaries( - root: str, - module_name: str, - register_name: str, -) -> dict[tuple[str, ...], str]: - try: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - top_parser = subparsers.add_parser(root) - register = getattr(module, register_name) - register(top_parser) - return _summaries_from_parser(parser) - except Exception: - return {} - - -@functools.lru_cache(maxsize=None) -def _builder_summaries( - module_name: str, - builder_name: str, -) -> dict[tuple[str, ...], str]: - try: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - getattr(module, builder_name)(subparsers) - return _summaries_from_parser(parser) - except Exception: - return {} - - -@functools.lru_cache(maxsize=None) -def _adder_summaries(module_name: str, add_name: str) -> dict[tuple[str, ...], str]: - try: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - getattr(module, add_name)(subparsers) - return _summaries_from_parser(parser) + return _summaries_from_parser(surface.build(root, live=False)) except Exception: return {} @@ -299,209 +281,198 @@ def _invoke_namespace(args: argparse.Namespace) -> object: return func(args) -def _set_attrs(args: argparse.Namespace, **attrs: object) -> argparse.Namespace: - for name, value in attrs.items(): - setattr(args, name, value) - return args - - -def _dispatch_extracted_subcommand( - *, +def _dispatch( + surface: _CliSurface, root: str, fixed: Sequence[str], args: Sequence[str], - module_name: str, - builder_name: str, - main_handler_name: str, namespace_update: Callable[[argparse.Namespace], None] | None = None, ) -> str: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - main_module = importlib.import_module("hermes_cli.main") - builder = getattr(module, builder_name) - main_handler = getattr(main_module, main_handler_name) - builder(subparsers, **{main_handler_name: main_handler}) + parser = surface.build(root, live=True) namespace = parser.parse_args([root, *fixed, *args]) if namespace_update: namespace_update(namespace) return _capture_output(lambda: _invoke_namespace(namespace)) -def _dispatch_registered_subcommand( - *, - root: str, - fixed: Sequence[str], - args: Sequence[str], - module_name: str, - register_name: str, - handler_name: str | None = None, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> str: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - top_parser = subparsers.add_parser(root) - register = getattr(module, register_name) - register(top_parser) - if handler_name: - top_parser.set_defaults(func=getattr(module, handler_name)) - namespace = parser.parse_args([root, *fixed, *args]) - if namespace_update: - namespace_update(namespace) - return _capture_output(lambda: _invoke_namespace(namespace)) +def _paths(spec: str) -> list[tuple[str, ...]]: + """``"list, snapshot export"`` -> ``[("list",), ("snapshot", "export")]``; ``"."`` is the bare root.""" + return [() if item.strip() == "." else tuple(item.split()) for item in spec.split(",") if item.strip()] -def _dispatch_builder_subcommand( - *, - root: str, - fixed: Sequence[str], - args: Sequence[str], - module_name: str, - builder_name: str, - main_handler_name: str, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> str: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - main_module = importlib.import_module("hermes_cli.main") - top_parser = getattr(module, builder_name)(subparsers) - top_parser.set_defaults(func=getattr(main_module, main_handler_name)) - namespace = parser.parse_args([root, *fixed, *args]) - if namespace_update: - namespace_update(namespace) - return _capture_output(lambda: _invoke_namespace(namespace)) +def _sub(module: str, builder: str, handler: str) -> _CliSurface: + return _CliSurface("extracted", f"hermes_cli.subcommands.{module}", builder, handler) -def _dispatch_adder_subcommand( - *, - root: str, - fixed: Sequence[str], - args: Sequence[str], - module_name: str, - add_name: str, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> str: - parser, subparsers = _parser_root() - module = importlib.import_module(module_name) - getattr(module, add_name)(subparsers) - namespace = parser.parse_args([root, *fixed, *args]) - if namespace_update: - namespace_update(namespace) - return _capture_output(lambda: _invoke_namespace(namespace)) +def _reg(module: str, handler: str | None = None) -> _CliSurface: + return _CliSurface("registered", f"hermes_cli.{module}", "register_cli", handler) -def _extracted_handler( - root: str, - fixed: Sequence[str], - module_name: str, - builder_name: str, - main_handler_name: str, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> Callable[["HermesConsoleEngine", list[str]], str]: - def handler(_engine: HermesConsoleEngine, args: list[str]) -> str: - return _dispatch_extracted_subcommand( - root=root, - fixed=fixed, - args=args, - module_name=module_name, - builder_name=builder_name, - main_handler_name=main_handler_name, - namespace_update=namespace_update, - ) +# root -> (surface, paths, mutating paths). Registered in this order. +_CLI_FAMILIES: dict[str, tuple[_CliSurface, str, str]] = { + "dump": (_sub("dump", "build_dump_parser", "cmd_dump"), ".", ""), + "debug": (_sub("debug", "build_debug_parser", "cmd_debug"), "share, delete", "share, delete"), + "prompt-size": (_sub("prompt_size", "build_prompt_size_parser", "cmd_prompt_size"), ".", ""), + "insights": (_sub("insights", "build_insights_parser", "cmd_insights"), ".", ""), + "security": (_sub("security", "build_security_parser", "cmd_security"), "audit", ""), + "backup": (_sub("backup", "build_backup_parser", "cmd_backup"), ".", "."), + "import": (_sub("import_cmd", "build_import_cmd_parser", "cmd_import"), ".", "."), + "config": (_sub("config", "build_config_parser", "cmd_config"), "env-path, check", ""), + "tools": ( + _sub("tools", "build_tools_parser", "cmd_tools"), + "list, enable, disable, post-setup", + "enable, disable, post-setup", + ), + "plugins": ( + _sub("plugins", "build_plugins_parser", "cmd_plugins"), + "list, enable, disable, install, update, remove", + "enable, disable, install, update, remove", + ), + "skills": ( + _sub("skills", "build_skills_parser", "cmd_skills"), + "browse, search, inspect, list, check, list-modified, diff, install, update, audit, " + "uninstall, reset, opt-in, opt-out, repair-official, snapshot export, snapshot import, " + "tap list, tap add, tap remove", + "install, update, audit, uninstall, reset, opt-in, opt-out, repair-official, " + "snapshot export, snapshot import, tap add, tap remove", + ), + "mcp": ( + _sub("mcp", "build_mcp_parser", "cmd_mcp"), + "list, catalog, test, add, remove, install, login, reauth, configure, picker", + "add, remove, install, login, reauth, configure, picker", + ), + "memory": (_sub("memory", "build_memory_parser", "cmd_memory"), "status, off, reset", "off, reset"), + "auth": ( + _sub("auth", "build_auth_parser", "cmd_auth"), + "list, status, reset, add, remove, logout, spotify status, spotify login, spotify logout", + "reset, add, remove, logout, spotify login, spotify logout", + ), + "pairing": ( + _sub("pairing", "build_pairing_parser", "cmd_pairing"), + "list, approve, revoke, clear-pending", + "approve, revoke, clear-pending", + ), + "webhook": ( + _sub("webhook", "build_webhook_parser", "cmd_webhook"), + "list, subscribe, remove, test", + "subscribe, remove", + ), + "hooks": ( + _sub("hooks", "build_hooks_parser", "cmd_hooks"), + "list, test, doctor, revoke", + "test, doctor, revoke", + ), + "slack": (_sub("slack", "build_slack_parser", "cmd_slack"), "manifest", ""), + "profile": ( + _sub("profile", "build_profile_parser", "cmd_profile"), + "list, show, info, create, use, describe, rename, delete, export, import, install, update", + "create, use, describe, rename, delete, export, import, install, update", + ), + "cron": ( + _sub("cron", "build_cron_parser", "cmd_cron"), + "create, edit, remove, tick", + "create, edit, remove, tick", + ), + "portal": (_CliSurface("adder", "hermes_cli.portal_cli", "add_parser"), "info, tools", ""), + "project": ( + _CliSurface("builder", "hermes_cli.projects_cmd", "build_parser", "cmd_project"), + "list, show, create, add-folder, remove-folder, rename, set-primary, use, archive, " + "restore, bind-board", + "create, add-folder, remove-folder, rename, set-primary, use, archive, restore, bind-board", + ), + "kanban": ( + _CliSurface("builder", "hermes_cli.kanban", "build_parser", "cmd_kanban"), + "init, boards list, boards create, boards rm, boards switch, boards current, " + "boards rename, boards set-workdir, create, list, show, assign, reclaim, reassign, " + "diagnose, link, unlink, claim, comment, complete, edit, block, schedule, unblock, " + "promote, archive, stats, runs, heartbeat, assignments, context", + "init, boards create, boards rm, boards switch, boards rename, boards set-workdir, " + "create, assign, reclaim, reassign, link, unlink, claim, comment, complete, edit, " + "block, schedule, unblock, promote, archive", + ), + "bundles": ( + _reg("bundles", "bundles_command"), + "list, show, create, delete, reload", + "create, delete, reload", + ), + "checkpoints": ( + _reg("checkpoints"), + "status, list, prune, clear, clear-legacy", + "prune, clear, clear-legacy", + ), + "curator": ( + _reg("curator"), + "status, run, pause, resume, pin, unpin, restore, list-archived, archive, prune, " + "backup, rollback", + "run, pause, resume, pin, unpin, restore, archive, prune, backup, rollback", + ), + "pets": ( + _reg("pets"), + "list, install, select, show, off, scale, remove, doctor", + "install, select, off, scale, remove", + ), +} - return handler +# Only extracted/registered families skip nested prompts after console confirmation +# (builder/adder families never did). +_CONFIRMED_KINDS = {"extracted", "registered"} - -def _registered_handler( - root: str, - fixed: Sequence[str], - module_name: str, - register_name: str, - handler_name: str | None = None, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> Callable[["HermesConsoleEngine", list[str]], str]: - def handler(_engine: HermesConsoleEngine, args: list[str]) -> str: - return _dispatch_registered_subcommand( - root=root, - fixed=fixed, - args=args, - module_name=module_name, - register_name=register_name, - handler_name=handler_name, - namespace_update=namespace_update, - ) - - return handler - - -def _builder_handler( - root: str, - fixed: Sequence[str], - module_name: str, - builder_name: str, - main_handler_name: str, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> Callable[["HermesConsoleEngine", list[str]], str]: - def handler(_engine: HermesConsoleEngine, args: list[str]) -> str: - return _dispatch_builder_subcommand( - root=root, - fixed=fixed, - args=args, - module_name=module_name, - builder_name=builder_name, - main_handler_name=main_handler_name, - namespace_update=namespace_update, - ) - - return handler - - -def _adder_handler( - root: str, - fixed: Sequence[str], - module_name: str, - add_name: str, - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> Callable[["HermesConsoleEngine", list[str]], str]: - def handler(_engine: HermesConsoleEngine, args: list[str]) -> str: - return _dispatch_adder_subcommand( - root=root, - fixed=fixed, - args=args, - module_name=module_name, - add_name=add_name, - namespace_update=namespace_update, - ) - - return handler +_SEND_SURFACE = _CliSurface("adder", "hermes_cli.send_cmd", "register_send_subparser") def _register_command_family( engine: "HermesConsoleEngine", - *, root: str, - paths: Iterable[Sequence[str]], - handler_factory: Callable[[Sequence[str]], Callable[["HermesConsoleEngine", list[str]], str]], - mutating: Iterable[Sequence[str]] = (), - summary: str = "", - summaries: dict[tuple[str, ...], str] | None = None, - confirmation: str = "", + surface: _CliSurface, + paths: str, + mutating: str, ) -> None: - mutating_paths = {tuple(path) for path in mutating} - for child_path in paths: - child_key = tuple(child_path) - full_path = (root, *tuple(child_path)) + summaries = _surface_summaries(surface, root) + mutating_paths = set(_paths(mutating)) + namespace_update = _apply_confirmed_defaults if surface.kind in _CONFIRMED_KINDS else None + for child_path in _paths(paths): + full_path = (root, *child_path) usage = " ".join(full_path) - command_summary = summary or (summaries or {}).get(full_path) or f"Run `hermes {usage}`." + + def handler(_engine: HermesConsoleEngine, args: list[str], fixed=child_path) -> str: + return _dispatch(surface, root, fixed, args, namespace_update) + engine.register( full_path, usage, - command_summary, - handler_factory(tuple(child_path)), - mutating=child_key in mutating_paths, - confirmation=confirmation or f"Run `hermes {usage}`?", + summaries.get(full_path) or f"Run `hermes {usage}`.", + handler, + mutating=child_path in mutating_paths, + confirmation=f"Run `hermes {usage}`?", ) +_BLOCKED_TOP = frozenset( + "acp chat claw completion dashboard desktop fallback gateway gui login logout model moa " + "oneshot proxy serve setup uninstall update whatsapp whatsapp-cloud".split() +) + +_BLOCKED_PAIRS = { + ("config", "edit"): "`config edit` opens an editor and is not available in Hermes Console.", + ("mcp", "serve"): "`mcp serve` starts a server and is not available in Hermes Console.", + ("profile", "alias"): "`profile alias` creates shell wrappers and is not available in Hermes Console.", + ("skills", "config"): "`skills config` is interactive and is not available in Hermes Console.", + ("skills", "publish"): "`skills publish` is not available in Hermes Console.", + ("portal", "login"): "`portal login` is interactive and is not available in Hermes Console.", + ("portal", "open"): "`portal open` opens a browser and is not available in Hermes Console.", + ("kanban", "tail"): "`kanban tail` streams output and is not available in Hermes Console.", + ("kanban", "watch"): "`kanban watch` streams output and is not available in Hermes Console.", + ("kanban", "daemon"): "`kanban daemon` starts a service and is not available in Hermes Console.", + ("kanban", "dispatcher"): "`kanban dispatcher` starts a worker and is not available in Hermes Console.", + ("kanban", "swarm"): "`kanban swarm` starts agent work and is not available in Hermes Console.", + ("kanban", "decompose"): "`kanban decompose` starts agent work and is not available in Hermes Console.", + ("kanban", "specify"): "`kanban specify` starts agent work and is not available in Hermes Console.", + ("kanban", "gc"): "`kanban gc` is not available in Hermes Console.", + ("sessions", "delete"): "`sessions delete` and `sessions prune` are not available in Hermes Console.", + ("sessions", "prune"): "`sessions delete` and `sessions prune` are not available in Hermes Console.", +} + + class HermesConsoleEngine: """Curated line-command executor for Hermes Console.""" @@ -521,7 +492,7 @@ class HermesConsoleEngine: if tokens and tokens[0] == "hermes": tokens = tokens[1:] if not tokens: - return self._help_result() + return ConsoleResult("ok", output=self.help_text()) if _contains_shell_syntax(raw_line, tokens): raise ConsoleCommandError( @@ -575,555 +546,21 @@ class HermesConsoleEngine: return "\n".join(lines) def _register_defaults(self) -> None: - self.register(("status",), "status", "Show Hermes component status.", _status) - self.register(("version",), "version", "Show Hermes version information.", _version) - self.register(("doctor",), "doctor", "Run diagnostics without auto-fix.", _doctor) - self.register(("logs",), "logs [name] [-n N]", "Show recent Hermes logs.", _logs) - self.register(("sessions", "list"), "sessions list [--limit N]", "List recent sessions.", _sessions_list) - self.register(("sessions", "stats"), "sessions stats", "Show session store statistics.", _sessions_stats) - self.register(("config", "show"), "config show", "Show current configuration.", _config_show) - self.register(("config", "path"), "config path", "Print config.yaml path.", _config_path) - self.register( - ("config", "set"), - "config set ", - "Set a configuration value.", - _config_set, - mutating=True, - confirmation="Update Hermes configuration?", - ) - self.register(("cron", "list"), "cron list [--all]", "List scheduled jobs.", _cron_list) - self.register(("cron", "status"), "cron status", "Show cron scheduler status.", _cron_status) - self.register( - ("cron", "pause"), - "cron pause ", - "Pause a scheduled job.", - _cron_pause, - mutating=True, - confirmation="Pause this cron job?", - ) - self.register( - ("cron", "resume"), - "cron resume ", - "Resume a paused cron job.", - _cron_resume, - mutating=True, - confirmation="Resume this cron job?", - ) - self.register( - ("cron", "run"), - "cron run ", - "Run a job on the next scheduler tick.", - _cron_run, - mutating=True, - confirmation="Trigger this cron job?", - ) - self._register_broad_cli_surface() - - def _register_broad_cli_surface(self) -> None: - """Register non-admin CLI commands that are safe for Hermes Console.""" - - extracted = { - "dump": ( - "hermes_cli.subcommands.dump", - "build_dump_parser", - "cmd_dump", - [()], - set(), - ), - "debug": ( - "hermes_cli.subcommands.debug", - "build_debug_parser", - "cmd_debug", - [("share",), ("delete",)], - {("share",), ("delete",)}, - ), - "prompt-size": ( - "hermes_cli.subcommands.prompt_size", - "build_prompt_size_parser", - "cmd_prompt_size", - [()], - set(), - ), - "insights": ( - "hermes_cli.subcommands.insights", - "build_insights_parser", - "cmd_insights", - [()], - set(), - ), - "security": ( - "hermes_cli.subcommands.security", - "build_security_parser", - "cmd_security", - [("audit",)], - set(), - ), - "backup": ( - "hermes_cli.subcommands.backup", - "build_backup_parser", - "cmd_backup", - [()], - {()}, - ), - "import": ( - "hermes_cli.subcommands.import_cmd", - "build_import_cmd_parser", - "cmd_import", - [()], - {()}, - ), - "config": ( - "hermes_cli.subcommands.config", - "build_config_parser", - "cmd_config", - [("env-path",), ("check",)], - set(), - ), - "tools": ( - "hermes_cli.subcommands.tools", - "build_tools_parser", - "cmd_tools", - [("list",), ("enable",), ("disable",), ("post-setup",)], - {("enable",), ("disable",), ("post-setup",)}, - ), - "plugins": ( - "hermes_cli.subcommands.plugins", - "build_plugins_parser", - "cmd_plugins", - [("list",), ("enable",), ("disable",), ("install",), ("update",), ("remove",)], - {("enable",), ("disable",), ("install",), ("update",), ("remove",)}, - ), - "skills": ( - "hermes_cli.subcommands.skills", - "build_skills_parser", - "cmd_skills", - [ - ("browse",), - ("search",), - ("inspect",), - ("list",), - ("check",), - ("list-modified",), - ("diff",), - ("install",), - ("update",), - ("audit",), - ("uninstall",), - ("reset",), - ("opt-in",), - ("opt-out",), - ("repair-official",), - ("snapshot", "export"), - ("snapshot", "import"), - ("tap", "list"), - ("tap", "add"), - ("tap", "remove"), - ], - { - ("install",), - ("update",), - ("audit",), - ("uninstall",), - ("reset",), - ("opt-in",), - ("opt-out",), - ("repair-official",), - ("snapshot", "export"), - ("snapshot", "import"), - ("tap", "add"), - ("tap", "remove"), - }, - ), - "mcp": ( - "hermes_cli.subcommands.mcp", - "build_mcp_parser", - "cmd_mcp", - [ - ("list",), - ("catalog",), - ("test",), - ("add",), - ("remove",), - ("install",), - ("login",), - ("reauth",), - ("configure",), - ("picker",), - ], - { - ("add",), - ("remove",), - ("install",), - ("login",), - ("reauth",), - ("configure",), - ("picker",), - }, - ), - "memory": ( - "hermes_cli.subcommands.memory", - "build_memory_parser", - "cmd_memory", - [("status",), ("off",), ("reset",)], - {("off",), ("reset",)}, - ), - "auth": ( - "hermes_cli.subcommands.auth", - "build_auth_parser", - "cmd_auth", - [ - ("list",), - ("status",), - ("reset",), - ("add",), - ("remove",), - ("logout",), - ("spotify", "status"), - ("spotify", "login"), - ("spotify", "logout"), - ], - { - ("reset",), - ("add",), - ("remove",), - ("logout",), - ("spotify", "login"), - ("spotify", "logout"), - }, - ), - "pairing": ( - "hermes_cli.subcommands.pairing", - "build_pairing_parser", - "cmd_pairing", - [("list",), ("approve",), ("revoke",), ("clear-pending",)], - {("approve",), ("revoke",), ("clear-pending",)}, - ), - "webhook": ( - "hermes_cli.subcommands.webhook", - "build_webhook_parser", - "cmd_webhook", - [("list",), ("subscribe",), ("remove",), ("test",)], - {("subscribe",), ("remove",)}, - ), - "hooks": ( - "hermes_cli.subcommands.hooks", - "build_hooks_parser", - "cmd_hooks", - [("list",), ("test",), ("doctor",), ("revoke",)], - {("test",), ("doctor",), ("revoke",)}, - ), - "slack": ( - "hermes_cli.subcommands.slack", - "build_slack_parser", - "cmd_slack", - [("manifest",)], - set(), - ), - "profile": ( - "hermes_cli.subcommands.profile", - "build_profile_parser", - "cmd_profile", - [ - ("list",), - ("show",), - ("info",), - ("create",), - ("use",), - ("describe",), - ("rename",), - ("delete",), - ("export",), - ("import",), - ("install",), - ("update",), - ], - { - ("create",), - ("use",), - ("describe",), - ("rename",), - ("delete",), - ("export",), - ("import",), - ("install",), - ("update",), - }, - ), - "cron": ( - "hermes_cli.subcommands.cron", - "build_cron_parser", - "cmd_cron", - [("create",), ("edit",), ("remove",), ("tick",)], - {("create",), ("edit",), ("remove",), ("tick",)}, - ), - } - - for root, (module, builder, main_handler, paths, mutating) in extracted.items(): - summaries = _extracted_summaries(module, builder, main_handler) - _register_command_family( - self, - root=root, - paths=paths, - mutating=mutating, - summaries=summaries, - handler_factory=lambda fixed, root=root, module=module, builder=builder, main_handler=main_handler: _extracted_handler( - root, - fixed, - module, - builder, - main_handler, - namespace_update=_apply_confirmed_defaults, - ), - ) - - self.register( - ("config", "migrate"), - "config migrate", - "Update config with new options.", - _config_migrate, - mutating=True, - confirmation="Update Hermes configuration with missing defaults?", - ) - self.register( - ("sessions", "export"), - "sessions export [--source SOURCE] [--session-id ID]", - "Export sessions to JSONL.", - _sessions_export, - mutating=True, - confirmation="Export session data?", - ) - self.register( - ("sessions", "rename"), - "sessions rename ", - "Rename a session.", - _sessions_rename, - mutating=True, - confirmation="Rename this session?", - ) - self.register( - ("sessions", "optimize"), - "sessions optimize", - "Optimize the session store.", - _sessions_optimize, - mutating=True, - confirmation="Optimize the session database?", - ) - self.register( - ("sessions", "repair"), - "sessions repair [--check-only] [--no-backup]", - "Repair a malformed session database schema.", - _sessions_repair, - mutating=True, - confirmation="Repair the session database?", - ) - - self.register( - ("profile",), - "profile", - "Show active profile status.", - _profile_status, - ) + for path, usage, summary, handler in _READONLY_COMMANDS: + self.register(path, usage, summary, handler) + for path, usage, summary, handler, confirmation in _MUTATING_COMMANDS: + self.register(path, usage, summary, handler, mutating=True, confirmation=confirmation) + for root, (surface, paths, mutating) in _CLI_FAMILIES.items(): + _register_command_family(self, root, surface, paths, mutating) self.register( ("send",), "send --to <target> <message>", "Send a message to a configured platform.", - _adder_handler("send", (), "hermes_cli.send_cmd", "register_send_subparser"), + lambda _engine, args: _dispatch(_SEND_SURFACE, "send", (), args), mutating=True, confirmation="Send this message?", ) - portal_paths = [("info",), ("tools",)] - _register_command_family( - self, - root="portal", - paths=portal_paths, - summaries=_adder_summaries("hermes_cli.portal_cli", "add_parser"), - handler_factory=lambda fixed: _adder_handler( - "portal", - fixed, - "hermes_cli.portal_cli", - "add_parser", - ), - ) - - _register_command_family( - self, - root="project", - paths=[ - ("list",), - ("show",), - ("create",), - ("add-folder",), - ("remove-folder",), - ("rename",), - ("set-primary",), - ("use",), - ("archive",), - ("restore",), - ("bind-board",), - ], - summaries=_builder_summaries("hermes_cli.projects_cmd", "build_parser"), - mutating=[ - ("create",), - ("add-folder",), - ("remove-folder",), - ("rename",), - ("set-primary",), - ("use",), - ("archive",), - ("restore",), - ("bind-board",), - ], - handler_factory=lambda fixed: _builder_handler( - "project", - fixed, - "hermes_cli.projects_cmd", - "build_parser", - "cmd_project", - ), - ) - - _register_command_family( - self, - root="kanban", - paths=[ - ("init",), - ("boards", "list"), - ("boards", "create"), - ("boards", "rm"), - ("boards", "switch"), - ("boards", "current"), - ("boards", "rename"), - ("boards", "set-workdir"), - ("create",), - ("list",), - ("show",), - ("assign",), - ("reclaim",), - ("reassign",), - ("diagnose",), - ("link",), - ("unlink",), - ("claim",), - ("comment",), - ("complete",), - ("edit",), - ("block",), - ("schedule",), - ("unblock",), - ("promote",), - ("archive",), - ("stats",), - ("runs",), - ("heartbeat",), - ("assignments",), - ("context",), - ], - summaries=_builder_summaries("hermes_cli.kanban", "build_parser"), - mutating=[ - ("init",), - ("boards", "create"), - ("boards", "rm"), - ("boards", "switch"), - ("boards", "rename"), - ("boards", "set-workdir"), - ("create",), - ("assign",), - ("reclaim",), - ("reassign",), - ("link",), - ("unlink",), - ("claim",), - ("comment",), - ("complete",), - ("edit",), - ("block",), - ("schedule",), - ("unblock",), - ("promote",), - ("archive",), - ], - handler_factory=lambda fixed: _builder_handler( - "kanban", - fixed, - "hermes_cli.kanban", - "build_parser", - "cmd_kanban", - ), - ) - - registered = { - "bundles": ( - "hermes_cli.bundles", - "register_cli", - "bundles_command", - [("list",), ("show",), ("create",), ("delete",), ("reload",)], - {("create",), ("delete",), ("reload",)}, - ), - "checkpoints": ( - "hermes_cli.checkpoints", - "register_cli", - None, - [("status",), ("list",), ("prune",), ("clear",), ("clear-legacy",)], - {("prune",), ("clear",), ("clear-legacy",)}, - ), - "curator": ( - "hermes_cli.curator", - "register_cli", - None, - [ - ("status",), - ("run",), - ("pause",), - ("resume",), - ("pin",), - ("unpin",), - ("restore",), - ("list-archived",), - ("archive",), - ("prune",), - ("backup",), - ("rollback",), - ], - { - ("run",), - ("pause",), - ("resume",), - ("pin",), - ("unpin",), - ("restore",), - ("archive",), - ("prune",), - ("backup",), - ("rollback",), - }, - ), - "pets": ( - "hermes_cli.pets", - "register_cli", - None, - [("list",), ("install",), ("select",), ("show",), ("off",), ("scale",), ("remove",), ("doctor",)], - {("install",), ("select",), ("off",), ("scale",), ("remove",)}, - ), - } - for root, (module, register, handler_name, paths, mutating) in registered.items(): - summaries = _registered_summaries(root, module, register) - _register_command_family( - self, - root=root, - paths=paths, - mutating=mutating, - summaries=summaries, - handler_factory=lambda fixed, root=root, module=module, register=register, handler_name=handler_name: _registered_handler( - root, - fixed, - module, - register, - handler_name=handler_name, - namespace_update=_apply_confirmed_defaults, - ), - ) - def register( self, path: Iterable[str], @@ -1144,7 +581,6 @@ class HermesConsoleEngine: confirmation=confirmation, ) - def _execute_builtin(self, tokens: list[str]) -> ConsoleResult | None: head = tokens[0] if head == "help": @@ -1183,59 +619,9 @@ class HermesConsoleEngine: first = tokens[0] if first.startswith("-"): return f"{first} is not available in Hermes Console." - blocked_top = { - "acp", - "chat", - "claw", - "completion", - "dashboard", - "desktop", - "fallback", - "gateway", - "gui", - "login", - "logout", - "model", - "moa", - "oneshot", - - "proxy", - "serve", - "setup", - "uninstall", - "update", - "whatsapp", - "whatsapp-cloud", - } - if first in blocked_top: + if first in _BLOCKED_TOP: return f"`hermes {first}` is not available in Hermes Console." - blocked_pairs = { - ("config", "edit"): "`config edit` opens an editor and is not available in Hermes Console.", - ("mcp", "serve"): "`mcp serve` starts a server and is not available in Hermes Console.", - ("profile", "alias"): "`profile alias` creates shell wrappers and is not available in Hermes Console.", - ("skills", "config"): "`skills config` is interactive and is not available in Hermes Console.", - ("skills", "publish"): "`skills publish` is not available in Hermes Console.", - ("portal", "login"): "`portal login` is interactive and is not available in Hermes Console.", - ("portal", "open"): "`portal open` opens a browser and is not available in Hermes Console.", - ("kanban", "tail"): "`kanban tail` streams output and is not available in Hermes Console.", - ("kanban", "watch"): "`kanban watch` streams output and is not available in Hermes Console.", - ("kanban", "daemon"): "`kanban daemon` starts a service and is not available in Hermes Console.", - ("kanban", "dispatcher"): "`kanban dispatcher` starts a worker and is not available in Hermes Console.", - ("kanban", "swarm"): "`kanban swarm` starts agent work and is not available in Hermes Console.", - ("kanban", "decompose"): "`kanban decompose` starts agent work and is not available in Hermes Console.", - ("kanban", "specify"): "`kanban specify` starts agent work and is not available in Hermes Console.", - ("kanban", "gc"): "`kanban gc` is not available in Hermes Console.", - } - if len(tokens) >= 2: - pair = (tokens[0], tokens[1]) - if pair in blocked_pairs: - return blocked_pairs[pair] - if tuple(tokens[:2]) in {("sessions", "delete"), ("sessions", "prune")}: - return "`sessions delete` and `sessions prune` are not available in Hermes Console." - return "" - - def _help_result(self) -> ConsoleResult: - return ConsoleResult("ok", output=self.help_text()) + return _BLOCKED_PAIRS.get(tuple(tokens[:2]), "") def _cap_output(self, output: str) -> str: if len(output) <= self.output_limit: @@ -1262,9 +648,12 @@ def _apply_confirmed_defaults(args: argparse.Namespace) -> None: # _confirm() for its orphan preview, and the console never redirects stdin. if getattr(args, "checkpoints_command", None) in {"prune", "clear", "clear-legacy"}: setattr(args, "force", True) - if getattr(args, "plugins_action", None) == "install": - if not getattr(args, "enable", False) and not getattr(args, "no_enable", False): - setattr(args, "no_enable", True) + if ( + getattr(args, "plugins_action", None) == "install" + and not getattr(args, "enable", False) + and not getattr(args, "no_enable", False) + ): + setattr(args, "no_enable", True) if getattr(args, "auth_action", None) == "add": auth_type = getattr(args, "auth_type", None) if auth_type in {"api-key", "api_key"} and not getattr(args, "api_key", None): @@ -1568,14 +957,7 @@ def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> str: def _profile_status(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "profile") - return _dispatch_extracted_subcommand( - root="profile", - fixed=(), - args=(), - module_name="hermes_cli.subcommands.profile", - builder_name="build_profile_parser", - main_handler_name="cmd_profile", - ) + return _dispatch(_CLI_FAMILIES["profile"][0], "profile", (), ()) def _cron_list(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -1594,18 +976,28 @@ def _cron_status(_engine: HermesConsoleEngine, args: list[str]) -> str: return _capture_output(cron_status) -def _cron_pause(_engine: HermesConsoleEngine, args: list[str]) -> str: +def _cron_job_action(args: list[str], usage: str, action: str, run) -> str: + """Shared body for single-job cron commands: ``run(job_ref) -> job | None``.""" if len(args) != 1: - raise ConsoleCommandError("Usage: cron pause <job>") - from cron.jobs import AmbiguousJobReference, pause_job + raise ConsoleCommandError(f"Usage: {usage}") + from cron.jobs import AmbiguousJobReference try: - job = pause_job(args[0], reason="paused from hermes console") + job = run(args[0]) except AmbiguousJobReference as exc: raise ConsoleCommandError(str(exc)) from exc if not job: raise ConsoleCommandError(f"Job not found: {args[0]}") - return _format_job(job, "Paused") + return _format_job(job, action) + + +def _cron_pause(_engine: HermesConsoleEngine, args: list[str]) -> str: + from cron.jobs import pause_job + + return _cron_job_action( + args, "cron pause <job>", "Paused", + lambda ref: pause_job(ref, reason="paused from hermes console"), + ) def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -1623,9 +1015,7 @@ def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: job = rearm_oneshot(ns.job, _hermes_now().isoformat() if ns.run_now else ns.at) else: job = resume_job(ns.job) - except AmbiguousJobReference as exc: - raise ConsoleCommandError(str(exc)) from exc - except ValueError as exc: + except (AmbiguousJobReference, ValueError) as exc: raise ConsoleCommandError(str(exc)) from exc if not job: raise ConsoleCommandError(f"Job not found: {ns.job}") @@ -1633,17 +1023,44 @@ def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: def _cron_run(_engine: HermesConsoleEngine, args: list[str]) -> str: - if len(args) != 1: - raise ConsoleCommandError("Usage: cron run <job>") - from cron.jobs import AmbiguousJobReference, trigger_job + from cron.jobs import trigger_job - try: - job = trigger_job(args[0]) - except AmbiguousJobReference as exc: - raise ConsoleCommandError(str(exc)) from exc - if not job: - raise ConsoleCommandError(f"Job not found: {args[0]}") - return _format_job(job, "Triggered") + return _cron_job_action(args, "cron run <job>", "Triggered", trigger_job) + + +_READONLY_COMMANDS = ( + (("status",), "status", "Show Hermes component status.", _status), + (("version",), "version", "Show Hermes version information.", _version), + (("doctor",), "doctor", "Run diagnostics without auto-fix.", _doctor), + (("logs",), "logs [name] [-n N]", "Show recent Hermes logs.", _logs), + (("sessions", "list"), "sessions list [--limit N]", "List recent sessions.", _sessions_list), + (("sessions", "stats"), "sessions stats", "Show session store statistics.", _sessions_stats), + (("config", "show"), "config show", "Show current configuration.", _config_show), + (("config", "path"), "config path", "Print config.yaml path.", _config_path), + (("cron", "list"), "cron list [--all]", "List scheduled jobs.", _cron_list), + (("cron", "status"), "cron status", "Show cron scheduler status.", _cron_status), + (("profile",), "profile", "Show active profile status.", _profile_status), +) + +# (path, usage, summary, handler, confirmation prompt) +_MUTATING_COMMANDS = ( + (("config", "set"), "config set <key> <value>", "Set a configuration value.", _config_set, + "Update Hermes configuration?"), + (("cron", "pause"), "cron pause <job>", "Pause a scheduled job.", _cron_pause, "Pause this cron job?"), + (("cron", "resume"), "cron resume <job>", "Resume a paused cron job.", _cron_resume, "Resume this cron job?"), + (("cron", "run"), "cron run <job>", "Run a job on the next scheduler tick.", _cron_run, + "Trigger this cron job?"), + (("config", "migrate"), "config migrate", "Update config with new options.", _config_migrate, + "Update Hermes configuration with missing defaults?"), + (("sessions", "export"), "sessions export <output> [--source SOURCE] [--session-id ID]", + "Export sessions to JSONL.", _sessions_export, "Export session data?"), + (("sessions", "rename"), "sessions rename <session> <title>", "Rename a session.", _sessions_rename, + "Rename this session?"), + (("sessions", "optimize"), "sessions optimize", "Optimize the session store.", _sessions_optimize, + "Optimize the session database?"), + (("sessions", "repair"), "sessions repair [--check-only] [--no-backup]", + "Repair a malformed session database schema.", _sessions_repair, "Repair the session database?"), +) def run_console_repl( diff --git a/hermes_cli/context_switch_guard.py b/hermes_cli/context_switch_guard.py index 561a7f9108..bb2f089511 100644 --- a/hermes_cli/context_switch_guard.py +++ b/hermes_cli/context_switch_guard.py @@ -1,14 +1,4 @@ -"""Warn when an in-session model switch will trigger preflight compression on the next turn. - -Addresses part of #23767 ("user-facing guardrail when switching from a -high-context provider to a substantially lower-context provider"). The other -proposed fixes from that issue (hard preflight token guard, metadata cache -invalidation on switch, compression safety invariant, oversized tool-output -handling) are tracked separately. - -Mirrors the expensive-model guard pattern: merge into ``ModelSwitchResult.warning_message`` -so Herm TUI, CLI, and gateway surfaces that already show switch warnings pick it up. -""" +"""Warn when an in-session model switch will trigger preflight compression on the next turn.""" from __future__ import annotations diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index c6bdf6d01c..65801fcc7a 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -1,11 +1,4 @@ -"""CLI subcommand: `hermes curator <subcommand>`. - -Thin shell around agent/curator.py and tools/skill_usage.py. Renders a status -table, triggers a run, pauses/resumes, and pins/unpins skills. - -This module intentionally has no side effects at import time — main.py wires -the argparse subparsers on demand. -""" +"""CLI subcommand: `hermes curator <subcommand>`.""" from __future__ import annotations @@ -16,17 +9,22 @@ from pathlib import Path from typing import Optional -def _fmt_ts(ts: Optional[str]) -> str: - if not ts: - return "never" +def _parse_ts(ts) -> Optional[datetime]: + """ISO timestamp -> aware UTC datetime, or None when unparseable.""" try: dt = datetime.fromisoformat(ts) except (TypeError, ValueError): + return None + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + + +def _fmt_ts(ts: Optional[str]) -> str: + if not ts: + return "never" + dt = _parse_ts(ts) + if dt is None: return str(ts) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - delta = datetime.now(timezone.utc) - dt - secs = int(delta.total_seconds()) + secs = int((datetime.now(timezone.utc) - dt).total_seconds()) if secs < 60: return f"{secs}s ago" if secs < 3600: @@ -36,15 +34,39 @@ def _fmt_ts(ts: Optional[str]) -> str: return f"{secs // 86400}d ago" +def _confirm(prompt: str, cancel: str = "cancelled", eof_prefix: str = "\n") -> bool: + """Ask ``prompt``; print ``cancel`` (prefixed on EOF/Ctrl-C) and return False unless y/yes.""" + try: + reply = input(prompt).strip().lower() + except (EOFError, KeyboardInterrupt): + print(f"{eof_prefix}{cancel}") + return False + if reply not in {"y", "yes"}: + print(cancel) + return False + return True + + +def _print_skill_rows(title: str, rows: list) -> None: + print(f"\n{title}:") + for r in rows: + print( + f" {r['name']:40s} " + f"activity={r.get('activity_count', 0):3d} " + f"use={r.get('use_count', 0):3d} " + f"view={r.get('view_count', 0):3d} " + f"patches={r.get('patch_count', 0):3d} " + f"last_activity={_fmt_ts(r.get('last_activity_at'))}" + ) + + def _print_unmanaged_summary() -> None: """Report curation-eligible skills that carry no provenance marker. - A skill only becomes curator-managed once ``created_by: agent`` lands on - its usage record, which happens ONLY for background-review creations. - Skills predating that marker, plus every foreground - ``skill_manage(create)``, are eligible but unmanaged — no automatic - transition ever considers them. Printing just the managed count made a - large library look fully curated while a big slice was untouchable. + A skill only becomes curator-managed once ``created_by: agent`` lands on its usage record, which + happens ONLY for background-review creations. Skills predating that marker, plus every + foreground ``skill_manage(create)``, are eligible but unmanaged — no automatic transition ever + considers them. """ from tools import skill_usage @@ -152,17 +174,7 @@ def _cmd_status(args) -> int: key=lambda r: r.get("last_activity_at") or r.get("created_at") or "", )[:5] if active: - print("\nleast recently active (top 5):") - for r in active: - last = _fmt_ts(r.get("last_activity_at")) - print( - f" {r['name']:40s} " - f"activity={r.get('activity_count', 0):3d} " - f"use={r.get('use_count', 0):3d} " - f"view={r.get('view_count', 0):3d} " - f"patches={r.get('patch_count', 0):3d} " - f"last_activity={last}" - ) + _print_skill_rows("least recently active (top 5)", active) # Show top 5 most-active and least-active skills by activity_count # (use + view + patch). This is a different signal from @@ -172,40 +184,15 @@ def _cmd_status(args) -> int: # recent but low-frequency. Both can matter. active_all = by_state.get("active", []) if active_all: - most_active = sorted( - active_all, - key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""), - reverse=True, - )[:5] - if most_active and (most_active[0].get("activity_count") or 0) > 0: - print("\nmost active (top 5):") - for r in most_active: - last = _fmt_ts(r.get("last_activity_at")) - print( - f" {r['name']:40s} " - f"activity={r.get('activity_count', 0):3d} " - f"use={r.get('use_count', 0):3d} " - f"view={r.get('view_count', 0):3d} " - f"patches={r.get('patch_count', 0):3d} " - f"last_activity={last}" - ) + def _freq(r): + return (r.get("activity_count") or 0, r.get("last_activity_at") or "") - least_active = sorted( - active_all, - key=lambda r: (r.get("activity_count") or 0, r.get("last_activity_at") or ""), - )[:5] + most_active = sorted(active_all, key=_freq, reverse=True)[:5] + if most_active and (most_active[0].get("activity_count") or 0) > 0: + _print_skill_rows("most active (top 5)", most_active) + least_active = sorted(active_all, key=_freq)[:5] if least_active: - print("\nleast active (top 5):") - for r in least_active: - last = _fmt_ts(r.get("last_activity_at")) - print( - f" {r['name']:40s} " - f"activity={r.get('activity_count', 0):3d} " - f"use={r.get('use_count', 0):3d} " - f"view={r.get('view_count', 0):3d} " - f"patches={r.get('patch_count', 0):3d} " - f"last_activity={last}" - ) + _print_skill_rows("least active (top 5)", least_active) return 0 @@ -273,81 +260,75 @@ def _cmd_run(args) -> int: return 0 -def _cmd_pause(args) -> int: +def _set_paused(paused: bool) -> int: from agent import curator - curator.set_paused(True) - print("curator: paused") + curator.set_paused(paused) + print("curator: paused" if paused else "curator: resumed") return 0 +def _cmd_pause(args) -> int: + return _set_paused(True) + + def _cmd_resume(args) -> int: - from agent import curator - curator.set_paused(False) - print("curator: resumed") + return _set_paused(False) + + +_PIN_MESSAGES = { + True: ( + "cannot pin (only agent-created skills participate in curation)", + "could not pin '{skill}' — the skill is not curation-eligible (protected built-in or " + "external). `hermes curator list-unmanaged` shows which skills the curator tracks.", + # Unmanaged (pre-marker) skills are never touched by auto-transitions, + # so "will bypass auto-transitions" overstates what this pin does. The + # pin IS recorded (and visible in `curator status`) but only becomes + # protective once the skill is adopted. Say so and point at `adopt`. + "pinned '{skill}' (recorded; this skill is unmanaged — auto-transitions never consider " + "it. Run `hermes curator adopt {skill}` to put it under curator management)", + "pinned '{skill}' (will bypass auto-transitions)", + ), + False: ( + "there's nothing to unpin (curator only tracks agent-created skills)", + "could not unpin '{skill}' — the skill is not curation-eligible (protected built-in or " + "external).", + "unpinned '{skill}' (recorded; this skill is unmanaged — it was never under " + "auto-transitions to begin with)", + "unpinned '{skill}'", + ), +} + + +def _set_pin(args, pinned: bool) -> int: + from tools import skill_usage + not_agent, not_eligible, unmanaged, done = _PIN_MESSAGES[pinned] + skill = args.skill + if not skill_usage.is_agent_created(skill): + print(f"curator: '{skill}' is bundled or hub-installed — {not_agent}") + return 1 + if not skill_usage.set_pinned(skill, pinned): + print("curator: " + not_eligible.replace("{skill}", skill)) + return 1 + if not skill_usage.is_curator_managed(skill): + print("curator: " + unmanaged.replace("{skill}", skill)) + return 0 + print("curator: " + done.replace("{skill}", skill)) return 0 def _cmd_pin(args) -> int: - from tools import skill_usage - if not skill_usage.is_agent_created(args.skill): - print( - f"curator: '{args.skill}' is bundled or hub-installed — cannot pin " - "(only agent-created skills participate in curation)" - ) - return 1 - if not skill_usage.set_pinned(args.skill, True): - print( - f"curator: could not pin '{args.skill}' — the skill is not " - "curation-eligible (protected built-in or external). " - "`hermes curator list-unmanaged` shows which skills the curator tracks." - ) - return 1 - if not skill_usage.is_curator_managed(args.skill): - # Unmanaged (pre-marker) skills are never touched by auto-transitions, - # so "will bypass auto-transitions" overstates what this pin does. The - # pin IS recorded (and now visible in `curator status`, #92993) but - # only becomes protective once the skill is adopted. Say so, and point - # at the handover command (#93002). - print( - f"curator: pinned '{args.skill}' (recorded; this skill is unmanaged " - "— auto-transitions never consider it. Run " - f"`hermes curator adopt {args.skill}` to put it under curator " - "management)" - ) - return 0 - print(f"curator: pinned '{args.skill}' (will bypass auto-transitions)") - return 0 + return _set_pin(args, True) def _cmd_unpin(args) -> int: - from tools import skill_usage - if not skill_usage.is_agent_created(args.skill): - print( - f"curator: '{args.skill}' is bundled or hub-installed — " - "there's nothing to unpin (curator only tracks agent-created skills)" - ) - return 1 - if not skill_usage.set_pinned(args.skill, False): - print( - f"curator: could not unpin '{args.skill}' — the skill is not " - "curation-eligible (protected built-in or external)." - ) - return 1 - if not skill_usage.is_curator_managed(args.skill): - print( - f"curator: unpinned '{args.skill}' (recorded; this skill is " - "unmanaged — it was never under auto-transitions to begin with)" - ) - return 0 - print(f"curator: unpinned '{args.skill}'") - return 0 + return _set_pin(args, False) def _cmd_list_unmanaged(args) -> int: """List curation-eligible skills that carry no provenance marker. - The same population `status` summarizes, itemized. Useful before deciding - what to hand over with `adopt`. + The same population `status` summarizes, itemized. Useful before deciding what to hand over with + `adopt`. """ from tools import skill_usage @@ -374,11 +355,9 @@ def _cmd_list_unmanaged(args) -> int: def _cmd_adopt(args) -> int: """Hand unmanaged skills to the curator by explicit user declaration. - Provenance cannot be inferred from telemetry: a high patch count proves - the agent MAINTAINS a skill, not that it AUTHORED it (the agent edits - user-written skills on the user's behalf constantly). So adoption is never - automatic — the user names what they're handing over, or passes - ``--all-unmanaged`` to hand over every eligible skill at once. + Provenance cannot be inferred from telemetry: a high patch count proves the agent MAINTAINS a + skill, not that it AUTHORED it (the agent edits user-written skills on the user's behalf + constantly). """ from tools import skill_usage @@ -408,12 +387,7 @@ def _cmd_adopt(args) -> int: if adopt_all and not bool(getattr(args, "yes", False)): print(f"curator: adopt {len(names)} unmanaged skill(s) into curator management?") print(" they become eligible for automatic staleness + archival") - try: - reply = input(" proceed? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - reply = "" - if reply not in {"y", "yes"}: - print("curator: aborted") + if not _confirm(" proceed? [y/N] ", "curator: aborted", eof_prefix=""): return 1 failed = 0 @@ -427,64 +401,54 @@ def _cmd_adopt(args) -> int: return 1 if failed else 0 -def _cmd_restore(args) -> int: - from tools import skill_ledger, skill_usage +def _as_user(fn, skill: str) -> int: + """Run a skill mutation with the ledger actor set to ``user``; print and map its result.""" + from tools import skill_ledger tok = skill_ledger.set_ledger_actor("user") try: - ok, msg = skill_usage.restore_skill(args.skill) + ok, msg = fn(skill) finally: skill_ledger.reset_ledger_actor(tok) print(f"curator: {msg}") return 0 if ok else 1 -def _cmd_archive(args) -> int: - """Manually archive an agent-created skill. Refuses if pinned. +def _cmd_restore(args) -> int: + from tools import skill_usage + return _as_user(skill_usage.restore_skill, args.skill) - The auto-curator archives stale skills on its own schedule; this verb is - for the user who wants to archive *now* without waiting for a run. - """ - from tools import skill_ledger, skill_usage + +def _cmd_archive(args) -> int: + """Manually archive an agent-created skill. Refuses if pinned.""" + from tools import skill_usage if skill_usage.get_record(args.skill).get("pinned"): print( f"curator: '{args.skill}' is pinned — unpin first with " f"`hermes curator unpin {args.skill}`" ) return 1 - tok = skill_ledger.set_ledger_actor("user") - try: - ok, msg = skill_usage.archive_skill(args.skill) - finally: - skill_ledger.reset_ledger_actor(tok) - print(f"curator: {msg}") - return 0 if ok else 1 + return _as_user(skill_usage.archive_skill, args.skill) def _idle_days(record: dict) -> Optional[int]: """Days since the skill's last activity (view / use / patch). - Falls back to ``created_at`` so a skill that was authored but never used - can still be pruned — otherwise never-touched skills would be immortal. - Returns None only when both fields are missing or unparseable. + Falls back to ``created_at`` so a skill that was authored but never used can still be pruned — + otherwise never-touched skills would be immortal. Returns None only when both fields are missing + or unparseable. """ ts = record.get("last_activity_at") or record.get("created_at") - if not ts: + dt = _parse_ts(str(ts)) if ts else None + if dt is None: return None - try: - dt = datetime.fromisoformat(str(ts)) - except (TypeError, ValueError): - return None - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) return max(0, (datetime.now(timezone.utc) - dt).days) def _cmd_prune(args) -> int: """Bulk-archive curator-managed skills idle for >= N days. - Pinned skills are exempt. Already-archived skills are skipped. Default - ``--days 90`` matches a conservative read of the curator's own archive - threshold; adjust with ``--days``. Use ``--dry-run`` to preview. + Pinned skills are exempt and already-archived skills are skipped. Default ``--days 90`` is a + conservative read of the curator's own archive threshold; ``--dry-run`` previews. """ from tools import skill_usage days = getattr(args, "days", 90) @@ -519,15 +483,8 @@ def _cmd_prune(args) -> int: print("\n(dry run — no changes made)") return 0 - if not skip_confirm: - try: - reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\ncurator: aborted") - return 1 - if reply not in {"y", "yes"}: - print("curator: aborted") - return 1 + if not skip_confirm and not _confirm(f"\nArchive {len(candidates)} skill(s)? [y/N] ", "curator: aborted"): + return 1 archived = 0 failures = [] @@ -600,9 +557,9 @@ def _cmd_ledger(args) -> int: def _cmd_purge(args) -> int: """Delete archived skills older than curator.archive_ttl_days. - Explicit command only — never runs automatically. Respects the ledger: - each purged skill is captured (before-blobs) and recorded as a 'purge' - entry, so even a purge is auditable and blob-recoverable. + Explicit command only — never runs automatically. Respects the ledger: each purged skill is + captured (before-blobs) and recorded as a 'purge' entry, so even a purge is auditable and blob- + recoverable. """ from hermes_cli.config import cfg_get, load_config from tools import skill_ledger @@ -641,15 +598,10 @@ def _cmd_purge(args) -> int: if getattr(args, "dry_run", False): print("(dry run — nothing deleted)") return 0 - if not getattr(args, "yes", False): - try: - ans = input(f"Permanently delete {len(candidates)} archived skill(s)? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\ncancelled") - return 1 - if ans not in {"y", "yes"}: - print("cancelled") - return 1 + if not getattr(args, "yes", False) and not _confirm( + f"Permanently delete {len(candidates)} archived skill(s)? [y/N] " + ): + return 1 purged = 0 for p in sorted(candidates): @@ -675,17 +627,12 @@ def _cmd_purge(args) -> int: def _cmd_rollback(args) -> int: - """Restore the skills tree from a snapshot, or a single mutation from - the audit ledger. + """Restore the skills tree from a snapshot, or a single mutation from the audit ledger. - With a positional ``entry_id``, restores exactly the files touched by - that one ledger entry (from content-addressed blobs), taking a - pre-rollback safety ledger entry first — and failing closed when that - safety capture fails. Without it, behaves as before: whole-tree tarball - restore. ``--list`` prints available snapshots and exits. ``--id - <stamp>`` picks a specific snapshot. Without ``-y``, prompts for - confirmation. A safety snapshot of the current tree is always taken - first, so rollbacks are themselves undoable. + With a positional ``entry_id``, restores exactly the files touched by that one ledger entry + (from content-addressed blobs), taking a pre-rollback safety ledger entry first — and failing + closed when that safety capture fails. Without it, behaves as before: whole-tree tarball + restore. """ from agent import curator_backup @@ -708,15 +655,8 @@ def _cmd_rollback(args) -> int: print(f" when: {entry.get('ts', '?')}") touched = {i.get("path") for i in (entry.get("before") or []) + (entry.get("after") or [])} print(f" files: {len(touched)}") - if not getattr(args, "yes", False): - try: - ans = input("Restore this mutation's before-state? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\ncancelled") - return 1 - if ans not in {"y", "yes"}: - print("cancelled") - return 1 + if not getattr(args, "yes", False) and not _confirm("Restore this mutation's before-state? [y/N] "): + return 1 ok, msg = skill_ledger.rollback_entry(entry_id) if ok: print(f"curator: {msg}") @@ -769,15 +709,8 @@ def _cmd_rollback(args) -> int: "restored from the snapshot; all other cron fields are left alone." ) - if not getattr(args, "yes", False): - try: - ans = input("Proceed? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\ncancelled") - return 1 - if ans not in {"y", "yes"}: - print("cancelled") - return 1 + if not getattr(args, "yes", False) and not _confirm("Proceed? [y/N] "): + return 1 ok, msg, _ = curator_backup.rollback(backup_id=target_path.name) if ok: @@ -802,9 +735,8 @@ def _cmd_list_archived(args) -> int: def _cmd_usage(args) -> int: """Show usage telemetry for ALL skills, with provenance. - Unlike `status` (curator-scoped to curated candidates), this lists - every skill on disk — bundled built-ins and hub-installed included — so you - can see how often each is actually used regardless of curation. + Unlike ``status`` (scoped to curated candidates), this lists every skill on disk — bundled + and hub-installed included — so real usage is visible regardless of curation. """ import json as _json from tools import skill_usage @@ -815,13 +747,14 @@ def _cmd_usage(args) -> int: if prov_filter: rows = [r for r in rows if r.get("provenance") == prov_filter] + # name: alphabetical; recent: most-recently-active first (never-active sinks + # to the bottom); activity (default): most-used first. sort_key = getattr(args, "sort", "activity") if sort_key == "name": rows.sort(key=lambda r: r["name"]) elif sort_key == "recent": - # Most-recently-active first; never-active sinks to the bottom. rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True) - else: # "activity" (default): most-used first + else: rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True) if getattr(args, "json", False): @@ -863,200 +796,137 @@ def _cmd_usage(args) -> int: # argparse wiring (called from hermes_cli.main) # --------------------------------------------------------------------------- -def register_cli(parent: argparse.ArgumentParser) -> None: - """Attach `curator` subcommands to *parent*. +# (name, parser kwargs, handler, ((flags...), add_argument kwargs) ...) +_SUBCOMMANDS = ( + ("status", {"help": "Show curator status and skill stats"}, _cmd_status), + ( + "usage", + {"help": "Show usage telemetry for ALL skills (built-in, hub, agent) with provenance"}, + _cmd_usage, + (("--sort",), dict( + choices=("activity", "recent", "name"), default="activity", + help="Sort order: activity (most-used first, default), recent " + "(most-recently-active first), or name (alphabetical)", + )), + (("--provenance",), dict( + choices=("agent", "bundled", "hub"), default=None, help="Only show skills of this origin", + )), + (("--json",), dict(action="store_true", help="Emit the full report as JSON instead of a table")), + ), + ( + "run", + {"help": "Trigger a curator review now"}, + _cmd_run, + (("--sync", "--synchronous"), dict( + dest="synchronous", action="store_true", + help="Wait for the LLM review pass to finish (default for manual runs)", + )), + (("--background",), dict( + dest="background", action="store_true", + help="Start the LLM review pass in a background thread and return immediately", + )), + (("--dry-run",), dict( + dest="dry_run", action="store_true", + help="Report only — no state changes, no archives, no consolidation " + "(use this to preview what curator would do)", + )), + (("--consolidate",), dict( + dest="consolidate", action="store_true", + help="Force the LLM umbrella-building consolidation pass on for this " + "run, overriding the config default (off). Without this flag the " + "run is prune-only unless `curator.consolidate: true` is set.", + )), + ), + ("pause", {"help": "Pause the curator until resumed"}, _cmd_pause), + ("resume", {"help": "Resume a paused curator"}, _cmd_resume), + ( + "pin", {"help": "Pin a skill so the curator never auto-transitions it"}, _cmd_pin, + (("skill",), dict(help="Skill name")), + ), + ("unpin", {"help": "Unpin a skill"}, _cmd_unpin, (("skill",), dict(help="Skill name"))), + ( + "list-unmanaged", + {"help": "List curation-eligible skills with no provenance marker"}, + _cmd_list_unmanaged, + ), + ( + "adopt", + {"help": "Hand unmanaged skills to the curator (provenance is a user declaration)"}, + _cmd_adopt, + (("skill",), dict(nargs="*", help="Skill name(s) to adopt. Omit when using --all-unmanaged.")), + (("--all-unmanaged",), dict( + action="store_true", help="Adopt every curation-eligible skill that has no provenance marker", + )), + (("--dry-run",), dict(action="store_true", help="List what would be adopted without writing anything")), + (("--yes",), dict(action="store_true", help="Skip the confirmation prompt for --all-unmanaged")), + ), + ("restore", {"help": "Restore an archived skill"}, _cmd_restore, (("skill",), dict(help="Skill name"))), + ("list-archived", {"help": "List archived skills"}, _cmd_list_archived), + ( + "archive", + {"help": "Manually archive a skill (move to .archive/, excluded from prompt)"}, + _cmd_archive, + (("skill",), dict(help="Skill name")), + ), + ( + "prune", + {"help": "Bulk-archive curator-managed skills idle for >= N days (default 90)"}, + _cmd_prune, + (("--days",), dict(type=int, default=90, help="Archive skills idle for at least N days (default: 90)")), + (("-y", "--yes"), dict(action="store_true", help="Skip the confirmation prompt")), + (("--dry-run",), dict( + dest="dry_run", action="store_true", help="Show what would be archived without doing it", + )), + ), + ( + "backup", + {"help": "Take a manual tar.gz snapshot of ~/.hermes/skills/ " + "(curator also does this automatically before every real run)"}, + _cmd_backup, + (("--reason",), dict(default=None, help="Free-text label stored in manifest.json (default: 'manual')")), + ), + ( + "rollback", + {"help": "Restore ~/.hermes/skills/ from a curator snapshot, or a single " + "mutation by ledger entry id (see `hermes curator ledger`)"}, + _cmd_rollback, + (("entry_id",), dict( + nargs="?", default=None, + help="Ledger entry id for single-mutation rollback (from " + "`hermes curator ledger`). Omit for whole-tree snapshot rollback.", + )), + (("--list",), dict(action="store_true", help="List available snapshots and exit without restoring")), + (("--id",), dict(dest="backup_id", default=None, help="Snapshot id to restore (see `--list`); default: newest")), + (("-y", "--yes"), dict(action="store_true", help="Skip confirmation prompt")), + ), + ( + "ledger", + {"help": "List the per-mutation skill audit ledger (all actors: curator/agent/user)"}, + _cmd_ledger, + (("--skill",), dict(default=None, help="Only show entries for this skill")), + (("--limit",), dict(type=int, default=20, help="Max entries to show (default: 20)")), + ), + ( + "purge", + {"help": "Delete archived skills older than curator.archive_ttl_days " + "(explicit only — never automatic; recorded in the ledger)"}, + _cmd_purge, + (("--days",), dict(type=int, default=None, help="Override curator.archive_ttl_days for this invocation")), + (("--dry-run",), dict(dest="dry_run", action="store_true", help="Show what would be purged without deleting")), + (("-y", "--yes"), dict(action="store_true", help="Skip the confirmation prompt")), + ), +) - main.py calls this with the ArgumentParser returned by - ``subparsers.add_parser("curator", ...)``. - """ + +def register_cli(parent: argparse.ArgumentParser) -> None: + """Attach `curator` subcommands to *parent*.""" parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1]) subs = parent.add_subparsers(dest="curator_command") - - p_status = subs.add_parser("status", help="Show curator status and skill stats") - p_status.set_defaults(func=_cmd_status) - - p_usage = subs.add_parser( - "usage", - help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance", - ) - p_usage.add_argument( - "--sort", choices=("activity", "recent", "name"), default="activity", - help="Sort order: activity (most-used first, default), recent " - "(most-recently-active first), or name (alphabetical)", - ) - p_usage.add_argument( - "--provenance", choices=("agent", "bundled", "hub"), default=None, - help="Only show skills of this origin", - ) - p_usage.add_argument( - "--json", action="store_true", - help="Emit the full report as JSON instead of a table", - ) - p_usage.set_defaults(func=_cmd_usage) - - p_run = subs.add_parser("run", help="Trigger a curator review now") - p_run.add_argument( - "--sync", "--synchronous", dest="synchronous", action="store_true", - help="Wait for the LLM review pass to finish (default for manual runs)", - ) - p_run.add_argument( - "--background", dest="background", action="store_true", - help="Start the LLM review pass in a background thread and return immediately", - ) - p_run.add_argument( - "--dry-run", dest="dry_run", action="store_true", - help="Report only — no state changes, no archives, no consolidation " - "(use this to preview what curator would do)", - ) - p_run.add_argument( - "--consolidate", dest="consolidate", action="store_true", - help="Force the LLM umbrella-building consolidation pass on for this " - "run, overriding the config default (off). Without this flag the " - "run is prune-only unless `curator.consolidate: true` is set.", - ) - p_run.set_defaults(func=_cmd_run) - - p_pause = subs.add_parser("pause", help="Pause the curator until resumed") - p_pause.set_defaults(func=_cmd_pause) - - p_resume = subs.add_parser("resume", help="Resume a paused curator") - p_resume.set_defaults(func=_cmd_resume) - - p_pin = subs.add_parser("pin", help="Pin a skill so the curator never auto-transitions it") - p_pin.add_argument("skill", help="Skill name") - p_pin.set_defaults(func=_cmd_pin) - - p_unpin = subs.add_parser("unpin", help="Unpin a skill") - p_unpin.add_argument("skill", help="Skill name") - p_unpin.set_defaults(func=_cmd_unpin) - - subs.add_parser( - "list-unmanaged", - help="List curation-eligible skills with no provenance marker", - ).set_defaults(func=_cmd_list_unmanaged) - - p_adopt = subs.add_parser( - "adopt", - help="Hand unmanaged skills to the curator (provenance is a user declaration)", - ) - p_adopt.add_argument( - "skill", nargs="*", - help="Skill name(s) to adopt. Omit when using --all-unmanaged.", - ) - p_adopt.add_argument( - "--all-unmanaged", action="store_true", - help="Adopt every curation-eligible skill that has no provenance marker", - ) - p_adopt.add_argument( - "--dry-run", action="store_true", - help="List what would be adopted without writing anything", - ) - p_adopt.add_argument( - "--yes", action="store_true", - help="Skip the confirmation prompt for --all-unmanaged", - ) - p_adopt.set_defaults(func=_cmd_adopt) - - p_restore = subs.add_parser("restore", help="Restore an archived skill") - p_restore.add_argument("skill", help="Skill name") - p_restore.set_defaults(func=_cmd_restore) - - subs.add_parser("list-archived", help="List archived skills") \ - .set_defaults(func=_cmd_list_archived) - - p_archive = subs.add_parser( - "archive", - help="Manually archive a skill (move to .archive/, excluded from prompt)", - ) - p_archive.add_argument("skill", help="Skill name") - p_archive.set_defaults(func=_cmd_archive) - - p_prune = subs.add_parser( - "prune", - help="Bulk-archive curator-managed skills idle for >= N days (default 90)", - ) - p_prune.add_argument( - "--days", type=int, default=90, - help="Archive skills idle for at least N days (default: 90)", - ) - p_prune.add_argument( - "-y", "--yes", action="store_true", - help="Skip the confirmation prompt", - ) - p_prune.add_argument( - "--dry-run", dest="dry_run", action="store_true", - help="Show what would be archived without doing it", - ) - p_prune.set_defaults(func=_cmd_prune) - - p_backup = subs.add_parser( - "backup", - help="Take a manual tar.gz snapshot of ~/.hermes/skills/ " - "(curator also does this automatically before every real run)", - ) - p_backup.add_argument( - "--reason", default=None, - help="Free-text label stored in manifest.json (default: 'manual')", - ) - p_backup.set_defaults(func=_cmd_backup) - - p_rollback = subs.add_parser( - "rollback", - help="Restore ~/.hermes/skills/ from a curator snapshot, or a single " - "mutation by ledger entry id (see `hermes curator ledger`)", - ) - p_rollback.add_argument( - "entry_id", nargs="?", default=None, - help="Ledger entry id for single-mutation rollback (from " - "`hermes curator ledger`). Omit for whole-tree snapshot rollback.", - ) - p_rollback.add_argument( - "--list", action="store_true", - help="List available snapshots and exit without restoring", - ) - p_rollback.add_argument( - "--id", dest="backup_id", default=None, - help="Snapshot id to restore (see `--list`); default: newest", - ) - p_rollback.add_argument( - "-y", "--yes", action="store_true", - help="Skip confirmation prompt", - ) - p_rollback.set_defaults(func=_cmd_rollback) - - p_ledger = subs.add_parser( - "ledger", - help="List the per-mutation skill audit ledger (all actors: " - "curator/agent/user)", - ) - p_ledger.add_argument( - "--skill", default=None, - help="Only show entries for this skill", - ) - p_ledger.add_argument( - "--limit", type=int, default=20, - help="Max entries to show (default: 20)", - ) - p_ledger.set_defaults(func=_cmd_ledger) - - p_purge = subs.add_parser( - "purge", - help="Delete archived skills older than curator.archive_ttl_days " - "(explicit only — never automatic; recorded in the ledger)", - ) - p_purge.add_argument( - "--days", type=int, default=None, - help="Override curator.archive_ttl_days for this invocation", - ) - p_purge.add_argument( - "--dry-run", dest="dry_run", action="store_true", - help="Show what would be purged without deleting", - ) - p_purge.add_argument( - "-y", "--yes", action="store_true", - help="Skip the confirmation prompt", - ) - p_purge.set_defaults(func=_cmd_purge) + for name, kwargs, handler, *arguments in _SUBCOMMANDS: + sub = subs.add_parser(name, **kwargs) + for flags, arg_kwargs in arguments: + sub.add_argument(*flags, **arg_kwargs) + sub.set_defaults(func=handler) def cli_main(argv=None) -> int: diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index e5bc139dca..9cc660d497 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -1,8 +1,8 @@ """Shared curses-based UI components for Hermes CLI. -Used by `hermes tools` and `hermes skills` for interactive checklists. -Provides a curses multi-select with keyboard navigation, plus a -text-based numbered fallback for terminals without curses support. +Used by `hermes tools` and `hermes skills` for interactive checklists. Provides a curses multi- +select with keyboard navigation, plus a text-based numbered fallback for terminals without curses +support. """ import sys from contextvars import ContextVar, Token @@ -42,24 +42,16 @@ class MenuNavigationEvent(str, Enum): class MenuNavigationHandler(Protocol): """Typed contract between shared menus and a scoped flow controller.""" - def __call__( - self, - event: MenuNavigationEvent, - value: object = None, - ) -> MenuNavigationStart | None: ... + def __call__(self, event: MenuNavigationEvent, value: object = None) -> MenuNavigationStart | None: ... _MENU_NAVIGATION_HANDLER: ContextVar[MenuNavigationHandler | None] = ContextVar( "hermes_menu_navigation_handler", default=None ) -_NUMBERED_BACK_ENABLED: ContextVar[bool] = ContextVar( - "hermes_numbered_back_enabled", default=False -) +_NUMBERED_BACK_ENABLED: ContextVar[bool] = ContextVar("hermes_numbered_back_enabled", default=False) -def set_menu_navigation_handler( - handler: MenuNavigationHandler, -) -> Token[MenuNavigationHandler | None]: +def set_menu_navigation_handler(handler: MenuNavigationHandler) -> Token[MenuNavigationHandler | None]: """Scope setup-style cancel/back behavior to the current CLI invocation.""" return _MENU_NAVIGATION_HANDLER.set(handler) @@ -69,18 +61,11 @@ def reset_menu_navigation_handler(token: Token[MenuNavigationHandler | None]) -> _MENU_NAVIGATION_HANDLER.reset(token) -def _cancel_scoped_navigation() -> None: - """Notify an active menu flow that a text fallback was interrupted.""" +def _notify_scoped_navigation(event: MenuNavigationEvent) -> None: + """Notify an active menu flow that a text fallback was interrupted (CANCEL) or requested BACK.""" handler = _MENU_NAVIGATION_HANDLER.get() if handler is not None: - handler(MenuNavigationEvent.CANCEL) - - -def _back_scoped_navigation() -> None: - """Notify an active menu flow that its text fallback requested back.""" - handler = _MENU_NAVIGATION_HANDLER.get() - if handler is not None: - handler(MenuNavigationEvent.BACK) + handler(event) class _NumberedNavigation(Enum): @@ -88,12 +73,35 @@ class _NumberedNavigation(Enum): BACK = "back" +_NAV_ABORT = object() + + +def _read_numbered_choice(prompt_text: str) -> int | None | object: + """Read a numbered fallback choice as a 0-based index. + + Returns ``None`` for empty input and ``_NAV_ABORT`` when the prompt was cancelled, backed out + of, interrupted, or given a non-integer (scoped navigation is notified for cancel/back). + """ + try: + val = _read_numbered_input(prompt_text) + except (KeyboardInterrupt, EOFError): + _notify_scoped_navigation(MenuNavigationEvent.CANCEL) + return _NAV_ABORT + if isinstance(val, _NumberedNavigation): + _notify_scoped_navigation(MenuNavigationEvent(val.value)) + return _NAV_ABORT + if not val.strip(): + return None + idx = _parse_int(val.strip(), default=None) + return _NAV_ABORT if idx is None else idx - 1 + + def _read_numbered_input(prompt_text: str) -> str | _NumberedNavigation: """Read a numbered fallback choice with setup navigation key bindings. - Ordinary numbered menus retain their historical ``input()`` behavior. - During setup/model flows, prompt_toolkit supplies portable Escape, Ctrl+C, - and Left bindings on POSIX and native Windows when curses is unavailable. + Ordinary numbered menus retain their historical ``input()`` behavior. During setup/model flows, + prompt_toolkit supplies portable Escape, Ctrl+C, and Left bindings on POSIX and native Windows + when curses is unavailable. """ if _MENU_NAVIGATION_HANDLER.get() is None: return input(prompt_text) @@ -132,21 +140,15 @@ def radio_item_plain(item: RadioItem) -> str: def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool): - """Map a segment style to a curses attribute. - - Cursor rows force the whole line green (pair 1) so selection stays - one solid highlight; unselected sale chrome uses yellow / dim. - """ + """Map a segment style to a curses attribute.""" + has_colors = curses.has_colors() if is_cursor: - attr = curses.A_BOLD - if curses.has_colors(): - attr |= curses.color_pair(1) - return attr - if style == "yellow" and curses.has_colors(): + return curses.A_BOLD | (curses.color_pair(1) if has_colors else 0) + if style == "yellow" and has_colors: return curses.color_pair(2) if style == "dim": attr = curses.A_DIM - if curses.has_colors(): + if has_colors: # Pair 3 is the dim-gray status color (extra_color_pairs). try: attr |= curses.color_pair(3) @@ -156,36 +158,56 @@ def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool): return curses.A_NORMAL +def _addnstr(stdscr, y: int, x: int, text: str, n: int, attr) -> None: + """``stdscr.addnstr`` that swallows ``curses.error`` (drawing past the screen edge).""" + import curses + + try: + stdscr.addnstr(y, x, text, n, attr) + except curses.error: + pass + + +def _draw_title_and_hint(stdscr, title: str, hint: str, max_x: int, *, hint_row: int = 1) -> None: + """Draw the bold/yellow menu title on row 0 and the dim key hint on ``hint_row``.""" + import curses + + hattr = curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0) + _addnstr(stdscr, 0, 0, title, max_x - 1, hattr) + _addnstr(stdscr, hint_row, 0, hint, max_x - 1, curses.A_DIM) + + +def _draw_plain_row(stdscr, y: int, line: str, max_x: int, *, is_cursor: bool) -> None: + """Draw a plain menu row, bold green when it is the cursor row.""" + import curses + + _addnstr(stdscr, y, 0, line, max_x - 1, _curses_style_attr(curses, None, is_cursor=is_cursor)) + + +def _draw_segments(stdscr, y: int, x: int, segments, max_x: int) -> None: + """Draw ``(text, attr)`` segments left to right from column ``x``, clipped at the screen edge.""" + col = x + for text, attr in segments: + remaining = max_x - 1 - col + if remaining <= 0: + break + chunk = text[:remaining] + _addnstr(stdscr, y, col, chunk, remaining, attr) + col += len(chunk) + + def _draw_description_line(stdscr, y: int, text: str, max_x: int) -> None: """Draw a description line, highlighting ★ in yellow when colors exist.""" import curses - col = 0 - i = 0 - star_attr = curses.A_NORMAL - if curses.has_colors(): - star_attr = curses.color_pair(2) - while i < len(text) and col < max_x - 1: - remaining = max_x - 1 - col - if remaining <= 0: - break - if text[i] == "★": - try: - stdscr.addnstr(y, col, "★", remaining, star_attr) - except curses.error: - pass - col += 1 - i += 1 - continue - next_star = text.find("★", i) - chunk = text[i:] if next_star < 0 else text[i:next_star] - chunk = chunk[:remaining] - try: - stdscr.addnstr(y, col, chunk, remaining, curses.A_NORMAL) - except curses.error: - pass - col += len(chunk) - i += len(chunk) + star_attr = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL + segments = [] + for i, part in enumerate(text.split("★")): + if i: + segments.append(("★", star_attr)) + if part: + segments.append((part, curses.A_NORMAL)) + _draw_segments(stdscr, y, 0, segments, max_x) def _draw_radio_item(stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_cursor: bool) -> None: @@ -194,48 +216,14 @@ def _draw_radio_item(stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_ if isinstance(item, str): attr = _curses_style_attr(curses, None, is_cursor=is_cursor) - try: - stdscr.addnstr(y, x, item, max(0, max_x - 1 - x), attr) - except curses.error: - pass + _addnstr(stdscr, y, x, item, max(0, max_x - 1 - x), attr) return - col = x - for text, style in item: - if col >= max_x - 1: - break - remaining = max_x - 1 - col - if remaining <= 0: - break - chunk = text[:remaining] - attr = _curses_style_attr(curses, style, is_cursor=is_cursor) - try: - stdscr.addnstr(y, col, chunk, remaining, attr) - except curses.error: - pass - col += len(chunk) - - -def _query_matches(label: str, query: str) -> bool: - """Return True when every query token is a case-insensitive subsequence.""" - normalized = label.lower() - tokens = query.lower().split() - - if not tokens: - return True - - for token in tokens: - pos = 0 - - for ch in token: - pos = normalized.find(ch, pos) - - if pos < 0: - return False - - pos += 1 - - return True + _draw_segments( + stdscr, y, x, + ((text, _curses_style_attr(curses, style, is_cursor=is_cursor)) for text, style in item), + max_x, + ) _WORD_BOUNDARY = frozenset("-_/. ") @@ -244,34 +232,26 @@ _WORD_BOUNDARY = frozenset("-_/. ") def _is_boundary(target: str, index: int) -> bool: """True if position ``index`` in ``target`` starts a word. - Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a - separator char, or a lower->upper camelCase transition. + Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a separator char, or a + lower->upper camelCase transition. """ if index == 0: return True - prev = target[index - 1] - if prev in _WORD_BOUNDARY: return True - # camelCase / lower->upper transition (e.g. the `O` in `gptO`). cur = target[index] - return prev == prev.lower() and cur != cur.lower() and cur == cur.upper() def _token_score(orig: str, lower: str, token: str) -> float | None: """Score one token against a target. None if the token isn't a subsequence. - A faithful port of ``fuzzyScore`` in ui-tui/src/lib/fuzzy.ts and - web/src/lib/fuzzy.ts so all three surfaces rank model ids identically: - contiguous runs, word-boundary / first-char starts, prefix matches, and - exact matches all score higher than scattered subsequence hits. - - ``lower`` is ``orig`` lowercased; matching is done against ``lower`` while - boundary detection uses ``orig`` (so the camelCase rule works), exactly as - in the TS scorer. + Faithful port of ``fuzzyScore`` in ui-tui and web ``fuzzy.ts`` so all three surfaces rank + model ids identically: contiguous runs, word-boundary/first-char starts, prefixes and exact + matches outrank scattered hits. Matching runs against ``lower`` while boundary detection + uses ``orig`` so the camelCase rule works, exactly as in the TS scorer. """ score = 0.0 prev = -1 @@ -280,24 +260,18 @@ def _token_score(orig: str, lower: str, token: str) -> float | None: for ch in token: idx = lower.find(ch, search_from) - if idx < 0: return None - positions.append(idx) score += 1 - if prev >= 0 and idx == prev + 1: score += 5 elif prev >= 0: score -= min(idx - prev - 1, 3) - if _is_boundary(orig, idx): score += 3 - if idx == 0: score += 5 - prev = idx search_from = idx + 1 @@ -318,50 +292,31 @@ def _token_score(orig: str, lower: str, token: str) -> float | None: def _fuzzy_score(label: str, query: str) -> float | None: """Aggregate score for a multi-token query (AND). None if any token fails. - Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated - token must match; per-token scores are summed. + Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated token must match; per- + token scores are summed. """ lower = label.lower() - tokens = query.lower().split() - - if not tokens: - return 0.0 - total = 0.0 - - for token in tokens: + for token in query.lower().split(): token_score = _token_score(label, lower, token) - if token_score is None: return None - total += token_score - return total def _filter_indices(items: List[str], query: str) -> List[int]: """Return item indices matching *query*, ranked best-first. - An empty query keeps every item in original order. Otherwise items are - filtered to fuzzy matches and sorted by score descending, ties broken by - original index so equal-scoring rows keep their catalog order. + An empty query keeps every item in original order. Otherwise items are filtered to fuzzy matches + and sorted by score descending, ties broken by original index so equal-scoring rows keep their + catalog order. """ q = query.strip() - if not q: return list(range(len(items))) - - scored = [] - - for i, label in enumerate(items): - score = _fuzzy_score(label, q) - - if score is not None: - scored.append((i, score)) - + scored = [(i, score) for i, label in enumerate(items) if (score := _fuzzy_score(label, q)) is not None] scored.sort(key=lambda pair: (-pair[1], pair[0])) - return [i for i, _ in scored] @@ -377,45 +332,28 @@ def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]: """Return ``(cursor, cursor_pos)`` inside the filtered index list.""" if not filtered: return cursor, 0 - if cursor not in filtered: cursor = filtered[0] - return cursor, filtered.index(cursor) -def _move_filtered_cursor( - filtered: List[int], cursor: int, cursor_pos: int, delta: int -) -> int: +def _move_filtered_cursor(filtered: List[int], cursor: int, cursor_pos: int, delta: int) -> int: """Move through the filtered index list, wrapping like the legacy menus.""" - if not filtered: - return cursor - - return filtered[(cursor_pos + delta) % len(filtered)] + return filtered[(cursor_pos + delta) % len(filtered)] if filtered else cursor -def _scroll_for_cursor( - scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int -) -> int: +def _scroll_for_cursor(scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int) -> int: """Clamp scroll offset so the cursor remains visible.""" visible_rows = max(1, visible_rows) - if cursor_pos < scroll_offset: scroll_offset = cursor_pos elif cursor_pos >= scroll_offset + visible_rows: scroll_offset = cursor_pos - visible_rows + 1 - return max(0, min(scroll_offset, max(0, total_rows - visible_rows))) -def _handle_active_search_key( - curses_mod, key: int, search: _SearchState -) -> tuple[bool, bool, bool]: - """Handle a key while the search prompt is active. - - Returns ``(handled, confirm, changed)``. Active search consumes query - editing keys, but leaves navigation keys for the menu loop to handle. - """ +def _handle_active_search_key(curses_mod, key: int, search: _SearchState) -> tuple[bool, bool, bool]: + """Handle a key while the search prompt is active.""" if not search.active: return False, False, False @@ -428,41 +366,31 @@ def _handle_active_search_key( search.query = "" return True, False, had_query - if key in (curses_mod.KEY_BACKSPACE, 127, 8): - search.query = search.query[:-1] - return True, False, True - - if key == 21: # Ctrl+U - search.query = "" - return True, False, True - if key in (curses_mod.KEY_ENTER, 10, 13): return True, True, False - if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255 + if key in (curses_mod.KEY_BACKSPACE, 127, 8): + search.query = search.query[:-1] + elif key == 21: # Ctrl+U + search.query = "" + elif 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255 search.query += chr(key) - return True, False, True - - return False, False, False + else: + return False, False, False + return True, False, True def flush_stdin() -> None: """Flush any stray bytes from the stdin input buffer. - Must be called after ``curses.wrapper()`` returns, and before the next - ``input()`` / ``getpass.getpass()`` call. - ``curses.endwin()`` restores the terminal but does NOT drain the OS input buffer. - Leftover escape-sequence bytes (from arrow keys, terminal mode-switch responses, or rapid keypresses) - remain buffered and silently get consumed by the next ``input()`` call, - corrupting user data (e.g. writing ``^[^[`` into .env files). - - On non-TTY stdin (piped, redirected) or Windows, this is a no-op. + Must be called after ``curses.wrapper()`` returns, and before the next ``input()`` / + ``getpass.getpass()`` call. ``curses.endwin()`` restores the terminal but does NOT drain the OS + input buffer. """ try: - if not sys.stdin.isatty(): - return - import termios - termios.tcflush(sys.stdin, termios.TCIFLUSH) + if sys.stdin.isatty(): + import termios + termios.tcflush(sys.stdin, termios.TCIFLUSH) except Exception: pass @@ -482,51 +410,30 @@ NAV_NONE = "none" def read_menu_key(stdscr) -> str: """Read one keypress and normalize it to a menu action. - Decodes raw arrow-key escape sequences in addition to the translated - ``curses.KEY_*`` values. Even with ``keypad(True)`` (which - ``curses.wrapper`` sets), some terminals/terminfo entries deliver cursor - keys as raw CSI/SS3 byte sequences — ``getch()`` then returns ``27`` (ESC) - followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a cancel is - what made the setup wizard's provider/model pickers bail to the numbered - fallback the moment a user pressed up/down. - - Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte - within a short window) is the only thing that maps to ``NAV_CANCEL`` via - the escape path; ``q`` also cancels. Unknown sequences map to - ``NAV_NONE`` so the caller simply ignores them rather than misfiring. + Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte within a short window) + is the only thing that maps to ``NAV_CANCEL`` via the escape path; ``q`` also cancels. Unknown + sequences map to ``NAV_NONE`` so the caller simply ignores them rather than misfiring. """ return _decode_menu_key(stdscr, stdscr.getch()) -@dataclass(frozen=True) -class _EnhancedKey: - codepoint: int - modifier: int = 1 - event_type: int = 1 - - -def _parse_int(value: str, default: int = 0) -> int: +def _parse_int(value: str, default=0): try: return int(value) except ValueError: return default -def _parse_csi_u_key(raw: str) -> _EnhancedKey | None: - """Parse a Kitty/CSI-u key, preserving its press/repeat/release type.""" +def _parse_csi_u_key(raw: str) -> tuple[int, int, int] | None: + """Parse a Kitty/CSI-u key into ``(codepoint, modifier, event_type)``; None without a codepoint.""" parts = raw.split(";") - codepoint = _parse_int(parts[0].split(":", 1)[0]) if parts else 0 + codepoint = _parse_int(parts[0].split(":", 1)[0]) if not codepoint: return None - - modifier = 1 - event_type = 1 - if len(parts) > 1: - modifier_parts = parts[1].split(":") - modifier = _parse_int(modifier_parts[0], 1) - if len(modifier_parts) > 1: - event_type = _parse_int(modifier_parts[1], 1) - return _EnhancedKey(codepoint, modifier, event_type) + mod_fields = parts[1].split(":") if len(parts) > 1 else [] + modifier = _parse_int(mod_fields[0], 1) if mod_fields else 1 + event_type = _parse_int(mod_fields[1], 1) if len(mod_fields) > 1 else 1 + return codepoint, modifier, event_type def _parse_csi_numbers(raw: str) -> list[int]: @@ -557,22 +464,22 @@ def _read_csi_tail(stdscr) -> tuple[str, int | None]: raw: list[str] = [] for _ in range(32): value = stdscr.getch() - if value == -1: - return "".join(raw), None if 0x40 <= value <= 0x7E: return "".join(raw), value - if 0x20 <= value <= 0x3F: - raw.append(chr(value)) - continue - return "".join(raw), None + if not 0x20 <= value <= 0x3F: + break + raw.append(chr(value)) return "".join(raw), None +_CSI_FINAL_NAV = {ord("A"): NAV_UP, ord("k"): NAV_UP, ord("B"): NAV_DOWN, ord("j"): NAV_DOWN, ord("D"): NAV_BACK} + + def _decode_menu_key(stdscr, key: int) -> str: """Normalize an already-read keypress to a menu action. - Split out from ``read_menu_key`` so search-aware loops can peek the raw - key (e.g. to catch ``/``) before falling back to nav decoding. + Split out from ``read_menu_key`` so search-aware loops can peek the raw key (e.g. to catch + ``/``) before falling back to nav decoding. """ import curses @@ -603,20 +510,15 @@ def _decode_menu_key(stdscr, key: int) -> str: if nxt in (ord("["), ord("O")): # CSI / SS3 introducer raw_params, final = _read_csi_tail(stdscr) - if final in (ord("A"), ord("k")): - return NAV_UP - if final in (ord("B"), ord("j")): - return NAV_DOWN - if final == ord("D"): - return NAV_BACK + if final in _CSI_FINAL_NAV: + return _CSI_FINAL_NAV[final] if final == ord("u"): enhanced = _parse_csi_u_key(raw_params) if enhanced is not None: - if enhanced.event_type == 3: # key release + codepoint, modifier, event_type = enhanced + if event_type == 3: # key release return NAV_NONE - return _enhanced_key_action( - enhanced.codepoint, enhanced.modifier - ) + return _enhanced_key_action(codepoint, modifier) if final == ord("~"): params = _parse_csi_numbers(raw_params) if len(params) >= 3 and params[0] == 27: @@ -652,58 +554,27 @@ def _run_curses_menu( ): """Shared curses single-/multi-select event loop. - Owns every piece the three public menus used to duplicate verbatim: - the non-TTY guard, ``curses.wrapper`` setup (cursor hide + color pairs), - the per-frame ``clear``/``getmaxyx``/``refresh`` cycle, scroll-offset math, - row iteration, the ``read_menu_key`` dispatch with ``NAV_UP``/``NAV_DOWN`` - cursor wrap, ``flush_stdin``, and the ``KeyboardInterrupt`` / curses- - unavailable fallback. Per-menu behavior is supplied as callbacks so the - rendered output stays byte-identical to the old hand-rolled loops. - - Callbacks / params: - draw_header(stdscr, max_y, max_x, *, search=None, back_enabled=False) -> int - Draw the title/hint/description rows. Returns the first screen row - index where the scrollable item list should start. When search is - active it receives the live ``_SearchState`` via the optional - ``search`` keyword (drawn by the menu so the hint line can show it). - ``back_enabled`` controls whether the ``← previous`` hint is shown. - draw_row(stdscr, y, idx, is_cursor, max_x) -> None - Draw one item row. ``idx`` is always the ORIGINAL item index, so - per-menu rendering is unchanged whether or not a filter is active. - on_action(action, cursor) -> value - Reducer for SELECT/TOGGLE/CANCEL/BACK. Return ``_KEEP`` to continue the - loop; return anything else to resolve the menu with that value. - (UP/DOWN cursor movement is handled by the driver itself.) - reserve_bottom: number of bottom screen rows kept clear of items - (1 = leave the final row blank, matching the old loops). - draw_footer(stdscr, max_y, max_x) -> None - Optional bottom-row painter (e.g. a status bar). Drawn after the - item rows; its row budget must be included in ``reserve_bottom``. - extra_color_pairs: also init pair 3 (dim gray) for status bars. - fallback() -> value - Called when curses errors out on a real TTY (curses unavailable). - cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt. - searchable: when true, ``/`` opens a type-to-filter prompt over - ``search_labels``. Returned values are always ORIGINAL item indices. - search_labels: per-item text used for filtering (required when - ``searchable`` is true; length must equal ``item_count``). + Owns the non-TTY guard, ``curses.wrapper`` setup, the per-frame clear/refresh cycle, scroll + math, key dispatch with cursor wrap, and the KeyboardInterrupt / curses-unavailable + fallback; per-menu behavior comes in as callbacks so rendering stays byte-identical to the + old hand-rolled loops. ``draw_row`` always receives the ORIGINAL item index (filtering + doesn't change rendering); ``on_action`` returns ``_KEEP`` to continue or any other value to + resolve the menu; a ``draw_footer`` row budget must be included in ``reserve_bottom``; with + ``searchable``, ``/`` filters over ``search_labels`` (length must equal ``item_count``) and + results are original indices. """ navigation_handler = _MENU_NAVIGATION_HANDLER.get() - navigation_start = ( - navigation_handler(MenuNavigationEvent.BEGIN) - if navigation_handler is not None - else None - ) - if navigation_start is not None and not isinstance( - navigation_start, MenuNavigationStart - ): + + def _notify(event, *value): + if navigation_handler is not None: + navigation_handler(event, *value) + + navigation_start = navigation_handler(MenuNavigationEvent.BEGIN) if navigation_handler else None + if navigation_start is not None and not isinstance(navigation_start, MenuNavigationStart): raise TypeError("menu navigation 'begin' must return MenuNavigationStart") allow_back = bool(navigation_start and navigation_start.allow_back) if navigation_start is not None and navigation_start.should_replay: - if navigation_handler is not None: - navigation_handler( - MenuNavigationEvent.RESOLVE, navigation_start.replay_value - ) + _notify(MenuNavigationEvent.RESOLVE, navigation_start.replay_value) return navigation_start.replay_value # Non-TTY (piped/redirected stdin): curses and input() both hang or spin, @@ -720,8 +591,7 @@ def _run_curses_menu( result = fallback() finally: _NUMBERED_BACK_ENABLED.reset(back_token) - if navigation_handler is not None: - navigation_handler(MenuNavigationEvent.RESOLVE, result) + _notify(MenuNavigationEvent.RESOLVE, result) return result try: @@ -732,6 +602,14 @@ def _run_curses_menu( try: result_holder = [_KEEP] + def _resolve(outcome) -> bool: + """Record a non-``_KEEP`` outcome; True when the menu is done.""" + if outcome is _KEEP: + return False + _notify(MenuNavigationEvent.RESOLVE, outcome) + result_holder[0] = outcome + return True + def _draw(stdscr): curses.curs_set(0) if curses.has_colors(): @@ -740,52 +618,27 @@ def _run_curses_menu( curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(2, curses.COLOR_YELLOW, -1) if extra_color_pairs: - curses.init_pair( - 3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1 - ) + curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) cursor = initial_cursor scroll_offset = 0 search = _SearchState() - # Non-None labels for filtering; empty when search is disabled so - # _filter_indices stays a cheap identity range. - labels: List[str] = ( - search_labels if (use_search and search_labels is not None) else [] - ) while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() - filtered = ( - _filter_indices(labels, search.query) - if use_search - else list(range(item_count)) - ) + filtered = _filter_indices(search_labels, search.query) if use_search else list(range(item_count)) cursor, cursor_pos = _reconcile_cursor(filtered, cursor) - items_start = draw_header( - stdscr, - max_y, - max_x, - search=search, - back_enabled=allow_back, - ) + items_start = draw_header(stdscr, max_y, max_x, search=search, back_enabled=allow_back) visible_rows = max(1, max_y - items_start - reserve_bottom) - scroll_offset = _scroll_for_cursor( - scroll_offset, cursor_pos, visible_rows, len(filtered) - ) + scroll_offset = _scroll_for_cursor(scroll_offset, cursor_pos, visible_rows, len(filtered)) if use_search and search.query and not filtered: - try: - stdscr.addnstr(items_start, 0, " No matches", max_x - 1, curses.A_DIM) - except curses.error: - pass + _addnstr(stdscr, items_start, 0, " No matches", max_x - 1, curses.A_DIM) - for draw_i, filtered_pos in enumerate( - range(scroll_offset, min(len(filtered), scroll_offset + visible_rows)) - ): - i = filtered[filtered_pos] + for draw_i, i in enumerate(filtered[scroll_offset : scroll_offset + visible_rows]): y = draw_i + items_start if y >= max_y - reserve_bottom: break @@ -796,79 +649,56 @@ def _run_curses_menu( stdscr.refresh() - if use_search: - key = stdscr.getch() - - if search.active and key == 27: - # Ghostty/Kitty enhanced keys also begin with ESC. - # Decode the full sequence before treating a genuine - # Escape as "stop search"; otherwise Enter/Left/Ctrl+C - # lose their tail while the search prompt is active. - action = _decode_menu_key(stdscr, key) - if action == NAV_CANCEL: - search.active = False - search.query = "" - scroll_offset = 0 - continue - if action == NAV_NONE: - continue - elif search.active: - # Active search consumes query-editing keys; nav keys - # fall through to be decoded below. - handled, confirm, changed = _handle_active_search_key( - curses, key, search - ) - if changed: - scroll_offset = 0 - cursor, cursor_pos = _reconcile_cursor( - _filter_indices(search_labels, search.query), cursor - ) - if confirm: - if filtered: - outcome = on_action(NAV_SELECT, cursor) - if outcome is not _KEEP: - if navigation_handler is not None: - navigation_handler( - MenuNavigationEvent.RESOLVE, outcome - ) - result_holder[0] = outcome - return - continue - if handled: - continue - action = _decode_menu_key(stdscr, key) - elif key == ord("/"): - search.active = True + key = stdscr.getch() + if use_search and search.active and key == 27: + # Ghostty/Kitty enhanced keys also begin with ESC. + # Decode the full sequence before treating a genuine + # Escape as "stop search"; otherwise Enter/Left/Ctrl+C + # lose their tail while the search prompt is active. + action = _decode_menu_key(stdscr, key) + if action == NAV_CANCEL: + search.active = False + search.query = "" + scroll_offset = 0 continue - else: - action = _decode_menu_key(stdscr, key) + if action == NAV_NONE: + continue + elif use_search and search.active: + # Active search consumes query-editing keys; nav keys + # fall through to be decoded below. + handled, confirm, changed = _handle_active_search_key(curses, key, search) + if changed: + scroll_offset = 0 + cursor, cursor_pos = _reconcile_cursor( + _filter_indices(search_labels, search.query), cursor + ) + if confirm: + if filtered and _resolve(on_action(NAV_SELECT, cursor)): + return + continue + if handled: + continue + action = _decode_menu_key(stdscr, key) + elif use_search and key == ord("/"): + search.active = True + continue else: - action = read_menu_key(stdscr) + action = _decode_menu_key(stdscr, key) if action == NAV_UP: cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1) elif action == NAV_DOWN: cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1) - elif action in ( - NAV_SELECT, - NAV_TOGGLE, - NAV_CANCEL, - NAV_INTERRUPT, - ) or ( + elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL, NAV_INTERRUPT) or ( action == NAV_BACK and allow_back ): if action == NAV_SELECT and use_search and not filtered: continue - if navigation_handler is not None: - if action in (NAV_CANCEL, NAV_INTERRUPT): - navigation_handler(MenuNavigationEvent.CANCEL) - elif action == NAV_BACK and allow_back: - navigation_handler(MenuNavigationEvent.BACK) - outcome = on_action(action, cursor) - if outcome is not _KEEP: - if navigation_handler is not None: - navigation_handler(MenuNavigationEvent.RESOLVE, outcome) - result_holder[0] = outcome + if action in (NAV_CANCEL, NAV_INTERRUPT): + _notify(MenuNavigationEvent.CANCEL) + elif action == NAV_BACK: + _notify(MenuNavigationEvent.BACK) + if _resolve(on_action(action, cursor)): return curses.wrapper(_draw) @@ -876,8 +706,7 @@ def _run_curses_menu( return result_holder[0] if result_holder[0] is not _KEEP else cancel_value except KeyboardInterrupt: - if navigation_handler is not None: - navigation_handler(MenuNavigationEvent.CANCEL) + _notify(MenuNavigationEvent.CANCEL) return cancel_value except curses.error: return _run_fallback() @@ -893,63 +722,28 @@ def curses_checklist( ) -> Set[int]: """Curses multi-select checklist. Returns set of selected indices. - Args: - title: Header line displayed above the checklist. - items: Display labels for each row. - selected: Indices that start checked (pre-selected). - cancel_returns: Returned on ESC/q. Defaults to the original *selected*. - status_fn: Optional callback ``f(chosen_indices) -> str`` whose return - value is rendered on the bottom row of the terminal. Use this for - live aggregate info (e.g. estimated token counts). + ``cancel_returns`` (default: the original *selected*) is returned on ESC/q. + ``status_fn(chosen)`` renders on the bottom row for live aggregate info such as token + estimates. """ if cancel_returns is None: cancel_returns = set(selected) chosen = set(selected) - def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False): - import curses - try: - hattr = curses.A_BOLD - if curses.has_colors(): - hattr |= curses.color_pair(2) - stdscr.addnstr(0, 0, title, max_x - 1, hattr) - hint = " ↑↓ navigate SPACE toggle ENTER confirm ESC cancel" - if back_enabled: - hint += " ← previous" - stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM) - except curses.error: - pass - return 3 - def _draw_row(stdscr, y, i, is_cursor, max_x): - import curses check = "✓" if i in chosen else " " arrow = "→" if is_cursor else " " - line = f" {arrow} [{check}] {items[i]}" - attr = curses.A_NORMAL - if is_cursor: - attr = curses.A_BOLD - if curses.has_colors(): - attr |= curses.color_pair(1) - try: - stdscr.addnstr(y, 0, line, max_x - 1, attr) - except curses.error: - pass + _draw_plain_row(stdscr, y, f" {arrow} [{check}] {items[i]}", max_x, is_cursor=is_cursor) def _draw_footer(stdscr, max_y, max_x): import curses - try: - status_text = status_fn(chosen) - if status_text: - # Right-align on the bottom row - sx = max(0, max_x - len(status_text) - 1) - sattr = curses.A_DIM - if curses.has_colors(): - sattr |= curses.color_pair(3) - stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr) - except curses.error: - pass + status_text = status_fn(chosen) + if status_text: + # Right-align on the bottom row + sx = max(0, max_x - len(status_text) - 1) + sattr = curses.A_DIM | (curses.color_pair(3) if curses.has_colors() else 0) + _addnstr(stdscr, max_y - 1, sx, status_text, max_x - sx - 1, sattr) def _on_action(action, cursor): if action == NAV_TOGGLE: @@ -962,7 +756,7 @@ def curses_checklist( return _run_curses_menu( initial_cursor=0, item_count=len(items), - draw_header=_draw_header, + draw_header=_simple_header(title, "SPACE toggle ENTER confirm", "ESC cancel", False), draw_row=_draw_row, on_action=_on_action, reserve_bottom=(2 if status_fn else 1), @@ -973,6 +767,28 @@ def curses_checklist( ) +def _search_hint(search, searchable: bool, confirm: str, cancel: str, back_enabled: bool) -> str: + """Key-hint row for menus, swapping to the search prompt while ``/`` is active.""" + if searchable and search is not None and search.active: + hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop" + else: + hint = f" \u2191\u2193 navigate {confirm} {'/ search ' if searchable else ''}{cancel}" + if back_enabled: + hint += " \u2190 previous" + return hint + + +def _simple_header(title: str, confirm: str, cancel: str, searchable: bool): + """``draw_header`` callback: title on row 0, key hint on row 1, items start on row 3.""" + + def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False): + hint = _search_hint(search, searchable, confirm, cancel, back_enabled) + _draw_title_and_hint(stdscr, title, hint, max_x) + return 3 + + return _draw_header + + def curses_radiolist( title: str, items: List[RadioItem], @@ -985,75 +801,37 @@ def curses_radiolist( ) -> int: """Curses single-select radio list. Returns the selected index. - Args: - title: Header line displayed above the list. - items: Display labels for each row. Each entry is either a plain - string or a sequence of ``(text, style)`` segments where - ``style`` is ``None``, ``"yellow"``, or ``"dim"``. Cursor rows - force the whole line green; unselected rows honor segment styles - (used for sale chrome in the model picker). - selected: Index that starts selected (pre-selected). - cancel_returns: Returned on ESC/q. Defaults to the original *selected*. - description: Optional multi-line text shown between the title and - the item list. Useful for context that should survive the - curses screen clear. - searchable: When true, ``/`` opens a type-to-filter prompt. The - returned value is always the original item index, not a filtered - row position. - search_labels: Optional haystacks for type-to-filter (length must - match ``items``). Defaults to the display labels when omitted. + Items are plain strings or ``(text, style)`` segment sequences + (``None``/``"yellow"``/``"dim"``); the cursor row is forced green, unselected rows honor + segment styles. ``description`` is shown between title and list so context survives the + curses screen clear. With ``searchable``, ``/`` filters over ``search_labels`` (default: + display labels) and the returned value is always the ORIGINAL item index, never a filtered + row position. """ if cancel_returns is None: cancel_returns = selected - desc_lines: list[str] = [] - if description: - desc_lines = description.splitlines() + desc_lines = description.splitlines() if description else [] - plain_labels = [radio_item_plain(item) for item in items] + plain_labels = [radio_item_plain(item) for item in items] if searchable else None def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False): - import curses - row = 0 - try: - hattr = curses.A_BOLD - if curses.has_colors(): - hattr |= curses.color_pair(2) - stdscr.addnstr(row, 0, title, max_x - 1, hattr) + # Description lines — paint ★ yellow so the sale legend matches rows. + row = 1 + for dline in desc_lines[: max(0, max_y - 2)]: + _draw_description_line(stdscr, row, dline, max_x) row += 1 - # Description lines — paint ★ yellow so the sale legend matches rows. - for dline in desc_lines: - if row >= max_y - 1: - break - _draw_description_line(stdscr, row, dline, max_x) - row += 1 - - if searchable and search is not None and search.active: - hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop" - elif searchable: - hint = " \u2191\u2193 navigate ENTER/SPACE select / search ESC cancel" - else: - hint = " \u2191\u2193 navigate ENTER/SPACE select ESC cancel" - if back_enabled: - hint += " \u2190 previous" - stdscr.addnstr(row, 0, hint, max_x - 1, curses.A_DIM) - row += 1 - except curses.error: - pass + hint = _search_hint(search, searchable, "ENTER/SPACE select", "ESC cancel", back_enabled) + _draw_title_and_hint(stdscr, title, hint, max_x, hint_row=row) # One blank row between the hint and the item list. - return row + 1 + return row + 2 def _draw_row(stdscr, y, i, is_cursor, max_x): - import curses radio = "\u25cf" if i == selected else "\u25cb" arrow = "\u2192" if is_cursor else " " prefix = f" {arrow} ({radio}) " - prefix_attr = _curses_style_attr(curses, None, is_cursor=is_cursor) - try: - stdscr.addnstr(y, 0, prefix, max_x - 1, prefix_attr) - except curses.error: - pass + _draw_plain_row(stdscr, y, prefix, max_x, is_cursor=is_cursor) _draw_radio_item( stdscr, y, len(prefix), items[i], max_x, is_cursor=is_cursor ) @@ -1075,11 +853,7 @@ def curses_radiolist( fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns), cancel_value=cancel_returns, searchable=searchable, - search_labels=( - list(search_labels) - if searchable and search_labels is not None - else (plain_labels if searchable else None) - ), + search_labels=(list(search_labels) if search_labels is not None else plain_labels) if searchable else None, ) @@ -1087,15 +861,12 @@ def format_radio_item_ansi(item: RadioItem) -> str: """Apply ANSI colors to a rich radiolist item (numbered fallback / prints).""" if isinstance(item, str): return item - parts: list[str] = [] - for text, style in item: - if style == "yellow": - parts.append(color(text, Colors.YELLOW)) - elif style == "dim": - parts.append(color(text, Colors.DIM)) - else: - parts.append(text) - return "".join(parts) + return "".join( + color(text, _ANSI_STYLE[style]) if style in _ANSI_STYLE else text for text, style in item + ) + + +_ANSI_STYLE = {"yellow": Colors.YELLOW, "dim": Colors.DIM} def _radio_numbered_fallback( @@ -1112,28 +883,10 @@ def _radio_numbered_fallback( marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)" print(f" {marker} {i + 1:>2}. {format_radio_item_ansi(label)}") print() - try: - val = _read_numbered_input( - color(f" Choice [default {selected + 1}]: ", Colors.DIM) - ) - if val is _NumberedNavigation.BACK: - _back_scoped_navigation() - return cancel_returns - if val is _NumberedNavigation.CANCEL: - _cancel_scoped_navigation() - return cancel_returns - val = val.strip() - if not val: - return selected - idx = int(val) - 1 - if 0 <= idx < len(items): - return idx - return selected - except ValueError: - return cancel_returns - except (KeyboardInterrupt, EOFError): - _cancel_scoped_navigation() + idx = _read_numbered_choice(color(f" Choice [default {selected + 1}]: ", Colors.DIM)) + if idx is _NAV_ABORT: return cancel_returns + return idx if idx is not None and 0 <= idx < len(items) else selected def curses_single_select( @@ -1146,47 +899,15 @@ def curses_single_select( ) -> int | None: """Curses single-select menu. Returns selected index or None on cancel. - Works inside prompt_toolkit. curses.wrapper() restores the terminal safely. - - When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the - returned value is always the original item index (or None for cancel). + When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the returned value is always + the original item index (or None for cancel). """ all_items = list(items) + [cancel_label] cancel_idx = len(items) - def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False): - import curses - try: - hattr = curses.A_BOLD - if curses.has_colors(): - hattr |= curses.color_pair(2) - stdscr.addnstr(0, 0, title, max_x - 1, hattr) - if searchable and search is not None and search.active: - hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop" - elif searchable: - hint = " ↑↓ navigate ENTER confirm / search ESC/q cancel" - else: - hint = " ↑↓ navigate ENTER confirm ESC/q cancel" - if back_enabled: - hint += " ← previous" - stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM) - except curses.error: - pass - return 3 - def _draw_row(stdscr, y, i, is_cursor, max_x): - import curses arrow = "→" if is_cursor else " " - line = f" {arrow} {all_items[i]}" - attr = curses.A_NORMAL - if is_cursor: - attr = curses.A_BOLD - if curses.has_colors(): - attr |= curses.color_pair(1) - try: - stdscr.addnstr(y, 0, line, max_x - 1, attr) - except curses.error: - pass + _draw_plain_row(stdscr, y, f" {arrow} {all_items[i]}", max_x, is_cursor=is_cursor) def _on_action(action, cursor): if action == NAV_SELECT: @@ -1200,7 +921,7 @@ def curses_single_select( return _run_curses_menu( initial_cursor=min(default_index, len(all_items) - 1), item_count=len(all_items), - draw_header=_draw_header, + draw_header=_simple_header(title, "ENTER confirm", "ESC/q cancel", searchable), draw_row=_draw_row, on_action=_on_action, reserve_bottom=1, @@ -1221,27 +942,8 @@ def _numbered_single_fallback( for i, label in enumerate(items, 1): print(f" {i}. {label}") print() - try: - val = _read_numbered_input(f" Choice [1-{len(items)}]: ") - if val is _NumberedNavigation.BACK: - _back_scoped_navigation() - return None - if val is _NumberedNavigation.CANCEL: - _cancel_scoped_navigation() - return None - val = val.strip() - if not val: - return None - idx = int(val) - 1 - if 0 <= idx < len(items) and idx < cancel_idx: - return idx - if idx == cancel_idx: - return None - except ValueError: - pass - except (KeyboardInterrupt, EOFError): - _cancel_scoped_navigation() - return None + idx = _read_numbered_choice(f" Choice [1-{len(items)}]: ") + return idx if isinstance(idx, int) and 0 <= idx < min(len(items), cancel_idx) else None def _numbered_fallback( @@ -1260,32 +962,15 @@ def _numbered_fallback( for i, label in enumerate(items): marker = color("[✓]", Colors.GREEN) if i in chosen else "[ ]" print(f" {marker} {i + 1:>2}. {label}") - if status_fn: - status_text = status_fn(chosen) - if status_text: - print(color(f"\n {status_text}", Colors.DIM)) + status_text = status_fn(chosen) if status_fn else "" + if status_text: + print(color(f"\n {status_text}", Colors.DIM)) print() - try: - val = _read_numbered_input( - color(" Toggle # (or Enter to confirm): ", Colors.DIM) - ) - if val is _NumberedNavigation.BACK: - _back_scoped_navigation() - return cancel_returns - if val is _NumberedNavigation.CANCEL: - _cancel_scoped_navigation() - return cancel_returns - val = val.strip() - if not val: - break - idx = int(val) - 1 - if 0 <= idx < len(items): - chosen.symmetric_difference_update({idx}) - except ValueError: - return cancel_returns - except (KeyboardInterrupt, EOFError): - _cancel_scoped_navigation() + idx = _read_numbered_choice(color(" Toggle # (or Enter to confirm): ", Colors.DIM)) + if idx is _NAV_ABORT: return cancel_returns + if idx is None: + return chosen + if 0 <= idx < len(items): + chosen.symmetric_difference_update({idx}) print() - - return chosen diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 947562ecff..834fb41f12 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -1,21 +1,4 @@ -"""``hermes debug`` debug tools for Hermes Agent. - -Currently supports: - hermes debug share Upload debug report (system info + logs) to a - paste service and print a shareable URL. - By default, log content is run through - ``agent.redact.redact_sensitive_text`` with - ``force=True`` before upload so credentials in - ``~/.hermes/logs/*.log`` are not leaked into - the public paste service. Pass ``--no-redact`` - to disable. - Pass ``--nous`` to upload instead to Nous-internal - storage (AWS S3) via a signed URL minted by the - Nous account service: the bundle is private - (viewable only by Nous staff / allowlisted mods via - a Google-login-gated viewer) and auto-deletes after - 14 days, rather than going to a public paste. -""" +"""``hermes debug`` debug tools for Hermes Agent.""" import datetime import gzip @@ -72,16 +55,9 @@ _AUTO_DELETE_SECONDS = 21600 def _pending_file() -> Path: """Path to ``~/.hermes/pastes/pending.json``. - Each entry: ``{"url": "...", "expire_at": <unix_ts>}``. Scheduled - DELETEs used to be handled by spawning a detached Python process per - paste that slept for 6 hours; those accumulated forever if the user - ran ``hermes debug share`` repeatedly. - - Deletion is now driven by the gateway's cron ticker - (``gateway/run.py::_start_cron_ticker``) which calls - ``_sweep_expired_pastes`` once per hour. ``hermes debug share`` also - runs an opportunistic sweep on entry as a fallback for CLI-only users - who never start the gateway. + Deletion is now driven by the gateway's cron ticker (``gateway/run.py::_start_cron_ticker``) + which calls ``_sweep_expired_pastes`` once per hour. ``hermes debug share`` also runs an + opportunistic sweep on entry as a fallback for CLI-only users who never start the gateway. """ return get_hermes_home() / "pastes" / "pending.json" @@ -92,15 +68,12 @@ def _load_pending() -> list[dict]: return [] try: data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, list): - # Filter to well-formed entries only - return [ - e for e in data - if isinstance(e, dict) and "url" in e and "expire_at" in e - ] - except (OSError, ValueError, json.JSONDecodeError): - pass - return [] + except (OSError, ValueError): + return [] + if not isinstance(data, list): + return [] + # Filter to well-formed entries only + return [e for e in data if isinstance(e, dict) and "url" in e and "expire_at" in e] def _save_pending(entries: list[dict]) -> None: @@ -116,33 +89,11 @@ def _save_pending(entries: list[dict]) -> None: pass -def _record_pending(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS) -> None: - """Record *urls* for deletion at ``now + delay_seconds``. - - Only paste.rs URLs are recorded (dpaste.com auto-expires). Entries - are merged into any existing pending.json. - """ - paste_rs_urls = [u for u in urls if _extract_paste_id(u)] - if not paste_rs_urls: - return - - entries = _load_pending() - # Dedupe by URL: keep the later expire_at if same URL appears twice - by_url: dict[str, float] = {e["url"]: float(e["expire_at"]) for e in entries} - expire_at = time.time() + delay_seconds - for u in paste_rs_urls: - by_url[u] = max(expire_at, by_url.get(u, 0.0)) - merged = [{"url": u, "expire_at": ts} for u, ts in by_url.items()] - _save_pending(merged) - - def _sweep_expired_pastes(now: Optional[float] = None) -> tuple[int, int]: """Synchronously DELETE any pending pastes whose ``expire_at`` has passed. - Returns ``(deleted, remaining)``. Best-effort: failed deletes stay in - the pending file and will be retried on the next sweep. Silent — - intended to be called from every ``hermes debug`` invocation with - minimal noise. + Returns ``(deleted, remaining)``. Best-effort: failed deletes stay in the pending file for + the next sweep. Silent, since it runs on every ``hermes debug`` invocation. """ entries = _load_pending() if not entries: @@ -221,10 +172,7 @@ _GATEWAY_PRIVACY_NOTICE = ( def _extract_paste_id(url: str) -> Optional[str]: - """Extract the paste ID from a paste.rs or dpaste.com URL. - - Returns the ID string, or None if the URL doesn't match a known service. - """ + """Extract the paste ID from a paste.rs or dpaste.com URL.""" url = url.strip().rstrip("/") for prefix in ("https://paste.rs/", "http://paste.rs/"): if url.startswith(prefix): @@ -233,10 +181,10 @@ def _extract_paste_id(url: str) -> Optional[str]: def delete_paste(url: str) -> bool: - """Delete a paste from paste.rs. Returns True on success. + """Delete a paste from paste.rs. Returns True on success. - Only paste.rs supports unauthenticated DELETE. dpaste.com pastes - expire automatically but cannot be deleted via API. + Only paste.rs supports unauthenticated DELETE. dpaste.com pastes expire automatically but cannot + be deleted via API. """ paste_id = _extract_paste_id(url) if not paste_id: @@ -256,45 +204,44 @@ def delete_paste(url: str) -> bool: def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS): """Record *urls* for deletion ``delay_seconds`` from now. - Previously this spawned a detached Python subprocess per call that slept - for 6 hours and then issued DELETE requests. Those subprocesses leaked — - every ``hermes debug share`` invocation added ~20 MB of resident Python - interpreters that never exited until the sleep completed. - - The replacement is stateless: we append to ``~/.hermes/pastes/pending.json`` - and the gateway's cron ticker sweeps expired entries once per hour. - ``hermes debug share`` also runs an opportunistic sweep as a fallback - for CLI-only users. If neither runs again, paste.rs's own retention - policy handles cleanup. + Only paste.rs URLs are recorded (dpaste.com auto-expires); entries are merged into any existing + pending.json. The old fork-and-sleep subprocesses leaked ~20 MB of resident interpreter per + ``hermes debug share``. This replacement is stateless: the gateway's cron ticker sweeps expired + entries once per hour, ``hermes debug share`` runs an opportunistic sweep as a fallback for + CLI-only users, and paste.rs's own retention policy is the last resort. """ - _record_pending(urls, delay_seconds=delay_seconds) + paste_rs_urls = [u for u in urls if _extract_paste_id(u)] + if not paste_rs_urls: + return + + # Dedupe by URL: keep the later expire_at if same URL appears twice + by_url: dict[str, float] = {e["url"]: float(e["expire_at"]) for e in _load_pending()} + expire_at = time.time() + delay_seconds + for u in paste_rs_urls: + by_url[u] = max(expire_at, by_url.get(u, 0.0)) + _save_pending([{"url": u, "expire_at": ts} for u, ts in by_url.items()]) -def _upload_paste_rs(content: str) -> str: - """Upload to paste.rs. Returns the paste URL. - - paste.rs accepts a plain POST body and returns the URL directly. - """ - data = content.encode("utf-8") +def _post_paste(service: str, endpoint: str, body: bytes, content_type: str) -> str: + """POST *body* to a paste service and return the paste URL it echoes back.""" req = urllib.request.Request( - _PASTE_RS_URL, data=data, method="POST", - headers={ - "Content-Type": "text/plain; charset=utf-8", - "User-Agent": "hermes-agent/debug-share", - }, + endpoint, data=body, method="POST", + headers={"Content-Type": content_type, "User-Agent": "hermes-agent/debug-share"}, ) with urllib.request.urlopen(req, timeout=30) as resp: url = resp.read().decode("utf-8").strip() if not url.startswith("http"): - raise ValueError(f"Unexpected response from paste.rs: {url[:200]}") + raise ValueError(f"Unexpected response from {service}: {url[:200]}") return url -def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str: - """Upload to dpaste.com. Returns the paste URL. +def _upload_paste_rs(content: str) -> str: + """Upload to paste.rs. Returns the paste URL.""" + return _post_paste("paste.rs", _PASTE_RS_URL, content.encode("utf-8"), "text/plain; charset=utf-8") - dpaste.com uses multipart form data. - """ + +def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str: + """Upload to dpaste.com. Returns the paste URL.""" boundary = "----HermesDebugBoundary9f3c" def _field(name: str, value: str) -> str: @@ -311,26 +258,11 @@ def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str: + _field("expiry_days", str(expiry_days)) + f"--{boundary}--\r\n" ).encode("utf-8") - - req = urllib.request.Request( - _DPASTE_COM_URL, data=body, method="POST", - headers={ - "Content-Type": f"multipart/form-data; boundary={boundary}", - "User-Agent": "hermes-agent/debug-share", - }, - ) - with urllib.request.urlopen(req, timeout=30) as resp: - url = resp.read().decode("utf-8").strip() - if not url.startswith("http"): - raise ValueError(f"Unexpected response from dpaste.com: {url[:200]}") - return url + return _post_paste("dpaste.com", _DPASTE_COM_URL, body, f"multipart/form-data; boundary={boundary}") def upload_to_pastebin(content: str, expiry_days: int = 7) -> str: - """Upload *content* to a paste service, trying paste.rs then dpaste.com. - - Returns the paste URL on success, raises on total failure. - """ + """Upload *content* to a paste service, trying paste.rs then dpaste.com.""" errors: list[str] = [] # Try paste.rs first (simple, fast) @@ -387,11 +319,7 @@ _CLIENT_SIDE_LOGS = { def _missing_log_note(log_name: str) -> str: - """Explain a missing log instead of stating a bare absence. - - For a client-side log the absence is expected on a remote backend, so the - note names the writer and the path to collect by hand. - """ + """Explain a missing log instead of stating a bare absence.""" reason = _CLIENT_SIDE_LOGS.get(log_name) if reason is None: return "(file not found)" @@ -404,9 +332,8 @@ def _missing_log_note(log_name: str) -> str: def _resolve_log_path(log_name: str) -> Optional[Path]: """Find the log file for *log_name*, falling back to the .1 rotation. - Returns the first non-empty candidate (primary, then .1), or None. - Callers distinguish 'empty primary' from 'truly missing' via - :func:`_primary_log_path`. + Returns the first non-empty candidate (primary, then .1), or None. Callers distinguish + 'empty primary' from 'truly missing' via :func:`_primary_log_path`. """ primary = _primary_log_path(log_name) if primary is None: @@ -425,11 +352,10 @@ def _resolve_log_path(log_name: str) -> Optional[Path]: def _redact_log_text(text: str) -> str: """Run ``redact_sensitive_text`` with ``force=True`` over upload-bound text. - Uses ``force=True`` so redaction fires regardless of the operator's - ``security.redact_secrets`` setting. The local on-disk log file is - not modified; only the in-memory copy headed for the public paste - service is sanitized. Returns the redacted text (or the original - when empty / non-string). + Uses ``force=True`` so redaction fires regardless of the operator's ``security.redact_secrets`` + setting. The local on-disk log file is not modified; only the in-memory copy headed for the + public paste service is sanitized. Returns the redacted text (or the original when empty / non- + string). """ if not text: return text @@ -448,24 +374,19 @@ def _capture_log_snapshot( ) -> LogSnapshot: """Capture a log once and derive summary/full-log views from it. - The report tail and standalone log upload must come from the same file - snapshot. Otherwise a rotation/truncate between reads can make the report - look newer than the uploaded ``agent.log`` paste. + The report tail and standalone log upload must come from the same file snapshot. Otherwise a + rotation/truncate between reads can make the report look newer than the uploaded ``agent.log`` + paste. - When ``redact`` is True (the default), both ``tail_text`` and - ``full_text`` are run through ``_redact_log_text`` so the snapshot - returned is upload-safe. The on-disk log file is never modified. - Pass ``redact=False`` to capture original log content (used by - ``hermes debug share --no-redact``). + When ``redact`` is True (the default), both ``tail_text`` and ``full_text`` are run through + ``_redact_log_text`` so the snapshot returned is upload-safe. The on-disk log file is never + modified. Pass ``redact=False`` to capture original log content (used by ``hermes debug share + --no-redact``). """ log_path = _resolve_log_path(log_name) if log_path is None: primary = _primary_log_path(log_name) - tail = ( - "(file empty)" - if primary and primary.exists() - else _missing_log_note(log_name) - ) + tail = "(file empty)" if primary and primary.exists() else _missing_log_note(log_name) return LogSnapshot(path=None, tail_text=tail, full_text=None) try: @@ -529,31 +450,22 @@ def _capture_log_snapshot( return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None) +# Logs the debug report tails, in output order. ``agent`` gets the full ``--lines`` budget; +# the rest are capped at 100 lines. Every log but ``errors`` is also uploaded in full. +_REPORT_LOGS = ("agent", "errors", "gateway", "gui", "desktop") +_FULL_LOGS = ("agent", "gateway", "gui", "desktop") + + def _capture_default_log_snapshots( log_lines: int, *, redact: bool = True ) -> dict[str, LogSnapshot]: - """Capture all logs used by debug-share exactly once. - - ``redact`` is forwarded to each ``_capture_log_snapshot`` call so all - captured logs share the same redaction policy for a given run. - """ + """Capture all logs used by debug-share exactly once.""" errors_lines = min(log_lines, 100) return { - "agent": _capture_log_snapshot( - "agent", tail_lines=log_lines, redact=redact - ), - "errors": _capture_log_snapshot( - "errors", tail_lines=errors_lines, redact=redact - ), - "gateway": _capture_log_snapshot( - "gateway", tail_lines=errors_lines, redact=redact - ), - "gui": _capture_log_snapshot( - "gui", tail_lines=errors_lines, redact=redact - ), - "desktop": _capture_log_snapshot( - "desktop", tail_lines=errors_lines, redact=redact - ), + name: _capture_log_snapshot( + name, tail_lines=log_lines if name == "agent" else errors_lines, redact=redact + ) + for name in _REPORT_LOGS } @@ -588,15 +500,8 @@ def collect_debug_report( ) -> str: """Build the summary debug report: system dump + log tails. - Parameters - ---------- - log_lines - Number of recent lines to include per log file. - dump_text - Pre-captured dump output. If empty, ``hermes dump`` is run - internally. - - Returns the report as a plain-text string ready for upload. + ``dump_text`` is pre-captured dump output; when empty, ``hermes dump`` is run internally. + Returns plain text ready for upload. """ buf = io.StringIO() @@ -628,27 +533,13 @@ def collect_debug_report( pass # ── Recent log tails (summary only) ────────────────────────────────── - buf.write("\n\n") - buf.write(f"--- agent.log (last {log_lines} lines) ---\n") - buf.write(log_snapshots["agent"].tail_text) - buf.write("\n\n") - errors_lines = min(log_lines, 100) - buf.write(f"--- errors.log (last {errors_lines} lines) ---\n") - buf.write(log_snapshots["errors"].tail_text) - buf.write("\n\n") - - buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n") - buf.write(log_snapshots["gateway"].tail_text) - buf.write("\n\n") - - buf.write(f"--- gui.log (last {errors_lines} lines) ---\n") - buf.write(log_snapshots["gui"].tail_text) - buf.write("\n\n") - - buf.write(f"--- desktop.log (last {errors_lines} lines) ---\n") - buf.write(log_snapshots["desktop"].tail_text) buf.write("\n") + for name in _REPORT_LOGS: + lines = log_lines if name == "agent" else errors_lines + buf.write(f"\n--- {name}.log (last {lines} lines) ---\n") + buf.write(log_snapshots[name].tail_text) + buf.write("\n") return buf.getvalue() @@ -668,21 +559,8 @@ def collect_share_bundle( ) -> dict[str, str]: """Collect the debug report + full logs as a label→text mapping. - Returns ``{"report": ..., "agent.log": ..., "gateway.log": ..., - "desktop.log": ...}`` where each value is the already-redacted (when - ``redact`` is True) text that would be uploaded. Keys for logs that are - absent/empty are simply omitted. - - This is the single source of collection + redaction shared by both - destinations: the paste.rs path (:func:`build_debug_share`) and the - Nous-S3 path (``--nous``). Centralising it guarantees the Nous bundle is - built from the *same* force-redacted snapshots as the public paste path — - redaction is the safety boundary, so the Nous path must never see raw - logs. - - The dump header is prepended to each full log (mirroring the historical - paste behaviour) so every file is self-contained, and the redaction - banner is prepended when ``redact`` is True. + The dump header is prepended to each full log (mirroring the historical paste behaviour) so + every file is self-contained, and the redaction banner is prepended when ``redact`` is True. """ dump_text = _capture_dump() log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact) @@ -692,54 +570,22 @@ def collect_share_bundle( dump_text=dump_text, log_snapshots=log_snapshots, ) - agent_log = log_snapshots["agent"].full_text - gateway_log = log_snapshots["gateway"].full_text - gui_log = log_snapshots["gui"].full_text - desktop_log = log_snapshots["desktop"].full_text - - # Prepend dump header to each full log so every file is self-contained. - if agent_log: - agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log - if gateway_log: - gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log - if gui_log: - gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log - if desktop_log: - desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log - # Visible banner so reviewers know redaction was applied at upload time. - if redact: - report = _REDACTION_BANNER + report - if agent_log: - agent_log = _REDACTION_BANNER + agent_log - if gateway_log: - gateway_log = _REDACTION_BANNER + gateway_log - if gui_log: - gui_log = _REDACTION_BANNER + gui_log - if desktop_log: - desktop_log = _REDACTION_BANNER + desktop_log - - bundle: dict[str, str] = {"report": report} - if agent_log: - bundle["agent.log"] = agent_log - if gateway_log: - bundle["gateway.log"] = gateway_log - if gui_log: - bundle["gui.log"] = gui_log - if desktop_log: - bundle["desktop.log"] = desktop_log + banner = _REDACTION_BANNER if redact else "" + bundle: dict[str, str] = {"report": banner + report} + # Prepend dump header to each full log so every file is self-contained. + for name in _FULL_LOGS: + full = log_snapshots[name].full_text + if full: + bundle[f"{name}.log"] = banner + dump_text + f"\n\n--- full {name}.log ---\n" + full return bundle def build_nous_bundle(bundle: dict[str, str], redact: bool = True) -> bytes: """Gzip-compress a :func:`collect_share_bundle` mapping into the Nous envelope. - The JSON shape is what the discord-support viewer (Repo 3) parses:: - - {"format": "hermes-debug-share/1", - "redacted": <bool>, - "created": <iso8601>, - "files": {"report": ..., "agent.log": ..., ...}} + The JSON shape (``format: hermes-debug-share/1``, ``redacted``, ``created``, ``files``) is + what the discord-support viewer parses — keep it stable. """ created = datetime.datetime.now(datetime.timezone.utc).isoformat() envelope = { @@ -759,9 +605,8 @@ def build_nous_bundle(bundle: dict[str, str], redact: bool = True) -> bytes: class DebugShareResult: """Structured outcome of a ``debug share`` upload. - Returned by :func:`build_debug_share` so non-CLI callers (the dashboard - web server, gateway) can render the uploaded paste URLs as real links - instead of scraping printed text. + Returned by :func:`build_debug_share` so non-CLI callers (dashboard web server, gateway) can + render the uploaded paste URLs as real links instead of scraping printed text. """ urls: dict # label -> paste URL (e.g. {"Report": "...", "agent.log": "..."}) @@ -779,14 +624,9 @@ def build_debug_share( ) -> DebugShareResult: """Collect the debug report + full logs, upload each, return the URLs. - This is the shared core behind ``hermes debug share`` (CLI) and the - dashboard ``POST /api/ops/debug-share`` endpoint. It performs blocking - network I/O (paste uploads) — callers inside an event loop must run it in - a worker thread. - - The summary report upload is required: on failure this raises - ``RuntimeError``. Full-log uploads are best-effort; their errors are - collected into ``failures`` rather than raised. + This is the shared core behind ``hermes debug share`` (CLI) and the dashboard ``POST + /api/ops/debug-share`` endpoint. It performs blocking network I/O (paste uploads) — callers + inside an event loop must run it in a worker thread. """ _best_effort_sweep_expired_pastes() @@ -810,7 +650,8 @@ def build_debug_share( urls["Report"] = upload_to_pastebin(report, expiry_days=expiry) # 2-5. Full logs (optional — failures are collected, not raised) - for label in ("agent.log", "gateway.log", "gui.log", "desktop.log"): + for name in _FULL_LOGS: + label = f"{name}.log" content = bundle.get(label) if not content: continue @@ -834,14 +675,8 @@ def build_debug_share( def _confirm_upload(args) -> bool: """Require explicit consent before any debug-share upload. - The privacy notice is printed by the caller. This gates the actual - upload: with ``--yes`` (or ``-y``) we proceed unprompted; otherwise we - ask an interactive ``[y/N]`` question. In a non-interactive context - (no TTY on stdin — scripts, CI, piped input) we refuse rather than - hang or upload silently, so debug data can't be exposed without a - deliberate ``--yes``. - - Returns True to proceed with the upload, False to abort. + The privacy notice is printed by the caller. This gates the actual upload: with ``--yes`` (or + ``-y``) we proceed unprompted; otherwise we ask an interactive ``[y/N]`` question. """ if bool(getattr(args, "yes", False)): return True @@ -879,17 +714,10 @@ def run_debug_share(args): print("Collecting debug report...") bundle = collect_share_bundle(log_lines=log_lines, redact=redact) print(bundle["report"]) - for title, label in ( - ("FULL agent.log", "agent.log"), - ("FULL gateway.log", "gateway.log"), - ("FULL gui.log", "gui.log"), - ("FULL desktop.log", "desktop.log"), - ): - body = bundle.get(label) + for name in _FULL_LOGS: + body = bundle.get(f"{name}.log") if body: - print(f"\n\n{'=' * 60}") - print(title) - print(f"{'=' * 60}\n") + print(f"\n\n{'=' * 60}\nFULL {name}.log\n{'=' * 60}\n") print(body) return @@ -950,10 +778,9 @@ _NOUS_PRIVACY_NOTICE = """\ def _run_debug_share_nous(args, *, log_lines: int, redact: bool) -> None: """Handle ``hermes debug share --nous``: upload the bundle to Nous-S3. - Collects the same force-redacted bundle as the paste path, gzips it into - the Nous envelope, requests a signed URL from NAS, uploads, and prints the - private viewer link. On any failure falls back to a clear error that - suggests ``--local``. + Collects the same force-redacted bundle as the paste path, gzips it into the Nous envelope, + requests a signed URL from NAS, uploads, and prints the private viewer link. On any failure + falls back to a clear error that suggests ``--local``. """ from hermes_cli.diagnostics_upload import share_to_nous @@ -1024,8 +851,7 @@ def run_debug_delete(args): for url in urls: try: - ok = delete_paste(url) - if ok: + if delete_paste(url): print(f" ✓ Deleted: {url}") else: print(f" ✗ Failed to delete: {url} (unexpected response)") @@ -1039,13 +865,8 @@ def run_debug(args): """Route debug subcommands.""" # Opportunistic sweep of expired pastes on every ``hermes debug`` call. # Replaces the old per-paste sleeping subprocess that used to leak as - # one orphaned Python interpreter per scheduled deletion. Silent and - # best-effort — any failure is swallowed so ``hermes debug`` stays - # reliable even when offline. - try: - _sweep_expired_pastes() - except Exception: - pass + # one orphaned Python interpreter per scheduled deletion. + _best_effort_sweep_expired_pastes() subcmd = getattr(args, "debug_command", None) if subcmd == "share": @@ -1053,20 +874,23 @@ def run_debug(args): elif subcmd == "delete": run_debug_delete(args) else: - # Default: show help - print("Usage: hermes debug <command>") - print() - print("Commands:") - print(" share Upload debug report to a paste service and print URL") - print(" delete Delete a previously uploaded paste") - print() - print("Options (share):") - print(" --lines N Number of log lines to include (default: 200)") - print(" --expire N Paste expiry in days (default: 7)") - print(" --local Print report locally instead of uploading") - print(" --nous Upload to Nous-internal storage (private, staff-only,") - print(" auto-deletes in 14 days) instead of a public paste") - print(" --no-redact Disable upload-time secret redaction (default: redact)") - print() - print("Options (delete):") - print(" <url> ... One or more paste URLs to delete") + print(_DEBUG_USAGE) + + +_DEBUG_USAGE = """\ +Usage: hermes debug <command> + +Commands: + share Upload debug report to a paste service and print URL + delete Delete a previously uploaded paste + +Options (share): + --lines N Number of log lines to include (default: 200) + --expire N Paste expiry in days (default: 7) + --local Print report locally instead of uploading + --nous Upload to Nous-internal storage (private, staff-only, + auto-deletes in 14 days) instead of a public paste + --no-redact Disable upload-time secret redaction (default: redact) + +Options (delete): + <url> ... One or more paste URLs to delete""" diff --git a/hermes_cli/default_soul.py b/hermes_cli/default_soul.py index 07cc89071a..1479eb4080 100644 --- a/hermes_cli/default_soul.py +++ b/hermes_cli/default_soul.py @@ -100,13 +100,9 @@ def _normalize_soul(text: str) -> str: def is_legacy_template_soul(text: str) -> bool: """True if ``text`` is a non-customized, auto-seeded SOUL.md. - Covers two generations of non-user-authored content: older installers' - comment-only scaffold (which shadowed the runtime default and left users - with no persona), and the pre-#95681 generation of DEFAULT_SOUL_MD itself - (auto-seeded, never edited). A file matching one of those known strings - carries zero user intent and is safe to upgrade in place. Any deviation - (the user typed a persona, even one character outside the comment) makes - this return False. + Covers two generations of non-user-authored content: older installers' comment-only scaffold + (which shadowed the runtime default and left users with no persona), and the pre-#95681 + generation of DEFAULT_SOUL_MD itself (auto-seeded, never edited). """ normalized = _normalize_soul(text) return any(normalized == _normalize_soul(t) for t in _LEGACY_TEMPLATE_SOULS) diff --git a/hermes_cli/diagnostics_upload.py b/hermes_cli/diagnostics_upload.py index 34f1378ffe..172e62bedf 100644 --- a/hermes_cli/diagnostics_upload.py +++ b/hermes_cli/diagnostics_upload.py @@ -1,23 +1,11 @@ """Client for uploading ``hermes debug share`` bundles to Nous-internal S3. -This is the opt-in (``--nous``) destination for ``hermes debug share``. -Unlike the public paste.rs path, bundles uploaded here go to a Nous-owned -S3 bucket via a short-lived signed URL minted by the Nous account service -(NAS). The bucket auto-expires objects after 14 days, and the contents are -only viewable by Nous staff (and allowlisted Discord mods) through a -Google-OAuth-gated viewer. +1. POST {NAS_BASE}/api/diagnostics/upload-url → {uploadUrl, viewUrl, id, ...} (the request body +carries ``sizeBytes``; NAS signs it into the presigned URL's ``ContentLength``, so the PUT must send +exactly that many bytes) 2. PUT <uploadUrl> (the gzipped bundle, Content-Type application/gzip) -Flow: - - 1. POST {NAS_BASE}/api/diagnostics/upload-url → {uploadUrl, viewUrl, id, ...} - (the request body carries ``sizeBytes``; NAS signs it into the presigned - URL's ``ContentLength``, so the PUT must send exactly that many bytes) - 2. PUT <uploadUrl> (the gzipped bundle, Content-Type application/gzip) - -NAS is stateless — the object's existence in S3 is the only state, so there is -no confirm/callback step. - -Uses stdlib ``urllib`` only, matching ``debug.py`` style — no third-party deps. +NAS is stateless — the object's existence in S3 is the only state, so there is no confirm/callback +step. """ import json @@ -39,17 +27,24 @@ _UPLOAD_TIMEOUT = 120 _USER_AGENT = "hermes-agent/debug-share" +def _urlopen_checked(req: urllib.request.Request, *, timeout: int, what: str): + """Open *req*; raise ``RuntimeError`` on non-2xx and return the response body bytes.""" + with urllib.request.urlopen(req, timeout=timeout) as resp: + status = getattr(resp, "status", None) + if status is None: + status = resp.getcode() + if not (200 <= status < 300): + raise RuntimeError(f"{what} failed: HTTP {status}") + return resp.read() + + def request_upload_url( content_type: str = "application/gzip", size_bytes: int | None = None, ) -> dict: """Ask NAS to mint a presigned PUT URL for a diagnostics bundle. - POSTs a small JSON body to ``{NAS_BASE}/api/diagnostics/upload-url`` and - returns the parsed JSON response, expected to contain at least - ``uploadUrl``, ``viewUrl`` and ``id`` (plus optional ``expiresAt`` / - ``uploadExpiresInSeconds``). - + Returns the parsed JSON, expected to carry at least ``uploadUrl``, ``viewUrl`` and ``id``. Raises on non-2xx responses or unparseable JSON. """ payload: dict = {"contentType": content_type} @@ -67,15 +62,9 @@ def request_upload_url( "User-Agent": _USER_AGENT, }, ) - with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: - status = getattr(resp, "status", None) - if status is None: - status = resp.getcode() - if not (200 <= status < 300): - raise RuntimeError( - f"diagnostics upload-url request failed: HTTP {status}" - ) - body = resp.read().decode("utf-8") + body = _urlopen_checked( + req, timeout=_REQUEST_TIMEOUT, what="diagnostics upload-url request" + ).decode("utf-8") try: result = json.loads(body) @@ -99,8 +88,8 @@ def put_bundle( ) -> None: """PUT the gzipped *data* bundle to a presigned *upload_url*. - Sets the ``Content-Type`` header (must match what NAS pinned when signing - the URL, otherwise S3 rejects the signature). Raises on non-2xx. + Sets the ``Content-Type`` header (must match what NAS pinned when signing the URL, otherwise S3 + rejects the signature). Raises on non-2xx. """ req = urllib.request.Request( upload_url, @@ -111,23 +100,15 @@ def put_bundle( "User-Agent": _USER_AGENT, }, ) - with urllib.request.urlopen(req, timeout=_UPLOAD_TIMEOUT) as resp: - status = getattr(resp, "status", None) - if status is None: - status = resp.getcode() - if not (200 <= status < 300): - raise RuntimeError(f"diagnostics bundle PUT failed: HTTP {status}") + _urlopen_checked(req, timeout=_UPLOAD_TIMEOUT, what="diagnostics bundle PUT") def share_to_nous(report_bundle: bytes) -> dict: """Orchestrate the full Nous-S3 upload of a gzipped *report_bundle*. - Two steps: mint a presigned PUT URL (sending the exact ``sizeBytes`` NAS - signs into the URL's ``ContentLength``), then PUT the bundle. NAS is - stateless — the object's existence in S3 is the only state, so there is no - confirm/callback step. Returns the dict from :func:`request_upload_url` - (which carries ``viewUrl`` / ``id`` / expiry metadata) so the caller can - print the viewer link. Raises on any failure of either step. + Two steps: mint a presigned PUT URL (sending the exact ``sizeBytes`` NAS signs into the URL's + ``ContentLength``), then PUT the bundle. NAS is stateless — the object's existence in S3 is the + only state, so there is no confirm/callback step. """ size_bytes = len(report_bundle) info = request_upload_url( diff --git a/hermes_cli/doctor_live.py b/hermes_cli/doctor_live.py index 5c990b2af1..b23147f4f7 100644 --- a/hermes_cli/doctor_live.py +++ b/hermes_cli/doctor_live.py @@ -1,19 +1,7 @@ """``hermes doctor --live`` — opt-in bounded real-call tool-backend probes. -Design invariants: - -- **Opt-in only.** These probes make real (cheap, metadata/read-only) network - calls and may spend a trivial amount of quota. They run ONLY when the user - passes ``hermes doctor --live``. -- **Bounded.** One probe per configured backend, sequential, each with a - ~10s timeout (configurable via ``doctor.live_probe_timeout`` in - config.yaml). -- **Read-only.** Metadata GETs only — no generation, no scrapes that spend - credits, no state mutation anywhere. -- **Failure-isolated.** A probe crashing must never crash the doctor run; - every probe is wrapped in a catch-all. -- **Configured-only.** Backends without credentials / config are skipped with - a note, never failed. +- **Opt-in only.** These probes make real (cheap, metadata/read-only) network calls and may spend a +trivial amount of quota. They run ONLY when the user passes ``hermes doctor --live``. """ from __future__ import annotations @@ -41,6 +29,11 @@ ELEVENLABS_VOICES_URL = "https://api.elevenlabs.io/v1/voices" # TTS/STT providers that never touch the network (nothing to probe). _LOCAL_AUDIO_PROVIDERS = {"", "local", "edge", "neutts", "kittentts", "piper"} +_AUDIO_PROBES = { + "openai": (OPENAI_MODELS_URL, "OPENAI_API_KEY", "Bearer"), + "groq": (GROQ_MODELS_URL, "GROQ_API_KEY", "Bearer"), + "elevenlabs": (ELEVENLABS_VOICES_URL, "ELEVENLABS_API_KEY", "xi"), +} @dataclass @@ -113,8 +106,8 @@ def _browser_available() -> bool: def _launch_browser_probe(timeout: float) -> tuple: """Launch a browser, open about:blank, close. Returns (ok, detail). - Uses Playwright directly (what agent-browser drives underneath) so the - probe owns the full lifecycle and always cleans up. + Uses Playwright directly (what agent-browser drives underneath) so the probe owns the full + lifecycle and always cleans up. """ try: from playwright.sync_api import sync_playwright @@ -133,10 +126,7 @@ def _launch_browser_probe(timeout: float) -> tuple: def _probe_mcp_server(name: str, config: dict, timeout: float): - """initialize + tools/list against one configured MCP server. - - Reuses the exact machinery behind ``hermes mcp test``. - """ + """initialize + tools/list against one configured MCP server.""" from hermes_cli.mcp_config import _probe_single_server return _probe_single_server(name, config, connect_timeout=timeout) @@ -157,25 +147,23 @@ def _classify_http(name: str, resp, key_hint: str) -> ProbeResult: return ProbeResult(name, "fail", f"(HTTP {code})") -def _probe_firecrawl(timeout: float) -> ProbeResult: - key = os.getenv("FIRECRAWL_API_KEY", "").strip() +def _keyed_probe(name: str, url: str, env_var: str, scheme: str, + timeout: float) -> ProbeResult: + """Metadata GET authenticated by one env var (never a generation call).""" + key = os.getenv(env_var, "").strip() if not key: - return ProbeResult("Firecrawl", "skip", "(not configured)") - resp = _http_get(FIRECRAWL_HEALTH_URL, - headers={"Authorization": f"Bearer {key}"}, + return ProbeResult(name, "skip", "(not configured)") + resp = _http_get(url, headers={"Authorization": f"{scheme} {key}"}, timeout=timeout) - return _classify_http("Firecrawl", resp, "FIRECRAWL_API_KEY") + return _classify_http(name, resp, env_var) + + +def _probe_firecrawl(timeout: float) -> ProbeResult: + return _keyed_probe("Firecrawl", FIRECRAWL_HEALTH_URL, "FIRECRAWL_API_KEY", "Bearer", timeout) def _probe_fal(timeout: float) -> ProbeResult: - key = os.getenv("FAL_KEY", "").strip() - if not key: - return ProbeResult("FAL", "skip", "(not configured)") - # Metadata GET only — never a generation call. - resp = _http_get(FAL_MODELS_URL, - headers={"Authorization": f"Key {key}"}, - timeout=timeout) - return _classify_http("FAL", resp, "FAL_KEY") + return _keyed_probe("FAL", FAL_MODELS_URL, "FAL_KEY", "Key", timeout) def _probe_browser(timeout: float) -> ProbeResult: @@ -195,12 +183,7 @@ def _audio_provider_probe(kind: str, provider: str, f"(provider '{provider or 'local'}' — no remote " "backend to probe)") - probes = { - "openai": (OPENAI_MODELS_URL, "OPENAI_API_KEY", "Bearer"), - "groq": (GROQ_MODELS_URL, "GROQ_API_KEY", "Bearer"), - "elevenlabs": (ELEVENLABS_VOICES_URL, "ELEVENLABS_API_KEY", "xi"), - } - entry = probes.get(provider) + entry = _AUDIO_PROBES.get(provider) if entry is None: return ProbeResult(name, "skip", f"(provider '{provider}' — no live probe " @@ -221,30 +204,26 @@ def _audio_provider_probe(kind: str, provider: str, return result -def _probe_tts(config: dict, timeout: float) -> ProbeResult: - provider = ((config.get("tts") or {}).get("provider")) or "" - return _audio_provider_probe("tts", provider, timeout) - - -def _probe_stt(config: dict, timeout: float) -> ProbeResult: - provider = ((config.get("stt") or {}).get("provider")) or "" - return _audio_provider_probe("stt", provider, timeout) +def _probe_audio(kind: str, config: dict, timeout: float) -> ProbeResult: + provider = ((config.get(kind) or {}).get("provider")) or "" + return _audio_provider_probe(kind, provider, timeout) # --------------------------------------------------------------------------- # Orchestration # --------------------------------------------------------------------------- +_REPORTERS = {"pass": check_ok, "warn": check_warn, "fail": check_fail} + + def _report(result: ProbeResult, issues: List[str]) -> None: - if result.status == "pass": - check_ok(result.name, result.detail) - elif result.status == "warn": - check_warn(result.name, result.detail) - elif result.status == "fail": - check_fail(result.name, result.detail) - issues.append(f"Live probe failed: {result.name} {result.detail}") - else: # skip + reporter = _REPORTERS.get(result.status) + if reporter is None: # skip check_info(f"{result.name} {result.detail} — skipped") + return + reporter(result.name, result.detail) + if result.status == "fail": + issues.append(f"Live probe failed: {result.name} {result.detail}") def _run_one(name: str, fn: Callable[[], ProbeResult], @@ -267,9 +246,8 @@ def _run_one(name: str, fn: Callable[[], ProbeResult], def run_live_checks(issues: List[str]) -> List[ProbeResult]: """Run one bounded, read-only probe per configured tool backend. - Sequential by design (bounded, predictable output ordering). Appends a - remediation line to ``issues`` for each failed probe. Skipped backends - never fail and never append issues. + Sequential by design (bounded, predictable output ordering). Appends a remediation line to + ``issues`` for each failed probe. Skipped backends never fail and never append issues. """ config = _load_config() try: @@ -309,10 +287,9 @@ def run_live_checks(issues: List[str]) -> List[ProbeResult]: results.append(ProbeResult("MCP", "skip", "(no servers configured)")) _report(results[-1], issues) - results.append(_run_one( - "TTS", lambda: _probe_tts(config, timeout), issues)) - results.append(_run_one( - "STT", lambda: _probe_stt(config, timeout), issues)) + for kind in ("tts", "stt"): + results.append(_run_one( + kind.upper(), lambda k=kind: _probe_audio(k, config, timeout), issues)) return results @@ -320,8 +297,8 @@ def run_live_checks(issues: List[str]) -> List[ProbeResult]: def maybe_run_live_checks(args, issues: List[str]): """Entry point called from ``run_doctor`` after the static checks. - No-ops (returns None) unless the user explicitly passed ``--live``. - A crash anywhere in the live subsystem must never break doctor. + No-ops (returns None) unless the user explicitly passed ``--live``. A crash anywhere in the live + subsystem must never break doctor. """ if not getattr(args, "live", False): return None diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index fa27044f43..d636eeaddf 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -1,10 +1,4 @@ -""" -Dump command for hermes CLI. - -Outputs a compact, plain-text summary of the user's Hermes setup -that can be copy-pasted into Discord/GitHub/Telegram for support context. -No ANSI colors, no checkmarks — just data. -""" +"""Dump command for hermes CLI.""" import json import os @@ -22,13 +16,10 @@ from agent.skill_utils import is_excluded_skill_path def _dotenv_key_names() -> set[str]: """Return the set of env-var names assigned a non-empty value in ~/.hermes/.env. - The managed backends (launchd / systemd / the desktop-spawned ``serve`` - process) load credentials from this file — NOT from an interactive shell's - exports. ``hermes debug share`` runs in a terminal, so ``os.getenv`` reflects - the shell's environment, which can include exported keys the managed backend - never sees. Comparing against this set lets the dump flag that mismatch (the - exact trap behind #48504-style "no web_search" reports: key exported in the - shell, absent from .env, invisible to the launchd backend). + The managed backends (launchd / systemd / the desktop-spawned ``serve`` process) load + credentials from this file — NOT from an interactive shell's exports. ``hermes debug share`` + runs in a terminal, so ``os.getenv`` reflects the shell's environment, which can include + exported keys the managed backend never sees. """ try: env_path = get_env_path() @@ -51,28 +42,31 @@ def _dotenv_key_names() -> set[str]: return names -def _get_git_commit(project_root: Path) -> str: - """Return short git commit hash, or '(unknown)'. - - Source installs and dev images resolve this live via ``git rev-parse``. - The published Docker image excludes ``.git`` from the build context, so - that lookup always fails — we fall back to the baked-in build SHA written - to ``<project_root>/.hermes_build_sha`` by the Dockerfile's - ``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``). - The output format is identical regardless of source. - """ +def _git_output(project_root: Path, *args: str) -> str: + """Stripped stdout of ``git <args>`` run in *project_root*, or '' on any failure.""" try: result = subprocess.run( - ["git", "rev-parse", "--short=8", "HEAD"], + ["git", *args], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, cwd=str(project_root), ) if result.returncode == 0: - value = result.stdout.strip() - if value: - return value + return result.stdout.strip() except Exception: pass + return "" + + +def _get_git_commit(project_root: Path) -> str: + """Return short git commit hash, or '(unknown)'. + + Source installs resolve live via ``git rev-parse``. The published Docker image excludes + ``.git``, so fall back to the build SHA baked into ``<project_root>/.hermes_build_sha`` by the + Dockerfile. Output format is identical either way. + """ + value = _git_output(project_root, "rev-parse", "--short=8", "HEAD") + if value: + return value # Fall back to the build-time baked SHA (populated in published Docker # images, absent otherwise). Defers the import so the dump module @@ -91,33 +85,18 @@ def _get_git_commit(project_root: Path) -> str: def _get_git_commit_date(project_root: Path) -> str: """Return the date the HEAD commit was authored (YYYY-MM-DD), or ''. - Resolves live via ``git log`` on source installs. The published Docker - image excludes ``.git``, so this returns '' there — the dump line simply - drops the date suffix in that case (the baked SHA still identifies the - build). + The published Docker image excludes ``.git``, so this returns '' there and the dump line + drops the date suffix; the baked SHA still identifies the build. """ - try: - result = subprocess.run( - ["git", "log", "-1", "--format=%cd", "--date=short", "HEAD"], - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, - cwd=str(project_root), - ) - if result.returncode == 0: - value = result.stdout.strip() - if value: - return value - except Exception: - pass - - return "" + return _git_output(project_root, "log", "-1", "--format=%cd", "--date=short", "HEAD") def _redact(value: str) -> str: """Redact all but first 4 and last 4 chars. - Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for - an empty value (matches the historical behavior of this helper — - ``hermes dump`` formats empty values as blank, not as ``"(not set)"``). + Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for an empty value (matches + the historical behavior of this helper — ``hermes dump`` formats empty values as blank, not as + ``"(not set)"``). """ from agent.redact import mask_secret return mask_secret(value) @@ -130,12 +109,8 @@ def _gateway_status() -> str: snapshot = get_gateway_runtime_snapshot() if snapshot.running: - mode = snapshot.manager - if snapshot.has_process_service_mismatch: - mode = "manual" + mode = "manual" if snapshot.has_process_service_mismatch else snapshot.manager return f"running ({mode}, pid {snapshot.gateway_pids[0]})" - if snapshot.service_installed and not snapshot.service_running: - return f"stopped ({snapshot.manager})" return f"stopped ({snapshot.manager})" except Exception: return "unknown" if sys.platform.startswith(("linux", "darwin")) else "N/A" @@ -146,19 +121,12 @@ def _count_skills(hermes_home: Path) -> int: skills_dir = hermes_home / "skills" if not skills_dir.is_dir(): return 0 - count = 0 - for item in skills_dir.rglob("SKILL.md"): - if is_excluded_skill_path(item): - continue - count += 1 - return count + return sum(1 for item in skills_dir.rglob("SKILL.md") if not is_excluded_skill_path(item)) def _count_mcp_servers(config: dict) -> int: """Count configured MCP servers.""" - mcp = config.get("mcp", {}) - servers = mcp.get("servers", {}) - return len(servers) + return len(config.get("mcp", {}).get("servers", {})) def _cron_summary(hermes_home: Path) -> str: @@ -178,34 +146,34 @@ def _cron_summary(hermes_home: Path) -> str: return "(error reading)" +_PLATFORM_ENV_VARS = { + "telegram": "TELEGRAM_BOT_TOKEN", + "discord": "DISCORD_BOT_TOKEN", + "slack": "SLACK_BOT_TOKEN", + "whatsapp": "WHATSAPP_ENABLED", + "signal": "SIGNAL_HTTP_URL", + "email": "EMAIL_ADDRESS", + "sms": "TWILIO_ACCOUNT_SID", + "matrix": "MATRIX_HOMESERVER_URL", + "mattermost": "MATTERMOST_URL", + "homeassistant": "HASS_TOKEN", + "dingtalk": "DINGTALK_CLIENT_ID", + "feishu": "FEISHU_APP_ID", + "wecom": "WECOM_BOT_ID", + "wecom_callback": "WECOM_CALLBACK_CORP_ID", + "weixin": "WEIXIN_ACCOUNT_ID", + "qqbot": "QQ_APP_ID", +} + + def _configured_platforms() -> list[str]: """Return list of configured messaging platform names.""" - checks = { - "telegram": "TELEGRAM_BOT_TOKEN", - "discord": "DISCORD_BOT_TOKEN", - "slack": "SLACK_BOT_TOKEN", - "whatsapp": "WHATSAPP_ENABLED", - "signal": "SIGNAL_HTTP_URL", - "email": "EMAIL_ADDRESS", - "sms": "TWILIO_ACCOUNT_SID", - "matrix": "MATRIX_HOMESERVER_URL", - "mattermost": "MATTERMOST_URL", - "homeassistant": "HASS_TOKEN", - "dingtalk": "DINGTALK_CLIENT_ID", - "feishu": "FEISHU_APP_ID", - "wecom": "WECOM_BOT_ID", - "wecom_callback": "WECOM_CALLBACK_CORP_ID", - "weixin": "WEIXIN_ACCOUNT_ID", - "qqbot": "QQ_APP_ID", - } - return [name for name, env in checks.items() if os.getenv(env)] + return [name for name, env in _PLATFORM_ENV_VARS.items() if os.getenv(env)] def _memory_provider(config: dict) -> str: """Return the active memory provider name.""" - mem = config.get("memory", {}) - provider = mem.get("provider", "") - return provider if provider else "built-in" + return config.get("memory", {}).get("provider", "") or "built-in" def _get_model_and_provider(config: dict) -> tuple[str, str]: @@ -213,21 +181,13 @@ def _get_model_and_provider(config: dict) -> tuple[str, str]: model_cfg = config.get("model", "") if isinstance(model_cfg, dict): model = model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "(not set)" - provider = model_cfg.get("provider") or "(auto)" - elif isinstance(model_cfg, str): - model = model_cfg or "(not set)" - provider = "(auto)" - else: - model = "(not set)" - provider = "(auto)" - return model, provider + return model, model_cfg.get("provider") or "(auto)" + model = model_cfg if isinstance(model_cfg, str) else "" + return model or "(not set)", "(auto)" def _config_overrides(config: dict) -> dict[str, str]: - """Find non-default config values worth reporting. - - Returns a flat dict of dotpath -> value for interesting overrides. - """ + """Find non-default config values worth reporting.""" from hermes_cli.config import DEFAULT_CONFIG overrides = {} @@ -278,6 +238,38 @@ def _config_overrides(config: dict) -> dict[str, str]: return overrides +# (env var, dump label) in display order. +_API_KEYS = [ + ("OPENROUTER_API_KEY", "openrouter"), + ("OPENAI_API_KEY", "openai"), + ("ANTHROPIC_API_KEY", "anthropic"), + ("ANTHROPIC_TOKEN", "anthropic_token"), + ("NOUS_API_KEY", "nous"), + ("GOOGLE_API_KEY", "google/gemini"), + ("GEMINI_API_KEY", "gemini"), + ("GLM_API_KEY", "glm/zai"), + ("ZAI_API_KEY", "zai"), + ("KIMI_API_KEY", "kimi"), + ("MINIMAX_API_KEY", "minimax"), + ("DEEPSEEK_API_KEY", "deepseek"), + ("DASHSCOPE_API_KEY", "dashscope"), + ("HF_TOKEN", "huggingface"), + ("NVIDIA_API_KEY", "nvidia"), + ("AI_GATEWAY_API_KEY", "ai_gateway"), + ("OPENCODE_ZEN_API_KEY", "opencode_zen"), + ("OPENCODE_GO_API_KEY", "opencode_go"), + ("COMMANDCODE_API_KEY", "commandcode"), + ("KILOCODE_API_KEY", "kilocode"), + ("FIRECRAWL_API_KEY", "firecrawl"), + ("TAVILY_API_KEY", "tavily"), + ("KEENABLE_API_KEY", "keenable"), + ("BROWSERBASE_API_KEY", "browserbase"), + ("FAL_KEY", "fal"), + ("ELEVENLABS_API_KEY", "elevenlabs"), + ("GITHUB_TOKEN", "github"), +] + + def run_dump(args): """Output a compact, copy-pasteable setup summary.""" show_keys = getattr(args, "show_keys", False) @@ -343,67 +335,32 @@ def run_dump(args): # OS info os_info = f"{platform.system()} {platform.release()} {platform.machine()}" - lines = [] - lines.append("--- hermes dump ---") # Identify the build by commit + the date that commit was made, resolved # live via git. __release_date__ (the package release date) is # intentionally NOT shown here — it reads like a wall-clock timestamp and # confuses support triage. The commit date is the real "as-of" date. - ver_str = f"{__version__}" - ver_str += f" [{commit}]" + ver_str = f"{__version__} [{commit}]" if commit_date: ver_str += f" ({commit_date})" - lines.append(f"version: {ver_str}") - lines.append(f"os: {os_info}") - lines.append(f"python: {sys.version.split()[0]}") - lines.append(f"openai_sdk: {openai_ver}") - lines.append(f"profile: {profile}") - lines.append(f"hermes_home: {display_hermes_home()}") - lines.append(f"model: {model}") - lines.append(f"provider: {provider}") - lines.append(f"terminal: {backend}") - - # API keys - lines.append("") - lines.append("api_keys:") - api_keys = [ - ("OPENROUTER_API_KEY", "openrouter"), - ("OPENAI_API_KEY", "openai"), - ("ANTHROPIC_API_KEY", "anthropic"), - ("ANTHROPIC_TOKEN", "anthropic_token"), - ("NOUS_API_KEY", "nous"), - ("GOOGLE_API_KEY", "google/gemini"), - ("GEMINI_API_KEY", "gemini"), - ("GLM_API_KEY", "glm/zai"), - ("ZAI_API_KEY", "zai"), - ("KIMI_API_KEY", "kimi"), - ("MINIMAX_API_KEY", "minimax"), - ("DEEPSEEK_API_KEY", "deepseek"), - ("DASHSCOPE_API_KEY", "dashscope"), - ("HF_TOKEN", "huggingface"), - ("NVIDIA_API_KEY", "nvidia"), - ("AI_GATEWAY_API_KEY", "ai_gateway"), - ("OPENCODE_ZEN_API_KEY", "opencode_zen"), - ("OPENCODE_GO_API_KEY", "opencode_go"), - ("COMMANDCODE_API_KEY", "commandcode"), - ("KILOCODE_API_KEY", "kilocode"), - ("FIRECRAWL_API_KEY", "firecrawl"), - ("TAVILY_API_KEY", "tavily"), - ("KEENABLE_API_KEY", "keenable"), - ("BROWSERBASE_API_KEY", "browserbase"), - ("FAL_KEY", "fal"), - ("ELEVENLABS_API_KEY", "elevenlabs"), - ("GITHUB_TOKEN", "github"), + lines = [ + "--- hermes dump ---", + f"version: {ver_str}", + f"os: {os_info}", + f"python: {sys.version.split()[0]}", + f"openai_sdk: {openai_ver}", + f"profile: {profile}", + f"hermes_home: {display_hermes_home()}", + f"model: {model}", + f"provider: {provider}", + f"terminal: {backend}", + "", + "api_keys:", ] - dotenv_keys = _dotenv_key_names() - for env_var, label in api_keys: + for env_var, label in _API_KEYS: val = os.getenv(env_var, "") - if show_keys and val: - display = _redact(val) - else: - display = "set" if val else "not set" + display = _redact(val) if show_keys and val else ("set" if val else "not set") # Set in this (shell) process but absent from ~/.hermes/.env: a managed # backend (launchd/systemd/desktop `serve`) loads .env, not the login # shell, so it likely can't see this key — even though the dump reads @@ -425,29 +382,27 @@ def run_dump(args): lines.append(f" {label:<20} {display}") # Features summary - lines.append("") - lines.append("features:") - toolsets = config.get("toolsets", ["hermes-cli"]) - lines.append(f" toolsets: {', '.join(toolsets) if toolsets else '(default)'}") - lines.append(f" mcp_servers: {_count_mcp_servers(config)}") - lines.append(f" memory_provider: {_memory_provider(config)}") - lines.append(f" gateway: {_gateway_status()}") - + lines += [ + "", + "features:", + f" toolsets: {', '.join(toolsets) if toolsets else '(default)'}", + f" mcp_servers: {_count_mcp_servers(config)}", + f" memory_provider: {_memory_provider(config)}", + f" gateway: {_gateway_status()}", + ] platforms = _configured_platforms() - lines.append(f" platforms: {', '.join(platforms) if platforms else 'none'}") - lines.append(f" cron_jobs: {_cron_summary(hermes_home)}") - lines.append(f" skills: {_count_skills(hermes_home)}") + lines += [ + f" platforms: {', '.join(platforms) if platforms else 'none'}", + f" cron_jobs: {_cron_summary(hermes_home)}", + f" skills: {_count_skills(hermes_home)}", + ] # Config overrides (non-default values) overrides = _config_overrides(config) if overrides: - lines.append("") - lines.append("config_overrides:") - for key, val in overrides.items(): - lines.append(f" {key}: {val}") + lines += ["", "config_overrides:"] + lines += [f" {key}: {val}" for key, val in overrides.items()] lines.append("--- end dump ---") - - output = "\n".join(lines) - print(output) + print("\n".join(lines)) diff --git a/hermes_cli/focus_view.py b/hermes_cli/focus_view.py index 0ec6c07688..b615736966 100644 --- a/hermes_cli/focus_view.py +++ b/hermes_cli/focus_view.py @@ -1,26 +1,13 @@ """Focus view — a display-only reduced-output mode. -``/focus`` answers one question the existing ``/verbose`` cycle cannot: -*"just show me my prompt and the answer — and tell me what you hid."* +``/focus`` answers one question the existing ``/verbose`` cycle cannot: *"just show me my prompt and +the answer — and tell me what you hid."* -``/verbose off`` already silences per-tool progress lines (the -``tool_progress_mode == "off"`` gate in ``agent/tool_executor.py`` and the -scrollback gate in ``HermesCLI._on_tool_progress``). Focus view **composes -with** that machinery instead of duplicating it: - -* turning focus ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the - mode the user had configured, so the *existing* suppression path does the - actual hiding; -* turning focus OFF restores that remembered mode verbatim; -* on top of that, focus view adds the two things ``/verbose off`` lacks — - a per-turn count of what was hidden plus a recovery hint, and a persistent - ``focus`` segment in the status bar so the reduced mode is never invisible. - -Everything in this module is **display-only**. Nothing here reads or mutates -conversation history, the system prompt, tool schemas, or any request payload. -Flipping focus view must never change a single byte of what is sent to the -model — that invariant is covered by -``tests/cli/test_focus_view.py::test_model_facing_messages_identical_with_focus_on_vs_off``. +* turning focus ON snaps ``tool_progress_mode`` to ``"off"`` and remembers the mode the user had +configured, so the *existing* suppression path does the actual hiding; * turning focus OFF restores +that remembered mode verbatim; * on top of that, focus view adds the two things ``/verbose off`` +lacks — a per-turn count of what was hidden plus a recovery hint, and a persistent ``focus`` segment +in the status bar so the reduced mode is never invisible. """ from __future__ import annotations @@ -51,15 +38,9 @@ _OFF_WORDS = frozenset({"off", "disable", "disabled", "false", "no", "0"}) _STATUS_WORDS = frozenset({"status", "show", "?"}) _TOGGLE_WORDS = frozenset({"", "toggle"}) -FOCUS_USAGE = "Usage: /focus [on|off|status]" - def normalize_tool_progress_mode(mode: object, default: str = "all") -> str: - """Coerce a raw config/attr value into a known tool-progress mode. - - YAML 1.1 parses a bare ``off`` as ``False``, and older configs stored - ``True``/``False`` booleans, so this mirrors ``cli.py``'s normalisation. - """ + """Coerce a raw config/attr value into a known tool-progress mode.""" if mode is False: return "off" if mode is True: @@ -76,10 +57,9 @@ def normalize_tool_progress_mode(mode: object, default: str = "all") -> str: def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]: """Map a ``/focus`` argument onto an action, following the sibling toggles. - Returns ``(action, target)`` where ``action`` is one of ``"set"``, - ``"status"`` or ``"usage"``. ``target`` is the requested enabled-state for - ``"set"`` and ``None`` otherwise. Bare ``/focus`` toggles, matching - ``/footer`` / ``/battery`` / ``/timestamps``. + Returns ``(action, target)`` where ``action`` is one of ``"set"``, ``"status"`` or ``"usage"``. + ``target`` is the requested enabled-state for ``"set"`` and ``None`` otherwise. Bare ``/focus`` + toggles, matching ``/footer`` / ``/battery`` / ``/timestamps``. """ text = str(arg or "").strip().lower() if text in _STATUS_WORDS: @@ -93,20 +73,6 @@ def resolve_focus_arg(arg: str, current: bool) -> tuple[str, Optional[bool]]: return "usage", None -def effective_tool_progress_mode(focus_enabled: bool, configured_mode: object) -> str: - """Return the tool-progress mode that should actually be in force. - - Focus view wins while it is on (it *is* "tool progress off" plus reporting). - When focus is off the user's configured mode is returned untouched — this is - what makes ``/focus off`` restore ``/verbose verbose`` rather than clobbering - it to ``all``. - """ - normalized = normalize_tool_progress_mode(configured_mode) - if focus_enabled: - return FOCUS_TOOL_PROGRESS_MODE - return normalized - - def would_display_tool_line( mode: object, function_name: str, @@ -114,18 +80,15 @@ def would_display_tool_line( ) -> bool: """Would the CLI have committed a scrollback line for this tool call? - Used to count *honestly*: if the user already had ``/verbose off``, focus - view is hiding nothing extra and must not claim otherwise. ``new`` mode - skips consecutive repeats of the same tool, so the counter skips them too. + Counts honestly: with ``/verbose off`` focus view hides nothing extra and must not claim + otherwise. ``new`` mode skips consecutive repeats of the same tool, so the counter does too. """ if not function_name: return False normalized = normalize_tool_progress_mode(mode) - if normalized not in TOOL_PROGRESS_VISIBLE_MODES: - return False - if normalized == "new" and function_name == last_tool_name: - return False - return True + return normalized in TOOL_PROGRESS_VISIBLE_MODES and not ( + normalized == "new" and function_name == last_tool_name + ) def format_hidden_line(count: int) -> Optional[str]: @@ -136,8 +99,7 @@ def format_hidden_line(count: int) -> Optional[str]: return None if n <= 0: return None - noun = "tool line" if n == 1 else "tool lines" - return f"⋯ {n} {noun} hidden · /focus off to show" + return f"⋯ {n} {'tool line' if n == 1 else 'tool lines'} hidden · /focus off to show" def focus_statusbar_segment(enabled: bool) -> str: @@ -147,15 +109,13 @@ def focus_statusbar_segment(enabled: bool) -> str: def format_focus_status(enabled: bool, configured_mode: object) -> str: """Human-readable ``/focus status`` body (no ANSI — callers colour it).""" - state = "ON" if enabled else "OFF" + mode = normalize_tool_progress_mode(configured_mode).upper() if enabled: - restore = normalize_tool_progress_mode(configured_mode) return ( - f"Focus view: {state} — only your prompt and the final response.\n" - f" /focus off restores tool progress: {restore.upper()}" + "Focus view: ON — only your prompt and the final response.\n" + f" /focus off restores tool progress: {mode}" ) - mode = normalize_tool_progress_mode(configured_mode) - return f"Focus view: {state} — tool progress: {mode.upper()}" + return f"Focus view: OFF — tool progress: {mode}" def format_focus_toggle_message(enabled: bool, configured_mode: object) -> str: diff --git a/hermes_cli/foreign_sessions.py b/hermes_cli/foreign_sessions.py index 1d565c336f..bc258d643b 100644 --- a/hermes_cli/foreign_sessions.py +++ b/hermes_cli/foreign_sessions.py @@ -1,35 +1,9 @@ """Import sessions from foreign coding agents (Claude Code, Codex CLI). -``hermes sessions import`` (and ``--resume @claude`` / ``--resume @codex``) -let a user pull a conversation they started in another agent CLI into -Hermes and continue it here. - Sources (read-only — foreign files are never modified): -* **Claude Code** stores one JSONL file per session under - ``~/.claude/projects/<encoded-cwd>/<uuid>.jsonl``. Each line is a JSON - object; ``type: "user"`` / ``type: "assistant"`` lines carry an - Anthropic-format ``message`` payload whose ``content`` is either a string - or a list of blocks (``text``, ``tool_use``, ``tool_result``, ...). - ``type: "summary"`` lines carry a human title for the thread. - -* **Codex CLI** stores rollout JSONL under - ``~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl``. The first line is a - ``session_meta`` record (cwd, session id); conversation turns are - ``response_item`` records whose payload is ``{"type": "message", - "role": user|assistant|developer, "content": [{"type": "input_text"| - "output_text", "text": ...}]}`` plus ``custom_tool_call`` / - ``function_call`` payloads for tool activity. (Schema verified against - real rollout files, Codex CLI 0.147.) - -Conversion contract — imported history must satisfy the provider -role-alternation invariant Hermes enforces everywhere else: - -* only plain ``user`` / ``assistant`` text messages are produced (tool - calls become short bracketed summaries inside the assistant text; we - never fabricate ``tool_calls`` structures); -* consecutive same-role turns are merged rather than stubbed; -* system/developer payloads are never imported. +Conversion contract — imported history must satisfy the provider role-alternation invariant Hermes +enforces everywhere else: """ from __future__ import annotations @@ -52,6 +26,7 @@ _WRAPPER_TAG_RE = re.compile( ) _TITLE_MAX = 60 +_SOURCE_LABELS = {"claude": "Claude Code", "codex": "Codex CLI"} @dataclass @@ -68,9 +43,7 @@ class ForeignSession: @property def label(self) -> str: - name = {"claude": "Claude Code", "codex": "Codex CLI"}.get( - self.source, self.source - ) + name = _SOURCE_LABELS.get(self.source, self.source) title = (self.title_guess or "").strip() or self.path.stem return f"[{name}] {title[:_TITLE_MAX]}" @@ -80,12 +53,9 @@ def _read_json_lines(path: Path): try: with open(path, "r", encoding="utf-8", errors="replace") as f: for line in f: - line = line.strip() - if not line: - continue try: - obj = json.loads(line) - except (json.JSONDecodeError, ValueError): + obj = json.loads(line) if line.strip() else None + except ValueError: # JSONDecodeError subclasses ValueError continue if isinstance(obj, dict): yield obj @@ -93,35 +63,26 @@ def _read_json_lines(path: Path): return -def _flatten_blocks(content: Any, *, source: str) -> str: - """Flatten a message ``content`` (string or block list) to plain text. - - Tool activity becomes a short bracketed summary; unknown block types - are skipped rather than guessed at. - """ +def _flatten_blocks(content: Any) -> str: + """Flatten a message ``content`` (string or block list) to plain text.""" if isinstance(content, str): return content if not isinstance(content, list): return "" parts: List[str] = [] for block in content: - if not isinstance(block, dict): - if isinstance(block, str): - parts.append(block) + if isinstance(block, str): + parts.append(block) + elif not isinstance(block, dict): continue - btype = block.get("type") - if btype in ("text", "input_text", "output_text"): + # tool_result (tool output echoed into a user message — not typed input), + # thinking / redacted_thinking / reasoning and unknown types are skipped. + elif (btype := block.get("type")) in ("text", "input_text", "output_text"): text = block.get("text") if isinstance(text, str) and text: parts.append(text) elif btype == "tool_use": # Claude Code assistant block - name = block.get("name") or "tool" - parts.append(f"[ran tool: {name}]") - elif btype == "tool_result": - # Tool output echoed into a user message — not typed input. - continue - elif btype in ("thinking", "redacted_thinking", "reasoning"): - continue + parts.append(f"[ran tool: {block.get('name') or 'tool'}]") elif btype == "image": parts.append("[image]") return "\n\n".join(p for p in (s.strip() for s in parts) if p) @@ -134,9 +95,8 @@ def _is_wrapper_text(text: str) -> bool: def _merge_turns(raw_turns: List[Tuple[str, str]]) -> List[Dict[str, str]]: """Merge consecutive same-role turns; guarantee strict alternation. - A leading assistant turn (session began before the log window) gets a - minimal user stub so the first message is always ``user``; this is the - only place a stub is ever inserted. + A leading assistant turn (session began before the log window) gets a minimal user stub so the + first message is always ``user``; this is the only place a stub is ever inserted. """ merged: List[Dict[str, str]] = [] for role, text in raw_turns: @@ -148,17 +108,28 @@ def _merge_turns(raw_turns: List[Tuple[str, str]]) -> List[Dict[str, str]]: else: merged.append({"role": role, "content": text}) if merged and merged[0]["role"] == "assistant": - merged.insert( - 0, - { - "role": "user", - "content": "(imported conversation begins with an assistant reply)", - }, - ) + merged.insert(0, {"role": "user", "content": "(imported conversation begins with an assistant reply)"}) return merged -# ── Claude Code ────────────────────────────────────────────────────────── +def _message_turn(role: Any, content: Any) -> Optional[Tuple[str, str]]: + """Normalize one message into a ``(role, text)`` turn, or None when it is not importable.""" + if role not in ("user", "assistant"): + return None + text = _flatten_blocks(content) + if not text or (role == "user" and _is_wrapper_text(text)): + return None + return (role, text) + + +def _parsed(turns: List[Tuple[str, str]], cwd: Optional[str], session_id: Optional[str], + title: Optional[str] = None) -> Dict[str, Any]: + return { + "turns": _merge_turns(turns), + "cwd": cwd, + "title_guess": title or _first_user_line(turns), + "session_id": session_id, + } def parse_claude_session(path: Path) -> Dict[str, Any]: @@ -185,51 +156,10 @@ def parse_claude_session(path: Path) -> Dict[str, Any]: message = obj.get("message") if not isinstance(message, dict): continue - role = message.get("role") - if role not in ("user", "assistant"): - continue - text = _flatten_blocks(message.get("content"), source="claude") - if not text or (role == "user" and _is_wrapper_text(text)): - continue - turns.append((role, text)) - return { - "turns": _merge_turns(turns), - "cwd": cwd, - "title_guess": summary or _first_user_line(turns), - "session_id": session_id, - } - - -def list_claude_sessions(root: Optional[Path] = None) -> List[ForeignSession]: - """Discover Claude Code sessions under ``~/.claude/projects``.""" - root = Path(root) if root else Path.home() / ".claude" / "projects" - results: List[ForeignSession] = [] - if not root.is_dir(): - return results - for jsonl in sorted(root.glob("*/*.jsonl")): - try: - mtime = jsonl.stat().st_mtime - except OSError: - continue - parsed = parse_claude_session(jsonl) - if not parsed["turns"]: - continue - results.append( - ForeignSession( - source="claude", - path=jsonl, - mtime=mtime, - cwd=parsed["cwd"], - title_guess=parsed["title_guess"], - turn_count=len(parsed["turns"]), - session_id=parsed["session_id"], - ) - ) - results.sort(key=lambda s: s.mtime, reverse=True) - return results - - -# ── Codex CLI ──────────────────────────────────────────────────────────── + turn = _message_turn(message.get("role"), message.get("content")) + if turn: + turns.append(turn) + return _parsed(turns, cwd, session_id, summary) def parse_codex_session(path: Path) -> Dict[str, Any]: @@ -253,55 +183,57 @@ def parse_codex_session(path: Path) -> Dict[str, Any]: continue ptype = payload.get("type") if ptype == "message": - role = payload.get("role") - if role not in ("user", "assistant"): - continue # developer/system payloads never imported - text = _flatten_blocks(payload.get("content"), source="codex") - if not text or (role == "user" and _is_wrapper_text(text)): - continue - turns.append((role, text)) + # developer/system payloads never imported + turn = _message_turn(payload.get("role"), payload.get("content")) + if turn: + turns.append(turn) elif ptype in ("custom_tool_call", "function_call", "local_shell_call"): name = payload.get("name") or payload.get("tool") or "tool" # Attach as assistant activity; merged into neighbors later. turns.append(("assistant", f"[ran tool: {name}]")) # tool outputs / reasoning / web_search etc. are skipped - return { - "turns": _merge_turns(turns), - "cwd": cwd, - "title_guess": _first_user_line(turns), - "session_id": session_id, - } + return _parsed(turns, cwd, session_id) -def list_codex_sessions(root: Optional[Path] = None) -> List[ForeignSession]: - """Discover Codex CLI rollouts under ``~/.codex/sessions``.""" - root = Path(root) if root else Path.home() / ".codex" / "sessions" +# source -> (default root under ~, glob pattern, recursive, parser) +_SOURCES = { + "claude": ((".claude", "projects"), "*/*.jsonl", False, parse_claude_session), + "codex": ((".codex", "sessions"), "rollout-*.jsonl", True, parse_codex_session), +} + + +def _list_sessions(source: str, root: Optional[Path]) -> List[ForeignSession]: + default_root, pattern, recursive, parse = _SOURCES[source] + root = Path(root) if root else Path.home().joinpath(*default_root) results: List[ForeignSession] = [] if not root.is_dir(): return results - for jsonl in sorted(root.rglob("rollout-*.jsonl")): + for jsonl in sorted(root.rglob(pattern) if recursive else root.glob(pattern)): try: mtime = jsonl.stat().st_mtime except OSError: continue - parsed = parse_codex_session(jsonl) - if not parsed["turns"]: - continue - results.append( - ForeignSession( - source="codex", - path=jsonl, - mtime=mtime, - cwd=parsed["cwd"], - title_guess=parsed["title_guess"], - turn_count=len(parsed["turns"]), + parsed = parse(jsonl) + if parsed["turns"]: + results.append(ForeignSession( + source=source, path=jsonl, mtime=mtime, cwd=parsed["cwd"], + title_guess=parsed["title_guess"], turn_count=len(parsed["turns"]), session_id=parsed["session_id"], - ) - ) + )) results.sort(key=lambda s: s.mtime, reverse=True) return results +def list_claude_sessions(root: Optional[Path] = None) -> List[ForeignSession]: + """Discover Claude Code sessions under ``~/.claude/projects``.""" + return _list_sessions("claude", root) + + +def list_codex_sessions(root: Optional[Path] = None) -> List[ForeignSession]: + """Discover Codex CLI rollouts under ``~/.codex/sessions``.""" + return _list_sessions("codex", root) + + def _first_user_line(turns: List[Tuple[str, str]]) -> Optional[str]: for role, text in turns: if role == "user": @@ -313,16 +245,14 @@ def _first_user_line(turns: List[Tuple[str, str]]) -> Optional[str]: # ── Import ─────────────────────────────────────────────────────────────── -_SOURCE_LABELS = {"claude": "Claude Code", "codex": "Codex CLI"} _SOURCE_DB_NAMES = {"claude": "claude-code", "codex": "codex-cli"} def import_foreign_session(source: str, path, db=None) -> str: """Import one foreign session into the Hermes SessionDB. - Returns the new Hermes session id. The foreign file is only read. - Raises ``ValueError`` on unknown source or a session with no usable - conversation turns. + Returns the new Hermes session id. The foreign file is only read. Raises ``ValueError`` on + unknown source or a session with no usable conversation turns. """ source = (source or "").strip().lower().lstrip("@") if source not in _SOURCE_LABELS: @@ -331,24 +261,17 @@ def import_foreign_session(source: str, path, db=None) -> str: if not path.is_file(): raise ValueError(f"Session file not found: {path}") - parsed = ( - parse_claude_session(path) - if source == "claude" - else parse_codex_session(path) - ) + parsed = _SOURCES[source][3](path) turns = parsed["turns"] if not turns: raise ValueError( f"No user/assistant conversation turns found in {path}" ) - label = _SOURCE_LABELS[source] - first_user = _first_user_line( - [(t["role"], t["content"]) for t in turns] - ) or path.stem + first_user = _first_user_line([(t["role"], t["content"]) for t in turns]) or path.stem if len(first_user) > _TITLE_MAX: first_user = first_user[: _TITLE_MAX - 1] + "…" - title = f"Imported from {label}: {first_user}" + title = f"Imported from {_SOURCE_LABELS[source]}: {first_user}" owns_db = db is None if owns_db: @@ -356,9 +279,7 @@ def import_foreign_session(source: str, path, db=None) -> str: db = SessionDB() try: - session_id = ( - f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - ) + session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" origin = { "imported_from": { "tool": _SOURCE_DB_NAMES[source], @@ -367,10 +288,7 @@ def import_foreign_session(source: str, path, db=None) -> str: } } db.create_session( - session_id, - source=_SOURCE_DB_NAMES[source], - cwd=parsed.get("cwd"), - origin_json=json.dumps(origin), + session_id, source=_SOURCE_DB_NAMES[source], cwd=parsed.get("cwd"), origin_json=json.dumps(origin) ) for turn in turns: db.append_message(session_id, turn["role"], turn["content"]) @@ -399,10 +317,9 @@ def gather_foreign_sessions( ) -> List[ForeignSession]: """List foreign sessions across sources, newest first.""" sessions: List[ForeignSession] = [] - if source in (None, "claude"): - sessions.extend(list_claude_sessions(claude_root)) - if source in (None, "codex"): - sessions.extend(list_codex_sessions(codex_root)) + for name, root in (("claude", claude_root), ("codex", codex_root)): + if source in (None, name): + sessions.extend(_list_sessions(name, root)) sessions.sort(key=lambda s: s.mtime, reverse=True) return sessions[:limit] if limit else sessions @@ -422,9 +339,7 @@ def pick_foreign_session( print("Foreign sessions (newest first):") for i, s in enumerate(sessions, 1): when = datetime.fromtimestamp(s.mtime).strftime("%Y-%m-%d %H:%M") - ws = "" - if s.cwd: - ws = f" ({os.path.basename(s.cwd.rstrip('/')) or s.cwd})" + ws = f" ({os.path.basename(s.cwd.rstrip('/')) or s.cwd})" if s.cwd else "" print(f" {i:>2}. {when} {s.label}{ws} [{s.turn_count} turns]") if not sys.stdin.isatty(): print( @@ -436,8 +351,7 @@ def pick_foreign_session( raw = input(f"Import which session? [1-{len(sessions)}, empty to cancel] ") except (EOFError, KeyboardInterrupt): return None - raw = raw.strip() - if not raw: + if not (raw := raw.strip()): return None try: idx = int(raw) diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 3f86247613..f71fdc43a2 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -1,28 +1,12 @@ """Windows gateway service backend (Scheduled Task + Startup-folder fallback). -This mirrors the contract exposed by ``launchd_install`` / ``launchd_start`` / -``launchd_status`` etc. on macOS and ``systemd_install`` / ``systemd_start`` on -Linux. It uses ``schtasks`` under the hood with ``/SC ONLOGON`` and restart-on- -failure XML settings, and falls back to a ``%APPDATA%\\...\\Startup\\<name>.vbs`` -dropper when Scheduled Task creation is denied (locked-down corporate boxes). +This mirrors the contract exposed by ``launchd_install`` / ``launchd_start`` / ``launchd_status`` +etc. on macOS and ``systemd_install`` / ``systemd_start`` on Linux. -Design notes ------------- -* ``schtasks /Create /SC ONLOGON /RL LIMITED`` means the task runs at the - CURRENT USER's next logon without any elevation prompt. Manual starts and - install ``--start-now`` use the direct hidden-console launcher instead - of ``schtasks /Run`` so start/restart behavior is consistent. -* We write a shared ``gateway.cmd`` wrapper plus a console-less ``gateway.vbs`` - launcher. Scheduled Task and Startup-folder persistence both route through - VBS/wscript; immediate manual starts route through direct ``subprocess`` spawn. -* Status = merge of "is the schtasks entry registered?" + "is the startup - login item present?" + "is there a gateway process running?" so the status - command keeps working regardless of which install path was taken. -* Quoting is tricky: schtasks parses ``/TR`` itself and cmd.exe parses the - generated ``gateway.cmd``. Those are DIFFERENT parsers. We keep two - separate quote helpers (same pattern OpenClaw uses) and never cross them. -* All of this is Windows-only. ``import`` paths are still safe on POSIX but - the functions raise if called on non-Windows. +Design notes ------------ * ``schtasks /Create /SC ONLOGON /RL LIMITED`` means the task runs at the +CURRENT USER's next logon without any elevation prompt. Manual starts and install ``--start-now`` +use the direct hidden-console launcher instead of ``schtasks /Run`` so start/restart behavior is +consistent. """ from __future__ import annotations @@ -51,7 +35,6 @@ logger = logging.getLogger(__name__) # Short timeouts: schtasks occasionally wedges and we don't want to hang forever. _SCHTASKS_TIMEOUT_S = 15 -_SCHTASKS_NO_OUTPUT_TIMEOUT_S = 30 # Patterns in schtasks stderr that mean "fall back to the Startup folder". _FALLBACK_PATTERNS = re.compile( r"(access is denied|acceso denegado|přístup byl odepřen|schtasks timed out|schtasks produced no output)", @@ -75,10 +58,9 @@ _TASK_RESTART_COUNT = 999 def _schtasks_encoding() -> str: """Best-effort console encoding for decoding ``schtasks.exe`` output. - On localized Windows (e.g. Chinese), ``schtasks`` emits text in the OEM/ANSI - code page rather than UTF-8. Decoding with the wrong codec raised - ``UnicodeDecodeError`` inside ``subprocess``' reader threads. Prefer the - locale's preferred encoding and fall back to UTF-8. + On localized Windows (e.g. Chinese), ``schtasks`` emits text in the OEM/ANSI code page rather + than UTF-8. Decoding with the wrong codec raised ``UnicodeDecodeError`` inside ``subprocess``' + reader threads. Prefer the locale's preferred encoding and fall back to UTF-8. """ try: return locale.getpreferredencoding(False) or "utf-8" @@ -96,12 +78,11 @@ def _assert_windows() -> None: def _preserve_hermes_home_path(path: str | Path) -> str: - """Render Hermes-owned paths under the configured HERMES_HOME spelling. + r"""Render Hermes-owned paths under the configured HERMES_HOME spelling. - Windows installs may keep ``%LOCALAPPDATA%\\hermes`` as a symlink/junction to - another drive. Runtime state should still identify itself by the configured - AppData path, so launcher files must not bake in the resolved target when a - path lives under HERMES_HOME. + Windows installs may keep ``%LOCALAPPDATA%\hermes`` as a symlink/junction to another drive. + Runtime state should still identify itself by the configured AppData path, so launcher files + must not bake in the resolved target when a path lives under HERMES_HOME. """ candidate = Path(path) try: @@ -121,15 +102,16 @@ def _preserve_hermes_home_path(path: str | Path) -> str: # --------------------------------------------------------------------------- -# Quoting helpers (two DIFFERENT parsers — do not mix) +# Quoting helpers. cmd.exe (.cmd body), VBScript literals and schtasks /TR are +# three DIFFERENT parsers — never reuse one helper for another. The task XML +# path avoids /TR quoting entirely (see _build_scheduled_task_xml). # --------------------------------------------------------------------------- def _quote_cmd_script_arg(value: str) -> str: """Quote a single argument for use INSIDE a .cmd file, for cmd.exe parsing. - cmd.exe splits on spaces/tabs outside of double quotes. Embedded quotes - are doubled. We also refuse line breaks because they'd terminate the - logical command line mid-script. + cmd.exe splits on spaces/tabs outside of double quotes. Embedded quotes are doubled. We also + refuse line breaks because they'd terminate the logical command line mid-script. """ if "\r" in value or "\n" in value: raise ValueError(f"refusing to quote value containing newline: {value!r}") @@ -140,18 +122,6 @@ def _quote_cmd_script_arg(value: str) -> str: return '"' + value.replace('"', '""') + '"' -def _quote_schtasks_arg(value: str) -> str: - """Quote a single argument for schtasks.exe's /TR parser. - - Schtasks uses a different quoting convention than cmd.exe: embedded - quotes are backslash-escaped, and the whole thing is wrapped in double - quotes if it contains whitespace or quotes. - """ - if not re.search(r'[ \t"]', value): - return value - return '"' + value.replace('"', '\\"') + '"' - - # --------------------------------------------------------------------------- # schtasks.exe wrapper # --------------------------------------------------------------------------- @@ -159,8 +129,8 @@ def _quote_schtasks_arg(value: str) -> str: def _exec_schtasks(args: list[str]) -> tuple[int, str, str]: """Run ``schtasks.exe`` with a hard timeout. Return (code, stdout, stderr). - If schtasks wedges, returns code=124 with a synthetic stderr string — - same convention OpenClaw uses, so the fallback detection regex matches. + If schtasks wedges, returns code=124 with a synthetic stderr string — same convention OpenClaw + uses, so the fallback detection regex matches. """ _assert_windows() schtasks = shutil.which("schtasks") @@ -218,14 +188,11 @@ def _current_profile_cli_args() -> list[str]: def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None = None) -> bool: """Launch an elevated gateway subcommand via UAC and return True on handoff. - The elevated child is the console ``python.exe`` launched with - ``SW_HIDE``: ShellExecuteW applies the show-command to a console app's - console window, so the child owns a single *hidden* console that its own - subprocess spawns (schtasks, taskkill, …) inherit — no visible window - after the UAC approval, and no per-descendant conhost flashes (the - console-less pythonw.exe alternative re-created #54220/#56747 for every - console-subsystem child). All operator decisions are already collected in - the parent shell before this point. + The elevated child is the console ``python.exe`` launched with ``SW_HIDE``: ShellExecuteW + applies the show-command to a console app's console window, so the child owns a single *hidden* + console that its own subprocess spawns (schtasks, taskkill, …) inherit — no visible window after + the UAC approval, and no per-descendant conhost flashes (the console-less pythonw.exe + alternative re-created #54220/#56747 for every console-subsystem child). """ _assert_windows() args = ["-m", "hermes_cli.main", *_current_profile_cli_args(), "gateway", command] @@ -259,29 +226,22 @@ def _launch_elevated_install( start_on_login: bool | None = None, ) -> bool: """Launch an elevated gateway install via UAC and return True on handoff.""" - old_start_now = os.environ.get("HERMES_GATEWAY_INSTALL_START_NOW") - old_start_on_login = os.environ.get("HERMES_GATEWAY_INSTALL_START_ON_LOGIN") - old_handoff = os.environ.get("HERMES_GATEWAY_ELEVATED_HANDOFF") + overrides = {"HERMES_GATEWAY_ELEVATED_HANDOFF": "1"} + extra_args = ["--elevated-handoff"] + if force: + extra_args.append("--force") + if start_now is not None: + overrides["HERMES_GATEWAY_INSTALL_START_NOW"] = "1" if start_now else "0" + extra_args.append("--start-now" if start_now else "--no-start-now") + if start_on_login is not None: + overrides["HERMES_GATEWAY_INSTALL_START_ON_LOGIN"] = "1" if start_on_login else "0" + extra_args.append("--start-on-login" if start_on_login else "--no-start-on-login") + saved = {key: os.environ.get(key) for key in overrides} try: - if start_now is not None: - os.environ["HERMES_GATEWAY_INSTALL_START_NOW"] = "1" if start_now else "0" - if start_on_login is not None: - os.environ["HERMES_GATEWAY_INSTALL_START_ON_LOGIN"] = "1" if start_on_login else "0" - os.environ["HERMES_GATEWAY_ELEVATED_HANDOFF"] = "1" - extra_args = ["--elevated-handoff"] - if force: - extra_args.append("--force") - if start_now is not None: - extra_args.append("--start-now" if start_now else "--no-start-now") - if start_on_login is not None: - extra_args.append("--start-on-login" if start_on_login else "--no-start-on-login") + os.environ.update(overrides) return _launch_elevated_gateway_command("install", extra_args) finally: - for key, old in ( - ("HERMES_GATEWAY_INSTALL_START_NOW", old_start_now), - ("HERMES_GATEWAY_INSTALL_START_ON_LOGIN", old_start_on_login), - ("HERMES_GATEWAY_ELEVATED_HANDOFF", old_handoff), - ): + for key, old in saved.items(): if old is None: os.environ.pop(key, None) else: @@ -298,11 +258,7 @@ def _launch_elevated_uninstall() -> bool: # --------------------------------------------------------------------------- def get_task_name() -> str: - """Scheduled Task name, scoped per profile. - - Default profile: ``Hermes_Gateway`` - Named profile X: ``Hermes_Gateway_<X>`` - """ + """Scheduled Task name, scoped per profile.""" _assert_windows() # Local import to avoid circular module initialization during hermes_cli boot. from hermes_cli.gateway import _profile_suffix @@ -321,9 +277,8 @@ def _sanitize_filename(value: str) -> str: def get_task_script_path() -> Path: """The generated ``gateway.cmd`` wrapper kept beside the VBS launcher. - Lives under ``%LOCALAPPDATA%\\hermes\\gateway-service\\<task_name>.cmd`` - (or ``<HERMES_HOME>/gateway-service/<task_name>.cmd`` so per-profile - Hermes installs stay self-contained). + Lives under ``%LOCALAPPDATA%\\hermes\\gateway-service\\`` (or ``<HERMES_HOME>/gateway-service/`` + so per-profile installs stay self-contained). """ _assert_windows() from hermes_cli.config import get_hermes_home @@ -340,15 +295,8 @@ def _startup_dir() -> Path: userprofile = os.environ.get("USERPROFILE", "").strip() or os.environ.get("HOME", "").strip() if not userprofile: raise RuntimeError("neither APPDATA nor USERPROFILE is set — cannot resolve Startup folder") - return ( - Path(userprofile) - / "AppData" - / "Roaming" - / "Microsoft" - / "Windows" - / "Start Menu" - / "Programs" - / "Startup" + return Path(userprofile).joinpath( + "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup" ) @@ -369,12 +317,9 @@ def _legacy_startup_entry_path() -> Path: def _stable_gateway_working_dir(project_root: Path) -> str: """Return a stable cwd for detached/startup gateway runs. - Mirror the POSIX service invariant: anchor at ``HERMES_HOME`` whenever it - exists so Scheduled Task / Startup launches do not fail at the ``cd`` step - after a transient checkout or worktree is moved away. Fall back to the - source checkout only if ``HERMES_HOME`` cannot be used yet. Preserve the - configured spelling instead of resolving symlinks so AppData installs backed - by a junction/symlink still identify themselves as AppData. + Mirror the POSIX service invariant: anchor at ``HERMES_HOME`` whenever it exists so Scheduled + Task / Startup launches do not fail at the ``cd`` step after a transient checkout or worktree is + moved away. Fall back to the source checkout only if ``HERMES_HOME`` cannot be used yet. """ from hermes_cli.config import get_hermes_home @@ -393,6 +338,30 @@ def _stable_gateway_working_dir(project_root: Path) -> str: # Script rendering # --------------------------------------------------------------------------- +def _gateway_run_argv(python_exe: str, profile_arg: str) -> list[str]: + """``python -m hermes_cli.main [--profile X] gateway run`` — shared by every launcher renderer.""" + argv = [python_exe, "-m", "hermes_cli.main"] + if profile_arg: + argv.extend(profile_arg.split()) + argv.extend(["gateway", "run"]) + return argv + + +def _launcher_settings() -> tuple[str, str, str, str]: + """Return (python_path, working_dir, hermes_home, profile_arg) for generated launchers.""" + # Local imports to avoid circular-init at module load time. + from hermes_cli.config import get_hermes_home + from hermes_cli.gateway import PROJECT_ROOT, _profile_arg, get_python_path + + hermes_home = str(Path(get_hermes_home())) + return ( + _preserve_hermes_home_path(get_python_path()), + _stable_gateway_working_dir(PROJECT_ROOT), + hermes_home, + _profile_arg(hermes_home), + ) + + def _build_gateway_cmd_script( python_path: str, working_dir: str, @@ -401,20 +370,9 @@ def _build_gateway_cmd_script( ) -> str: """Build the ``gateway.cmd`` wrapper content (CRLF-terminated). - The script: - - cd's into a stable working directory - - exports HERMES_HOME, PYTHONIOENCODING, VIRTUAL_ENV - - invokes ``python -m hermes_cli.main [--profile X] gateway run`` - - The .cmd is a compatibility/manual-run artifact: service persistence - (Scheduled Task, Startup folder) routes through the ``.vbs`` launcher, - which runs this same command line hidden (window style 0). Run by hand - in a real terminal, the console interpreter keeps the gateway attached - to that terminal like a normal foreground ``hermes gateway run``. - - We intentionally do NOT inline PATH overrides here — cmd.exe inherits - the per-user PATH the Scheduled Task was created with, and forcibly - rewriting PATH tends to break Homebrew/nvm-style installations. + We intentionally do NOT inline PATH overrides here — cmd.exe inherits the per-user PATH the + Scheduled Task was created with, and forcibly rewriting PATH tends to break Homebrew/nvm-style + installations. """ lines = ["@echo off", f"rem {_TASK_DESCRIPTION}"] lines.append(f"cd /d {_quote_cmd_script_arg(working_dir)}") @@ -432,15 +390,11 @@ def _build_gateway_cmd_script( ] lines.append(f'set "PYTHONPATH={";".join([*pythonpath_entries, "%PYTHONPATH%"])}"') - prog_args = [python_exe_path, "-m", "hermes_cli.main"] - if profile_arg: - prog_args.extend(profile_arg.split()) - prog_args.extend(["gateway", "run"]) # Do NOT use `start` here; that creates an extra wrapper process and made # gateway lifecycle/status harder to reason about. # Do NOT use `--replace` for service-managed starts; repeated /Run calls # should be idempotent, not churn parent/child takeover loops. - lines.append(" ".join(_quote_cmd_script_arg(a) for a in prog_args)) + lines.append(" ".join(_quote_cmd_script_arg(a) for a in _gateway_run_argv(python_exe_path, profile_arg))) lines.append("exit /b 0") return "\r\n".join(lines) + "\r\n" @@ -448,8 +402,8 @@ def _build_gateway_cmd_script( def _quote_vbs_string(value: str) -> str: """Quote a value as a VBScript double-quoted string literal. - VBScript escapes an embedded double-quote by doubling it. A newline cannot - appear inside a literal, so refuse it (same guard as ``_quote_cmd_script_arg``). + VBScript escapes an embedded double-quote by doubling it. A newline cannot appear inside a + literal, so refuse it (same guard as ``_quote_cmd_script_arg``). """ if "\r" in value or "\n" in value: raise ValueError(f"refusing to quote VBScript value containing newline: {value!r}") @@ -464,34 +418,16 @@ def _build_gateway_vbs_script( ) -> str: """Build a hidden-console ``gateway.vbs`` launcher (CRLF-terminated). - The Scheduled Task runs this through ``wscript.exe`` instead of ``cmd.exe``. - - Why: issue #45599 root cause #1. Driving the gateway through ``cmd.exe`` - allocates a console, and during logon Windows broadcasts ``CTRL_CLOSE_EVENT`` - to console process groups — reaping cmd.exe and the half-initialized gateway - with ``STATUS_CONTROL_C_EXIT`` (``0xC000013A``). Task Scheduler treats that - code as a user cancel, so the ``RestartOnFailure`` policy never fires and the - gateway silently disappears on every reboot. - - ``wscript.exe`` is a GUI-subsystem executable with no console, so this - launcher receives no console control events. It ``Run``s the console - ``python.exe`` with window style 0 (hidden): the gateway owns a single - hidden console — never shown, never CTRL_CLOSE'd at logon, and inherited - by every console-subsystem descendant (git, gh, node, …) so none of them - allocate a visible flashing conhost (#54220/#56747; the previous - console-less pythonw.exe gateway forced exactly that per-descendant - flash). No cmd.exe anywhere in the chain. Mirrors - ``_build_gateway_cmd_script`` (same env + argv via - ``_resolve_detached_python``). + The task runs this via ``wscript.exe``, not ``cmd.exe``: cmd.exe allocates a console, and at + logon Windows broadcasts ``CTRL_CLOSE_EVENT`` to console groups, killing the half-started + gateway with ``STATUS_CONTROL_C_EXIT``, which Task Scheduler treats as a user cancel so + ``RestartOnFailure`` never fires. wscript has no console; it runs ``python.exe`` with window + style 0 so the gateway owns one hidden console that descendants (git, gh, node) inherit + instead of flashing their own conhost. Mirrors ``_build_gateway_cmd_script`` for env + argv. """ python_exe_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) - - prog_args = [python_exe_path, "-m", "hermes_cli.main"] - if profile_arg: - prog_args.extend(profile_arg.split()) - prog_args.extend(["gateway", "run"]) # list2cmdline gives CreateProcess-correct quoting for WScript.Shell.Run. - command_line = subprocess.list2cmdline(prog_args) + command_line = subprocess.list2cmdline(_gateway_run_argv(python_exe_path, profile_arg)) repo_root = _preserve_hermes_home_path(Path(__file__).resolve().parent.parent) static_pythonpath = os.pathsep.join( @@ -529,11 +465,8 @@ def _build_gateway_vbs_script( def _build_startup_launcher(script_path: Path) -> str: """The tiny .vbs that goes in the Startup folder and chains hidden. - Defense-in-depth: bail out silently if the target script is gone. Test - fixtures historically wrote Startup entries pointing at pytest tmp_path - directories that vanish after the test session. Without the existence - guard, every subsequent Windows login could attempt a stale launcher. The - check + ``WScript.Quit 0`` keeps that case silent. + Bails out silently (``WScript.Quit 0``) if the target script is gone: stale Startup entries + pointing at vanished directories would otherwise error on every Windows login. """ target = str(script_path.with_suffix(".vbs")) command = subprocess.list2cmdline(["wscript.exe", target]) @@ -553,36 +486,23 @@ def _build_startup_launcher(script_path: Path) -> str: def _write_task_script() -> Path: """Generate and write the gateway.cmd wrapper. Return its absolute path.""" _assert_windows() - # Local imports to avoid circular-init at module load time. - from hermes_cli.config import get_hermes_home - from hermes_cli.gateway import ( - PROJECT_ROOT, - _profile_arg, - get_python_path, - ) - - python_path = _preserve_hermes_home_path(get_python_path()) - working_dir = _stable_gateway_working_dir(PROJECT_ROOT) - hermes_home = str(Path(get_hermes_home())) - profile_arg = _profile_arg(hermes_home) - - content = _build_gateway_cmd_script(python_path, working_dir, hermes_home, profile_arg) + settings = _launcher_settings() script_path = get_task_script_path() - tmp = script_path.with_suffix(".tmp") - tmp.write_text(content, encoding="utf-8", newline="") - tmp.replace(script_path) - + _atomic_write(script_path, _build_gateway_cmd_script(*settings), script_path.with_suffix(".tmp")) # Also render the console-less .vbs launcher used by Scheduled Task and the # Startup-folder fallback via wscript.exe (issue #45599 fix A). The .cmd # wrapper stays as a generated helper/compatibility artifact. - vbs_content = _build_gateway_vbs_script(python_path, working_dir, hermes_home, profile_arg) vbs_path = script_path.with_suffix(".vbs") - vbs_tmp = vbs_path.with_name(vbs_path.name + ".tmp") - vbs_tmp.write_text(vbs_content, encoding="utf-8", newline="") - vbs_tmp.replace(vbs_path) + _atomic_write(vbs_path, _build_gateway_vbs_script(*settings), vbs_path.with_name(vbs_path.name + ".tmp")) return script_path +def _atomic_write(path: Path, content: str, tmp: Path) -> None: + """Write ``content`` verbatim (no newline translation) via ``tmp`` then rename over ``path``.""" + tmp.write_text(content, encoding="utf-8", newline="") + tmp.replace(path) + + # --------------------------------------------------------------------------- # Install / uninstall # --------------------------------------------------------------------------- @@ -601,9 +521,8 @@ def _resolve_task_user() -> str | None: def _build_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> str: """Render a Task Scheduler XML definition with safe long-running defaults. - ``launcher_path`` is the console-less ``.vbs`` the task runs via - ``wscript.exe`` — not the ``.cmd`` (see ``_build_gateway_vbs_script`` / - issue #45599 root cause #1). + ``launcher_path`` is the console-less ``.vbs`` run via ``wscript.exe``, not the ``.cmd`` + (see ``_build_gateway_vbs_script`` for why cmd.exe must stay out of the chain). """ user_principal = f"\n <UserId>{escape(user)}</UserId>" if user else "" return f"""<?xml version="1.0" encoding="UTF-16"?> @@ -656,36 +575,29 @@ def _build_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | N """ -def _write_scheduled_task_xml(task_name: str, launcher_path: Path, user: str | None) -> Path: - xml_path = launcher_path.with_suffix(".task.xml") - xml_path.write_text( - _build_scheduled_task_xml(task_name, launcher_path, user), - encoding="utf-16", - newline="", - ) - return xml_path - - def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, str]: """Create or replace the Scheduled Task. Returns (success, detail). - Always recreate instead of ``/Change``. Older Hermes builds and failed - experiments may have left repeat/restart settings on the task; ``/Change`` - preserves those stale triggers and can make the gateway relaunch every - minute. Delete+create gives us a clean ONLOGON task every install. + Always recreate instead of ``/Change``. Older Hermes builds and failed experiments may have left + repeat/restart settings on the task; ``/Change`` preserves those stale triggers and can make the + gateway relaunch every minute. Delete+create gives us a clean ONLOGON task every install. """ delete_code, delete_out, delete_err = _exec_schtasks(["/Delete", "/F", "/TN", task_name]) delete_detail = (delete_err or delete_out or "").strip() - if delete_code != 0 and delete_detail and "cannot find" not in delete_detail.lower(): - if _is_access_denied(delete_detail): - return (False, f"schtasks /Delete failed (code {delete_code}): {delete_detail}") - # Non-fatal: /Create /F below may still replace it. Keep the detail in - # the final error if creation also fails. + if delete_detail and "cannot find" in delete_detail.lower(): + delete_detail = "" + if delete_code != 0 and delete_detail and _is_access_denied(delete_detail): + return (False, f"schtasks /Delete failed (code {delete_code}): {delete_detail}") + # Other /Delete failures are non-fatal: /Create /F below may still replace + # it. Keep the detail for the final error if creation also fails. user = _resolve_task_user() # The Scheduled Task launches the console-less .vbs (issue #45599 fix A), not # the .cmd. Immediate manual starts use _spawn_detached(). launcher_path = script_path.with_suffix(".vbs") - xml_path = _write_scheduled_task_xml(task_name, launcher_path, user) + xml_path = launcher_path.with_suffix(".task.xml") + xml_path.write_text( + _build_scheduled_task_xml(task_name, launcher_path, user), encoding="utf-16", newline="" + ) base = ["/Create", "/F", "/TN", task_name, "/XML", str(xml_path)] variants = [[*base, "/RU", user, "/NP", "/IT"]] if user else [] variants.append(base) @@ -703,20 +615,16 @@ def _install_scheduled_task(task_name: str, script_path: Path) -> tuple[bool, st xml_path.unlink(missing_ok=True) except OSError: pass - if delete_detail and "cannot find" not in delete_detail.lower(): + if delete_detail: last_err = f"{last_err.strip()} (delete detail: {delete_detail})" return (False, f"schtasks /Create failed (code {last_code}): {last_err.strip()}") - - def _install_startup_entry(script_path: Path) -> Path: """Write the Startup-folder fallback launcher. Returns its path.""" entry = get_startup_entry_path() entry.parent.mkdir(parents=True, exist_ok=True) - tmp = entry.with_suffix(".tmp") - tmp.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="") - tmp.replace(entry) + _atomic_write(entry, _build_startup_launcher(script_path), entry.with_suffix(".tmp")) legacy_entry = _legacy_startup_entry_path() try: if legacy_entry.exists(): @@ -729,40 +637,8 @@ def _install_startup_entry(script_path: Path) -> Path: def _resolve_detached_python(python_exe: str) -> tuple[str, Path, list[str]]: """Return (hidden_console_python, venv_dir, extra_pythonpath) for detached runs. - Returns the venv's **console** ``python.exe`` — deliberately NOT - ``pythonw.exe``. Every detached launch path pairs this interpreter with a - hidden-console mechanism (``CREATE_NO_WINDOW`` creationflags, or - ``WScript.Shell.Run`` window style 0), so the daemon owns a single hidden - console that all of its console-subsystem descendants (git, gh, cmd, node, - wmic, powershell, …) inherit instead of each allocating a visible flashing - one. A GUI-subsystem ``pythonw.exe`` daemon has NO console, which is what - made every descendant spawn flash (#54220/#56747) and forced the endless - per-call-site CREATE_NO_WINDOW sweep. Root cause isolated + A/B verified - on Windows 11 by the desktop backend fix (commit aa2ae36c3f). - - Two historical premises behind the old pythonw selection were re-tested on - current Windows in that fix and did not hold up: - - - uv venv launcher: ``venv\\Scripts\\python.exe`` under ``CREATE_NO_WINDOW`` - re-execs the base interpreter *windowless* — the child inherits the - shim's hidden console, so no conhost flashes (the #52239 concern). The - historical "CREATE_NO_WINDOW cannot suppress the second window" - observations were made while ``DETACHED_PROCESS`` was in the flag - bundle, where MSDN specifies CREATE_NO_WINDOW is IGNORED — the hide bit - was dead, not ineffective. The base-interpreter + PYTHONPATH-overlay - detour is therefore unnecessary; the venv shim resolves imports itself. - - Console python restores stdout/stderr, so daemon logs flow normally. - - ``extra_pythonpath`` is always empty now; the tuple shape is kept so the - call sites (argv builders, cmd/vbs renderers, restart-spec rewriter, - gateway watcher) stay unchanged. - - Legacy normalization: launchers and argv snapshots from pre-aa2ae36c3f - installs lead with ``pythonw.exe``. When the sibling console - ``python.exe`` exists, swap to it so respawns and regenerated launchers - get the hidden-console design instead of resurrecting the console-less - daemon (the #54220/#56747 flash class, plus the ``sys.stderr is None`` - startup-crash class from #71671). + ``extra_pythonpath`` is always empty now; the tuple shape is kept so the call sites (argv + builders, cmd/vbs renderers, restart-spec rewriter, gateway watcher) stay unchanged. """ p = Path(python_exe) if p.name.lower() in ("pythonw.exe", "pythonw"): @@ -793,31 +669,17 @@ def _prepend_pythonpath(env_overlay: dict[str, str], entries: list[str]) -> None def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: """Build (argv, working_dir, env_overlay) for the gateway subprocess. - Same logical command as what gateway.cmd runs, but assembled as a - native argv for direct ``subprocess.Popen`` invocation — no cmd.exe - layer in between. + Same logical command as gateway.cmd, but assembled as a native argv for direct + ``subprocess.Popen`` so no cmd.exe layer sits in between. """ _assert_windows() - from hermes_cli.config import get_hermes_home - from hermes_cli.gateway import ( - PROJECT_ROOT, - _profile_arg, - get_python_path, - ) + from hermes_cli.gateway import PROJECT_ROOT - python_exe, venv_dir, extra_pythonpath = _resolve_detached_python( - _preserve_hermes_home_path(get_python_path()) - ) + python_path, working_dir, hermes_home, profile_arg = _launcher_settings() + python_exe, venv_dir, extra_pythonpath = _resolve_detached_python(python_path) project_root = _preserve_hermes_home_path(PROJECT_ROOT) - working_dir = _stable_gateway_working_dir(PROJECT_ROOT) - hermes_home = str(Path(get_hermes_home())) - profile_arg = _profile_arg(hermes_home) - - argv = [python_exe, "-m", "hermes_cli.main"] - if profile_arg: - argv.extend(profile_arg.split()) - argv.extend(["gateway", "run"]) + argv = _gateway_run_argv(python_exe, profile_arg) env_overlay = { "HERMES_HOME": hermes_home, "PYTHONIOENCODING": "utf-8", @@ -827,9 +689,7 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]: } _prepend_pythonpath( env_overlay, - [project_root, *[_preserve_hermes_home_path(entry) for entry in extra_pythonpath]] - if extra_pythonpath - else [project_root], + [project_root, *[_preserve_hermes_home_path(entry) for entry in extra_pythonpath]], ) return argv, working_dir, env_overlay @@ -839,23 +699,10 @@ def windowless_gateway_restart_spec( ) -> tuple[list[str], str, dict[str, str]]: """Return the (argv, cwd, env overlay) for a hidden-console gateway respawn. - The post-update restart paths build their respawn command from - ``get_python_path()`` (the venv's console ``python.exe``). That is the - right interpreter: the watcher launches it with ``CREATE_NO_WINDOW`` - detach flags, so the respawned gateway owns a single hidden console that - all of its descendants inherit — nothing flashes (#54220/#56747; the old - pythonw.exe rewrite here produced a console-less gateway whose every - console-subsystem child allocated a visible conhost). This helper now - only normalizes the interpreter via ``_resolve_detached_python`` and - supplies the stable cwd + env overlay (HERMES_HOME, VIRTUAL_ENV, - PYTHONPATH) so the respawn doesn't depend on the watcher's transient - working directory. - - Returns ``(new_argv, working_dir, env_overlay)``. ``new_argv`` - preserves every argument after the interpreter (``-m hermes_cli.main - [--profile X] gateway run [--replace]``) verbatim. On non-Windows, or - if ``run_argv`` doesn't start with a resolvable python, the argv is - returned unchanged with an empty overlay. + Returns ``(new_argv, working_dir, env_overlay)``. ``new_argv`` preserves every argument after + the interpreter (``-m hermes_cli.main [--profile X] gateway run [--replace]``) verbatim. On non- + Windows, or if ``run_argv`` doesn't start with a resolvable python, the argv is returned + unchanged with an empty overlay. """ if not run_argv: return run_argv, "", {} @@ -894,32 +741,19 @@ def windowless_gateway_restart_spec( } if hermes_home: env_overlay["HERMES_HOME"] = hermes_home - _prepend_pythonpath( - env_overlay, - [project_root, *extra_pythonpath] if extra_pythonpath else [project_root], - ) + _prepend_pythonpath(env_overlay, [project_root, *extra_pythonpath]) return new_argv, working_dir, env_overlay def _spawn_detached(script_path: Path | None = None) -> int: """Launch the gateway as a fully detached background process. - We spawn ``python.exe -m hermes_cli.main gateway run`` directly — NOT - through a cmd.exe shim — because on Windows a cmd.exe child inherits the - parent session's console handle and tends to get reaped when the spawning - shell exits. With ``CREATE_NO_WINDOW`` the gateway gets its OWN hidden - console instead of inheriting ours, so it survives our shell closing, and - every console-subsystem descendant it spawns inherits that hidden console - instead of flashing a visible one (#54220/#56747 — this is why we don't - use console-less pythonw.exe here). Combined with - CREATE_NEW_PROCESS_GROUP + DEVNULL stdin + a fresh env, the resulting - process is independent of whichever shell started it. + We spawn ``python.exe -m hermes_cli.main gateway run`` directly — NOT through a cmd.exe shim — + because on Windows a cmd.exe child inherits the parent session's console handle and tends to get + reaped when the spawning shell exits. - Arg ``script_path`` is accepted for API symmetry with older callers - but ignored — we don't need it now that we go direct. - - Returns the spawned PID so callers can verify the process actually - came up. + Arg ``script_path`` is accepted for API symmetry with older callers but ignored — we don't need + it now that we go direct. """ _assert_windows() argv, working_dir, env_overlay = _build_gateway_argv() @@ -978,14 +812,12 @@ def _spawn_detached(script_path: Path | None = None) -> int: "CREATE_BREAKAWAY_FROM_JOB", error_code, ) - flags_no_breakaway = windows_detach_flags_without_breakaway() - fallback_env = {**env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "0"} with open(stray_log, "ab", buffering=0) as log_fh: proc = subprocess.Popen( argv, cwd=working_dir, - env=fallback_env, - creationflags=flags_no_breakaway, + env={**env, _WINDOWS_GATEWAY_BREAKAWAY_ENV: "0"}, + creationflags=windows_detach_flags_without_breakaway(), close_fds=True, stdin=subprocess.DEVNULL, stdout=log_fh, @@ -1033,6 +865,16 @@ def _prompt_install_choices( return start_now, start_on_login +def _start_or_report_running() -> None: + """Spawn the gateway unless one is already running for this profile.""" + running_pids = _gateway_pids() + if running_pids: + print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})") + else: + pid = _spawn_detached() + _report_gateway_start(f"direct spawn (PID {pid})") + + def _install_startup_fallback(script_path: Path, start_now: bool, detail: str) -> None: """Install the Startup-folder fallback and optionally start once.""" print(f"↻ Scheduled Task install blocked ({detail.splitlines()[0]}) — using Startup folder fallback") @@ -1044,15 +886,15 @@ def _install_startup_fallback(script_path: Path, start_now: bool, detail: str) - # Startup-folder fallback only installs login persistence. Starting is # controlled by the pre-UAC start_now answer so all user decisions happen # before any elevation prompt. - from hermes_cli.gateway import find_gateway_pids, _profile_arg - - running_pids = list(find_gateway_pids()) + running_pids = _gateway_pids() if running_pids: print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})") elif start_now: pid = _spawn_detached() _report_gateway_start(f"direct spawn (PID {pid})") else: + from hermes_cli.gateway import _profile_arg + profile_arg = _profile_arg() start_cmd = f"hermes {profile_arg} gateway start" if profile_arg else "hermes gateway start" print("ℹ Startup fallback installed; gateway not started now.") @@ -1060,6 +902,28 @@ def _install_startup_fallback(script_path: Path, start_now: bool, detail: str) - _print_next_steps() +def _offer_elevated_install( + headline: str, force: bool, start_now: bool, start_on_login: bool +) -> bool: + """Offer the UAC prompt for a Scheduled Task install. True when handed off to an elevated child.""" + from hermes_cli.setup import prompt_yes_no + + print(headline) + print(" UAC is Windows' admin approval prompt; it is needed to create/update the Scheduled Task.") + if prompt_yes_no(" Open the UAC prompt now?", False): + if _launch_elevated_install(force=force, start_now=start_now, start_on_login=start_on_login): + print("✓ Launched elevated Hermes gateway install prompt.") + if start_now: + print(" Approve the Windows UAC prompt; the elevated install will start the gateway afterwards.") + else: + print(" Approve the Windows UAC prompt, then run: hermes gateway status") + return True + print("⚠ Falling back to Startup folder because elevation was unavailable or cancelled.") + else: + print(" Skipped elevation. Falling back to Startup folder.") + return False + + def install( force: bool = False, *, @@ -1069,9 +933,9 @@ def install( ) -> None: """Install the gateway as a Windows Scheduled Task (with Startup fallback). - Idempotent: re-running updates the task to point at the current python/ - project paths. ``force`` is accepted for API parity with ``launchd_install`` - / ``systemd_install`` but isn't needed — we always reconcile. + Idempotent: re-running updates the task to point at the current python/ project paths. ``force`` + is accepted for API parity with ``launchd_install`` / ``systemd_install`` but isn't needed — we + always reconcile. """ _assert_windows() start_now, start_on_login = _prompt_install_choices(start_now, start_on_login) @@ -1079,12 +943,7 @@ def install( if not start_on_login: print("ℹ Skipped Windows login auto-start install.") if start_now: - running_pids = _gateway_pids() - if running_pids: - print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})") - else: - pid = _spawn_detached() - _report_gateway_start(f"direct spawn (PID {pid})") + _start_or_report_running() else: print("ℹ Gateway not started and no auto-start service installed.") print(" Run later with: hermes gateway start") @@ -1098,21 +957,11 @@ def install( # Access Denied. We already collected all intent questions above, so avoid # a mysterious post-question pause: ask for UAC before touching schtasks. if not _is_running_as_admin() and not elevated_handoff: - from hermes_cli.setup import prompt_yes_no - - print("↻ Scheduled Task install may need administrator approval on this Windows account.") - print(" UAC is Windows' admin approval prompt; it is needed to create/update the Scheduled Task.") - if prompt_yes_no(" Open the UAC prompt now?", False): - if _launch_elevated_install(force=force, start_now=start_now, start_on_login=start_on_login): - print("✓ Launched elevated Hermes gateway install prompt.") - if start_now: - print(" Approve the Windows UAC prompt; the elevated install will start the gateway afterwards.") - else: - print(" Approve the Windows UAC prompt, then run: hermes gateway status") - return - print("⚠ Falling back to Startup folder because elevation was unavailable or cancelled.") - else: - print(" Skipped elevation. Falling back to Startup folder.") + if _offer_elevated_install( + "↻ Scheduled Task install may need administrator approval on this Windows account.", + force, start_now, start_on_login, + ): + return _install_startup_fallback(script_path, start_now, "administrator approval was not used") return @@ -1122,12 +971,7 @@ def install( print(f" Task script: {script_path}") print("ℹ Gateway auto-start installed for Windows login.") if start_now: - running_pids = _gateway_pids() - if running_pids: - print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})") - else: - pid = _spawn_detached() - _report_gateway_start(f"direct spawn (PID {pid})") + _start_or_report_running() else: print("ℹ Gateway not started now.") print(" Start manually with: hermes gateway start") @@ -1138,48 +982,15 @@ def install( # Startup-folder fallback when the only blocker is elevation. This gives # users a UAC prompt instead of silently installing a less reliable login # item, and keeps the fallback for locked-down boxes / cancelled prompts. - if _is_access_denied(detail) and not _is_running_as_admin(): - from hermes_cli.setup import prompt_yes_no - - print(f"↻ Scheduled Task install needs administrator approval ({detail.splitlines()[0]})") - print(" UAC is Windows' admin approval prompt; it is needed to create/update the Scheduled Task.") - if prompt_yes_no(" Open the UAC prompt now?", False): - if _launch_elevated_install(force=force, start_now=start_now, start_on_login=start_on_login): - print("✓ Launched elevated Hermes gateway install prompt.") - if start_now: - print(" Approve the Windows UAC prompt; the elevated install will start the gateway afterwards.") - else: - print(" Approve the Windows UAC prompt, then run: hermes gateway status") - return - print("⚠ Falling back to Startup folder because elevation was unavailable or cancelled.") - else: - print(" Skipped elevation. Falling back to Startup folder.") + if _is_access_denied(detail) and not _is_running_as_admin() and _offer_elevated_install( + f"↻ Scheduled Task install needs administrator approval ({detail.splitlines()[0]})", + force, start_now, start_on_login, + ): + return # schtasks create didn't work. See if it's a "fall back to startup" case. if _should_fall_back(1, detail): - print(f"↻ Scheduled Task install blocked ({detail.splitlines()[0]}) — using Startup folder fallback") - entry = _install_startup_entry(script_path) - print(f"✓ Installed Windows login item: {entry}") - print(f" Task script: {script_path}") - - # Re-running `hermes -p <profile> gateway install` must be safe. - # Startup-folder fallback only installs login persistence. Starting is - # controlled by the pre-UAC start_now answer so all user decisions happen - # before any elevation prompt. - from hermes_cli.gateway import find_gateway_pids, _profile_arg - - running_pids = list(find_gateway_pids()) - if running_pids: - print(f"✓ Gateway already running (PID: {', '.join(map(str, running_pids))})") - elif start_now: - pid = _spawn_detached() - _report_gateway_start(f"direct spawn (PID {pid})") - else: - profile_arg = _profile_arg() - start_cmd = f"hermes {profile_arg} gateway start" if profile_arg else "hermes gateway start" - print("ℹ Startup fallback installed; gateway not started now.") - print(f" Start manually with: {start_cmd}") - _print_next_steps() + _install_startup_fallback(script_path, start_now, detail) return # Unknown schtasks error — surface it and bail. @@ -1192,12 +1003,9 @@ def _confirm_gateway_stable( ) -> list[int]: """Re-check a freshly detected gateway for ``confirm_s`` seconds. - A single process-table hit only proves the child was *created*, not that - it survived startup — a gateway that crashes moments after spawn (or is - reaped by the parent shell's Job Object, #91675/#84185) passes a - first-hit poll and then dies. Require the gateway to stay visible for - the whole confirmation window before we vouch for it. Returns the last - observed PID list, or ``[]`` if the gateway vanished mid-window. + A single process-table hit only proves the child was *created*, not that it survived startup — a + gateway that crashes moments after spawn (or is reaped by the parent shell's Job Object, + #91675/#84185) passes a first-hit poll and then dies. """ if confirm_s <= 0: return initial_pids @@ -1221,19 +1029,9 @@ def _wait_for_gateway_ready( ) -> list[int]: """Poll for a live gateway process for up to ``timeout_s`` seconds. - A first process-table hit is treated as *provisional*: the gateway must - then stay visible for ``confirm_s`` more seconds before we report it - ready (see :func:`_confirm_gateway_stable` — a child that dies right - after spawn must not earn a ✓, #91675). If it vanishes during the - confirmation window, polling resumes until the deadline. - - ``all_profiles`` widens the scan across every profile's gateway — the - post-update resume path relaunches the whole fleet, not just the active - profile. - - Returns the list of PIDs found. Empty list means nothing (stable) came - up in time — the caller should surface that to the user as a failed - start. + A first process-table hit is treated as *provisional*: the gateway must then stay visible for + ``confirm_s`` more seconds before we report it ready (see :func:`_confirm_gateway_stable` — a + child that dies right after spawn must not earn a ✓, #91675). """ from hermes_cli.gateway import find_gateway_pids @@ -1322,18 +1120,10 @@ def _attested_pid_exited_cleanly(pid: int) -> bool: def check_start_attestation(current_pids: list[int] | None = None) -> str | None: """Surface (once) a gateway that died after a ✓ was printed for it. - Reads the attestation marker left by the last successful-looking start. - Outcomes: - - * gateway currently running → the start held (or the service healed it); - clear the marker silently. - * attested PIDs all gone, lifecycle ledger shows a clean exit for one of - them → planned stop; clear silently. - * attested PIDs all gone with NO clean-exit record → the previous ✓ was - false (#91675: parent Job Object teardown killed the child after the - poll). Return a warning string and consume the marker so it prints - exactly once. - + Reads the attestation marker from the last successful-looking start. Gateway running: the + start held, clear silently. PIDs gone with a clean-exit ledger record: planned stop, clear + silently. PIDs gone with NO clean-exit record: the ✓ was false (parent Job Object teardown + killed the child after the poll); return a warning and consume the marker so it prints once. Never raises. """ import json as _json @@ -1378,15 +1168,22 @@ def check_start_attestation(current_pids: list[int] | None = None) -> str | None " This usually means the shell that ran `hermes gateway start` was inside " "a Windows Job Object that killed the gateway on exit (#91675).", ] + hint = _task_run_hint( + " Recovery: schtasks /Run /TN {} (Task Scheduler starts the gateway outside any Job Object)" + ) + if hint: + lines.append(hint) + return "\n".join(lines) + + +def _task_run_hint(fmt: str) -> str | None: + """``fmt`` with the task name filled in, when a Scheduled Task is registered. Never raises.""" try: if is_task_registered(): - lines.append( - f" Recovery: schtasks /Run /TN {get_task_name()} " - "(Task Scheduler starts the gateway outside any Job Object)" - ) + return fmt.format(get_task_name()) except Exception: pass - return "\n".join(lines) + return None def _print_start_attestation_warning() -> None: @@ -1408,13 +1205,9 @@ def _report_gateway_start(via: str) -> None: "⚠ The gateway could not break away from this shell's Job Object; " "it may be killed when this shell exits." ) - try: - if is_task_registered(): - print( - f" If it dies, start it with: schtasks /Run /TN {get_task_name()}" - ) - except Exception: - pass + hint = _task_run_hint(" If it dies, start it with: schtasks /Run /TN {}") + if hint: + print(hint) _write_start_attestation(pids, via) else: print( @@ -1429,14 +1222,11 @@ def _report_gateway_start(via: str) -> None: from hermes_cli.config import get_hermes_home print(f" type {Path(get_hermes_home())}\\logs\\gateway.log") print(f" type {Path(get_hermes_home())}\\logs\\gateway-stdio.log") - try: - if is_task_registered(): - print( - f" Recovery: schtasks /Run /TN {get_task_name()} " - "(starts the gateway outside any Job Object)" - ) - except Exception: - pass + hint = _task_run_hint( + " Recovery: schtasks /Run /TN {} (starts the gateway outside any Job Object)" + ) + if hint: + print(hint) def _print_next_steps() -> None: @@ -1547,18 +1337,14 @@ def _gateway_pids() -> list[int]: def _print_deep_probes() -> None: """Print PASS/FAIL per individual probe of gateway liveness. - The default ``status`` output collapses several signals into one - ✓ / ✗ line, which is great when they agree and confusing when they - don't. The deep-probe block shows each underlying check independently - so the user can see exactly which signal is wrong. + The default ``status`` output collapses several signals into one ✓ / ✗ line, which is great when + they agree and confusing when they don't. The deep-probe block shows each underlying check + independently so the user can see exactly which signal is wrong. - Probes: - [1] PID file present - [2] Lock file present and held by some process - [3] gateway.status.get_running_pid() returns a PID - [4] _pid_exists(pid) — OS confirms the process is alive - [5] gateway_state.json exists and parses (and is fresh-ish) - [6] Last lifecycle event in gateway-exit-diag.log + Probes: [1] PID file present [2] Lock file present and held by some process [3] + gateway.status.get_running_pid() returns a PID [4] _pid_exists(pid) — OS confirms the process is + alive [5] gateway_state.json exists and parses (and is fresh-ish) [6] Last lifecycle event in + gateway-exit-diag.log """ import json from datetime import datetime, timezone @@ -1574,35 +1360,31 @@ def _print_deep_probes() -> None: print() print("Deep probes:") - def _mark(ok: bool) -> str: - return "PASS" if ok else "FAIL" + def _probe(index: int, ok: bool, message: str) -> None: + print(f" [{index}] {'PASS' if ok else 'FAIL':4s} {message}") # [1] PID file - pid_exists = pid_path.exists() pid_value: int | None = None - if pid_exists: + if pid_path.exists(): try: data = json.loads(pid_path.read_text(encoding="utf-8")) pid_value = int(data.get("pid")) if data.get("pid") is not None else None - print(f" [1] {_mark(True):4s} PID file present: {pid_path} (pid={pid_value})") + _probe(1, True, f"PID file present: {pid_path} (pid={pid_value})") except Exception as exc: - print(f" [1] {_mark(False):4s} PID file present but unreadable: {exc}") + _probe(1, False, f"PID file present but unreadable: {exc}") else: - print(f" [1] {_mark(False):4s} PID file missing: {pid_path}") + _probe(1, False, f"PID file missing: {pid_path}") # [2] Lock file present + held - lock_held = False - lock_present = lock_path.exists() - if lock_present: + if lock_path.exists(): try: from gateway.status import is_gateway_runtime_lock_active - lock_held = is_gateway_runtime_lock_active(lock_path) - print(f" [2] {_mark(lock_held):4s} Lock file held by a live process: {lock_path}") + _probe(2, is_gateway_runtime_lock_active(lock_path), f"Lock file held by a live process: {lock_path}") except Exception as exc: - print(f" [2] {_mark(False):4s} Could not probe lock: {exc}") + _probe(2, False, f"Could not probe lock: {exc}") else: - print(f" [2] {_mark(False):4s} Lock file missing: {lock_path}") + _probe(2, False, f"Lock file missing: {lock_path}") # [3] get_running_pid() running_pid: int | None = None @@ -1610,9 +1392,9 @@ def _print_deep_probes() -> None: from gateway.status import get_running_pid running_pid = get_running_pid(cleanup_stale=False) - print(f" [3] {_mark(running_pid is not None):4s} get_running_pid() => {running_pid}") + _probe(3, running_pid is not None, f"get_running_pid() => {running_pid}") except Exception as exc: - print(f" [3] {_mark(False):4s} get_running_pid() raised: {exc!r}") + _probe(3, False, f"get_running_pid() raised: {exc!r}") # [4] _pid_exists() on the probed PID candidate_pid = running_pid if running_pid is not None else pid_value @@ -1621,11 +1403,11 @@ def _print_deep_probes() -> None: from gateway.status import _pid_exists alive = bool(_pid_exists(candidate_pid)) - print(f" [4] {_mark(alive):4s} _pid_exists({candidate_pid}) => {alive}") + _probe(4, alive, f"_pid_exists({candidate_pid}) => {alive}") except Exception as exc: - print(f" [4] {_mark(False):4s} _pid_exists raised: {exc!r}") + _probe(4, False, f"_pid_exists raised: {exc!r}") else: - print(f" [4] {_mark(False):4s} No candidate PID to verify") + _probe(4, False, "No candidate PID to verify") # [5] runtime status file if state_path.exists(): @@ -1637,17 +1419,15 @@ def _print_deep_probes() -> None: if updated_at: try: updated_dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) - now = datetime.now(timezone.utc) - age_seconds = int((now - updated_dt).total_seconds()) + age_seconds = int((datetime.now(timezone.utc) - updated_dt).total_seconds()) age_str = f" (updated {age_seconds}s ago)" except Exception: pass - ok = gateway_state == "running" - print(f" [5] {_mark(ok):4s} gateway_state.json state={gateway_state!r}{age_str}") + _probe(5, gateway_state == "running", f"gateway_state.json state={gateway_state!r}{age_str}") except Exception as exc: - print(f" [5] {_mark(False):4s} gateway_state.json present but unreadable: {exc}") + _probe(5, False, f"gateway_state.json present but unreadable: {exc}") else: - print(f" [5] {_mark(False):4s} gateway_state.json missing: {state_path}") + _probe(5, False, f"gateway_state.json missing: {state_path}") # [6] Last lifecycle event from the exit-diag log if diag_path.exists(): @@ -1663,18 +1443,18 @@ def _print_deep_probes() -> None: try: event = json.loads(last_event) tag = event.get("tag", "?") - pid = event.get("pid", "?") - ts = event.get("ts", "?") - healthy = tag in ("gateway.start",) - print(f" [6] {_mark(healthy):4s} Last lifecycle event: tag={tag} pid={pid} ts={ts}") + _probe( + 6, tag in ("gateway.start",), + f"Last lifecycle event: tag={tag} pid={event.get('pid', '?')} ts={event.get('ts', '?')}", + ) except Exception: - print(f" [6] {_mark(False):4s} Last lifecycle line not JSON: {last_event[:120]}") + _probe(6, False, f"Last lifecycle line not JSON: {last_event[:120]}") else: - print(f" [6] {_mark(False):4s} exit-diag log empty: {diag_path}") + _probe(6, False, f"exit-diag log empty: {diag_path}") except Exception as exc: - print(f" [6] {_mark(False):4s} exit-diag log unreadable: {exc}") + _probe(6, False, f"exit-diag log unreadable: {exc}") else: - print(f" [6] {_mark(False):4s} exit-diag log missing: {diag_path}") + _probe(6, False, f"exit-diag log missing: {diag_path}") def status(deep: bool = False) -> None: @@ -1762,16 +1542,9 @@ def start() -> None: def _drain_gateway_pid(pid: int, drain_timeout: float) -> bool: """Write the planned-stop marker and wait for the gateway PID to exit. - Windows cannot deliver POSIX signals to a Python asyncio loop - (``loop.add_signal_handler`` raises NotImplementedError), so writing - the marker is the ONLY way to ask a running gateway to drain - in-flight agents and persist ``resume_pending`` before exit. The - gateway's planned-stop watcher thread (gateway/run.py) polls for - the marker and drives the same shutdown path the SIGTERM handler - would have on POSIX. - - Returns True if the PID exited within the timeout, False if it - didn't (caller should escalate to schtasks /End + taskkill). + Windows cannot deliver POSIX signals to a Python asyncio loop (``loop.add_signal_handler`` + raises NotImplementedError), so writing the marker is the ONLY way to ask a running gateway to + drain in-flight agents and persist ``resume_pending`` before exit. """ if pid <= 0: return False @@ -1857,12 +1630,9 @@ def _collect_gateway_stop_pids(primary_pid: int | None = None) -> list[int]: def stop() -> None: """Stop the gateway. - Writes the planned-stop marker first so the gateway can drain - in-flight agents and persist ``resume_pending`` before exit (the - gateway's marker-watcher thread picks this up — Windows asyncio - can't deliver SIGTERM to the loop, so the marker is our only IPC). - Then escalates with bounded Windows process termination against the - known gateway PID(s). + Writes the planned-stop marker first so the gateway can drain in-flight agents and persist + ``resume_pending`` before exit (the gateway's marker-watcher thread picks this up — Windows + asyncio can't deliver SIGTERM to the loop, so the marker is our only IPC). """ _assert_windows() from gateway.status import get_running_pid @@ -1910,10 +1680,8 @@ def stop() -> None: def _wait_for_gateway_absent(timeout_s: float = 30.0, interval_s: float = 0.5) -> bool: """Block until no gateway process is detectable, or the timeout elapses. - ``stop()`` can return while the previous gateway is still draining - in-flight agents (the drain runs up to the restart-drain timeout). Uses the - authoritative ``get_running_pid()`` (lock + liveness + start-time + - gateway-shape) plus the now-strict ``_gateway_pids()`` scan so a relaunch + ``stop()`` can return while the old gateway is still draining in-flight agents. Uses the + authoritative ``get_running_pid()`` plus the strict ``_gateway_pids()`` scan so a relaunch never races a still-alive old process. """ from gateway.status import get_running_pid @@ -1929,10 +1697,9 @@ def _wait_for_gateway_absent(timeout_s: float = 30.0, interval_s: float = 0.5) - def restart() -> None: """Stop the gateway then start it again. - Waits for the old gateway to be authoritatively gone before relaunching -- - otherwise ``start()``'s "already running" guard sees the still-draining old - process and no-ops, and when that process later exits nothing replaces it (a - silent outage). Fails loudly if the process can't be cleared or the relaunch + Waits for the old gateway to be authoritatively gone first; otherwise ``start()``'s + "already running" guard sees the draining process and no-ops, and when it exits nothing + replaces it (a silent outage). Fails loudly if the process can't be cleared or the relaunch doesn't produce a running gateway. """ _assert_windows() diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index d159adbb65..cc829e3faf 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -1,30 +1,12 @@ """Persistent session goals — the Ralph loop for Hermes. -A goal is a free-form user objective that stays active across turns. After -each turn completes, a small judge call asks an auxiliary model "is this -goal satisfied by the assistant's last response?". If not, Hermes feeds a -continuation prompt back into the same session and keeps working until the -goal is done, turn budget is exhausted, the user pauses/clears it, or the -user sends a new message (which takes priority and pauses the goal loop). +A goal is a free-form user objective that stays active across turns. After each turn completes, a +small judge call asks an auxiliary model "is this goal satisfied by the assistant's last response?". -State is persisted in SessionDB's ``state_meta`` table keyed by -``goal:<session_id>`` so ``/resume`` picks it up. - -Design notes / invariants: - -- The continuation prompt is just a normal user message appended to the - session via ``run_conversation``. No system-prompt mutation, no toolset - swap — prompt caching stays intact. -- Judge failures are fail-OPEN: ``continue``. A broken judge must not wedge - progress; the turn budget is the backstop. -- When a real user message arrives mid-loop it preempts the continuation - prompt and also pauses the goal loop for that turn (we still re-judge - after, so if the user's message happens to complete the goal the judge - will say ``done``). -- This module has zero hard dependency on ``cli.HermesCLI`` or the gateway - runner — both wire the same ``GoalManager`` in. - -Nothing in this module touches the agent's system prompt or toolset. +- The continuation prompt is just a normal user message appended to the session via +``run_conversation``. No system-prompt mutation, no toolset swap — prompt caching stays intact. - +Judge failures are fail-OPEN: ``continue``. A broken judge must not wedge progress; the turn budget +is the backstop. """ from __future__ import annotations @@ -347,12 +329,8 @@ _CONTRACT_ALIASES = { class GoalContract: """Optional structured completion contract for a goal. - Each field is free-form prose the user (or :func:`draft_contract`) - supplies. Empty fields are omitted everywhere — a goal with no contract - behaves exactly like the original free-form goal. The contract is woven - into both the continuation prompt (so the agent targets the verification - surface and respects constraints) and the judge prompt (so "done" is - decided against evidence, not vibes). + Each field is free-form prose the user (or :func:`draft_contract`) supplies. Empty fields are + omitted everywhere — a goal with no contract behaves exactly like the original free-form goal. """ outcome: str = "" @@ -387,21 +365,12 @@ class GoalContract: def parse_contract(text: str) -> Tuple[str, GoalContract]: """Split user-typed goal text into a headline + structured contract. - Supports inline ``field: value`` lines so power users can type a full - contract in one shot, e.g.:: + Supports inline ``field: value`` lines so power users can type a full contract in one shot, + e.g.:: - Migrate auth to JWT - verify: the auth test suite passes - constraints: keep the public /login response shape unchanged - boundaries: only touch services/auth and its tests - stop when: a schema change needs product sign-off - - The first non-field line(s) become the goal headline; recognized - ``field:`` lines populate the contract. Lines for the same field are - joined. Unrecognized prefixes stay part of the headline, so a plain - free-form goal with an incidental colon (``Fix bug: the parser``) - is NOT mangled — only lines whose prefix matches a known alias are - pulled out. Returns ``(headline, contract)``. + Migrate auth to JWT verify: the auth test suite passes constraints: keep the public /login + response shape unchanged boundaries: only touch services/auth and its tests stop when: a schema + change needs product sign-off """ if not text: return "", GoalContract() @@ -442,16 +411,9 @@ def parse_contract(text: str) -> Tuple[str, GoalContract]: class GoalGate: """A deterministic shell command that must pass before a goal can be done. - Gates run at turn boundary BEFORE the LLM judge. A failing gate - short-circuits judging entirely: its bounded output becomes the - continuation prompt, so the agent iterates against concrete evidence. - Only when every gate passes does the judge get to decide DONE. - - ``attempts`` counts failed runs; when it exceeds ``max_retries`` the goal - auto-pauses (mirrors the turn-budget pause) instead of spinning. A gate - that failed on an unchanged workspace is not re-run — the recorded - failure is replayed and the attempt count advances, so a stuck agent - can't burn wall-clock re-running the same red suite. + Gates run at turn boundary BEFORE the LLM judge. A failing gate short-circuits judging entirely: + its bounded output becomes the continuation prompt, so the agent iterates against concrete + evidence. Only when every gate passes does the judge get to decide DONE. """ command: str @@ -485,10 +447,8 @@ class GoalGate: def workspace_fingerprint(cwd: Optional[str] = None) -> str: """Cheap workspace change fingerprint for unchanged-gate skip. - Uses ``git status --porcelain`` + ``git rev-parse HEAD`` when inside a git - repo (covers tracked edits, stages, and commits). Outside git, returns - an empty string — an empty fingerprint never matches, so gates simply - always re-run (safe fallback, no behavior regression for non-repo work). + Uses ``git status --porcelain`` + ``git rev-parse HEAD`` inside a repo. Outside git returns an + empty string, which never matches, so gates always re-run — a safe fallback. """ workdir = cwd or os.getcwd() try: @@ -517,9 +477,8 @@ def workspace_fingerprint(cwd: Optional[str] = None) -> str: def run_gate(gate: GoalGate, *, cwd: Optional[str] = None) -> Tuple[bool, int, str]: """Run one gate command. Returns ``(passed, exit_code, output_tail)``. - The command runs through the shell in ``cwd`` (default: process cwd) with - a hard timeout; on timeout the process is killed and treated as failed - with exit code -1. Output is the combined stdout+stderr tail, bounded to + Runs through the shell in ``cwd`` with a hard timeout; on timeout the process is killed and + treated as failed with exit code -1. Output is the combined stdout+stderr tail, bounded to ``_GATE_OUTPUT_TAIL_CHARS``. """ try: @@ -661,8 +620,7 @@ class GoalState: # --- subgoals helpers ------------------------------------------------- def render_subgoals_block(self) -> str: - """Render the subgoals as a numbered ``- N. text`` block. Empty - when no subgoals exist.""" + """Render the subgoals as a numbered ``- N. text`` block. Empty when no subgoals exist.""" if not self.subgoals: return "" return "\n".join(f"- {i}. {text}" for i, text in enumerate(self.subgoals, start=1)) @@ -735,24 +693,15 @@ def _bootstrap_session_db(home: str, done: threading.Event) -> None: def _get_session_db() -> Optional[Any]: """Return a SessionDB instance for the current HERMES_HOME. - SessionDB has no built-in singleton, but opening a new connection per - /goal call would thrash the file. We cache one instance per - ``hermes_home`` path so profile switches still pick up the right DB. - Defensive against import/instantiation failures so tests and - non-standard launchers can still use the GoalManager. + SessionDB has no built-in singleton, but opening a new connection per /goal call would thrash + the file. We cache one instance per ``hermes_home`` path so profile switches still pick up the + right DB. Defensive against import/instantiation failures so tests and non-standard launchers + can still use the GoalManager. - Never constructs SessionDB on an event-loop thread. ``SessionDB.__init__`` - runs schema init, and a migration against a contended state.db blocks for - seconds — on the gateway's loop thread that starves the loop-liveness - watchdog, which hard-exits the process (exit 75) and crash-loops the - gateway (enterprise field report, 2026-08-14). On a cache miss with a running - loop we kick a one-shot background bootstrap and wait a bounded grace - window for it. The kick call waits the one-time init window - (``_DB_BOOTSTRAP_INIT_WAIT_S``), so a healthy cold init completes and - the first write is not dropped. Later calls wait only the short window - (``_DB_BOOTSTRAP_LOOP_WAIT_S``). On timeout we return None. Every - caller degrades gracefully on None, and a later call returns the - cached instance. + Never constructs SessionDB on an event-loop thread. On a cache miss with a running loop we kick + a one-shot background bootstrap and wait a bounded grace window for it. The kick call waits the + one-time init window (``_DB_BOOTSTRAP_INIT_WAIT_S``), so a healthy cold init completes and the + first write is not dropped. """ try: from hermes_constants import get_hermes_home @@ -829,9 +778,8 @@ def _get_session_db() -> Optional[Any]: def _warn_dropped_write(manager: str, kind: str, session_id: str) -> None: """Log a dropped state write at WARNING. - The reply already told the user that the state was set. A silent - drop makes that reply a lie. One shared message keeps the goal, - loop, and heartbeat logs greppable as one bug class. + The reply already told the user the state was set; a silent drop makes that reply a lie. One + shared message keeps goal, loop and heartbeat logs greppable as one bug class. """ logger.warning( "%s: %s for %s not persisted — session DB unavailable " @@ -889,17 +837,8 @@ def clear_goal(session_id: str) -> None: def migrate_goal_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool: """Carry a persistent /goal from a parent session to its continuation. - Context compression rotates ``session_id`` to a fresh child session, - but ``load_goal`` does a flat ``goal:<session_id>`` lookup with no - parent-lineage walk — so an active goal silently dies at the - compaction boundary (#33618). Copy the goal onto the new session and - archive the old row as ``cleared`` so exactly one active goal row - exists per logical conversation (avoids the "two active goals" - hazard of a pure copy). - - Returns True when a goal was migrated, False when there was nothing - to migrate or the DB was unavailable. Best-effort and never raises — - a failure here must not block compression. + Returns True when a goal was migrated, False when there was nothing to migrate or the DB was + unavailable. Best-effort and never raises — a failure here must not block compression. """ if not old_session_id or not new_session_id or old_session_id == new_session_id: return False @@ -940,13 +879,10 @@ def _truncate(text: str, limit: int) -> str: def _pid_alive(pid: int) -> bool: """Return True if a process with ``pid`` is currently alive. - Delegates to ``gateway.status._pid_exists`` — the canonical, - cross-platform, footgun-safe liveness check (psutil with a ctypes / - POSIX fallback). Critically this avoids ``os.kill(pid, 0)``, which on - Windows is NOT a no-op: it routes to ``CTRL_C_EVENT`` and hard-kills the - target's console process group (bpo-14484). Any error resolves to False - (treat unknown as dead) so a stale barrier never wedges the loop — the - worst case is the goal resumes one turn early, which is safe. + Delegates to ``gateway.status._pid_exists`` — the canonical, cross-platform, footgun-safe + liveness check (psutil with a ctypes / POSIX fallback). Critically this avoids ``os.kill(pid, + 0)``, which on Windows is NOT a no-op: it routes to ``CTRL_C_EVENT`` and hard-kills the target's + console process group (bpo-14484). """ if not pid or pid <= 0: return False @@ -968,10 +904,9 @@ def _pid_alive(pid: int) -> bool: def _session_waiting(session_id: str) -> bool: """Whether a goal parked on a process_registry session should stay parked. - Delegates to ``process_registry.is_session_waiting`` — True while the - session is running and (if it has watch_patterns) its trigger hasn't fired. - Fail-safe: any import/registry error yields False (don't wait) so a stale - barrier can never wedge the loop. + Delegates to ``process_registry.is_session_waiting`` — True while the session is running and (if + it has watch_patterns) its trigger hasn't fired. Fail-safe: any import/registry error yields + False (don't wait) so a stale barrier can never wedge the loop. """ if not session_id: return False @@ -989,9 +924,9 @@ _JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL) def _goal_judge_max_tokens() -> int: """Resolve auxiliary.goal_judge.max_tokens, falling back to the default. - ``load_config()`` is cached on the config file's (mtime, size), so calling - this once per judge turn is cheap. A non-positive or non-int value falls - back to the default rather than crashing the goal loop. + ``load_config()`` is cached on the config file's (mtime, size), so calling this once per judge + turn is cheap. A non-positive or non-int value falls back to the default rather than crashing + the goal loop. """ try: from hermes_cli.config import load_config @@ -1013,13 +948,8 @@ def _goal_judge_max_tokens() -> int: def _goal_judge_timeout() -> float: """Resolve auxiliary.goal_judge.timeout, falling back to the default. - Mirrors :func:`_goal_judge_max_tokens`. The key is declared in - ``DEFAULT_CONFIG`` and surfaces in the auxiliary config UI, but the - judge path used to hardcode ``DEFAULT_JUDGE_TIMEOUT`` and never read - it — so a user raising the timeout for a slow-but-healthy reasoning - endpoint got no effect, and the loop auto-paused on misleading - transport failures pointing at provider/key (#91022). A non-positive - or non-numeric value falls back rather than crashing the goal loop. + Mirrors :func:`_goal_judge_max_tokens`. A non-positive or non-numeric value falls back rather + than crashing the goal loop. """ try: from hermes_cli.config import load_config @@ -1041,19 +971,11 @@ def _goal_judge_timeout() -> float: def _parse_judge_response(raw: str) -> Tuple[str, str, bool, Optional[Dict[str, Any]]]: """Parse the judge's reply. Fail-open on unusable output. - Returns ``(verdict, reason, parse_failed, wait_directive)`` where: - - ``verdict`` is ``"done"``, ``"blocked"``, ``"continue"``, or ``"wait"``. - - ``parse_failed`` is True when the judge returned output that couldn't - be interpreted as the expected JSON verdict (empty body, prose, - malformed JSON). Callers use it to auto-pause after N consecutive - parse failures so a weak judge model doesn't silently burn the budget. - - ``wait_directive`` is set only for ``verdict == "wait"``: a dict with - ``{"pid": int}`` or ``{"seconds": int}`` (whichever the judge supplied). - ``None`` otherwise. If a wait verdict carries neither a usable pid nor - seconds, it is downgraded to ``continue`` (can't park on nothing). - - Accepts both the new ``{"verdict": ...}`` shape and the legacy - ``{"done": <bool>}`` shape. + Returns ``(verdict, reason, parse_failed, wait_directive)``. ``parse_failed`` flags output that + wasn't the expected JSON verdict so callers can auto-pause after N consecutive failures instead + of letting a weak judge burn the budget. ``wait_directive`` is ``{"pid"}`` or ``{"seconds"}`` + for a ``wait`` verdict; a wait with neither is downgraded to ``continue``. Accepts both the + ``{"verdict": ...}`` shape and the legacy ``{"done": <bool>}`` shape. """ if not raw: return "continue", "judge returned empty response", True, None @@ -1138,11 +1060,10 @@ def _parse_judge_response(raw: str) -> Tuple[str, str, bool, Optional[Dict[str, def _render_background_block(background_processes: Optional[List[Dict[str, Any]]]) -> str: """Render the live background-process list for the judge prompt. - Each entry is a ``process_registry.list_sessions()`` dict. Only RUNNING - processes are worth showing (an exited one is nothing to wait on). Returns - an empty string when there's nothing running, so the judge prompt is - byte-identical to the no-background case (no behavior change for the - common path). + Each entry is a ``process_registry.list_sessions()`` dict. Only RUNNING processes are worth + showing (an exited one is nothing to wait on). Returns an empty string when there's nothing + running, so the judge prompt is byte-identical to the no-background case (no behavior change for + the common path). """ if not background_processes: return "" @@ -1192,40 +1113,14 @@ def judge_goal( ) -> Tuple[str, str, bool, Optional[Dict[str, Any]], bool]: """Ask the auxiliary model whether the goal is satisfied. - Returns ``(verdict, reason, parse_failed, wait_directive, transport_failed)`` where verdict - is ``"done"``, ``"blocked"``, ``"continue"``, ``"wait"``, or ``"skipped"`` (when the - judge couldn't be reached). ``wait_directive`` is set only for ``"wait"`` - (``{"pid": int}`` or ``{"seconds": int}``); ``None`` otherwise. + Returns ``(verdict, reason, parse_failed, wait_directive, transport_failed)`` where verdict is + ``"done"``, ``"blocked"``, ``"continue"``, ``"wait"``, or ``"skipped"`` (when the judge couldn't + be reached). ``wait_directive`` is set only for ``"wait"`` (``{"pid": int}`` or ``{"seconds": + int}``); ``None`` otherwise. - ``parse_failed`` is True only when the judge call succeeded but its output - was unusable (empty or non-JSON). API/transport errors return False — they - are transient and should fail-open silently. - - ``transport_failed`` is True only when the judge couldn't reach the API at - all (auth 401, timeout, DNS, connection error). Repeated transport - failures signal a permanent config problem (e.g. invalid API key). Callers - use this flag to auto-pause after N consecutive transport failures (see - ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``). Callers use this flag to - auto-pause after N consecutive parse failures (see - ``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``). - - ``subgoals`` is an optional list of user-added criteria (from - ``/subgoal``) factored into the verdict. ``background_processes`` is the - live ``process_registry.list_sessions()`` snapshot; when the agent is - waiting on one (a CI poller, build, etc.) the judge can return a ``wait`` - verdict naming its pid, parking the loop instead of re-poking. - ``contract`` is an optional structured completion contract; when present - the judge decides DONE strictly against its Verification criterion and - refuses completion when a Constraint was violated. All three are additive - — a contract, subgoals, and a background-process list can coexist in one - judge prompt; when none are set, behavior is identical to the original - free-form judge. - - This is deliberately fail-open: transport errors return ``("continue", ..., ..., None, True)`` - — the ``transport_failed=True`` flag lets callers track and auto-pause after - N consecutive transport failures (see - ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``) so a permanently broken - judge doesn't burn the entire turn budget. + ``parse_failed`` is True only when the judge call succeeded but its output was unusable (empty + or non-JSON). API/transport errors return False — they are transient and should fail-open + silently. """ if not goal.strip(): return "skipped", "empty goal", False, None, False @@ -1320,12 +1215,10 @@ def judge_goal( def gather_background_processes(task_id: Optional[str] = None) -> List[Dict[str, Any]]: """Return the live background-process snapshot for the goal judge. - Thin, fail-safe wrapper over ``process_registry.list_sessions(task_id)``. - Returns only RUNNING processes (an exited one is nothing to wait on) and - never raises — any import/registry failure yields ``[]`` so the goal loop - degrades to its pre-wait-barrier behavior (judge just won't see processes). - The drivers (CLI + gateway) call this and pass the result into - ``GoalManager.evaluate_after_turn(background_processes=...)``. + Thin, fail-safe wrapper over ``process_registry.list_sessions(task_id)``. Returns only RUNNING + processes (an exited one is nothing to wait on) and never raises — any import/registry failure + yields ``[]`` so the goal loop degrades to its pre-wait-barrier behavior (judge just won't see + processes). """ try: from tools.process_registry import process_registry @@ -1340,12 +1233,9 @@ def gather_background_processes(task_id: Optional[str] = None) -> List[Dict[str, def draft_contract(objective: str, *, timeout: Optional[float] = None) -> Optional[GoalContract]: """Expand a plain-language objective into a structured completion contract. - Uses the ``goal_judge`` auxiliary task (main-model-first, cache-safe — it - is a side LLM call, not a conversation turn). Returns a populated - :class:`GoalContract` on success, or ``None`` when the auxiliary client is - unavailable or the model's reply can't be parsed. Callers fall back to a - bare free-form goal in that case, so a missing/weak aux model never blocks - setting a goal. + Uses the ``goal_judge`` auxiliary task (main-model-first, cache-safe — it is a side LLM call, + not a conversation turn). Returns a populated :class:`GoalContract` on success, or ``None`` when + the auxiliary client is unavailable or the model's reply can't be parsed. """ objective = (objective or "").strip() if not objective: @@ -1392,8 +1282,8 @@ def draft_contract(objective: str, *, timeout: Optional[float] = None) -> Option def _extract_json_object(raw: str) -> Optional[Dict[str, Any]]: """Best-effort: pull the first JSON object out of a model reply. - Shares the fence-stripping + first-object fallback logic used by the - judge parser, but returns the dict (or None) rather than a verdict. + Shares the fence-stripping + first-object fallback logic used by the judge parser, but returns + the dict (or None) rather than a verdict. """ if not raw: return None @@ -1424,18 +1314,9 @@ def _extract_json_object(raw: str) -> Optional[Dict[str, Any]]: class GoalManager: """Per-session goal state + continuation decisions. - The CLI and gateway each hold one ``GoalManager`` per live session. - - Methods: - - - ``set(goal)`` — start a new standing goal. - - ``clear()`` — remove the active goal. - - ``pause()`` / ``resume()`` — explicit user controls. - - ``status()`` — printable one-liner. - - ``evaluate_after_turn(last_response)`` — call the judge, update state, - and return a decision dict the caller uses to drive the next turn. - - ``next_continuation_prompt()`` — the canonical user-role message to - feed back into ``run_conversation``. + The CLI and gateway each hold one per live session. ``evaluate_after_turn`` calls the judge and + returns the decision dict that drives the next turn; ``next_continuation_prompt`` is the + canonical user-role message to feed back into ``run_conversation``. """ def __init__(self, session_id: str, *, default_max_turns: int = DEFAULT_MAX_TURNS): @@ -1506,10 +1387,7 @@ class GoalManager: return state def set_contract(self, contract: GoalContract) -> Optional[GoalState]: - """Attach or replace the completion contract on the active goal. - - Returns the updated state, or None when there is no goal to attach to. - """ + """Attach or replace the completion contract on the active goal.""" if self._state is None: return None self._state.contract = contract or GoalContract() @@ -1564,10 +1442,8 @@ class GoalManager: # --- /subgoal user controls --------------------------------------- def add_subgoal(self, text: str) -> str: - """Append a user-added criterion to the active goal. Requires - ``has_goal()``; raises ``RuntimeError`` otherwise. - - Returns the cleaned text so the caller can show it back to the user. + """Append a user-added criterion to the active goal. Requires ``has_goal()``; raises + ``RuntimeError`` otherwise. """ if self._state is None or not self.has_goal(): raise RuntimeError("no active goal") @@ -1619,8 +1495,8 @@ class GoalManager: ) -> GoalGate: """Append a quality-gate command to the active goal. - Requires ``has_goal()``; raises ``RuntimeError`` otherwise. Returns - the created gate so callers can echo it back. + Requires ``has_goal()``; raises ``RuntimeError`` otherwise. Returns the created gate so + callers can echo it back. """ if self._state is None or not self.has_goal(): raise RuntimeError("no active goal") @@ -1675,16 +1551,9 @@ class GoalManager: def _check_gates(self) -> Optional[Dict[str, Any]]: """Run quality gates in order; return a decision dict on failure. - Returns ``None`` when there are no gates or every gate passes — - the caller then proceeds to the LLM judge. On the first failing - gate, returns a full ``evaluate_after_turn``-shaped decision dict: - either a continuation carrying the gate's output (attempts left) - or an auto-pause (retries exhausted). - - An unchanged workspace since the last failure of the same gate is - NOT re-run — the recorded failure is replayed and the attempt count - advances, so a stalled agent can't spin re-running an identical red - suite (mirrors Prime-Agent's unchanged-gate rule). + An unchanged workspace since the last failure of the same gate is NOT re-run — the recorded + failure is replayed and the attempt count advances, so a stalled agent can't spin re-running + an identical red suite (mirrors Prime-Agent's unchanged-gate rule). """ state = self._state if state is None or not state.gates: @@ -1761,13 +1630,11 @@ class GoalManager: def wait_on(self, pid: int, reason: str = "") -> GoalState: """Park the goal loop on a background process PID. - While the PID is alive, ``evaluate_after_turn`` returns - ``should_continue=False`` without burning a turn or calling the - judge — the loop quiesces instead of re-poking the agent into busy - work. The barrier auto-clears when the process exits. Requires an - active goal. For a process with a watch_patterns/notify_on_complete - trigger, prefer ``wait_on_session`` so a mid-run trigger (not just - exit) releases the barrier. + While the PID is alive ``evaluate_after_turn`` returns ``should_continue=False`` without + calling the judge, so the loop quiesces instead of re-poking the agent into busy work. + Auto-clears when the process exits; requires an active goal. For a process with a + watch/notify trigger prefer ``wait_on_session`` so a mid-run trigger (not just exit) + releases the barrier. """ if self._state is None or self._state.status != "active": raise RuntimeError("no active goal to park") @@ -1785,11 +1652,10 @@ class GoalManager: def wait_on_session(self, session_id: str, reason: str = "") -> GoalState: """Park the goal loop on a process_registry session's OWN trigger. - Unlike ``wait_on`` (which releases only on PID exit), this releases - when the session's trigger fires: it exits, OR — if it was started - with ``watch_patterns`` — its pattern matches. This is the right - barrier for a long-lived watcher/server/poller that signals mid-run - and may never exit. Requires an active goal. + Unlike ``wait_on`` (which releases only on PID exit), this releases when the session's + trigger fires: it exits, OR — if it was started with ``watch_patterns`` — its pattern + matches. This is the right barrier for a long-lived watcher/server/poller that signals mid- + run and may never exit. Requires an active goal. """ if self._state is None or self._state.status != "active": raise RuntimeError("no active goal to park") @@ -1807,10 +1673,8 @@ class GoalManager: def wait_for_seconds(self, seconds: int, reason: str = "") -> GoalState: """Park the goal loop until ``seconds`` from now have elapsed. - Time-based counterpart to ``wait_on`` — for backoff / cooldown waits - where there's no process to track (e.g. the agent is rate-limited). - The barrier auto-clears once the deadline passes. Requires an active - goal. + Time-based counterpart to ``wait_on`` for backoff/cooldown waits with no process to track + (e.g. rate limits). Auto-clears once the deadline passes; requires an active goal. """ if self._state is None or self._state.status != "active": raise RuntimeError("no active goal to park") @@ -1826,8 +1690,7 @@ class GoalManager: return self._state def stop_waiting(self) -> bool: - """Clear any active wait barrier (pid / session / time). Returns True - if one was cleared.""" + """Clear any active wait barrier (pid / session / time). Returns True if one was cleared.""" if self._state is None: return False if ( @@ -1847,11 +1710,9 @@ class GoalManager: def is_waiting(self) -> bool: """True iff a barrier is set AND not yet satisfied. - Session barrier: active until the process exits or its watch-pattern - trigger fires. Pid barrier: active while the process is alive. Time - barrier: active until the deadline passes. Side effect: a satisfied - barrier is cleared here (lazy auto-clear) so the next evaluation - resumes normal judging. + Session barrier: until the process exits or its watch-pattern fires. Pid barrier: while + alive. Time barrier: until the deadline. Side effect: a satisfied barrier is cleared here + (lazy auto-clear) so the next evaluation resumes normal judging. """ s = self._state if s is None: @@ -1884,22 +1745,13 @@ class GoalManager: ) -> Dict[str, Any]: """Run the judge and update state. Return a decision dict. - ``user_initiated`` distinguishes a real user prompt (True) from a - continuation prompt we fed ourselves (False). Both increment - ``turns_used`` because both consume model budget. + ``user_initiated`` distinguishes a real user prompt (True) from a continuation prompt we fed + ourselves (False). Both increment ``turns_used`` because both consume model budget. - ``background_processes`` is the live ``process_registry.list_sessions()`` - snapshot for this session. It's handed to the judge so it can decide - to WAIT on an in-flight process (CI poller, build, ...) instead of - re-poking the agent — the automatic counterpart to ``/goal wait``. - - Decision keys: - - ``status``: current goal status after update - - ``should_continue``: bool — caller should fire another turn - - ``continuation_prompt``: str or None - - ``verdict``: "done" | "blocked" | "continue" | "wait" | "skipped" | "inactive" - - ``reason``: str - - ``message``: user-visible one-liner to print/send + Decision keys: - ``status``: current goal status after update - ``should_continue``: bool — + caller should fire another turn - ``continuation_prompt``: str or None - ``verdict``: "done" + | "blocked" | "continue" | "wait" | "skipped" | "inactive" - ``reason``: str - ``message``: + user-visible one-liner to print/send """ state = self._state if state is None or state.status != "active": @@ -2215,31 +2067,9 @@ def run_kanban_goal_loop( ) -> Dict[str, Any]: """Drive a kanban worker through a Ralph-style goal loop. - The dispatcher spawns a goal-mode worker exactly like a normal worker - (``hermes -p <profile> chat -q "work kanban task <id>"``). The worker's - first turn has already run by the time this is called; ``first_response`` - is that turn's reply. From here we: - - 1. Check whether the worker already terminated the task (called - ``kanban_complete`` / ``kanban_block``). If so, stop — nothing to do. - 2. Otherwise judge the latest response against ``goal_text`` (the card's - title + body). ``continue`` → feed a continuation prompt and run - another turn IN THE SAME SESSION via ``run_turn``. ``done`` but the - task is still open → one explicit "call kanban_complete" nudge. - 3. When the turn budget is exhausted and the worker still hasn't - terminated the task, ``block_fn`` is invoked so the card lands in a - sticky ``blocked`` state for human review (NOT a silent exit). - - This function performs NO SessionDB persistence — a worker process is - ephemeral, so the turn budget lives in a local counter. It is fully - decoupled from the CLI for testability: callers inject ``run_turn`` - (str -> str), ``task_status_fn`` (() -> str|None), and ``block_fn`` - (reason: str -> None). - - Returns a decision dict: ``{"outcome", "turns_used", "reason"}`` where - outcome is one of ``"completed_by_worker"``, ``"review_requested_by_worker"``, - ``"changes_requested_by_reviewer"``, ``"blocked_budget"``, - ``"blocked_unachievable"``, ``"blocked_by_worker"``, or ``"stopped"``. + 1. Check whether the worker already terminated the task (called ``kanban_complete`` / + ``kanban_block``). If so, stop — nothing to do. 2. Otherwise judge the latest response against + ``goal_text`` (the card's title + body). """ def _log(msg: str) -> None: diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index aaff4f73b2..94779248b4 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -1,20 +1,4 @@ -"""hermes hooks — inspect and manage shell-script hooks. - -Usage:: - - hermes hooks list - hermes hooks test <event> [--for-tool X] [--payload-file F] - hermes hooks revoke <command> - hermes hooks doctor - -Consent records live under ``~/.hermes/shell-hooks-allowlist.json`` and -hook definitions come from the ``hooks:`` block in ``~/.hermes/config.yaml`` -(the same config read by the CLI / gateway at startup). - -This module is a thin CLI shell over :mod:`agent.shell_hooks`; every -shared concern (payload serialisation, response parsing, allowlist -format) lives there. -""" +"""hermes hooks — inspect and manage shell-script hooks.""" from __future__ import annotations @@ -32,16 +16,11 @@ def hooks_command(args) -> None: print("Run 'hermes hooks --help' for details.") return - if sub in {"list", "ls"}: - _cmd_list(args) - elif sub == "test": - _cmd_test(args) - elif sub in {"revoke", "remove", "rm"}: - _cmd_revoke(args) - elif sub == "doctor": - _cmd_doctor(args) - else: + handler = _ACTIONS.get(sub) + if handler is None: print(f"Unknown hooks subcommand: {sub}") + return + handler(args) # --------------------------------------------------------------------------- @@ -70,54 +49,59 @@ def _cmd_list(_args) -> None: for spec in specs: by_event.setdefault(spec.event, []).append(spec) - allowlist = shell_hooks.load_allowlist() approved = { (e.get("event"), e.get("command")) - for e in allowlist.get("approvals", []) + for e in shell_hooks.load_allowlist().get("approvals", []) if isinstance(e, dict) } print(f"Configured shell hooks ({len(specs)} total):\n") - for event in sorted(by_event.keys()): + for event in sorted(by_event): print(f" [{event}]") for spec in by_event[event]: is_approved = (spec.event, spec.command) in approved status = "✓ allowed" if is_approved else "✗ not allowlisted" - matcher_part = f" matcher={spec.matcher!r}" if spec.matcher else "" - print( - f" - {spec.command}{matcher_part} " - f"(timeout={spec.timeout}s, {status})" - ) + print(f" - {spec.command}{_matcher_part(spec)} (timeout={spec.timeout}s, {status})") - if is_approved: - entry = shell_hooks.allowlist_entry_for(spec.event, spec.command) - if entry and entry.get("approved_at"): - print(f" approved_at: {entry['approved_at']}") - mtime_now = shell_hooks.script_mtime_iso(spec.command) - mtime_at = entry.get("script_mtime_at_approval") - if mtime_now and mtime_at and mtime_now > mtime_at: - print( - f" ⚠ script modified since approval " - f"(was {mtime_at}, now {mtime_now}) — " - f"run `hermes hooks doctor` to re-validate" - ) + entry = shell_hooks.allowlist_entry_for(spec.event, spec.command) if is_approved else None + if entry and entry.get("approved_at"): + print(f" approved_at: {entry['approved_at']}") + drift, mtime_at, mtime_now = _mtime_drift(shell_hooks, spec, entry) + if drift is True: + print( + f" ⚠ script modified since approval " + f"(was {mtime_at}, now {mtime_now}) — " + f"run `hermes hooks doctor` to re-validate" + ) print() if outbound: print(f"Configured outbound webhooks ({len(outbound)} total):\n") for target in outbound: signed = "signed" if target.secret else "UNSIGNED" - matcher_part = f" matcher={target.matcher!r}" if target.matcher else "" print(f" - {target.label}") print(f" url: {target.url}") - print( - f" events: {', '.join(target.events)}{matcher_part} " - f"(timeout={target.timeout}s, {signed})" - ) + print(f" events: {', '.join(target.events)}{_matcher_part(target)} (timeout={target.timeout}s, {signed})") print() +def _matcher_part(obj) -> str: + return f" matcher={obj.matcher!r}" if obj.matcher else "" + + +def _mtime_drift(shell_hooks, spec, entry) -> tuple[bool | None, str, str]: + """Return ``(drift, mtime_at, mtime_now)``: drift is True if the script changed since approval, + False if unchanged, None when either mtime is unknown.""" + mtime_now = shell_hooks.script_mtime_iso(spec.command) + mtime_at = entry.get("script_mtime_at_approval") + if not (mtime_now and mtime_at): + return None, mtime_at, mtime_now + if mtime_now > mtime_at: + return True, mtime_at, mtime_now + return (False if mtime_now == mtime_at else None), mtime_at, mtime_now + + # --------------------------------------------------------------------------- # test # --------------------------------------------------------------------------- @@ -255,9 +239,10 @@ def _cmd_test(args) -> None: # Synthetic kwargs in the same shape invoke_hook() would pass. Merged # with --for-tool (overrides tool_name) and --payload-file (extra kwargs). payload = dict(_DEFAULT_PAYLOADS.get(event, {"session_id": "test-session"})) + for_tool = getattr(args, "for_tool", None) - if getattr(args, "for_tool", None): - payload["tool_name"] = args.for_tool + if for_tool: + payload["tool_name"] = for_tool if getattr(args, "payload_file", None): try: @@ -270,20 +255,15 @@ def _cmd_test(args) -> None: print(f"Error reading payload file: {exc}") return - specs = shell_hooks.iter_configured_hooks(load_config()) - specs = [s for s in specs if s.event == event] + specs = [s for s in shell_hooks.iter_configured_hooks(load_config()) if s.event == event] - if getattr(args, "for_tool", None): - specs = [ - s for s in specs - if s.event not in {"pre_tool_call", "post_tool_call"} - or s.matches_tool(args.for_tool) - ] + if for_tool: + specs = [s for s in specs if s.event not in {"pre_tool_call", "post_tool_call"} or s.matches_tool(for_tool)] if not specs: print(f"No shell hooks configured for event: {event}") - if getattr(args, "for_tool", None): - print(f"(with matcher filter --for-tool={args.for_tool})") + if for_tool: + print(f"(with matcher filter --for-tool={for_tool})") return print(f"Firing {len(specs)} hook(s) for event '{event}':\n") @@ -302,16 +282,12 @@ def _print_run_result(result: Dict[str, Any]) -> None: print(f" ✗ timed out after {result['elapsed_seconds']}s") return - rc = result.get("returncode") - elapsed = result.get("elapsed_seconds", 0) - print(f" exit={rc} elapsed={elapsed}s") + print(f" exit={result.get('returncode')} elapsed={result.get('elapsed_seconds', 0)}s") - stdout = (result.get("stdout") or "").strip() - stderr = (result.get("stderr") or "").strip() - if stdout: - print(f" stdout: {_truncate(stdout, 400)}") - if stderr: - print(f" stderr: {_truncate(stderr, 400)}") + for stream in ("stdout", "stderr"): + text = (result.get(stream) or "").strip() + if text: + print(f" {stream}: {_truncate(text, 400)}") parsed = result.get("parsed") if parsed: @@ -364,10 +340,7 @@ def _cmd_doctor(_args) -> None: problems += _doctor_one(spec, shell_hooks) print() - if problems: - print(f"{problems} issue(s) found. Fix before relying on these hooks.") - else: - print("All shell hooks look healthy.") + print(f"{problems} issue(s) found. Fix before relying on these hooks." if problems else "All shell hooks look healthy.") def _doctor_one(spec, shell_hooks) -> int: @@ -392,14 +365,13 @@ def _doctor_one(spec, shell_hooks) -> int: # 3. Mtime drift if entry and entry.get("script_mtime_at_approval"): - mtime_now = shell_hooks.script_mtime_iso(spec.command) - mtime_at = entry["script_mtime_at_approval"] - if mtime_now and mtime_at and mtime_now > mtime_at: + drift, mtime_at, mtime_now = _mtime_drift(shell_hooks, spec, entry) + if drift is True: problems += 1 print(f" ⚠ script modified since approval " f"(was {mtime_at}, now {mtime_now}) — review changes, " f"then `hermes hooks revoke` + re-approve to refresh") - elif mtime_now and mtime_at and mtime_now == mtime_at: + elif drift is False: print(" ✓ script unchanged since approval") # 4. Produces valid JSON for a synthetic payload — only when the entry @@ -439,3 +411,11 @@ def _doctor_one(spec, shell_hooks) -> int: f"(exit={rc}, {elapsed}s) — hook is observer-only") return problems + + +_ACTIONS = { + "list": _cmd_list, "ls": _cmd_list, + "test": _cmd_test, + "revoke": _cmd_revoke, "remove": _cmd_revoke, "rm": _cmd_revoke, + "doctor": _cmd_doctor, +} diff --git a/hermes_cli/init_command.py b/hermes_cli/init_command.py index 576b84fbbc..7f299a8319 100644 --- a/hermes_cli/init_command.py +++ b/hermes_cli/init_command.py @@ -1,27 +1,9 @@ #!/usr/bin/env python3 """``/init`` — build the prompt that generates or updates a project AGENTS.md. -Port of Codex ``/init`` (Claude Code has the same for CLAUDE.md). Hermes -already *loads* AGENTS.md / CLAUDE.md / .cursorrules as project context, but -had no command to bootstrap one. ``/init`` hands the live agent ONE -guidance-laden prompt instructing it to: - - 1. Inspect the project with its own read-only tools (``read_file`` / - ``search_files`` on manifests, CI configs, lockfiles, existing docs) to - learn the layout, toolchain, and the exact build/test/lint commands. - 2. Write a CONCISE ``AGENTS.md`` (target under 100 lines) with the sections - an agent actually needs — overview, setup, commands, conventions, - pitfalls — not an essay. - 3. If an AGENTS.md already exists, UPDATE it: preserve the user's existing - content and merge in what's missing, never blow it away. - -There is no engine and no model-tool footprint: the agent does the work with -its existing toolset, so this works identically on local, Docker, and remote -terminal backends. Every surface (CLI ``/init``, gateway ``/init``, TUI -``/init``) calls :func:`build_init_prompt` and feeds the result to the agent -as a normal user turn — the same prompt-injection pattern as ``/learn`` and -``/blueprint``, which preserves prompt-cache invariants (no system-prompt or -history mutation). +1. Inspect the project with its own read-only tools (``read_file`` / ``search_files`` on manifests, +CI configs, lockfiles, existing docs) to learn the layout, toolchain, and the exact build/test/lint +commands. 2. """ from __future__ import annotations @@ -57,17 +39,9 @@ def build_init_prompt( ) -> str: """Build the agent prompt for a ``/init`` request. - Args: - cwd: the project directory the agent should scan and write - ``AGENTS.md`` into (usually the session working directory). - existing_file: the current content of ``AGENTS.md`` if one already - exists, else ``None``. When present the prompt switches to - update-and-merge discipline instead of fresh generation. - extra: free-text the user gave after ``/init`` — emphasis or notes to - honor while authoring (e.g. "focus on the test setup"). - - Returns: - A complete instruction the agent runs as a normal turn. + When ``existing_file`` (current ``AGENTS.md`` content) is given, the prompt switches to + update-and-merge discipline instead of fresh generation. ``extra`` is the user's free text + after ``/init`` to honor while authoring. """ extra = (extra or "").strip() @@ -131,11 +105,7 @@ def build_init_prompt( def build_init_prompt_for_cwd(cwd: str | None = None, extra: str = "") -> str: - """Convenience wrapper used by the dispatch surfaces. - - Resolves ``cwd`` (defaults to the process working directory), reads an - existing ``AGENTS.md`` there if present, and returns the full prompt. - """ + """Convenience wrapper used by the dispatch surfaces.""" import os resolved = os.path.abspath(cwd or os.getcwd()) diff --git a/hermes_cli/input_sanitize.py b/hermes_cli/input_sanitize.py index 90ca4c4d39..e69ece09ae 100644 --- a/hermes_cli/input_sanitize.py +++ b/hermes_cli/input_sanitize.py @@ -16,12 +16,9 @@ _DESKTOP_PASTE_ARTIFACT = "~[[e" def strip_leaked_bracketed_paste_wrappers(text: str) -> str: """Strip leaked bracketed-paste wrapper markers from user-visible text. - Defensive normalization for cases where terminal/prompt_toolkit parsing - fails and bracketed-paste markers end up in the buffer as literal text. - - Canonical wrappers are stripped unconditionally. Degraded visible forms like - ``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries - so embedded literals such as ``literal[200~tag`` stay intact. + Canonical wrappers are stripped unconditionally. Degraded visible forms like ``[200~`` / + ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries so embedded literals such as + ``literal[200~tag`` stay intact. """ if not text: return text diff --git a/hermes_cli/journey.py b/hermes_cli/journey.py index 27fb9d1ce3..106bc6e0c3 100644 --- a/hermes_cli/journey.py +++ b/hermes_cli/journey.py @@ -1,12 +1,4 @@ -"""``hermes journey`` — what Hermes has learned, on a timeline. - -A terminal-native rendition of the desktop Star Map / Memory Graph: a horizontal -timeline bar chart of learned skills and memories over time (oldest at top, -newest at bottom) plus the playable constellation scrubber. Graph assembly, -layout, and the (ported-from-desktop) palette all live in -``agent.learning_graph`` / ``agent.learning_graph_render`` so the CLI, the TUI -``/journey`` overlay, and the desktop panel draw the same data. -""" +"""``hermes journey`` — what Hermes has learned, on a timeline.""" from __future__ import annotations @@ -154,9 +146,12 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): parts: list[Any] = [] + def st(style: Optional[str]) -> Optional[str]: + return style if color else None + title = Text() - title.append("✦ Journey ", style=f"bold {_TITLE_COLOR}" if color else None) - title.append("· learned skills & memories over time", style="grey62" if color else None) + title.append("✦ Journey ", style=st(f"bold {_TITLE_COLOR}")) + title.append("· learned skills & memories over time", style=st("grey62")) parts.append(title) legend_line = Text(" ") @@ -164,7 +159,7 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): if i: legend_line.append(" ") legend_line.append(item["glyph"] + " ", style=_resolve(item["style"], 1.0) if color else None) - legend_line.append(item["label"], style="grey62" if color else None) + legend_line.append(item["label"], style=st("grey62")) parts.append(legend_line) if categories: @@ -173,7 +168,7 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): if i: cat_line.append(" ") cat_line.append(item["glyph"] + " ", style=_fade(item.get("color"), 1.0) if color else None) - cat_line.append(item["label"], style="grey54" if color else None) + cat_line.append(item["label"], style=st("grey54")) parts.append(cat_line) parts.append(Text("")) @@ -185,28 +180,27 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): # Date axis under the field (oldest → now), with the playhead date centered. axis_line = Text(" ") - axis_line.append(axis["start"], style="grey54" if color else None) + axis_line.append(axis["start"], style=st("grey54")) gap = max(1, inner - len(axis["start"]) - len(axis["end"])) axis_line.append(" " * gap) - axis_line.append(axis["end"], style="grey54" if color else None) + axis_line.append(axis["end"], style=st("grey54")) parts.append(axis_line) pct = int(round(reveal * 100)) foot = Text(" ") - foot.append("◷ ", style="grey54" if color else None) - foot.append(frame["date"] or "—", style=_TITLE_COLOR if color else None) - foot.append(f" {frame['visible']}/{count} revealed · {pct}%", style="grey54" if color else None) + foot.append("◷ ", style=st("grey54")) + foot.append(frame["date"] or "—", style=st(_TITLE_COLOR)) + foot.append(f" {frame['visible']}/{count} revealed · {pct}%", style=st("grey54")) parts.append(foot) labels = frame.get("labels", []) if labels: parts.append(Text("")) - heading = Text(" charted signals", style="grey62" if color else None) - parts.append(heading) + parts.append(Text(" charted signals", style=st("grey62"))) - def label_row(item) -> Text: + for item in labels[:6]: row = Text(" ") - row.append(f"{item['key']} ", style="grey70" if color else None) + row.append(f"{item['key']} ", style=st("grey70")) signal_style = ( _resolve_charted_signal(item["style"], float(item.get("alpha", 1.0))) if color @@ -215,24 +209,20 @@ def _frame_renderable(payload, *, cols, rows, reveal, color): row.append(f"{item['glyph']} ", style=signal_style) row.append(str(item["label"]), style=signal_style) meta = str(item["meta"]) - row.append(f" {meta if len(meta) <= 32 else meta[:29] + '…'}", style="grey54" if color else None) - return row - - for item in labels[:6]: - row = label_row(item) + row.append(f" {meta if len(meta) <= 32 else meta[:29] + '…'}", style=st("grey54")) parts.append(row) for line_text in summary: - parts.append(Text(" " + line_text, style="grey62" if color else None)) + parts.append(Text(" " + line_text, style=st("grey62"))) return Group(*parts) def _console(*, color: bool, width: Optional[int] = None, force: bool = False): - """A Rich console. ``force`` emits truecolor ANSI even into a captured - stream — the interactive CLI grabs that output and re-renders it through - prompt_toolkit (raw escapes to a real terminal would otherwise be - swallowed). Mirrors the ``ChatConsole`` idiom in ``cli.py``.""" + """A Rich console. ``force`` emits truecolor ANSI even into a captured stream — the interactive CLI + grabs that output and re-renders it through prompt_toolkit (raw escapes to a real terminal would + otherwise be swallowed). Mirrors the ``ChatConsole`` idiom in ``cli.py``. + """ from rich.console import Console extra = {"force_terminal": True, "color_system": "truecolor"} if force else {} @@ -308,12 +298,22 @@ def _cmd_list(args: argparse.Namespace) -> int: return 0 -def _cmd_delete(args: argparse.Namespace) -> int: - from agent.learning_mutations import delete_node, node_detail +def _lookup_node(node_id: str) -> Optional[dict]: + """Resolve a node id via ``node_detail``; print the failure and return None when missing.""" + from agent.learning_mutations import node_detail - detail = node_detail(args.node) + detail = node_detail(node_id) if not detail.get("ok"): print(f" {detail.get('message', 'not found')}") + return None + return detail + + +def _cmd_delete(args: argparse.Namespace) -> int: + from agent.learning_mutations import delete_node + + detail = _lookup_node(args.node) + if detail is None: return 1 if not getattr(args, "yes", False): try: @@ -329,11 +329,10 @@ def _cmd_delete(args: argparse.Namespace) -> int: def _cmd_edit(args: argparse.Namespace) -> int: - from agent.learning_mutations import edit_node, node_detail + from agent.learning_mutations import edit_node - detail = node_detail(args.node) - if not detail.get("ok"): - print(f" {detail.get('message', 'not found')}") + detail = _lookup_node(args.node) + if detail is None: return 1 suffix = ".md" if detail["kind"] == "skill" else ".txt" edited = _open_in_editor(detail["content"], suffix=suffix) @@ -403,10 +402,6 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_edit.set_defaults(func=_cmd_edit) -def cmd_journey(args: argparse.Namespace) -> int: - return _cmd_show(args) - - if __name__ == "__main__": _p = argparse.ArgumentParser(prog="hermes journey") register_cli(_p) diff --git a/hermes_cli/logs.py b/hermes_cli/logs.py index a214d52c8a..2ed920b19a 100644 --- a/hermes_cli/logs.py +++ b/hermes_cli/logs.py @@ -1,22 +1,12 @@ """``hermes logs`` — view and filter Hermes log files. -Supports tailing, following, session filtering, level filtering, -component filtering, and relative time ranges. All log files live -under ``~/.hermes/logs/``. - -Usage examples:: - - hermes logs # last 50 lines of agent.log - hermes logs -f # follow agent.log in real time - hermes logs errors # last 50 lines of errors.log - hermes logs gateway -n 100 # last 100 lines of gateway.log - hermes logs gui -f # follow gui.log (dashboard/pty/ws) - hermes logs desktop -f # follow desktop.log (Electron app boot/backend) - hermes logs --level WARNING # only WARNING+ lines - hermes logs --session abc123 # filter by session ID substring - hermes logs --component tools # only tool-related lines - hermes logs --since 1h # lines from the last hour - hermes logs --since 30m -f # follow, starting 30 min ago +hermes logs # last 50 lines of agent.log hermes logs -f # follow agent.log in real time hermes logs +errors # last 50 lines of errors.log hermes logs gateway -n 100 # last 100 lines of gateway.log +hermes logs gui -f # follow gui.log (dashboard/pty/ws) hermes logs desktop -f # follow desktop.log +(Electron app boot/backend) hermes logs --level WARNING # only WARNING+ lines hermes logs --session +abc123 # filter by session ID substring hermes logs --component tools # only tool-related lines +hermes logs --since 1h # lines from the last hour hermes logs --since 30m -f # follow, starting 30 +min ago """ import re @@ -69,15 +59,8 @@ def _parse_since(since_str: str) -> Optional[datetime]: match = re.match(r"^(\d+)\s*([smhd])$", since_str) if not match: return None - value = int(match.group(1)) - unit = match.group(2) - delta = { - "s": timedelta(seconds=value), - "m": timedelta(minutes=value), - "h": timedelta(hours=value), - "d": timedelta(days=value), - }[unit] - return datetime.now() - delta + unit = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"}[match.group(2)] + return datetime.now() - timedelta(**{unit: int(match.group(1))}) def _parse_line_timestamp(line: str) -> Optional[datetime]: @@ -106,9 +89,7 @@ def _extract_logger_name(line: str) -> Optional[str]: def _line_matches_component(line: str, prefixes: Sequence[str]) -> bool: """Check if a log line's logger name starts with any of *prefixes*.""" name = _extract_logger_name(line) - if name is None: - return False - return name.startswith(tuple(prefixes)) + return name is not None and name.startswith(tuple(prefixes)) def _matches_filters( @@ -124,22 +105,13 @@ def _matches_filters( ts = _parse_line_timestamp(line) if ts is not None and ts < since: return False - if min_level is not None: level = _extract_level(line) - if level is not None: - if _LEVEL_ORDER.get(level, 0) < _LEVEL_ORDER.get(min_level, 0): - return False - - if session_filter is not None: - if session_filter not in line: + if level is not None and _LEVEL_ORDER.get(level, 0) < _LEVEL_ORDER.get(min_level, 0): return False - - if component_prefixes is not None: - if not _line_matches_component(line, component_prefixes): - return False - - return True + if session_filter is not None and session_filter not in line: + return False + return component_prefixes is None or _line_matches_component(line, component_prefixes) def tail_log( @@ -152,25 +124,7 @@ def tail_log( since: Optional[str] = None, component: Optional[str] = None, ) -> None: - """Read and display log lines, optionally following in real time. - - Parameters - ---------- - log_name - Which log to read: ``"agent"``, ``"errors"``, ``"gateway"``, ``"gui"``. - num_lines - Number of recent lines to show (before follow starts). - follow - If True, keep watching for new lines (Ctrl+C to stop). - level - Minimum log level to show (e.g. ``"WARNING"``). - session - Session ID substring to filter on. - since - Relative time string (e.g. ``"1h"``, ``"30m"``). - component - Component name to filter by (e.g. ``"gateway"``, ``"tools"``). - """ + """Read and display log lines, optionally following in real time.""" filename = LOG_FILES.get(log_name) if filename is None: print(f"Unknown log: {log_name!r}. Available: {', '.join(sorted(LOG_FILES))}") @@ -206,38 +160,26 @@ def tail_log( sys.exit(1) component_prefixes = COMPONENT_PREFIXES[component_lower] - has_filters = ( - min_level is not None - or session is not None - or since_dt is not None - or component_prefixes is not None - ) + filters = dict(min_level=min_level, session_filter=session, + since=since_dt, component_prefixes=component_prefixes) + has_filters = any(v is not None for v in filters.values()) # Read and display the tail try: - lines = _read_tail(log_path, num_lines, has_filters=has_filters, - min_level=min_level, session_filter=session, - since=since_dt, component_prefixes=component_prefixes) + lines = _read_tail(log_path, num_lines, has_filters=has_filters, **filters) except PermissionError: print(f"Permission denied: {log_path}") sys.exit(1) # Print header - filter_parts = [] - if min_level: - filter_parts.append(f"level>={min_level}") - if session: - filter_parts.append(f"session={session}") - if component: - filter_parts.append(f"component={component}") - if since: - filter_parts.append(f"since={since}") + filter_parts = [ + f"{label}={value}" for label, value in + (("level>", min_level), ("session", session), ("component", component), ("since", since)) + if value + ] filter_desc = f" [{', '.join(filter_parts)}]" if filter_parts else "" - - if follow: - print(f"--- {display_hermes_home()}/logs/{filename}{filter_desc} (Ctrl+C to stop) ---") - else: - print(f"--- {display_hermes_home()}/logs/{filename}{filter_desc} (last {num_lines}) ---") + mode = "Ctrl+C to stop" if follow else f"last {num_lines}" + print(f"--- {display_hermes_home()}/logs/{filename}{filter_desc} ({mode}) ---") for line in lines: print(line, end="") @@ -247,8 +189,7 @@ def tail_log( # Follow mode — poll for new content try: - _follow_log(log_path, min_level=min_level, session_filter=session, - since=since_dt, component_prefixes=component_prefixes) + _follow_log(log_path, **filters) except KeyboardInterrupt: print("\n--- stopped ---") @@ -263,31 +204,27 @@ def _read_tail( since: Optional[datetime] = None, component_prefixes: Optional[Sequence[str]] = None, ) -> list: - """Read the last *num_lines* matching lines from a log file. - - When filters are active, we read more raw lines to find enough matches. - """ - if has_filters: - # Read more lines to ensure we get enough after filtering. - # For large files, read last 10K lines and filter down. - raw_lines = _read_last_n_lines(path, max(num_lines * 20, 2000)) - filtered = [ - l for l in raw_lines - if _matches_filters(l, min_level=min_level, - session_filter=session_filter, since=since, - component_prefixes=component_prefixes) - ] - return filtered[-num_lines:] - else: + """Read the last *num_lines* matching lines from a log file.""" + if not has_filters: return _read_last_n_lines(path, num_lines) + # Read more lines to ensure we get enough after filtering. + # For large files, read last 10K lines and filter down. + raw_lines = _read_last_n_lines(path, max(num_lines * 20, 2000)) + filtered = [ + l for l in raw_lines + if _matches_filters(l, min_level=min_level, session_filter=session_filter, + since=since, component_prefixes=component_prefixes) + ] + return filtered[-num_lines:] + + +def _read_all_lines(path: Path) -> list: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.readlines() def _read_last_n_lines(path: Path, n: int) -> list: - """Efficiently read the last N lines from a file. - - For files under 1MB, reads the whole file (fast, simple). - For larger files, reads chunks from the end. - """ + """Efficiently read the last N lines from a file.""" try: size = path.stat().st_size if size == 0: @@ -295,9 +232,7 @@ def _read_last_n_lines(path: Path, n: int) -> list: # For files up to 1MB, just read the whole thing — simple and correct. if size <= 1_048_576: - with open(path, "r", encoding="utf-8", errors="replace") as f: - all_lines = f.readlines() - return all_lines[-n:] + return _read_all_lines(path)[-n:] # For large files, read chunks from the end. with open(path, "rb") as f: @@ -321,21 +256,12 @@ def _read_last_n_lines(path: Path, n: int) -> list: chunk_size = min(chunk_size * 2, 65536) # Decode and return last N non-empty lines. - decoded = [] - for raw in lines: - if not raw.strip(): - continue - try: - decoded.append(raw.decode("utf-8", errors="replace") + "\n") - except Exception: - decoded.append(raw.decode("latin-1") + "\n") + decoded = [raw.decode("utf-8", errors="replace") + "\n" for raw in lines if raw.strip()] return decoded[-n:] except Exception: # Fallback: read entire file - with open(path, "r", encoding="utf-8", errors="replace") as f: - all_lines = f.readlines() - return all_lines[-n:] + return _read_all_lines(path)[-n:] def _follow_log( @@ -373,21 +299,22 @@ def list_logs() -> None: found = False for entry in sorted(log_dir.iterdir()): if entry.is_file() and entry.suffix == ".log": - size = entry.stat().st_size - mtime = datetime.fromtimestamp(entry.stat().st_mtime) + st = entry.stat() + size = st.st_size + mtime = datetime.fromtimestamp(st.st_mtime) if size < 1024: size_str = f"{size}B" elif size < 1024 * 1024: size_str = f"{size / 1024:.1f}KB" else: size_str = f"{size / (1024 * 1024):.1f}MB" - age = datetime.now() - mtime - if age.total_seconds() < 60: + age_s = (datetime.now() - mtime).total_seconds() + if age_s < 60: age_str = "just now" - elif age.total_seconds() < 3600: - age_str = f"{int(age.total_seconds() / 60)}m ago" - elif age.total_seconds() < 86400: - age_str = f"{int(age.total_seconds() / 3600)}h ago" + elif age_s < 3600: + age_str = f"{int(age_s / 60)}m ago" + elif age_s < 86400: + age_str = f"{int(age_s / 3600)}h ago" else: age_str = mtime.strftime("%Y-%m-%d") print(f" {entry.name:<25} {size_str:>8} {age_str}") diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 04e8dc7c02..7bff26f1d5 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -1,46 +1,9 @@ """Recurring in-session wakeups — the /loop command (Claude Code parity). -``/loop [interval] <prompt>`` re-runs a prompt (or a slash command) on a -recurring cadence INSIDE the current session. Each tick is a real agent -turn: the wakeup prompt is injected through the exact same input path as -a typed user message, so the agent always sees current state (latest CI -result, newest queue depth, the file as it is now). - -Two cadence modes, mirroring Claude Code's ``/loop``: - -- **Fixed interval** — ``/loop 5m check the deploy`` fires every 5 minutes. -- **Self-paced** — ``/loop keep refining the failing test until green`` - (no interval token) lets the loop set its own rhythm: it starts fast and - backs off exponentially while the agent's replies stop changing, then - snaps back to the floor as soon as a reply differs. Zero extra LLM cost — - change detection is a local digest comparison. - -Stop conditions (any of): - -- The agent ends a wakeup reply with ``LOOP_COMPLETE`` on its own line - (the wakeup prompt teaches it to do so when the task is done/moot). -- ``--times N`` — stop after N ticks. -- ``--until <condition>`` — an evidence-based stop judged by the same - auxiliary judge that powers /goal (fail-open: a broken judge never - wedges the loop; the tick budget is the backstop). -- ``/loop stop`` / ``/loop clear`` — user control. -- ``loops.max_ticks`` config backstop (default 100, 0 = unlimited). +- The agent ends a wakeup reply with ``LOOP_COMPLETE`` on its own line (the wakeup prompt teaches it +to do so when the task is done/moot). - ``--times N`` — stop after N ticks. Design notes / invariants (same contract as ``hermes_cli/goals.py``): - -- A wakeup is just a normal user-role message appended via the surface's - ordinary input path. No system-prompt mutation, no toolset swap — - prompt caching stays intact and role alternation is preserved. -- Wakeups only fire while the session is IDLE. A real user message always - wins; the tick just re-arms and fires at the next idle boundary. -- State is persisted in SessionDB's ``state_meta`` table keyed by - ``loop:<session_id>`` so ``/resume`` picks the loop back up. -- /goal mixing: an active /goal takes priority. When the goal loop has a - continuation queued (or the goal judge is mid-flight), the /loop tick - defers to the next interval instead of racing a second synthetic turn. - Goal-continuation turns never count as loop ticks and vice versa. -- This module has zero hard dependency on ``cli.HermesCLI``, the gateway - runner, or the TUI gateway — all three drive the same ``LoopManager``. """ from __future__ import annotations @@ -50,7 +13,7 @@ import json import logging import re import time -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass, field, fields, asdict from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) @@ -125,9 +88,8 @@ WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE = ( def parse_interval_token(token: str) -> Optional[int]: """Parse a compact interval token (``30s``/``5m``/``2h``/``1h30m``). - Returns total seconds, or None when the token is not an interval. - A bare number is NOT an interval (too easy to collide with prompt - text like ``/loop 3 things to check``) — units are required. + Returns total seconds, or None when the token is not an interval. A bare number is NOT an + interval (it collides with prompt text like ``/loop 3 things to check``); units are required. """ if not token: return None @@ -142,27 +104,12 @@ def parse_interval_token(token: str) -> Optional[int]: def parse_loop_args(text: str) -> Dict[str, Any]: """Parse the argument string of ``/loop [interval] <prompt> [flags]``. - Recognized shapes:: - - /loop 5m check the deploy status - /loop every 10m /babysit-prs - /loop keep fixing the failing test until the suite passes - /loop 2m poll CI --times 30 - /loop 5m watch the queue --until queue depth reaches zero - - Returns ``{"interval_seconds": int|None, "prompt": str, "times": int, - "until": str, "error": str|None}``. ``interval_seconds`` None means - self-paced. ``error`` is set for unusable input (empty prompt, - interval-only, bad --times). + Returns ``{"interval_seconds": int|None, "prompt": str, "times": int, "until": str, "error": + str|None}``. ``interval_seconds`` None means self-paced. ``error`` is set for unusable input + (empty prompt, interval-only, bad --times). """ raw = (text or "").strip() - result: Dict[str, Any] = { - "interval_seconds": None, - "prompt": "", - "times": 0, - "until": "", - "error": None, - } + result: Dict[str, Any] = {"interval_seconds": None, "prompt": "", "times": 0, "until": "", "error": None} if not raw: result["error"] = "empty" return result @@ -171,9 +118,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]: # --until clause can't confuse the front parse. Flags may appear in # either order at the end of the line; --until consumes to end-of-line # (or to a following --times). - times = 0 - until = "" - + times, until = 0, "" m_times = re.search(r"\s--times\s+(\S+)", raw) if m_times: try: @@ -207,10 +152,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]: result["error"] = "missing prompt (usage: /loop [interval] <prompt>)" return result - result["interval_seconds"] = interval - result["prompt"] = raw - result["times"] = times - result["until"] = until + result.update(interval_seconds=interval, prompt=raw, times=times, until=until) return result @@ -239,44 +181,35 @@ def _loops_config() -> Dict[str, Any]: try: from hermes_cli.config import load_config - cfg = load_config() or {} - section = cfg.get("loops") or {} + section = (load_config() or {}).get("loops") or {} return section if isinstance(section, dict) else {} except Exception: return {} -def min_interval_seconds() -> int: +def _config_int(key: str, default: int, floor: int) -> int: + """``loops.<key>`` as an int clamped to ``floor``; ``default`` on any bad value.""" try: - value = int(_loops_config().get("min_interval_seconds", DEFAULT_MIN_INTERVAL_SECONDS)) - return max(5, value) + return max(floor, int(_loops_config().get(key, default))) except Exception: - return DEFAULT_MIN_INTERVAL_SECONDS + return default + + +def min_interval_seconds() -> int: + return _config_int("min_interval_seconds", DEFAULT_MIN_INTERVAL_SECONDS, 5) def max_ticks_default() -> int: - try: - value = int(_loops_config().get("max_ticks", DEFAULT_MAX_TICKS)) - return max(0, value) - except Exception: - return DEFAULT_MAX_TICKS + return _config_int("max_ticks", DEFAULT_MAX_TICKS, 0) def self_paced_floor_seconds() -> int: - try: - value = int(_loops_config().get("self_paced_floor_seconds", DEFAULT_SELF_PACED_FLOOR_SECONDS)) - return max(10, value) - except Exception: - return DEFAULT_SELF_PACED_FLOOR_SECONDS + return _config_int("self_paced_floor_seconds", DEFAULT_SELF_PACED_FLOOR_SECONDS, 10) def self_paced_ceiling_seconds() -> int: floor = self_paced_floor_seconds() - try: - value = int(_loops_config().get("self_paced_ceiling_seconds", DEFAULT_SELF_PACED_CEILING_SECONDS)) - return max(floor, value) - except Exception: - return max(floor, DEFAULT_SELF_PACED_CEILING_SECONDS) + return _config_int("self_paced_ceiling_seconds", max(floor, DEFAULT_SELF_PACED_CEILING_SECONDS), floor) # ────────────────────────────────────────────────────────────────────── @@ -321,25 +254,21 @@ class LoopState: def from_json(cls, raw: str) -> "LoopState": data = json.loads(raw) route = data.get("route") - return cls( - prompt=data.get("prompt", ""), - status=data.get("status", "active"), - mode=data.get("mode", "interval"), - interval_seconds=float(data.get("interval_seconds", 0.0) or 0.0), - current_delay=float(data.get("current_delay", 0.0) or 0.0), - times=int(data.get("times", 0) or 0), - until=str(data.get("until", "") or ""), - max_ticks=int(data.get("max_ticks", DEFAULT_MAX_TICKS) or 0), - ticks_fired=int(data.get("ticks_fired", 0) or 0), - created_at=float(data.get("created_at", 0.0) or 0.0), - last_fired_at=float(data.get("last_fired_at", 0.0) or 0.0), - next_due_at=float(data.get("next_due_at", 0.0) or 0.0), - awaiting_response=bool(data.get("awaiting_response", False)), - last_response_digest=str(data.get("last_response_digest", "") or ""), - paused_reason=data.get("paused_reason"), - last_stop_reason=data.get("last_stop_reason"), - route=route if isinstance(route, dict) else {}, - ) + kwargs: Dict[str, Any] = { + "prompt": data.get("prompt", ""), + "status": data.get("status", "active"), + "mode": data.get("mode", "interval"), + "awaiting_response": bool(data.get("awaiting_response", False)), + "paused_reason": data.get("paused_reason"), + "last_stop_reason": data.get("last_stop_reason"), + "route": route if isinstance(route, dict) else {}, + } + for f in fields(cls): + if f.name not in kwargs: + # Missing key -> dataclass default; present-but-falsy -> the type's zero. + cast = {"str": str, "int": int, "float": float}[f.type] + kwargs[f.name] = cast(data.get(f.name, f.default) or cast()) + return cls(**kwargs) # --- helpers ------------------------------------------------------- @@ -372,13 +301,10 @@ def _meta_key(session_id: str) -> str: def _get_session_db() -> Optional[Any]: """One SessionDB per HERMES_HOME. - Delegates to the goals module's cached SessionDB so goals, loops, - and heartbeats share one connection (same pattern as - ``hermes_cli/heartbeat.py``). The delegation also inherits the - off-loop bootstrap and the window logic: a cold cache on the loop - thread never runs ``SessionDB()`` inline. The previous copy here - did, which froze the loop for the init duration and dropped the - first ``loop:*`` write (the /goal bug class, #88965). + Delegates to the goals module's cached SessionDB so goals, loops, and heartbeats share one + connection (same pattern as ``hermes_cli/heartbeat.py``). The delegation also inherits the off- + loop bootstrap and the window logic: a cold cache on the loop thread never runs ``SessionDB()`` + inline. """ try: from hermes_cli.goals import _get_session_db as _goals_db @@ -388,6 +314,15 @@ def _get_session_db() -> Optional[Any]: return _goals_db() +def _db_op(label: str, fn, default=None): + """Run one SessionDB call; any error is logged at debug and yields ``default``.""" + try: + return fn() + except Exception as exc: + logger.debug("LoopManager: %s failed: %s", label, exc) + return default + + def load_loop(session_id: str) -> Optional[LoopState]: """Load the loop for a session, or None if none exists.""" if not session_id: @@ -395,11 +330,7 @@ def load_loop(session_id: str) -> Optional[LoopState]: db = _get_session_db() if db is None: return None - try: - raw = db.get_meta(_meta_key(session_id)) - except Exception as exc: - logger.debug("LoopManager: get_meta failed: %s", exc) - return None + raw = _db_op("get_meta", lambda: db.get_meta(_meta_key(session_id))) if not raw: return None try: @@ -419,10 +350,7 @@ def save_loop(session_id: str, state: LoopState) -> None: _warn_dropped_write("LoopManager", "loop", session_id) return - try: - db.set_meta(_meta_key(session_id), state.to_json()) - except Exception as exc: - logger.debug("LoopManager: set_meta failed: %s", exc) + _db_op("set_meta", lambda: db.set_meta(_meta_key(session_id), state.to_json())) def clear_loop(session_id: str) -> None: @@ -437,20 +365,14 @@ def clear_loop(session_id: str) -> None: def list_active_loops() -> List[Tuple[str, LoopState]]: """Return ``[(session_id, LoopState), ...]`` for every ACTIVE loop. - Used by the gateway's idle wakeup watcher, which has no per-session - scheduler and instead scans for due loops on a coarse tick. Best-effort: - any DB error yields ``[]``. + Used by the gateway's idle wakeup watcher, which has no per-session scheduler and scans for + due loops on a coarse tick. Best-effort: any DB error yields ``[]``. """ db = _get_session_db() if db is None: return [] - try: - rows = db.list_meta_prefix(_META_PREFIX) - except Exception as exc: - logger.debug("LoopManager: list_meta_prefix failed: %s", exc) - return [] out: List[Tuple[str, LoopState]] = [] - for key, raw in rows: + for key, raw in _db_op("list_meta_prefix", lambda: db.list_meta_prefix(_META_PREFIX), []): session_id = key[len(_META_PREFIX):] if not session_id or not raw: continue @@ -466,11 +388,9 @@ def list_active_loops() -> List[Tuple[str, LoopState]]: def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool: """Carry a persistent /loop from a parent session to its continuation. - Context compression rotates ``session_id`` to a fresh child session; - without this the loop silently dies at the compaction boundary (the - same hazard /goal hit in #33618). Copies the loop onto the new session - and archives the old row as ``cleared`` so exactly one active loop row - exists per logical conversation. Best-effort and never raises. + Context compression rotates ``session_id`` to a fresh child session; without this the loop + silently dies at the compaction boundary (the same hazard /goal hit in #33618). Best-effort and + never raises. """ if not old_session_id or not new_session_id or old_session_id == new_session_id: return False @@ -497,19 +417,20 @@ def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason: # ────────────────────────────────────────────────────────────────────── +def _ticks_label(n: int) -> str: + return f"{n} tick{'s' if n != 1 else ''}" + + def response_signals_complete(response: str) -> bool: """True when the agent ended its reply with the LOOP_COMPLETE marker.""" - if not response: - return False - return _LOOP_COMPLETE_RE.search(response) is not None + return bool(response) and _LOOP_COMPLETE_RE.search(response) is not None def _digest_response(response: str) -> str: """Stable digest for self-paced change detection. - Normalizes whitespace and strips volatile timestamp-ish tokens so a - reply that differs only by 'checked at 14:02:33' doesn't defeat the - backoff. + Normalizes whitespace and strips volatile timestamp-ish tokens so a reply that differs only by + 'checked at 14:02:33' doesn't defeat the backoff. """ text = (response or "").strip().lower() # Drop clock/timestamp tokens (14:02:33, 2026-07-26, 1500s, 25m ago...). @@ -528,15 +449,10 @@ def _digest_response(response: str) -> str: class LoopManager: """Per-session /loop state + tick decisions. - Drivers (CLI process_loop, gateway wakeup watcher, TUI ticker) call: - - - ``set(...)`` / ``pause()`` / ``resume()`` / ``clear()`` — user controls. - - ``is_due()`` — should a wakeup fire now? (cheap, in-memory) - - ``fire_tick()`` — claim the tick; returns the wakeup message to inject. - - ``complete_tick(last_response)`` — evaluate the finished wakeup turn: - detect LOOP_COMPLETE, judge --until, apply --times / max_ticks caps, - schedule the next tick (with self-paced backoff when applicable). - - ``status_line()`` — printable one-liner. + Drivers (CLI process_loop, gateway wakeup watcher, TUI ticker) call ``set``/``pause``/ + ``resume``/``clear`` for user controls, ``is_due()`` (cheap, in-memory), ``fire_tick()`` to + claim a tick and get the wakeup message, ``complete_tick()`` to evaluate the finished turn + (LOOP_COMPLETE, --until judge, --times caps, next-tick scheduling), and ``status_line()``. """ def __init__(self, session_id: str): @@ -563,22 +479,19 @@ class LoopManager: s = self._state if s is None or s.status == "cleared": return "No loop set. Start one with /loop [interval] <prompt>." - fired = f"{s.ticks_fired} tick{'s' if s.ticks_fired != 1 else ''}" - caps = [] + fired = _ticks_label(s.ticks_fired) if s.times: - caps.append(f"{s.ticks_fired}/{s.times} runs") + caps = [f"{s.ticks_fired}/{s.times} runs"] elif s.max_ticks: - caps.append(f"{s.ticks_fired}/{s.max_ticks} budget") + caps = [f"{s.ticks_fired}/{s.max_ticks} budget"] else: - caps.append(fired) + caps = [fired] if s.until: caps.append(f"until: {s.until}") meta = f"{s.cadence_label()}, {', '.join(caps)}" if s.status == "active": remaining = s.remaining_label() - tail = f", {remaining}" if remaining else "" - if s.awaiting_response: - tail = ", wakeup running" + tail = ", wakeup running" if s.awaiting_response else (f", {remaining}" if remaining else "") return f"↻ Loop (active, {meta}{tail}): {s.prompt}" if s.status == "paused": extra = f" — {s.paused_reason}" if s.paused_reason else "" @@ -599,39 +512,26 @@ class LoopManager: until: str = "", route: Optional[Dict[str, str]] = None, ) -> LoopState: - """Start a new loop (replaces any existing one for the session). - - The first wakeup is due immediately (next idle poll / gateway - watcher scan); subsequent wakeups follow the cadence. - """ + """Start a new loop (replaces any existing one for the session).""" prompt = (prompt or "").strip() if not prompt: raise ValueError("loop prompt is empty") now = time.time() - if interval_seconds is not None: - interval = max(int(interval_seconds), min_interval_seconds()) - state = LoopState( - prompt=prompt, - mode="interval", - interval_seconds=float(interval), - current_delay=float(interval), - next_due_at=now, - ) - else: - floor = self_paced_floor_seconds() - state = LoopState( - prompt=prompt, - mode="self_paced", - interval_seconds=0.0, - current_delay=float(floor), - next_due_at=now, - ) - state.times = max(0, int(times or 0)) - state.until = (until or "").strip() - state.max_ticks = max_ticks_default() - state.created_at = now - state.route = dict(route or {}) + self_paced = interval_seconds is None + interval = 0.0 if self_paced else float(max(int(interval_seconds), min_interval_seconds())) + state = LoopState( + prompt=prompt, + mode="self_paced" if self_paced else "interval", + interval_seconds=interval, + current_delay=float(self_paced_floor_seconds()) if self_paced else interval, + times=max(0, int(times or 0)), + until=(until or "").strip(), + max_ticks=max_ticks_default(), + created_at=now, + next_due_at=now, + route=dict(route or {}), + ) self._state = state save_loop(self.session_id, state) return state @@ -665,14 +565,6 @@ class LoopManager: self._state = None return True - def mark_done(self, reason: str) -> None: - if not self._state: - return - self._state.status = "done" - self._state.last_stop_reason = reason - self._state.awaiting_response = False - save_loop(self.session_id, self._state) - # --- tick lifecycle ------------------------------------------------- def is_due(self, now: Optional[float] = None) -> bool: @@ -685,12 +577,10 @@ class LoopManager: def fire_tick(self) -> Optional[str]: """Claim a due tick. Returns the message to inject, or None. - The returned text is either the wakeup-framed prompt or — when the - loop's prompt is itself a slash command (``/loop 10m /recap``) — - the raw command so the surface's normal slash dispatch handles it. - Marks ``awaiting_response`` so the tick can't double-fire while its - turn runs; drivers MUST follow up with ``complete_tick`` (or - ``abandon_tick`` on injection failure). + Returns the wakeup-framed prompt, or the raw command when the loop's prompt is itself a + slash command (``/loop 10m /recap``) so normal slash dispatch handles it. Marks + ``awaiting_response`` so the tick can't double-fire; drivers MUST follow up with + ``complete_tick`` (or ``abandon_tick`` on injection failure). """ s = self._state if s is None or not self.is_due(): @@ -721,34 +611,35 @@ class LoopManager: s.ticks_fired = max(0, s.ticks_fired - 1) save_loop(self.session_id, s) + def _stop(self, status: str, reason: str, message: str) -> Dict[str, Any]: + """Persist a terminal (``done``) or recoverable (``paused``) stop and build the result.""" + s = self._state + s.status = status + if status == "done": + s.last_stop_reason = reason + else: + s.paused_reason = reason + save_loop(self.session_id, s) + return {"status": status, "stopped": True, "reason": reason, "message": message} + def complete_tick(self, last_response: str) -> Dict[str, Any]: """Evaluate the finished wakeup turn and schedule what's next. - Returns a decision dict:: - - {"status": "active|done|paused", "stopped": bool, - "reason": str, "message": str} - - ``message`` is a user-visible one-liner ("" when nothing worth - saying — the common still-looping case stays quiet). + Returns ``{"status": "active|done|paused", "stopped": bool, "reason": str, + "message": str}``; ``message`` is a user-visible one-liner, "" in the common + still-looping case. """ s = self._state if s is None or not s.awaiting_response: return {"status": s.status if s else None, "stopped": False, "reason": "no tick in flight", "message": ""} s.awaiting_response = False now = time.time() + ticks = _ticks_label(s.ticks_fired) # 1. Agent self-stop marker. if response_signals_complete(last_response): - s.status = "done" - s.last_stop_reason = "agent signaled the task is complete" - save_loop(self.session_id, s) - return { - "status": "done", - "stopped": True, - "reason": s.last_stop_reason, - "message": f"✓ Loop finished after {s.ticks_fired} tick{'s' if s.ticks_fired != 1 else ''} — task complete.", - } + return self._stop("done", "agent signaled the task is complete", + f"✓ Loop finished after {ticks} — task complete.") # 2. Evidence-based --until judge (reuses the /goal judge; fail-open). if s.until and (last_response or "").strip(): @@ -759,54 +650,27 @@ class LoopManager: except Exception as exc: verdict, reason = "continue", f"judge unavailable: {type(exc).__name__}" if verdict == "done": - s.status = "done" - s.last_stop_reason = f"stop condition met: {reason}" - save_loop(self.session_id, s) - return { - "status": "done", - "stopped": True, - "reason": s.last_stop_reason, - "message": f"✓ Loop finished after {s.ticks_fired} tick{'s' if s.ticks_fired != 1 else ''} — {reason}", - } + return self._stop("done", f"stop condition met: {reason}", + f"✓ Loop finished after {ticks} — {reason}") if verdict == "blocked": # Judge ruled the stop condition unachievable — don't spin # until the tick budget; pause so the user can re-scope. - s.status = "paused" - s.paused_reason = f"stop condition judged unachievable: {reason}" - save_loop(self.session_id, s) - return { - "status": "paused", - "stopped": True, - "reason": s.paused_reason, - "message": f"⏸ Loop paused — {s.paused_reason}. /loop resume to keep going, /loop stop to end it.", - } + why = f"stop condition judged unachievable: {reason}" + return self._stop("paused", why, + f"⏸ Loop paused — {why}. /loop resume to keep going, /loop stop to end it.") # 3. --times user cap. if s.times and s.ticks_fired >= s.times: - s.status = "done" - s.last_stop_reason = f"completed the requested {s.times} runs" - save_loop(self.session_id, s) - return { - "status": "done", - "stopped": True, - "reason": s.last_stop_reason, - "message": f"✓ Loop finished — ran {s.times}/{s.times} times.", - } + return self._stop("done", f"completed the requested {s.times} runs", + f"✓ Loop finished — ran {s.times}/{s.times} times.") # 4. Config backstop budget → pause (recoverable), not done. if s.max_ticks and s.ticks_fired >= s.max_ticks: - s.status = "paused" - s.paused_reason = f"tick budget exhausted ({s.ticks_fired}/{s.max_ticks})" - save_loop(self.session_id, s) - return { - "status": "paused", - "stopped": True, - "reason": s.paused_reason, - "message": ( - f"⏸ Loop paused — {s.ticks_fired}/{s.max_ticks} ticks used " - "(loops.max_ticks). /loop resume to keep going, /loop stop to end it." - ), - } + return self._stop( + "paused", f"tick budget exhausted ({s.ticks_fired}/{s.max_ticks})", + f"⏸ Loop paused — {s.ticks_fired}/{s.max_ticks} ticks used " + "(loops.max_ticks). /loop resume to keep going, /loop stop to end it.", + ) # 5. Still looping — schedule the next tick from turn end. if s.mode == "self_paced": @@ -823,12 +687,7 @@ class LoopManager: s.current_delay = s.interval_seconds s.next_due_at = now + s.current_delay save_loop(self.session_id, s) - return { - "status": "active", - "stopped": False, - "reason": "loop continues", - "message": "", - } + return {"status": "active", "stopped": False, "reason": "loop continues", "message": ""} # ────────────────────────────────────────────────────────────────────── @@ -839,22 +698,16 @@ class LoopManager: def goal_blocks_loop_tick(session_id: str) -> bool: """True when an ACTIVE /goal should defer this session's /loop tick. - Both features inject synthetic continuation turns at idle boundaries. - When a goal is actively driving the session (status ``active`` and not - parked on a wait barrier), its judge-driven continuations own the idle - boundary — firing a loop wakeup in between would interleave two - synthetic conversations and burn the goal's turn budget on loop chatter. - A goal that is parked (waiting on a pid/session/deadline), paused, or - done does NOT block the loop. + Both features inject synthetic turns at idle boundaries. An active, non-parked goal owns the + boundary; firing a loop wakeup in between would interleave two synthetic conversations and + burn the goal's turn budget. Parked, paused, or done goals do NOT block the loop. """ try: from hermes_cli.goals import GoalManager mgr = GoalManager(session_id=session_id) - if not mgr.is_active(): - return False - # Parked goal → the loop may use the idle time. - return not mgr.is_waiting() + # Parked (waiting) goal → the loop may use the idle time. + return mgr.is_active() and not mgr.is_waiting() except Exception: return False @@ -864,6 +717,43 @@ def goal_blocks_loop_tick(session_id: str) -> bool: # ────────────────────────────────────────────────────────────────────── +LOOP_HELP = ( + "Usage: /loop [interval] <prompt> [--times N] [--until <condition>]\n" + " /loop 5m check the deploy status — first run now, then every 5m\n" + " /loop every 10m /recap — loop a slash command\n" + " /loop keep fixing tests until green — self-paced (backs off while output is unchanged)\n" + " /loop 2m poll CI --times 30 — stop after 30 runs\n" + " /loop 5m watch the queue --until queue is empty\n" + "Controls: /loop status · /loop pause · /loop resume · /loop stop\n" + "The loop also stops itself when the agent replies with " + f"{LOOP_COMPLETE_MARKER}." +) + + +def _pause_output(mgr: "LoopManager") -> str: + state = mgr.pause(reason="user-paused") + return "No loop set." if state is None else f"⏸ Loop paused: {state.prompt}\nUse /loop resume to continue." + + +def _resume_output(mgr: "LoopManager") -> str: + state = mgr.resume() + return "No loop to resume." if state is None else f"▶ Loop resumed ({state.cadence_label()}): {state.prompt}" + + +def _stop_output(mgr: "LoopManager") -> str: + return "✓ Loop stopped." if mgr.clear() else "No active loop." + + +# Control words -> handler returning the output text. Anything else is a new loop spec. +_CONTROL_COMMANDS = { + **dict.fromkeys(("", "status"), lambda mgr: mgr.status_line()), + "pause": _pause_output, + "resume": _resume_output, + **dict.fromkeys(("stop", "clear", "cancel"), _stop_output), + **dict.fromkeys(("help", "--help", "-h"), lambda mgr: LOOP_HELP), +} + + def dispatch_loop_command( mgr: "LoopManager", args: str, @@ -872,52 +762,15 @@ def dispatch_loop_command( ) -> Dict[str, Any]: """Surface-agnostic handler for ``/loop <args>``. - Returns ``{"output": str, "created": bool}``. ``output`` is ready to - print/send verbatim; each surface only decorates it (dim colors on the - CLI, plain text on messaging platforms). ``route`` is stored on newly - created loops so the gateway's idle watcher can inject wakeups back - into the right chat; CLI/TUI pass None. + Returns ``{"output": str, "created": bool}``. ``output`` is ready to print/send verbatim; each + surface only decorates it (dim colors on the CLI, plain text on messaging platforms). ``route`` + is stored on newly created loops so the gateway's idle watcher can inject wakeups back into the + right chat; CLI/TUI pass None. """ arg = (args or "").strip() - lower = arg.lower() - - if not arg or lower == "status": - return {"output": mgr.status_line(), "created": False} - - if lower == "pause": - state = mgr.pause(reason="user-paused") - if state is None: - return {"output": "No loop set.", "created": False} - return {"output": f"⏸ Loop paused: {state.prompt}\nUse /loop resume to continue.", "created": False} - - if lower == "resume": - state = mgr.resume() - if state is None: - return {"output": "No loop to resume.", "created": False} - return { - "output": f"▶ Loop resumed ({state.cadence_label()}): {state.prompt}", - "created": False, - } - - if lower in {"stop", "clear", "cancel"}: - had = mgr.clear() - return {"output": "✓ Loop stopped." if had else "No active loop.", "created": False} - - if lower in {"help", "--help", "-h"}: - return { - "output": ( - "Usage: /loop [interval] <prompt> [--times N] [--until <condition>]\n" - " /loop 5m check the deploy status — first run now, then every 5m\n" - " /loop every 10m /recap — loop a slash command\n" - " /loop keep fixing tests until green — self-paced (backs off while output is unchanged)\n" - " /loop 2m poll CI --times 30 — stop after 30 runs\n" - " /loop 5m watch the queue --until queue is empty\n" - "Controls: /loop status · /loop pause · /loop resume · /loop stop\n" - "The loop also stops itself when the agent replies with " - f"{LOOP_COMPLETE_MARKER}." - ), - "created": False, - } + control = _CONTROL_COMMANDS.get(arg.lower()) + if control is not None: + return {"output": control(mgr), "created": False} parsed = parse_loop_args(arg) if parsed["error"]: @@ -964,22 +817,9 @@ def dispatch_loop_command( __all__ = [ - "LoopState", - "LoopManager", - "parse_loop_args", - "parse_interval_token", - "format_interval", - "response_signals_complete", - "goal_blocks_loop_tick", - "load_loop", - "save_loop", - "clear_loop", - "list_active_loops", - "migrate_loop_to_session", - "dispatch_loop_command", - "LOOP_COMPLETE_MARKER", - "WAKEUP_PROMPT_TEMPLATE", - "WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE", - "DEFAULT_MIN_INTERVAL_SECONDS", + "LoopState", "LoopManager", "parse_loop_args", "parse_interval_token", "format_interval", + "response_signals_complete", "goal_blocks_loop_tick", "load_loop", "save_loop", "clear_loop", + "list_active_loops", "migrate_loop_to_session", "dispatch_loop_command", "LOOP_COMPLETE_MARKER", + "WAKEUP_PROMPT_TEMPLATE", "WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE", "DEFAULT_MIN_INTERVAL_SECONDS", "DEFAULT_MAX_TICKS", ] diff --git a/hermes_cli/managed_scope.py b/hermes_cli/managed_scope.py index 12af07ad1e..89c89a276c 100644 --- a/hermes_cli/managed_scope.py +++ b/hermes_cli/managed_scope.py @@ -1,19 +1,12 @@ """Managed scope — IT-pushed, user-immutable config & env layer. -A system-level directory (default ``/etc/hermes``, root-owned and not -user-writable) supplies ``config.yaml`` and ``.env`` values that WIN over the -user's ``~/.hermes/config.yaml`` and ``~/.hermes/.env`` on a per-leaf-key basis. +This is DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED``, which is a coarse +package-manager write-lock (declarative-distro / formula installs). That lock blocks all mutation; +this layer injects specific immutable values. The two are independent and may coexist. -This is DISTINCT from ``hermes_cli.config.is_managed()`` / ``HERMES_MANAGED``, -which is a coarse package-manager write-lock (declarative-distro / formula -installs). That lock blocks all mutation; this layer injects specific immutable -values. The two are independent and may coexist. - -v1 enforcement is filesystem permissions only — see -``docs/design/managed-scope.md`` §7. v1 is Linux/POSIX-first; ``get_managed_dir()`` -is the single seam for adding macOS / Windows native locations later. - -Attribution: do not reference any third-party product by name in this file. +v1 enforcement is filesystem permissions only — see ``docs/design/managed-scope.md`` §7. v1 is +Linux/POSIX-first; ``get_managed_dir()`` is the single seam for adding macOS / Windows native +locations later. """ from __future__ import annotations @@ -41,10 +34,9 @@ _ENV_CACHE: Dict[str, tuple] = {} def _under_pytest() -> bool: """True when running inside the test suite. - Used to ignore the system default ``/etc/hermes`` during tests so a real - managed scope on a developer/CI box can't leak policy into the suite. Tests - that exercise managed scope set ``HERMES_MANAGED_DIR`` explicitly, which is - still honored (the override path below runs before this guard takes effect). + Used to ignore the system default ``/etc/hermes`` under tests so a real managed scope on a + dev/CI box can't leak policy into the suite. An explicit ``HERMES_MANAGED_DIR`` is still + honored because that override runs before this guard. """ return "PYTEST_CURRENT_TEST" in os.environ @@ -52,23 +44,21 @@ def _under_pytest() -> bool: def get_managed_dir() -> Optional[Path]: """Resolve the managed-scope directory, or None when no scope is present. - Resolution (highest priority first): - 1. ``$HERMES_MANAGED_DIR`` — deployment/bootstrap path override (IT-only; - never persisted to any .env). Honored only when set to a non-empty value - AND the directory exists. - 2. ``/etc/hermes`` — POSIX default, when it exists. Ignored under pytest so - a real system managed scope can't leak into the test suite. + Resolution (highest priority first): 1. ``$HERMES_MANAGED_DIR`` — deployment/bootstrap path + override (IT-only; never persisted to any .env). Honored only when set to a non-empty value AND + the directory exists. 2. ``/etc/hermes`` — POSIX default, when it exists. - A non-existent directory at either tier resolves to None (no managed scope), - which is the common case and must be cheap + side-effect-free. + A non-existent directory at either tier resolves to None (no managed scope), which is the common + case and must be cheap + side-effect-free. """ override = os.environ.get("HERMES_MANAGED_DIR", "").strip() if override: p = Path(override) - return p if p.is_dir() else None - if _under_pytest(): + elif _under_pytest(): return None - return _DEFAULT_MANAGED_DIR if _DEFAULT_MANAGED_DIR.is_dir() else None + else: + p = _DEFAULT_MANAGED_DIR + return p if p.is_dir() else None def invalidate_managed_cache() -> None: @@ -81,10 +71,9 @@ def invalidate_managed_cache() -> None: def _cached_read(path: Path, cache: Dict[str, tuple], parse): """Shared (mtime_ns, size)-keyed read. Returns a deepcopy of the parsed value. - Returns ``None`` when the file is absent or fails to parse (fail-open). A - parse failure is logged LOUDLY — the admin needs to know their policy isn't - being applied — but never raises, so a malformed managed file can't brick - startup. + Returns ``None`` when the file is absent or fails to parse (fail-open). A parse failure is + logged LOUDLY — the admin needs to know their policy isn't being applied — but never raises, so + a malformed managed file can't brick startup. """ try: st = path.stat() @@ -103,56 +92,44 @@ def _cached_read(path: Path, cache: Dict[str, tuple], parse): logger.warning( "managed scope: failed to parse %s: %s — IGNORING this managed file. " "Admin policy from this file is NOT being applied. Fix and restart.", - path, - exc, + path, exc, ) return None with _CACHE_LOCK: - cache[path_key] = (key[0], key[1], copy.deepcopy(parsed)) + cache[path_key] = (*key, copy.deepcopy(parsed)) return parsed +def _load_managed_file(name: str, cache: Dict[str, tuple], parse) -> dict: + managed_dir = get_managed_dir() + if managed_dir is None: + return {} + parsed = _cached_read(managed_dir / name, cache, parse) + return parsed if isinstance(parsed, dict) else {} + + def load_managed_config() -> dict: """Parsed managed config.yaml, or {} when absent/malformed (fail-open).""" - managed_dir = get_managed_dir() - if managed_dir is None: - return {} - parsed = _cached_read( - managed_dir / "config.yaml", - _CONFIG_CACHE, - lambda f: yaml.safe_load(f) or {}, - ) - return parsed if isinstance(parsed, dict) else {} + return _load_managed_file("config.yaml", _CONFIG_CACHE, lambda f: yaml.safe_load(f) or {}) def load_managed_env() -> Dict[str, str]: """Parsed managed .env (KEY=VALUE), or {} when absent (fail-open).""" - managed_dir = get_managed_dir() - if managed_dir is None: - return {} - parsed = _cached_read(managed_dir / ".env", _ENV_CACHE, _parse_env) - return parsed if isinstance(parsed, dict) else {} + return _load_managed_file(".env", _ENV_CACHE, _parse_env) def apply_managed_overlay(config: dict) -> dict: """Overlay administrator-pinned config values on top of an already-built dict. - The single, shared way for any config loader that builds its own dict - (rather than going through hermes_cli.config.load_config) to honor managed - scope. Mirrors hermes_cli.config._load_config_impl's managed merge exactly: + * expand the managed config's ``${VAR}`` refs against the PROCESS env only (never user-config- + defined refs), so a user cannot shadow a managed literal via a ${VAR} they control; * normalize + the managed config's root ``model`` key (a bare ``model: x/y`` string is promoted to + ``model.default``) so it can't clobber the dict shape callers expect; * leaf-level deep-merge + managed ON TOP, so managed wins per-leaf while sibling keys stay user-controlled. - * expand the managed config's ``${VAR}`` refs against the PROCESS env only - (never user-config-defined refs), so a user cannot shadow a managed - literal via a ${VAR} they control; - * normalize the managed config's root ``model`` key (a bare ``model: x/y`` - string is promoted to ``model.default``) so it can't clobber the dict - shape callers expect; - * leaf-level deep-merge managed ON TOP, so managed wins per-leaf while - sibling keys stay user-controlled. - - Fail-open: returns ``config`` unchanged if no managed scope is present or on - any error — managed scope must never break a caller's startup. Mutates and - returns ``config`` (callers pass a dict they own). + Fail-open: returns ``config`` unchanged if no managed scope is present or on any error — managed + scope must never break a caller's startup. Mutates and returns ``config`` (callers pass a dict + they own). """ try: managed = load_managed_config() @@ -179,12 +156,10 @@ def apply_managed_overlay(config: dict) -> dict: def _parse_env(f) -> Dict[str, str]: out: Dict[str, str] = {} - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, value = line.partition("=") - out[key.strip()] = value.strip().strip("\"'") + for line in map(str.strip, f): + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + out[key.strip()] = value.strip().strip("\"'") return out diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index 4489d18729..cf9f2a6062 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -1,28 +1,8 @@ """MCP catalog — curated, Nous-approved MCP servers shipped with the repo. -Mirrors the optional-skills/ pattern: each catalog entry lives under -``optional-mcps/<name>/manifest.yaml`` and ships disabled. Users discover -entries via ``hermes mcp catalog`` or the interactive ``hermes mcp picker``, -and install them with ``hermes mcp install <name>`` (or by toggling in the -picker, which flows them through any required env/OAuth setup). - -Catalog policy: -- Entries are added only by merging a PR into hermes-agent. Presence in the - ``optional-mcps/`` directory = Nous approval. No community tier, no trust - signals beyond "it's in the catalog". -- Manifests pin transport details (commands, args, refs). Pins follow the - same supply-chain rules as pyproject dependencies: exact versions for - package launchers (``uvx pkg==X``, ``npx pkg@X``), full commit SHAs for - git installs, and the pinned release should be at least 2 weeks old at - pin time. MCPs are never - auto-updated; users explicitly re-run ``hermes mcp install <name>`` to - pull a new manifest version after a repo update. -- Secrets prompted at install time go to ``~/.hermes/.env`` (the - .env-is-for-secrets rule). Non-secret env vars also go to .env to keep - one credential store. - -See website/docs/user-guide/mcp-catalog.md for user docs. -See references/mcp-catalog.md (this repo's skill) for the manifest schema. +Catalog policy: - Entries are added only by merging a PR into hermes-agent. Presence in the +``optional-mcps/`` directory = Nous approval. No community tier, no trust signals beyond "it's in +the catalog". - Manifests pin transport details (commands, args, refs). """ from __future__ import annotations @@ -90,10 +70,7 @@ class TransportSpec: @dataclass class InstallSpec: - """Optional bootstrap step (git clone + dep install). - - Omit for one-shot launchable servers (npx, uvx). - """ + """Optional bootstrap step (git clone + dep install).""" type: str # "git" url: str ref: str # commit/tag/branch — pinned, never floats @@ -104,8 +81,8 @@ class InstallSpec: class ToolsSpec: """Manifest-side tool-selection hints. - Drives the pre-checked state of the install-time tool checklist, and acts - as the fallback selection when probe fails. See install_entry() flow. + Drives the pre-checked state of the install-time tool checklist, and acts as the fallback + selection when probe fails. See install_entry() flow. """ # If declared, these tool names are pre-checked in the checklist (or @@ -126,16 +103,9 @@ class ToolsSpec: class SuggestSpec: """Composer-suggestion metadata (desktop "brand pill" triggers). - Optional. When present, UI surfaces (currently the desktop composer) - may suggest installing this entry when the user's draft contains one - of the keywords as a completed whole word, or pastes a link whose - hostname ends with one of the host suffixes. Purely advisory — the - install itself always flows through the ordinary validated paths. - - NOTE: GitHub is intentionally NOT in the catalog and must not be - suggested here: its hosted MCP requires a per-host OAuth app (generic - DCR 404s), and the bundled github/* skills (gh CLI) are the far more - capable integration. Point users at the skills instead. + NOTE: GitHub is intentionally NOT in the catalog and must not be suggested here: its hosted MCP + requires a per-host OAuth app (generic DCR 404s), and the bundled github/* skills (gh CLI) are + the far more capable integration. Point users at the skills instead. """ # Lowercase whole-word/phrase triggers matched against the draft. @@ -187,6 +157,27 @@ def _parse_env_spec(raw: Any) -> EnvVarSpec: ) +def _require_mapping(path: Path, key: str, raw: Any) -> dict: + if not isinstance(raw, dict): + raise CatalogError(f"{path}: '{key}' must be a mapping") + return raw + + +def _require_list(path: Path, field: str, raw: Any) -> list: + if not isinstance(raw, list): + raise CatalogError(f"{path}: {field} must be a list") + return raw + + +def _require_str_list(path: Path, field: str, raw: Any, *, non_empty: bool = False) -> None: + ok = isinstance(raw, list) and all( + isinstance(t, str) and (t.strip() if non_empty else True) for t in raw + ) + if not ok: + kind = "non-empty strings" if non_empty else "strings" + raise CatalogError(f"{path}: {field} must be a list of {kind}") + + def _parse_manifest(path: Path) -> CatalogEntry: """Read and validate a manifest.yaml. Raise CatalogError on any problem.""" try: @@ -215,15 +206,11 @@ def _parse_manifest(path: Path) -> CatalogEntry: source = str(data.get("source") or "").strip() - transport_raw = data.get("transport") or {} - if not isinstance(transport_raw, dict): - raise CatalogError(f"{path}: 'transport' must be a mapping") + transport_raw = _require_mapping(path, "transport", data.get("transport") or {}) t_type = transport_raw.get("type") if t_type not in ("stdio", "http"): raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'") - args = transport_raw.get("args") or [] - if not isinstance(args, list): - raise CatalogError(f"{path}: transport.args must be a list") + args = _require_list(path, "transport.args", transport_raw.get("args") or []) env_raw = transport_raw.get("env") or {} if not isinstance(env_raw, dict) or not all( isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items() @@ -244,16 +231,14 @@ def _parse_manifest(path: Path) -> CatalogEntry: if t_type == "http" and not transport.url: raise CatalogError(f"{path}: http transport requires 'url'") - auth_raw = data.get("auth") or {"type": "none"} - if not isinstance(auth_raw, dict): - raise CatalogError(f"{path}: 'auth' must be a mapping") + auth_raw = _require_mapping(path, "auth", data.get("auth") or {"type": "none"}) a_type = auth_raw.get("type") or "none" if a_type not in ("api_key", "oauth", "none"): raise CatalogError(f"{path}: auth.type must be 'api_key'|'oauth'|'none'") - env_list_raw = auth_raw.get("env") or [] - if not isinstance(env_list_raw, list): - raise CatalogError(f"{path}: auth.env must be a list") - env_list = [_parse_env_spec(e) for e in env_list_raw] + env_list = [ + _parse_env_spec(e) + for e in _require_list(path, "auth.env", auth_raw.get("env") or []) + ] auth = AuthSpec( type=a_type, env=env_list, @@ -271,59 +256,33 @@ def _parse_manifest(path: Path) -> CatalogEntry: from hermes_cli.mcp_config import _env_key_for_server _required_key = _env_key_for_server(name) - if not any(spec.name == _required_key for spec in env_list): + if all(spec.name != _required_key for spec in env_list): raise CatalogError( f"{path}: http + api_key auth requires auth.env to declare " f"'{_required_key}' (the key the Authorization header references)" ) - tools_raw = data.get("tools") or {} - if not isinstance(tools_raw, dict): - raise CatalogError(f"{path}: 'tools' must be a mapping") + tools_raw = _require_mapping(path, "tools", data.get("tools") or {}) default_enabled = tools_raw.get("default_enabled") - if default_enabled is not None: - if not isinstance(default_enabled, list) or not all( - isinstance(t, str) for t in default_enabled - ): - raise CatalogError( - f"{path}: tools.default_enabled must be a list of strings" - ) default_excluded = tools_raw.get("default_excluded") - if default_excluded is not None: - if not isinstance(default_excluded, list) or not all( - isinstance(t, str) for t in default_excluded - ): - raise CatalogError( - f"{path}: tools.default_excluded must be a list of strings" - ) + for key, val in (("default_enabled", default_enabled), ("default_excluded", default_excluded)): + if val is not None: + _require_str_list(path, f"tools.{key}", val) if default_enabled is not None and default_excluded is not None: raise CatalogError( f"{path}: tools.default_enabled and tools.default_excluded are " "mutually exclusive" ) - tools_spec = ToolsSpec( - default_enabled=default_enabled, default_excluded=default_excluded - ) + tools_spec = ToolsSpec(default_enabled=default_enabled, default_excluded=default_excluded) suggest: Optional[SuggestSpec] = None suggest_raw = data.get("suggest") if suggest_raw is not None: - if not isinstance(suggest_raw, dict): - raise CatalogError(f"{path}: 'suggest' must be a mapping") + _require_mapping(path, "suggest", suggest_raw) kw_raw = suggest_raw.get("keywords") or [] hosts_raw = suggest_raw.get("hosts") or [] - if not isinstance(kw_raw, list) or not all( - isinstance(k, str) and k.strip() for k in kw_raw - ): - raise CatalogError( - f"{path}: suggest.keywords must be a list of non-empty strings" - ) - if not isinstance(hosts_raw, list) or not all( - isinstance(h, str) and h.strip() for h in hosts_raw - ): - raise CatalogError( - f"{path}: suggest.hosts must be a list of non-empty strings" - ) + _require_str_list(path, "suggest.keywords", kw_raw, non_empty=True) + _require_str_list(path, "suggest.hosts", hosts_raw, non_empty=True) if not kw_raw and not hosts_raw: raise CatalogError( f"{path}: 'suggest' requires at least one keyword or host" @@ -338,24 +297,15 @@ def _parse_manifest(path: Path) -> CatalogEntry: install: Optional[InstallSpec] = None install_raw = data.get("install") if install_raw is not None: - if not isinstance(install_raw, dict): - raise CatalogError(f"{path}: 'install' must be a mapping") + _require_mapping(path, "install", install_raw) i_type = install_raw.get("type") if i_type != "git": raise CatalogError(f"{path}: install.type must be 'git' (got {i_type!r})") - url = install_raw.get("url") or "" - ref = install_raw.get("ref") or "" + url, ref = install_raw.get("url") or "", install_raw.get("ref") or "" if not url or not ref: raise CatalogError(f"{path}: install.url and install.ref are required") - bootstrap = install_raw.get("bootstrap") or [] - if not isinstance(bootstrap, list): - raise CatalogError(f"{path}: install.bootstrap must be a list") - install = InstallSpec( - type=i_type, - url=url, - ref=ref, - bootstrap=[str(c) for c in bootstrap], - ) + bootstrap = _require_list(path, "install.bootstrap", install_raw.get("bootstrap") or []) + install = InstallSpec(type=i_type, url=url, ref=ref, bootstrap=[str(c) for c in bootstrap]) return CatalogEntry( name=name, @@ -374,10 +324,9 @@ def _parse_manifest(path: Path) -> CatalogEntry: def list_catalog() -> List[CatalogEntry]: """Return all valid catalog entries, sorted by name. - Invalid manifests are skipped silently (CI tests catch them at PR time). - Manifests with a future ``manifest_version`` are also skipped, but the - skip is surfaced via :func:`catalog_diagnostics` so the picker / catalog - UIs can tell the user their Hermes is out of date. + Invalid manifests are skipped silently (CI catches them). Manifests with a future + ``manifest_version`` are also skipped but surfaced via :func:`catalog_diagnostics` so UIs can + tell the user their Hermes is out of date. """ root = _catalog_root() if not root.exists(): @@ -394,11 +343,10 @@ def list_catalog() -> List[CatalogEntry]: msg = str(exc) # Recognize the future-manifest error specifically so the UI can # surface a more actionable nudge than "broken manifest". - if "manifest_version" in msg and "unsupported" in msg: - _CATALOG_DIAGNOSTICS.append((child.name, "future_manifest", msg)) - else: - _CATALOG_DIAGNOSTICS.append((child.name, "invalid", msg)) - continue + future = "manifest_version" in msg and "unsupported" in msg + _CATALOG_DIAGNOSTICS.append( + (child.name, "future_manifest" if future else "invalid", msg) + ) return entries @@ -410,12 +358,8 @@ _CATALOG_DIAGNOSTICS: List[tuple] = [] def catalog_diagnostics() -> List[tuple]: """Diagnostics from the most recent :func:`list_catalog` call. - Returns a list of ``(entry_name, kind, message)`` tuples where ``kind`` - is one of: - - ``future_manifest`` — manifest_version is newer than this Hermes - understands. Update Hermes to install this entry. - - ``invalid`` — manifest is malformed in some other way (caught by - CI for shipped manifests; user-modified manifests can hit this). + Returns ``(entry_name, kind, message)`` tuples; ``kind`` is ``future_manifest`` (newer than + this Hermes understands, update to install) or ``invalid`` (malformed, e.g. user-edited). """ return list(_CATALOG_DIAGNOSTICS) @@ -424,10 +368,7 @@ def get_entry(name: str) -> Optional[CatalogEntry]: """Look up a single entry by name. ``official/<name>`` prefix accepted.""" if name.startswith("official/"): name = name[len("official/"):] - for entry in list_catalog(): - if entry.name == name: - return entry - return None + return next((e for e in list_catalog() if e.name == name), None) # ─── Status helpers ────────────────────────────────────────────────────────── @@ -444,20 +385,41 @@ def is_installed(name: str) -> bool: return name in installed_servers() -def is_enabled(name: str) -> bool: - servers = installed_servers() - cfg = servers.get(name) - if not cfg: - return False +def server_enabled(cfg: dict) -> bool: + """Interpret a server block's ``enabled`` flag (bools, and yes/true/1 strings).""" enabled = cfg.get("enabled", True) if isinstance(enabled, str): return enabled.lower() in {"true", "1", "yes"} return bool(enabled) +def is_enabled(name: str) -> bool: + cfg = installed_servers().get(name) + return bool(cfg) and server_enabled(cfg) + + +def remove_server(name: str) -> bool: + """Drop ``mcp_servers.<name>`` from config.yaml (pruning an empty block). True if it existed.""" + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + if name not in servers: + return False + del servers[name] + if not servers: + cfg.pop("mcp_servers", None) + else: + cfg["mcp_servers"] = servers + save_config(cfg) + return True + + # ─── Install ───────────────────────────────────────────────────────────────── +def _say(msg: str, colour: str = Colors.GREEN) -> None: + print(color(msg, colour)) + + def _install_root() -> Path: """Where git-bootstrapped MCPs are cloned. Per-user, profile-aware.""" root = get_hermes_home() / "mcp-installs" @@ -466,18 +428,12 @@ def _install_root() -> Path: def _run_bootstrap(cwd: Path, commands: List[str]) -> None: - """Execute bootstrap commands in *cwd*. Raise CatalogError on first failure. - - Each command runs through the shell (so `&&` etc. work). The output is - streamed to the user's terminal for visibility. - """ + """Execute bootstrap commands in *cwd*. Raise CatalogError on first failure.""" for cmd in commands: - print(color(f" $ {cmd}", Colors.DIM)) - proc = subprocess.run(cmd, cwd=str(cwd), shell=True) - if proc.returncode != 0: - raise CatalogError( - f"bootstrap step failed (exit {proc.returncode}): {cmd}" - ) + _say(f" $ {cmd}", Colors.DIM) + rc = subprocess.run(cmd, cwd=str(cwd), shell=True).returncode + if rc != 0: + raise CatalogError(f"bootstrap step failed (exit {rc}): {cmd}") def _do_git_install(entry: CatalogEntry) -> Path: @@ -494,10 +450,10 @@ def _do_git_install(entry: CatalogEntry) -> Path: if dest.exists(): # Fresh checkout each install — manifest version is the source of truth, # so wipe + re-clone for determinism. - print(color(f" Removing existing install at {dest}", Colors.DIM)) + _say(f" Removing existing install at {dest}", Colors.DIM) shutil.rmtree(dest) - print(color(f" Cloning {install.url} ({install.ref}) → {dest}", Colors.CYAN)) + _say(f" Cloning {install.url} ({install.ref}) → {dest}", Colors.CYAN) # `git clone --branch` only accepts branches and tags, NOT commit SHAs. # Detecting SHA-shaped refs upfront avoids a guaranteed stderr leak on @@ -510,35 +466,24 @@ def _do_git_install(entry: CatalogEntry) -> Path: # username/password prompt (private repo, bad remote, auth required). _git_env = noninteractive_git_env() - if not is_sha_ref: - proc = subprocess.run( - [git, "clone", "--depth", "1", "--branch", install.ref, install.url, str(dest)], - stdin=subprocess.DEVNULL, - env=_git_env, - ) - if proc.returncode == 0: - pass - else: - # Branch/tag form failed (unlikely for valid manifests; possible if - # the ref was deleted upstream). Fall through to the full-clone path. - if dest.exists(): - shutil.rmtree(dest) - is_sha_ref = True # treat the same as a SHA ref from here + def _git(*args: str) -> int: + return subprocess.run( + [git, *args], stdin=subprocess.DEVNULL, env=_git_env + ).returncode + + if not is_sha_ref and _git( + "clone", "--depth", "1", "--branch", install.ref, install.url, str(dest) + ) != 0: + # Branch/tag form failed (unlikely for valid manifests; possible if + # the ref was deleted upstream). Fall through to the full-clone path. + if dest.exists(): + shutil.rmtree(dest) + is_sha_ref = True # treat the same as a SHA ref from here if is_sha_ref: - proc = subprocess.run( - [git, "clone", install.url, str(dest)], - stdin=subprocess.DEVNULL, - env=_git_env, - ) - if proc.returncode != 0: + if _git("clone", install.url, str(dest)) != 0: raise CatalogError(f"git clone failed for {install.url}") - proc = subprocess.run( - [git, "-C", str(dest), "checkout", install.ref], - stdin=subprocess.DEVNULL, - env=_git_env, - ) - if proc.returncode != 0: + if _git("-C", str(dest), "checkout", install.ref) != 0: raise CatalogError(f"git checkout {install.ref} failed") if install.bootstrap: @@ -564,7 +509,7 @@ def _prompt_env_vars(specs: List[EnvVarSpec]) -> Dict[str, str]: for spec in specs: existing = get_env_value(spec.name) if existing: - print(color(f" ✓ {spec.name} already set in .env", Colors.GREEN)) + _say(f" ✓ {spec.name} already set in .env") collected[spec.name] = existing continue value = _prompt_input( @@ -572,20 +517,18 @@ def _prompt_env_vars(specs: List[EnvVarSpec]) -> Dict[str, str]: default=spec.default or None, password=spec.secret, ) - if not value: - if spec.required: - raise CatalogError(f"{spec.name} is required but no value was provided") - continue - save_env_value(spec.name, value) - collected[spec.name] = value + if value: + save_env_value(spec.name, value) + collected[spec.name] = value + elif spec.required: + raise CatalogError(f"{spec.name} is required but no value was provided") return collected def _build_server_config( entry: CatalogEntry, install_dir: Optional[Path] ) -> dict: - """Translate a manifest into the ``mcp_servers.<name>`` block format used - by hermes_cli/mcp_config.py.""" + """Translate a manifest into the ``mcp_servers.<name>`` block format used by hermes_cli/mcp_config.py.""" cfg: dict = {} t = entry.transport if t.type == "stdio": @@ -605,53 +548,29 @@ def _build_server_config( return cfg -def _read_prior_tool_selection(name: str) -> Optional[List[str]]: - """Return the user's prior `tools.include` for *name*, if any. +def _read_prior_tool_list(name: str, key: str) -> Optional[List[str]]: + """Return the user's prior ``tools.<key>`` (``include``/``exclude``) for *name*, if well-formed. - Used during reinstalls so the install-time checklist starts pre-checked - with whatever the user already had. Tools no longer on the server are - silently dropped at checklist-display time. + Read BEFORE a reinstall overwrites the server entry: a prior include list pre-checks the + checklist (tools no longer on the server are dropped at display time), and a user-edited + exclude list survives reinstall instead of being clobbered by the manifest's ``default_excluded``. """ - servers = installed_servers() - cfg = servers.get(name) or {} - tools_cfg = cfg.get("tools") or {} + tools_cfg = (installed_servers().get(name) or {}).get("tools") or {} if not isinstance(tools_cfg, dict): return None - include = tools_cfg.get("include") - if isinstance(include, list) and all(isinstance(t, str) for t in include): - return list(include) - return None - - -def _read_prior_tool_exclude(name: str) -> Optional[List[str]]: - """Return the user's prior `tools.exclude` for *name*, if any. - - The exclude-mode counterpart of :func:`_read_prior_tool_selection`. - Read BEFORE a reinstall overwrites the server entry, so a user-edited - exclude list survives reinstalling an exclude-mode catalog entry instead - of being clobbered by the manifest's ``default_excluded``. - """ - servers = installed_servers() - cfg = servers.get(name) or {} - tools_cfg = cfg.get("tools") or {} - if not isinstance(tools_cfg, dict): - return None - exclude = tools_cfg.get("exclude") - if isinstance(exclude, list) and all(isinstance(t, str) for t in exclude): - return list(exclude) - return None + value = tools_cfg.get(key) + ok = isinstance(value, list) and all(isinstance(t, str) for t in value) + return list(value) if ok else None def _probe_tools(name: str) -> Optional[List[tuple]]: """Connect to a freshly-configured MCP and list its tools. - Returns a list of ``(tool_name, description)`` tuples on success, or - ``None`` on any failure (server unreachable, OAuth not yet completed, - backing service offline, etc.). Failures are intentionally swallowed - here — the fallback path in :func:`_apply_tool_selection` handles them. + Returns a list of ``(tool_name, description)`` tuples on success, or ``None`` on any failure + (server unreachable, OAuth not yet completed, backing service offline, etc.). Failures are + intentionally swallowed here — the fallback path in :func:`_apply_tool_selection` handles them. """ - servers = installed_servers() - server_cfg = servers.get(name) + server_cfg = installed_servers().get(name) if not server_cfg: return None try: @@ -662,46 +581,30 @@ def _probe_tools(name: str) -> Optional[List[tuple]]: return list(tools) if tools is not None else [] except Exception as exc: # Display the cause but never raise from the install path. - print(color(f" Probe failed: {exc}", Colors.YELLOW)) + _say(f" Probe failed: {exc}", Colors.YELLOW) return None -def _write_tools_include(name: str, include: Optional[List[str]]) -> None: - """Persist or clear ``mcp_servers.<name>.tools.include``.""" +def _write_tools_filter(name: str, mode: str, values: Optional[List[str]]) -> None: + """Persist ``mcp_servers.<name>.tools.<mode>`` (``include``/``exclude``), clearing the other + mode; ``values=None`` drops the whole tools block (no filter).""" cfg = load_config() servers = cfg.setdefault("mcp_servers", {}) server_entry = servers.get(name) or {} - if include is None: - # No filter — drop any existing tools block. + if values is None: server_entry.pop("tools", None) else: tools_block = server_entry.get("tools") or {} if not isinstance(tools_block, dict): tools_block = {} - tools_block["include"] = list(include) - tools_block.pop("exclude", None) + tools_block[mode] = list(values) + tools_block.pop("exclude" if mode == "include" else "include", None) server_entry["tools"] = tools_block servers[name] = server_entry cfg["mcp_servers"] = servers save_config(cfg) -def _write_tools_exclude(name: str, exclude: List[str]) -> None: - """Persist ``mcp_servers.<name>.tools.exclude`` (names or glob patterns).""" - cfg = load_config() - servers = cfg.setdefault("mcp_servers", {}) - server_entry = servers.get(name) or {} - tools_block = server_entry.get("tools") or {} - if not isinstance(tools_block, dict): - tools_block = {} - tools_block["exclude"] = list(exclude) - tools_block.pop("include", None) - server_entry["tools"] = tools_block - servers[name] = server_entry - cfg["mcp_servers"] = servers - save_config(cfg) - - def _apply_tool_selection( entry: CatalogEntry, *, @@ -710,21 +613,18 @@ def _apply_tool_selection( ) -> None: """Probe the server and let the user pick which tools to enable. - Probe-success path: - - Curses checklist of all probed tools. - - Pre-check uses (in priority order): - 1. *prior_selection* (reinstall: preserve what the user had) - 2. manifest's ``tools.default_enabled`` - 3. all tools (default) - - All-on selection clears any filter (no ``tools.include`` written). - - Sub-selection writes ``tools.include``. + Probe-success path: - Curses checklist of all probed tools. - Pre-check uses (in priority + order): 1. *prior_selection* (reinstall: preserve what the user had) 2. manifest's + ``tools.default_enabled`` 3. all tools (default) - All-on selection clears any filter (no + ``tools.include`` written). - Probe-fail path: - - If manifest declares ``tools.default_enabled`` → apply directly. - - Otherwise → leave config with no filter (all on when reachable). - - Either way, point the user at ``hermes mcp configure <name>``. + Probe-fail path: - If manifest declares ``tools.default_enabled`` → apply directly. - Otherwise + → leave config with no filter (all on when reachable). - Either way, point the user at ``hermes + mcp configure <name>``. """ print() + name = entry.name + configure_hint = f"`hermes mcp configure {name}`" # Exclude-mode manifests short-circuit the checklist entirely: the curated # exclude list (names or glob patterns) is written as-is, everything else @@ -735,29 +635,23 @@ def _apply_tool_selection( # manifest defaults. # (No probe announcement here — this path deliberately never probes.) if entry.tools.default_excluded and prior_selection is None: + edit_hint = ( + f"Edit mcp_servers.{name}.tools.exclude in config.yaml or run " + f"{configure_hint} to change." + ) if prior_exclude is not None: - _write_tools_exclude(entry.name, prior_exclude) - print(color( - f" Kept your existing exclude list ({len(prior_exclude)} " - f"entries). Edit mcp_servers.{entry.name}.tools.exclude in " - "config.yaml or run " - f"`hermes mcp configure {entry.name}` to change.", - Colors.GREEN, - )) + _write_tools_filter(name, "exclude", prior_exclude) + _say(f" Kept your existing exclude list ({len(prior_exclude)} entries). {edit_hint}") return - _write_tools_exclude(entry.name, entry.tools.default_excluded) - print(color( - f" Applied manifest exclude list " - f"({len(entry.tools.default_excluded)} entries); everything else " - f"stays enabled. Edit mcp_servers.{entry.name}.tools.exclude in " - "config.yaml or run " - f"`hermes mcp configure {entry.name}` to change.", - Colors.GREEN, - )) + _write_tools_filter(name, "exclude", entry.tools.default_excluded) + _say( + f" Applied manifest exclude list ({len(entry.tools.default_excluded)} entries); " + f"everything else stays enabled. {edit_hint}" + ) return - print(color(f" Probing '{entry.name}' for available tools...", Colors.CYAN)) - probed = _probe_tools(entry.name) + _say(f" Probing '{name}' for available tools...", Colors.CYAN) + probed = _probe_tools(name) # Probe failure path. Order matters: a reinstall must come out of a # failed probe with the user's previous filter intact (common for OAuth @@ -765,79 +659,60 @@ def _apply_tool_selection( # regularly unreachable right here), not with the filter reset or wiped. if probed is None: manifest_default = entry.tools.default_enabled + refine_hint = f"Run {configure_hint} after the server is reachable to refine." if prior_selection is not None: - _write_tools_include(entry.name, prior_selection) - print(color( - f" Couldn\'t probe server. Kept your previous tool " - f"selection ({len(prior_selection)} tools). " - f"Run `hermes mcp configure {entry.name}` after the server " - "is reachable to refine.", - Colors.YELLOW, - )) + _write_tools_filter(name, "include", prior_selection) + msg = ( + f" Couldn't probe server. Kept your previous tool selection " + f"({len(prior_selection)} tools). {refine_hint}" + ) elif prior_exclude is not None: - _write_tools_exclude(entry.name, prior_exclude) - print(color( - f" Couldn\'t probe server. Kept your existing exclude " - f"list ({len(prior_exclude)} entries).", - Colors.YELLOW, - )) + _write_tools_filter(name, "exclude", prior_exclude) + msg = f" Couldn't probe server. Kept your existing exclude list ({len(prior_exclude)} entries)." elif manifest_default: - _write_tools_include(entry.name, manifest_default) - print(color( - f" Couldn\'t probe server. Applied manifest default " - f"({len(manifest_default)} tools). " - f"Run `hermes mcp configure {entry.name}` after the server " - "is reachable to refine.", - Colors.YELLOW, - )) + _write_tools_filter(name, "include", manifest_default) + msg = ( + f" Couldn't probe server. Applied manifest default " + f"({len(manifest_default)} tools). {refine_hint}" + ) else: - _write_tools_include(entry.name, None) - print(color( - f" Couldn\'t probe server; installed with no tool filter " - "(all tools enabled when reachable). " - f"Run `hermes mcp configure {entry.name}` after first " - "connect to prune.", - Colors.YELLOW, - )) + _write_tools_filter(name, "include", None) + msg = ( + " Couldn't probe server; installed with no tool filter (all tools enabled when " + f"reachable). Run {configure_hint} after first connect to prune." + ) + _say(msg, Colors.YELLOW) return if not probed: # Probe succeeded but server reported zero tools. Nothing to filter. - _write_tools_include(entry.name, None) - print(color(" Server reported no tools.", Colors.YELLOW)) + _write_tools_filter(name, "include", None) + _say(" Server reported no tools.", Colors.YELLOW) return tool_names = [t[0] for t in probed] - # Build the pre-checked set in priority order - if prior_selection: - pre_set = {n for n in prior_selection if n in tool_names} - elif entry.tools.default_enabled: - pre_set = {n for n in entry.tools.default_enabled if n in tool_names} - else: - pre_set = set(tool_names) - - pre_indices = {i for i, n in enumerate(tool_names) if n in pre_set} - # Non-TTY: skip the checklist. Priority matches the interactive # pre-check priority: prior user selection > manifest default > all-on. import sys as _sys if not _sys.stdin.isatty(): - if prior_selection is not None: - include = [n for n in prior_selection if n in tool_names] - _write_tools_include(entry.name, include) - elif entry.tools.default_enabled: - include = [n for n in entry.tools.default_enabled if n in tool_names] - _write_tools_include(entry.name, include) - else: - _write_tools_include(entry.name, None) + preferred = ( + prior_selection if prior_selection is not None + else (entry.tools.default_enabled or None) + ) + _write_tools_filter( + name, "include", None if preferred is None else [n for n in preferred if n in tool_names] + ) return - print(color( - f" Found {len(probed)} tool(s). " - f"Pre-checked: {len(pre_indices)}.", - Colors.GREEN, - )) + # Build the pre-checked set in priority order + pre_set = { + n for n in (prior_selection or entry.tools.default_enabled or tool_names) + if n in tool_names + } + pre_indices = {i for i, n in enumerate(tool_names) if n in pre_set} + + _say(f" Found {len(probed)} tool(s). Pre-checked: {len(pre_indices)}.") from hermes_cli.curses_ui import curses_checklist @@ -846,7 +721,7 @@ def _apply_tool_selection( for n, d in probed ] chosen_indices = curses_checklist( - f"Select tools for '{entry.name}' (SPACE toggle, ENTER confirm)", + f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices, ) @@ -854,12 +729,8 @@ def _apply_tool_selection( if not chosen_indices: # User unchecked everything; treat as "no tools" — write empty include # so the server is installed but contributes nothing until reconfigured. - _write_tools_include(entry.name, []) - print(color( - f" No tools selected. Run `hermes mcp configure {entry.name}` " - "to change.", - Colors.YELLOW, - )) + _write_tools_filter(name, "include", []) + _say(f" No tools selected. Run {configure_hint} to change.", Colors.YELLOW) return if len(chosen_indices) == len(probed): @@ -868,82 +739,66 @@ def _apply_tool_selection( # version) will also be auto-enabled. To pin to the current set, # the user can re-run `hermes mcp configure <name>` and unselect a # tool to switch back to include-mode. - _write_tools_include(entry.name, None) - print(color( + _write_tools_filter(name, "include", None) + _say( f" ✓ All {len(probed)} tools enabled (no filter — new tools " - "the server adds later will be auto-enabled).", - Colors.GREEN, - )) + "the server adds later will be auto-enabled)." + ) return chosen_names = [tool_names[i] for i in sorted(chosen_indices)] - _write_tools_include(entry.name, chosen_names) - print(color( - f" ✓ {len(chosen_names)}/{len(probed)} tools enabled.", - Colors.GREEN, - )) + _write_tools_filter(name, "include", chosen_names) + _say(f" ✓ {len(chosen_names)}/{len(probed)} tools enabled.") def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: """Install a catalog entry end-to-end. - Steps: - 1. If ``install.type == git``, clone + run bootstrap commands. - 2. If ``auth.type == api_key``, prompt for env vars, save to .env. - 3. If ``auth.type == oauth`` (remote MCP / case 1), write the - ``auth: oauth`` marker (MCP client handles browser on first connect - in the non-pre-authenticated case). - 4. Translate the manifest into an ``mcp_servers.<name>`` block and - save into config.yaml. - 5. Probe the server, present a curses checklist for tool selection, - write ``tools.include`` (or no filter, depending on choice). - If probe fails, fall back to the manifest's - ``tools.default_enabled`` or all-on. - 6. Print post_install notes. + Order: git clone + bootstrap (if ``install.type == git``); API-key prompt to .env or the + ``auth: oauth`` marker; translate the manifest into ``mcp_servers.<name>`` in config.yaml; + probe the server and offer a tool checklist (falling back to ``tools.default_enabled`` or + all-on when the probe fails); print post_install notes. """ print() - print(color(f" Installing MCP '{entry.name}'", Colors.CYAN + Colors.BOLD)) + _say(f" Installing MCP '{entry.name}'", Colors.CYAN + Colors.BOLD) if entry.description: - print(color(f" {entry.description}", Colors.DIM)) + _say(f" {entry.description}", Colors.DIM) if entry.source: - print(color(f" Source: {entry.source}", Colors.DIM)) + _say(f" Source: {entry.source}", Colors.DIM) print() - install_dir: Optional[Path] = None - if entry.install is not None: - install_dir = _do_git_install(entry) + install_dir = _do_git_install(entry) if entry.install is not None else None # Auth if entry.auth.type == "api_key": print() - print(color(" Configure credentials:", Colors.CYAN)) + _say(" Configure credentials:", Colors.CYAN) _prompt_env_vars(entry.auth.env) + elif entry.auth.type == "oauth" and entry.auth.provider: + # Case 2: provider-mediated (Google, GitHub, etc.). We rely on + # the existing `hermes auth <provider>` flow. Surface guidance + # here rather than auto-running it — keeps the catalog install + # decoupled from provider-auth lifecycle. + _say( + f" This MCP uses {entry.auth.provider} OAuth. Run " + f"`hermes auth {entry.auth.provider}` if you have not " + "already authenticated.", + Colors.YELLOW, + ) elif entry.auth.type == "oauth": - if entry.auth.provider: - # Case 2: provider-mediated (Google, GitHub, etc.). We rely on - # the existing `hermes auth <provider>` flow. Surface guidance - # here rather than auto-running it — keeps the catalog install - # decoupled from provider-auth lifecycle. - print(color( - f" This MCP uses {entry.auth.provider} OAuth. Run " - f"`hermes auth {entry.auth.provider}` if you have not " - "already authenticated.", - Colors.YELLOW, - )) - else: - print(color( - " This MCP uses native OAuth 2.1; tokens will be acquired " - "on first connection (browser flow).", - Colors.DIM, - )) + _say( + " This MCP uses native OAuth 2.1; tokens will be acquired " + "on first connection (browser flow).", + Colors.DIM, + ) # auth.type == "none": nothing to do. # ── Preserve any prior user tool selection across reinstalls ──────── # Reading BEFORE we overwrite the entry below so a reinstall pre-checks # whatever the user picked last time (include mode) or keeps the user's # edited exclude list (exclude mode). - prior_selection = _read_prior_tool_selection(entry.name) - prior_exclude = _read_prior_tool_exclude(entry.name) + prior_selection = _read_prior_tool_list(entry.name, "include") + prior_exclude = _read_prior_tool_list(entry.name, "exclude") # Build and write the mcp_servers entry (without tools filter yet; # _apply_tool_selection() finalizes it below). @@ -963,38 +818,25 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: ) print() - print(color( + _say( f" ✓ Installed '{entry.name}' " f"({'enabled' if enable else 'disabled'}). " - f"Start a new Hermes session to load its tools.", - Colors.GREEN, - )) + f"Start a new Hermes session to load its tools." + ) if entry.post_install: print() for line in entry.post_install.strip().splitlines(): - print(color(f" {line}", Colors.DIM)) + _say(f" {line}", Colors.DIM) print() def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool: """Remove a catalog-installed MCP from config and (optionally) wipe its clone directory. Returns True if anything was removed.""" - cfg = load_config() - servers = cfg.get("mcp_servers") or {} - removed = False - if name in servers: - del servers[name] - if not servers: - cfg.pop("mcp_servers", None) - else: - cfg["mcp_servers"] = servers - save_config(cfg) - removed = True - + removed = remove_server(name) if purge_install_dir: clone = _install_root() / name if clone.exists(): shutil.rmtree(clone) removed = True - return removed diff --git a/hermes_cli/mcp_picker.py b/hermes_cli/mcp_picker.py index 8bf2beffaf..7a797c3e18 100644 --- a/hermes_cli/mcp_picker.py +++ b/hermes_cli/mcp_picker.py @@ -1,20 +1,4 @@ -"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`). - -Lists every catalog entry plus any custom MCP servers the user has added via -``hermes mcp add``, lets them pick one, and routes to install / enable / -disable / uninstall / configure-tools flows. - -Mirrors the `hermes plugin` picker UX: arrow keys to navigate, ENTER on a row -to act on it. The action depends on current status: - - not installed (catalog) → install (clone/bootstrap if needed, prompt for creds) - installed / disabled → enable - installed / enabled → submenu: configure tools / disable / uninstall / reinstall - custom (non-catalog) → submenu: configure tools / enable / disable / remove - -The picker loops until the user hits ESC/q so they can manage multiple -entries in one session. -""" +"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`).""" from __future__ import annotations @@ -34,6 +18,8 @@ from hermes_cli.mcp_catalog import ( is_installed, list_catalog, installed_servers, + remove_server, + server_enabled, uninstall_entry, ) from hermes_cli.config import load_config, save_config @@ -70,35 +56,27 @@ def _build_rows() -> List[_Row]: """Return catalog rows + any custom (non-catalog) MCPs found in config.""" catalog_entries = list_catalog() catalog_names = {e.name for e in catalog_entries} + servers = installed_servers() rows: List[_Row] = [] for entry in catalog_entries: - if not is_installed(entry.name): + cfg = servers.get(entry.name) + if entry.name not in servers: status = _STATUS_NOT_INSTALLED - elif is_enabled(entry.name): + elif cfg and server_enabled(cfg): status = _STATUS_ENABLED else: status = _STATUS_DISABLED - rows.append( - _Row( - name=entry.name, - description=entry.description, - status=status, - entry=entry, - ) - ) + rows.append(_Row(entry.name, entry.description, status, entry)) # Custom MCPs the user added directly (not in the catalog) - for name, cfg in sorted(installed_servers().items()): + for name, cfg in sorted(servers.items()): if name in catalog_names: continue - enabled = cfg.get("enabled", True) - if isinstance(enabled, str): - enabled = enabled.lower() in {"true", "1", "yes"} - status = _STATUS_CUSTOM_ENABLED if enabled else _STATUS_CUSTOM_DISABLED + status = _STATUS_CUSTOM_ENABLED if server_enabled(cfg) else _STATUS_CUSTOM_DISABLED # Use the transport URL/command as the "description" for custom rows desc = cfg.get("url") or cfg.get("command") or "(no transport)" - rows.append(_Row(name=name, description=str(desc), status=status)) + rows.append(_Row(name, str(desc), status)) return rows @@ -128,43 +106,59 @@ def _enable_disable(name: str, *, enable: bool) -> None: def _configure_tools(name: str) -> None: - """Open the tool selection checklist for an already-installed MCP. - - Delegates to the existing ``cmd_mcp_configure`` flow which probes the - server, displays a checklist, and writes ``tools.include``. - """ - import argparse + """Open the tool selection checklist for an already-installed MCP.""" + from argparse import Namespace from hermes_cli.mcp_config import cmd_mcp_configure - cmd_mcp_configure(argparse.Namespace(name=name)) + cmd_mcp_configure(Namespace(name=name)) def _remove_custom(name: str) -> None: """Remove a non-catalog MCP entry from config.yaml.""" - cfg = load_config() - servers = cfg.get("mcp_servers") or {} - if name not in servers: + if not is_installed(name): print(color(f" '{name}' is not configured.", Colors.RED)) return if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False): return - del servers[name] - if not servers: - cfg.pop("mcp_servers", None) - else: - cfg["mcp_servers"] = servers - save_config(cfg) + remove_server(name) print(color(f" ✓ Removed '{name}'", Colors.GREEN)) +def _install(entry: CatalogEntry, verb: str) -> bool: + """Install *entry*, printing (not raising) a CatalogError. True on success.""" + try: + install_entry(entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ {verb} failed: {exc}", Colors.RED)) + return False + return True + + +def _uninstall(name: str) -> None: + if not prompt_yes_no(f"Uninstall '{name}'?", default=False): + return + if uninstall_entry(name): + print(color( + f" ✓ Uninstalled '{name}'. " + "Credentials in .env preserved — delete manually if no longer needed.", + Colors.GREEN, + )) + else: + print(color(f" '{name}' was not installed", Colors.DIM)) + + +def _run_submenu(title: str, actions: list) -> None: + """Show a single-select of ``(label, callback)`` pairs and run the picked callback.""" + choice = curses_single_select(title, [label for label, _ in actions]) + if choice is not None: + actions[choice][1]() + + def _handle_row(row: _Row) -> None: """Act on the picked row based on its current status.""" # === Catalog row, not yet installed === if row.entry and not is_installed(row.name): - try: - install_entry(row.entry, enable=True) - except CatalogError as exc: - print(color(f" ✗ install failed: {exc}", Colors.RED)) + _install(row.entry, "install") return # === Catalog row, installed but disabled === @@ -175,54 +169,26 @@ def _handle_row(row: _Row) -> None: # === Catalog row, installed + enabled OR custom row === if row.is_custom: # Custom (non-catalog) row submenu - actions = [ - "Configure tools (probe server + re-pick)", - "Enable" if not is_enabled(row.name) else "Disable", - "Remove from config", - ] - choice = curses_single_select(f"Action for '{row.name}' (custom)", actions) - if choice is None: - return - if choice == 0: - _configure_tools(row.name) - elif choice == 1: - _enable_disable(row.name, enable=not is_enabled(row.name)) - elif choice == 2: - _remove_custom(row.name) + enabled = is_enabled(row.name) + _run_submenu(f"Action for '{row.name}' (custom)", [ + ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)), + ("Enable" if not enabled else "Disable", + lambda: _enable_disable(row.name, enable=not is_enabled(row.name))), + ("Remove from config", lambda: _remove_custom(row.name)), + ]) return # Catalog row, installed + enabled print() print(color(f" '{row.name}' is already enabled.", Colors.DIM)) - actions = [ - "Configure tools (probe server + re-pick)", - "Disable (keep config, stop loading on next session)", - "Uninstall (remove config and any cloned files)", - "Reinstall (re-clone, re-prompt for credentials)", - ] - choice = curses_single_select(f"Action for '{row.name}'", actions) - if choice is None: - return - if choice == 0: - _configure_tools(row.name) - elif choice == 1: - _enable_disable(row.name, enable=False) - elif choice == 2: - if prompt_yes_no(f"Uninstall '{row.name}'?", default=False): - if uninstall_entry(row.name): - print(color( - f" ✓ Uninstalled '{row.name}'. " - "Credentials in .env preserved — delete manually if no longer needed.", - Colors.GREEN, - )) - else: - print(color(f" '{row.name}' was not installed", Colors.DIM)) - elif choice == 3: - try: - assert row.entry is not None - install_entry(row.entry, enable=True) - except CatalogError as exc: - print(color(f" ✗ reinstall failed: {exc}", Colors.RED)) + _run_submenu(f"Action for '{row.name}'", [ + ("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)), + ("Disable (keep config, stop loading on next session)", + lambda: _enable_disable(row.name, enable=False)), + ("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)), + ("Reinstall (re-clone, re-prompt for credentials)", + lambda: _install(row.entry, "reinstall")), + ]) # ─── Output / entry points ──────────────────────────────────────────────────── @@ -231,13 +197,12 @@ def _handle_row(row: _Row) -> None: def _print_rows_text(rows: List[_Row]) -> None: """Plain-text catalog dump used as a fallback when curses can't run, and as the default output of `hermes mcp catalog`.""" + print() if not rows: - print() print(color(" No MCPs in the catalog or configured.", Colors.DIM)) print() return - print() print(color(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD)) print() print(f" {'Name':<18} {'Status':<24} Description") @@ -252,11 +217,10 @@ def _print_rows_text(rows: List[_Row]) -> None: # Surface manifest-version warnings so users know when their Hermes is # too old to install everything in the catalog. - diags = catalog_diagnostics() - future = [d for d in diags if d[1] == "future_manifest"] + future = [d for d in catalog_diagnostics() if d[1] == "future_manifest"] if future: print() - for name, _, msg in future: + for name, _, _msg in future: print(color( f" ⚠ '{name}' requires a newer Hermes — run `hermes update` " "to install this entry.", @@ -274,17 +238,13 @@ def show_catalog() -> None: def run_picker() -> None: """`hermes mcp picker` (and default `hermes mcp`) — interactive selector. - Loops until the user hits ESC/q. After each action the picker re-renders - so the user can manage several entries in one session. + Loops until the user hits ESC/q. After each action the picker re-renders so the user can manage + several entries in one session. """ - if not sys.stdin.isatty(): - # Non-interactive shell: degrade to the text dump rather than failing. - _print_rows_text(_build_rows()) - return - while True: rows = _build_rows() - if not rows: + if not rows or not sys.stdin.isatty(): + # Non-interactive shell: degrade to the text dump rather than failing. _print_rows_text(rows) return @@ -299,11 +259,7 @@ def run_picker() -> None: def install_by_name(identifier: str) -> int: - """`hermes mcp install <name>` — non-interactive entry-point. - - Returns 0 on success, non-zero on failure (so the CLI can propagate - exit codes). - """ + """`hermes mcp install <name>` — non-interactive entry-point.""" from hermes_cli.mcp_catalog import get_entry entry = get_entry(identifier) @@ -314,9 +270,4 @@ def install_by_name(identifier: str) -> int: Colors.RED, )) return 1 - try: - install_entry(entry, enable=True) - except CatalogError as exc: - print(color(f" ✗ install failed: {exc}", Colors.RED)) - return 1 - return 0 + return 0 if _install(entry, "install") else 1 diff --git a/hermes_cli/mcp_security.py b/hermes_cli/mcp_security.py index fac473c0c0..316078f500 100644 --- a/hermes_cli/mcp_security.py +++ b/hermes_cli/mcp_security.py @@ -1,27 +1,12 @@ """Security checks for user-configured MCP server entries. -MCP stdio transports intentionally support arbitrary local commands so users can -run custom servers. This module does not try to sandbox that capability. It -blocks two high-signal abuse shapes seen in the wild: +3. A hardcoded indicator-of-compromise (IOC) blocklist for that campaign — the attacker's +``hermes-0day`` SSH public key and source IPs. Any entry whose command/args/env carry an IOC is +refused outright, regardless of shape, so a pre-planted ``config.yaml`` cannot spawn it. -1. The exfiltration shape from #45620: a shell interpreter whose inline script - invokes network egress tooling. -2. The persistence shape from the June 2026 ``hermes-0day`` campaign: a shell - interpreter whose inline script writes to OS persistence surfaces - (``~/.ssh/authorized_keys``, ``/etc/ssh``, ``/etc/pam.d``, ``sudoers``, - crontab, shell rc files). The campaign planted ``command: bash`` MCP entries - whose payload appended an attacker SSH key to ``authorized_keys``; Hermes - re-executed them on every cron tick / startup, re-installing the backdoor. - -3. A hardcoded indicator-of-compromise (IOC) blocklist for that campaign — the - attacker's ``hermes-0day`` SSH public key and source IPs. Any entry whose - command/args/env carry an IOC is refused outright, regardless of shape, so a - pre-planted ``config.yaml`` cannot spawn it. - -These checks run BOTH at save time (``_save_mcp_server`` — dashboard API + CLI) -and at spawn time (``tools.mcp_tool._filter_suspicious_mcp_servers`` — discovery -/ cron / startup), so a hand-edited or pre-planted entry is also caught before -it can execute. +These checks run BOTH at save time (``_save_mcp_server`` — dashboard API + CLI) and at spawn time +(``tools.mcp_tool._filter_suspicious_mcp_servers`` — discovery / cron / startup), so a hand-edited +or pre-planted entry is also caught before it can execute. """ from __future__ import annotations @@ -121,14 +106,9 @@ def _entry_text(entry: dict[str, Any]) -> str: def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]: """Return security warnings for an MCP server entry. - Empty return means the entry is not suspicious. This is intentionally not a - whitelist: legitimate local MCPs can still use custom commands, Python - scripts, npx, uvx, etc. We block three narrow shapes only: - - * a known hermes-0day IOC anywhere in command/args/env (hardcoded blocklist); - * a shell interpreter whose inline script invokes network egress (#45620); - * a shell interpreter whose inline script writes to an OS persistence - surface (June 2026 hermes-0day SSH/PAM/sudoers/cron shape). + Empty return means the entry is not suspicious. This is intentionally not a whitelist: + legitimate local MCPs can still use custom commands, Python scripts, npx, uvx, etc. We block + three narrow shapes only: """ if not isinstance(entry, dict): return [] @@ -175,7 +155,3 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]: ) return issues - - -def is_mcp_server_entry_suspicious(name: str, entry: dict[str, Any]) -> bool: - return bool(validate_mcp_server_entry(name, entry)) diff --git a/hermes_cli/mcp_startup.py b/hermes_cli/mcp_startup.py index 77a972591d..0b04ef77e7 100644 --- a/hermes_cli/mcp_startup.py +++ b/hermes_cli/mcp_startup.py @@ -6,6 +6,12 @@ import threading from contextlib import nullcontext from typing import Optional +from hermes_constants import ( + get_hermes_home_override, + reset_hermes_home_override, + set_hermes_home_override, +) + _mcp_discovery_lock = threading.Lock() _mcp_discovery_started = False _mcp_discovery_thread: Optional[threading.Thread] = None @@ -18,8 +24,7 @@ def _has_configured_mcp_servers() -> bool: from hermes_cli.config import read_raw_config raw_config = read_raw_config() or {} - mcp_servers = raw_config.get("mcp_servers") - if isinstance(mcp_servers, dict) and len(mcp_servers) > 0: + if isinstance(raw_config.get("mcp_servers"), dict) and raw_config["mcp_servers"]: return True from hermes_cli.agent_plugins import has_enabled_agent_plugin_mcp @@ -30,13 +35,18 @@ def _has_configured_mcp_servers() -> bool: return True +def _any_mcp_connected() -> bool: + from tools.mcp_tool import get_mcp_status + + return any(entry.get("connected") for entry in (get_mcp_status() or [])) + + def start_background_mcp_discovery(*, logger, thread_name: str) -> None: """Spawn one shared background MCP discovery thread for this process. - If the first background discovery run exits without connecting any MCP - server (for example after startup cancellation / OOM restart), later calls - are allowed to retry instead of permanently pinning the process in a - "discovery already started" state with zero MCP tools. + If the first background discovery run exits without connecting any MCP server (for example after + startup cancellation / OOM restart), later calls are allowed to retry instead of permanently + pinning the process in a "discovery already started" state with zero MCP tools. """ global _mcp_discovery_started, _mcp_discovery_thread @@ -46,10 +56,7 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None: if thread is not None and thread.is_alive(): return try: - from tools.mcp_tool import get_mcp_status - - status = get_mcp_status() or [] - if any(entry.get("connected") for entry in status): + if _any_mcp_connected(): return except Exception: return @@ -71,27 +78,14 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None: # "switched" to profile X would discover the LAUNCH profile's # mcp_servers instead (#67605). The config gate above already runs on # the caller's thread, so it sees the same override. - try: - from hermes_constants import get_hermes_home_override - - home_override = get_hermes_home_override() - except Exception: - home_override = None + home_override = get_hermes_home_override() def _discover() -> None: - token = None - try: - from hermes_constants import set_hermes_home_override - - token = set_hermes_home_override(home_override) - except Exception: - token = None + token = set_hermes_home_override(home_override) try: _discover_mcp_tools_without_interactive_oauth() try: - from tools.mcp_tool import get_mcp_status - status = get_mcp_status() or [] - if not any(entry.get("connected") for entry in status): + if not _any_mcp_connected(): logger.warning( "Background MCP discovery completed with zero connected servers" ) @@ -100,15 +94,9 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None: except Exception: logger.debug("Background MCP tool discovery failed", exc_info=True) finally: - if token is not None: - try: - from hermes_constants import reset_hermes_home_override - - reset_hermes_home_override(token) - except Exception: - pass + reset_hermes_home_override(token) with _mcp_discovery_lock: - global _mcp_discovery_thread, _mcp_discovery_started + global _mcp_discovery_thread _mcp_discovery_thread = None thread = threading.Thread( @@ -125,18 +113,9 @@ def _resolve_discovery_timeout( ) -> float: """Resolve the MCP discovery wait bound: explicit arg > config > default. - Reads ``mcp_discovery_timeout`` from config.yaml, defaulting to the value in - ``DEFAULT_CONFIG`` (single source of truth) when the key is absent. Kept lazy - and fail-safe — a missing/invalid value or a broken config falls back to a - short safe bound so startup can never hang or crash. - - When ``single_query`` is True (``hermes -z "..."`` / ``-q``), the larger - ``mcp_single_query_discovery_timeout`` bound is used instead. In single-query - mode there is only ONE turn, so the between-turns late-binding refresh never - runs — a server that misses the small interactive bound would be invisible to - the LLM for the whole session. The wait still returns the instant discovery - completes (see ``wait_for_mcp_discovery``), so fast servers pay ~0s; the - larger bound only caps how long a genuinely slow cold-start may block. + Reads ``mcp_discovery_timeout`` from config.yaml, defaulting to the value in ``DEFAULT_CONFIG`` + (single source of truth) when the key is absent. Kept lazy and fail-safe — a missing/invalid + value or a broken config falls back to a short safe bound so startup can never hang or crash. """ if explicit is not None: return explicit @@ -150,14 +129,13 @@ def _resolve_discovery_timeout( from hermes_cli.config import load_config, DEFAULT_CONFIG default = float(DEFAULT_CONFIG.get(key, fallback)) - try: - raw = (load_config() or {}).get(key, default) - val = float(raw) - return val if val > 0 else default - except Exception: - return default except Exception: return fallback + try: + val = float((load_config() or {}).get(key, default)) + return val if val > 0 else default + except Exception: + return default def _discover_mcp_tools_without_interactive_oauth() -> None: @@ -176,13 +154,10 @@ def _discover_mcp_tools_without_interactive_oauth() -> None: def defer_background_mcp_discovery(*, logger, thread_name: str, delay: float) -> None: """Arm ``start_background_mcp_discovery`` to run ``delay`` seconds from now. - Used by the Desktop ``serve`` backend after its socket is announced: the - discovery thread's first act is the ~350ms ``mcp`` SDK import, which holds - the GIL against the renderer's connect + first hydration reads if it starts - at bind time, and against the web_server import if it starts before. Any - consumer that needs discovery sooner (``wait_for_mcp_discovery`` from an - agent build) fires the deferred start immediately, so the bounded join and - the late-binding refresh behave exactly as if it had been started eagerly. + Used by the Desktop ``serve`` backend after its socket is announced: the discovery thread's + first act is the ~350ms ``mcp`` SDK import, which holds the GIL against the renderer's connect + + first hydration reads if it starts at bind time, and against the web_server import if it starts + before. """ global _mcp_discovery_deferred with _mcp_discovery_lock: @@ -217,16 +192,13 @@ def wait_for_mcp_discovery( ) -> None: """Wait for background MCP discovery before the first tool snapshot. - ``thread.join(timeout)`` returns the INSTANT discovery completes, so this - only ever blocks for the real connect time of a still-pending server — - users with no MCP servers or fast servers pay ~0s. The bound (from - ``mcp_discovery_timeout`` in config) just caps the wait so a dead server - can't freeze startup; servers that miss it are picked up by the automatic - late-binding refresh. + ``thread.join(timeout)`` returns the INSTANT discovery completes, so this only ever blocks for + the real connect time of a still-pending server — users with no MCP servers or fast servers pay + ~0s. - When ``single_query`` is True, the bound comes from - ``mcp_single_query_discovery_timeout`` instead (default 15s vs 1.5s - interactive) because one-shot sessions have no second turn to recover. + When ``single_query`` is True, the bound comes from ``mcp_single_query_discovery_timeout`` + instead (default 15s vs 1.5s interactive) because one-shot sessions have no second turn to + recover. """ _start_deferred_mcp_discovery_now() thread = _mcp_discovery_thread @@ -238,13 +210,9 @@ def wait_for_mcp_discovery( def mcp_discovery_in_flight() -> bool: """Return True if THIS module's background discovery thread is still running. - Mirrors ``tui_gateway.entry.mcp_discovery_in_flight`` for the surfaces that - start discovery through ``start_background_mcp_discovery`` here (the desktop - app + dashboard WebSocket sidecar via ``tui_gateway/ws.py``, and - ``hermes dashboard``). Those processes populate THIS module's - ``_mcp_discovery_thread``, not ``tui_gateway.entry``'s, so the late-refresh - scheduler must consult both to decide whether a slow server's tools are - still pending (see #51587). + Mirrors ``tui_gateway.entry.mcp_discovery_in_flight`` for surfaces that start discovery here + (desktop app, dashboard WebSocket sidecar, ``hermes dashboard``). Those populate THIS module's + thread, so the late-refresh scheduler must consult both to know if a slow server is pending. """ thread = _mcp_discovery_thread return thread is not None and thread.is_alive() @@ -253,10 +221,9 @@ def mcp_discovery_in_flight() -> bool: def join_mcp_discovery(timeout: "float | None" = None) -> bool: """Block until THIS module's background discovery finishes, up to ``timeout``. - Returns True if discovery has completed (thread absent or no longer alive), - False if it is still running after the timeout. Unlike - ``wait_for_mcp_discovery`` this accepts an unbounded/long wait and reports - the outcome, for the off-critical-path late-refresh waiter. + Returns True once discovery has completed, False if still running after the timeout. Unlike + ``wait_for_mcp_discovery`` this accepts a long wait and reports the outcome, for the + off-critical-path late-refresh waiter. """ thread = _mcp_discovery_thread if thread is None: @@ -274,24 +241,13 @@ def ensure_mcp_discovery_before_agent_build( ) -> None: """Give configured MCP tools a bounded chance to register before AIAgent. - Non-interactive first turns (``chat -q``, ``hermes -z``) can construct - ``AIAgent`` before the normal banner or tool-list paths touch - ``get_tool_definitions()``. Because the agent snapshots its tool - registry at construction time, the first and only model turn can miss - native ``mcp__...`` tools even when the MCP server is healthy. + Non-interactive first turns (``chat -q``, ``hermes -z``) can construct ``AIAgent`` before the + normal banner or tool-list paths touch ``get_tool_definitions()``. - ``wait_for_mcp_discovery()`` only joins an already-created discovery - thread, so it no-ops if a direct/single-query path reaches agent - construction before MCP startup created that thread. This helper makes - the construction site self-sufficient: start discovery if needed, then - wait up to the configured bound. - - When ``single_query`` is True, the larger - ``mcp_single_query_discovery_timeout`` bound is used (default 15s vs 1.5s - interactive) because one-shot sessions have no second turn to recover. - - Failures are swallowed so a broken MCP config never aborts agent - construction — the agent runs without MCP tools, same as before. + ``wait_for_mcp_discovery()`` only joins an already-created discovery thread, so it no-ops if a + direct/single-query path reaches agent construction before MCP startup created that thread. This + helper makes the construction site self-sufficient: start discovery if needed, then wait up to + the configured bound. """ try: start_background_mcp_discovery( diff --git a/hermes_cli/mem_trim.py b/hermes_cli/mem_trim.py index ad54f59a38..761e5e9b97 100644 --- a/hermes_cli/mem_trim.py +++ b/hermes_cli/mem_trim.py @@ -1,8 +1,8 @@ """Rate-limited heap release for long-lived Hermes gateway processes. -On Linux/glibc, ``malloc_trim(0)`` can return pages from freed Python/C -allocations to the OS. Other platforms and allocators are safe no-ops. -Behavior is configured under ``context.memory_trim`` in ``config.yaml``. +On Linux/glibc, ``malloc_trim(0)`` can return pages from freed Python/C allocations to the OS. Other +platforms and allocators are safe no-ops. Behavior is configured under ``context.memory_trim`` in +``config.yaml``. """ from __future__ import annotations @@ -33,9 +33,7 @@ _trim_call_count = 0 def _config_settings() -> tuple[bool, float, int, float]: """Return fail-open settings from the normal Hermes config path.""" enabled = True - cooldown: Any = _DEFAULT_COOLDOWN_SECONDS - log_every_n: Any = _DEFAULT_LOG_EVERY_N - info_log_min_delta_mb: Any = _DEFAULT_INFO_LOG_MIN_DELTA_MB + settings: Any = None try: # Read-only access: settings are only .get()ed and coerced, never # mutated — use the no-deepcopy variant. This runs on EVERY trim @@ -47,50 +45,32 @@ def _config_settings() -> tuple[bool, float, int, float]: config = load_config_readonly() or {} context = config.get("context") if isinstance(config, dict) else None settings = context.get("memory_trim") if isinstance(context, dict) else None - if isinstance(settings, dict): - configured_enabled = settings.get("enabled") - if isinstance(configured_enabled, bool): - enabled = configured_enabled - cooldown = settings.get("cooldown_seconds", _DEFAULT_COOLDOWN_SECONDS) - log_every_n = settings.get("log_every_n", _DEFAULT_LOG_EVERY_N) - info_log_min_delta_mb = settings.get( - "info_log_min_delta_mb", _DEFAULT_INFO_LOG_MIN_DELTA_MB - ) except Exception: pass + if not isinstance(settings, dict): + settings = {} + if isinstance(settings.get("enabled"), bool): + enabled = settings["enabled"] return ( enabled, - _cooldown_seconds(cooldown), - _log_every_n(log_every_n), - _nonnegative_float(info_log_min_delta_mb, _DEFAULT_INFO_LOG_MIN_DELTA_MB), + _cooldown_seconds(settings.get("cooldown_seconds")), + _coerce(settings.get("log_every_n"), _DEFAULT_LOG_EVERY_N, int, 1), + _coerce(settings.get("info_log_min_delta_mb"), _DEFAULT_INFO_LOG_MIN_DELTA_MB, float, 0.0), ) +def _coerce(value: Any, default, cast, floor): + """``cast(value)`` clamped to ``floor``; bools and unparseable values fall back to ``default``.""" + if isinstance(value, bool): + return default + try: + return max(floor, cast(value)) + except (TypeError, ValueError): + return default + + def _cooldown_seconds(value: Any) -> float: - if isinstance(value, bool): - return _DEFAULT_COOLDOWN_SECONDS - try: - return max(0.0, float(value)) - except (TypeError, ValueError): - return _DEFAULT_COOLDOWN_SECONDS - - -def _log_every_n(value: Any) -> int: - if isinstance(value, bool): - return _DEFAULT_LOG_EVERY_N - try: - return max(1, int(value)) - except (TypeError, ValueError): - return _DEFAULT_LOG_EVERY_N - - -def _nonnegative_float(value: Any, default: float) -> float: - if isinstance(value, bool): - return default - try: - return max(0.0, float(value)) - except (TypeError, ValueError): - return default + return _coerce(value, _DEFAULT_COOLDOWN_SECONDS, float, 0.0) def _read_proc_status() -> str | None: @@ -106,8 +86,8 @@ def _read_proc_status() -> str | None: def collect_memory_snapshot(history_bytes: int | None = None) -> dict[str, int | None]: """Return lightweight process-memory telemetry for trim logs and canaries. - ``VmRSS`` and ``RssAnon`` are Linux-only best effort fields. The helper is - intentionally dependency-free so allocation recovery never requires psutil. + ``VmRSS`` and ``RssAnon`` are Linux-only best effort fields. The helper is intentionally + dependency-free so allocation recovery never requires psutil. """ snapshot: dict[str, int | None] = { "rss_kib": None, @@ -136,7 +116,7 @@ def _should_log_trim( # successful trim is an explicit observability event, regardless of RSS. if force: return True - if not force and call_count % log_every_n: + if call_count % log_every_n: return False before_rss = before.get("rss_kib") after_rss = after.get("rss_kib") @@ -174,16 +154,11 @@ def trim_memory( ) -> bool: """Collect cycles and ask glibc to release free heap pages. - Returns ``True`` only when ``malloc_trim(0)`` ran and reported success. - Unsupported allocators, the config kill switch, cooldown suppression, and all - runtime errors return ``False`` without affecting the caller. + Returns ``True`` only when ``malloc_trim(0)`` ran and reported success. Unsupported allocators, + the config kill switch, cooldown suppression, and all runtime errors return ``False`` without + affecting the caller. """ - ( - enabled, - configured_cooldown, - log_every_n, - info_log_min_delta_mb, - ) = _config_settings() + enabled, configured_cooldown, log_every_n, info_log_min_delta_mb = _config_settings() if not enabled: return False @@ -193,11 +168,7 @@ def trim_memory( if trim is None: return False now = time.monotonic() - cooldown = ( - configured_cooldown - if cooldown_seconds is None - else _cooldown_seconds(cooldown_seconds) - ) + cooldown = configured_cooldown if cooldown_seconds is None else _cooldown_seconds(cooldown_seconds) if not force and _last_trim_monotonic and now - _last_trim_monotonic < cooldown: return False # Even forced trims honor a short floor: AIAgent.close() forces a trim, @@ -206,11 +177,7 @@ def trim_memory( # passes (50-500ms each in a large gateway process). 5s coalesces the # burst while keeping the parent's final close-trim effective. _FORCE_FLOOR_SECONDS = 5.0 - if ( - force - and _last_trim_monotonic - and now - _last_trim_monotonic < _FORCE_FLOOR_SECONDS - ): + if force and _last_trim_monotonic and now - _last_trim_monotonic < _FORCE_FLOOR_SECONDS: return False # Record the attempt before calling into libc so repeated failures do not # turn every turn boundary into an expensive full collection. @@ -225,31 +192,20 @@ def trim_memory( duration_ms = (time.perf_counter() - started) * 1000 _trim_call_count += 1 if released and _should_log_trim( - force=force, - log_every_n=log_every_n, - call_count=_trim_call_count, - before=before, - after=after, - info_log_min_delta_mb=info_log_min_delta_mb, + force=force, log_every_n=log_every_n, call_count=_trim_call_count, + before=before, after=after, info_log_min_delta_mb=info_log_min_delta_mb, ): logger.info( "memory trim: reason=%s malloc_trim=%s rss_kib=%s->%s " "rss_anon_kib=%s->%s threads=%s duration_ms=%.1f", - reason or "cleanup", - trim_result, - before.get("rss_kib"), - after.get("rss_kib"), - before.get("rss_anon_kib"), - after.get("rss_anon_kib"), - after.get("thread_count"), - duration_ms, + reason or "cleanup", trim_result, + before.get("rss_kib"), after.get("rss_kib"), + before.get("rss_anon_kib"), after.get("rss_anon_kib"), + after.get("thread_count"), duration_ms, ) return released except Exception as exc: logger.warning( - "memory trim failed after %s: %s: %s", - reason or "cleanup", - type(exc).__name__, - exc, + "memory trim failed after %s: %s: %s", reason or "cleanup", type(exc).__name__, exc, ) return False diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 59620b20ee..e2fc9ea831 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -1,9 +1,4 @@ -"""hermes memory setup|status — configure memory provider plugins. - -Auto-detects installed memory providers via the plugin system. -Interactive curses-based UI for provider selection, then walks through -the provider's config schema. Writes config to config.yaml + .env. -""" +"""hermes memory setup|status — configure memory provider plugins.""" from __future__ import annotations @@ -21,13 +16,8 @@ _CANCELLED = -1 def _provider_pip_dependencies(provider_name: str, declared: list) -> list: """Return the pip deps a provider actually needs on THIS install. - ``plugin.yaml`` declares the provider's baseline bridge packages, but - some providers install mode-dependent extras at setup time that the - manifest can't express. Hindsight's ``local_embedded`` mode installs - ``hindsight-all`` (daemon + embedder + client) during - ``hermes memory setup`` — if the update-time refresh only reinstalled - the declared ``hindsight-client``, the embedded daemon would stay - broken after a venv rebuild stripped ``hindsight-embed`` (#70636). + ``plugin.yaml`` declares the provider's baseline bridge packages, but some providers install + mode-dependent extras at setup time that the manifest can't express. """ deps = list(declared or []) if provider_name == "hindsight": @@ -55,11 +45,7 @@ def _curses_select( *, cancel_returns: int | None = None, ) -> int: - """Interactive single-select with arrow keys. - - items: list of (label, description) tuples. - Returns selected index, or cancel_returns/default on escape/quit. - """ + """Interactive single-select with arrow keys.""" from hermes_cli.curses_ui import curses_radiolist if cancel_returns is None: @@ -106,12 +92,9 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str def _install_dependencies(provider_name: str, *, force: bool = False) -> None: """Install pip dependencies declared in ``plugin.yaml``. - When ``force`` is true, every declared dependency is handed to the - installer even if its import currently succeeds — the resolver then - reinstalls anything missing or version-drifted and no-ops on satisfied - ranges. This is how ``hermes update`` heals the active memory provider - after a venv rebuild/sync removed or downgraded its bridge packages - (#53272, #70636). + With ``force`` every declared dependency goes to the installer even if it imports; the resolver + reinstalls anything missing or version-drifted and no-ops otherwise. This is how ``hermes + update`` heals the active memory provider after a venv rebuild removed its bridge packages. """ import subprocess from plugins.memory import find_provider_dir @@ -202,10 +185,7 @@ def _install_dependencies(provider_name: str, *, force: bool = False) -> None: def _get_available_providers() -> list: - """Discover memory providers from plugins/memory/. - - Returns list of (name, description, provider_instance) tuples. - """ + """Discover memory providers from plugins/memory/.""" try: from plugins.memory import discover_memory_providers, load_memory_provider raw = discover_memory_providers() @@ -429,32 +409,12 @@ def _write_env_vars( ) -> None: """Persist memory-provider env vars through the canonical ``.env`` writer. - Delegates to ``hermes_cli.config.save_env_value`` so every key flows - through the same input-validation gate as every other ``.env`` writer: - the ``_ENV_VAR_NAME_RE`` regex (no malformed identifiers), the - ``_ENV_VAR_NAME_DENYLIST`` (no ``LD_PRELOAD`` / ``PYTHONPATH`` / - ``HERMES_HOME`` / etc.), CR/LF stripping on the value, and the atomic - 0o600-from-creation write (no TOCTOU permission window). This function - previously wrote via ``Path.write_text`` directly, bypassing all of - that: a memory-provider plugin schema declaring ``env_var: "LD_PRELOAD"`` - would land in ``.env`` verbatim and load via the ``env_loader.py`` - ``.env`` -> ``os.environ`` chain on the next Hermes startup, and the - file existed at the default umask between the write and the later - ``chmod`` regardless of key legitimacy. - - Validation failures (``ValueError`` from ``save_env_value`` — a - denylisted name or an identifier rejected by ``_ENV_VAR_NAME_RE``) are - surfaced and skipped rather than aborting the wizard, so a single bad - key from one schema field doesn't take down the rest of the batch. - Non-validation errors (filesystem failures, permission errors) are - intentionally NOT caught — those indicate the wizard cannot safely - persist any subsequent key either and should propagate. - - ``hermes_home`` may be supplied by plugin ``post_setup`` hooks that - already received an explicit home directory (e.g. a non-default - profile). It is applied through the context-local Hermes home override - so ``save_env_value`` still owns the validation, sanitization, and - atomic-write path without mutating global ``os.environ``. + Delegates to ``save_env_value`` so keys pass the same gate as every other ``.env`` writer: name + regex, denylist (``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME``…), CR/LF stripping and atomic + 0o600 writes. A direct write previously let a plugin schema inject ``LD_PRELOAD`` into ``.env``. + ``ValueError`` from validation is reported and skipped so one bad key doesn't sink the batch; + filesystem errors propagate since later keys can't be persisted safely either. ``hermes_home`` + is applied via the context-local home override, not by mutating ``os.environ``. """ from hermes_cli.config import save_env_value from hermes_constants import reset_hermes_home_override, set_hermes_home_override diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index e8c00e8200..15c012f8a5 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -1,8 +1,8 @@ """Hermes middleware contract helpers. -Observer hooks report what happened. Middleware can change what happens by -rewriting a request or wrapping the actual execution callback. Keep the small -contract helpers here so agent-loop call sites and plugins share one vocabulary. +Observer hooks report what happened. Middleware can change what happens by rewriting a request or +wrapping the actual execution callback. Keep the small contract helpers here so agent-loop call +sites and plugins share one vocabulary. """ from __future__ import annotations @@ -22,10 +22,6 @@ TOOL_EXECUTION_MIDDLEWARE = "tool_execution" LLM_REQUEST_MIDDLEWARE = "llm_request" LLM_EXECUTION_MIDDLEWARE = "llm_execution" -# Back-compat aliases for older PoC branches that used API terminology. -API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE -API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE - VALID_MIDDLEWARE: set[str] = { TOOL_REQUEST_MIDDLEWARE, TOOL_EXECUTION_MIDDLEWARE, @@ -58,12 +54,9 @@ def middleware_payload(**kwargs: Any) -> Dict[str, Any]: def _safe_copy(payload: Any) -> Any: """Deep-copy a request payload, tolerating non-deepcopyable members. - Request payloads are normally plain JSON-shaped dicts, but an LLM request - can occasionally carry non-deepcopyable objects (clients, callbacks, file - handles). A hard ``deepcopy`` failure there would otherwise abort the whole - request-middleware pass. Fall back to a shallow ``dict`` copy so middleware - still runs and the original nested objects are shared by reference rather - than corrupting the live payload. + Request payloads are normally plain JSON-shaped dicts, but an LLM request can occasionally carry + non-deepcopyable objects (clients, callbacks, file handles). A hard ``deepcopy`` failure there + would otherwise abort the whole request-middleware pass. """ try: return deepcopy(payload) @@ -74,41 +67,56 @@ def _safe_copy(payload: Any) -> Any: return payload +def _apply_request_chain( + kind: str, + payload_key: str, + trace: List[Dict[str, Any]], + **kwargs: Any, +) -> Dict[str, Any]: + """Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``.""" + from hermes_cli.plugins import invoke_middleware + + current = kwargs[payload_key] + for result in invoke_middleware(kind, **middleware_payload(**kwargs)): + if not isinstance(result, dict): + continue + next_payload = result.get(payload_key) + if not isinstance(next_payload, dict): + continue + current = _safe_copy(next_payload) + entry = { + key: value + for key in ("source", "reason", "name") + if isinstance(value := result.get(key), str) and value + } + trace.append(entry or {"source": "plugin"}) + return current + + def apply_llm_request_middleware( request: Dict[str, Any], **context: Any, ) -> RequestMiddlewareResult: """Apply registered LLM request middleware. - Middleware may return ``{"request": {...}}`` to replace the effective - provider kwargs before Hermes sends them. + Middleware may return ``{"request": {...}}`` to replace the effective provider kwargs before + Hermes sends them. """ - if not _has_middleware(LLM_REQUEST_MIDDLEWARE): - return RequestMiddlewareResult( - payload=request, - original_payload=request, - changed=False, - trace=[], - ) + from hermes_cli.plugins import has_middleware + + if not has_middleware(LLM_REQUEST_MIDDLEWARE): + return RequestMiddlewareResult(payload=request, original_payload=request) original_request = _safe_copy(request) - current_request = _safe_copy(original_request) trace: List[Dict[str, Any]] = [] - - for result in _invoke_middleware( + current_request = _apply_request_chain( LLM_REQUEST_MIDDLEWARE, - request=current_request, + "request", + trace, + request=_safe_copy(original_request), original_request=original_request, **context, - ): - if not isinstance(result, dict): - continue - next_request = result.get("request") - if not isinstance(next_request, dict): - continue - current_request = _safe_copy(next_request) - trace.append(_trace_entry(result)) - + ) return RequestMiddlewareResult( payload=current_request, original_payload=original_request, @@ -124,8 +132,8 @@ def apply_tool_request_middleware( ) -> RequestMiddlewareResult: """Apply registered tool request middleware. - Middleware may return ``{"args": {...}}`` to replace the effective tool - arguments before hooks, guardrails, approvals, and execution see them. + Middleware may return ``{"args": {...}}`` to replace the effective tool arguments before hooks, + guardrails, approvals, and execution see them. """ original_args = _safe_copy(args) current_args = _safe_copy(original_args) @@ -145,7 +153,9 @@ def apply_tool_request_middleware( current_args = _safe_copy(relay_args) trace.append({"source": "nemo_relay"}) - if not _has_middleware(TOOL_REQUEST_MIDDLEWARE): + from hermes_cli.plugins import has_middleware + + if not has_middleware(TOOL_REQUEST_MIDDLEWARE): return RequestMiddlewareResult( payload=args if not trace else current_args, original_payload=args, @@ -153,21 +163,15 @@ def apply_tool_request_middleware( trace=trace, ) - for result in _invoke_middleware( + current_args = _apply_request_chain( TOOL_REQUEST_MIDDLEWARE, + "args", + trace, tool_name=tool_name, args=current_args, original_args=original_args, **context, - ): - if not isinstance(result, dict): - continue - next_args = result.get("args") - if not isinstance(next_args, dict): - continue - current_args = _safe_copy(next_args) - trace.append(_trace_entry(result)) - + ) return RequestMiddlewareResult( payload=current_args, original_payload=original_args, @@ -176,26 +180,14 @@ def apply_tool_request_middleware( ) -def apply_api_request_middleware( - request: Dict[str, Any], - **context: Any, -) -> RequestMiddlewareResult: - """Compatibility wrapper for older ``api_request`` naming.""" - return apply_llm_request_middleware(request, **context) - - def run_llm_execution_middleware( request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any, ) -> Any: """Run provider execution through registered LLM execution middleware.""" - callbacks = _get_middleware_callbacks(LLM_EXECUTION_MIDDLEWARE) - if not callbacks: - return next_call(request) return _run_execution_chain( LLM_EXECUTION_MIDDLEWARE, - callbacks, next_call, request=request, original_request=context.pop("original_request", request), @@ -210,12 +202,8 @@ def run_tool_execution_middleware( **context: Any, ) -> Any: """Run tool execution through registered tool execution middleware.""" - callbacks = _get_middleware_callbacks(TOOL_EXECUTION_MIDDLEWARE) - if not callbacks: - return next_call(args) return _run_execution_chain( TOOL_EXECUTION_MIDDLEWARE, - callbacks, next_call, tool_name=tool_name, args=args, @@ -224,40 +212,17 @@ def run_tool_execution_middleware( ) -def run_api_execution_middleware( - request: Dict[str, Any], - next_call: Callable[[Dict[str, Any]], Any], - **context: Any, -) -> Any: - """Compatibility wrapper for older ``api_execution`` naming.""" - return run_llm_execution_middleware(request, next_call, **context) - - -def _invoke_middleware(kind: str, **kwargs: Any) -> List[Any]: - from hermes_cli.plugins import invoke_middleware - - return invoke_middleware(kind, **middleware_payload(**kwargs)) - - -def _has_middleware(kind: str) -> bool: - from hermes_cli.plugins import has_middleware - - return has_middleware(kind) - - -def _get_middleware_callbacks(kind: str) -> List[Callable]: - from hermes_cli.plugins import get_plugin_manager - - return list(get_plugin_manager()._middleware.get(kind, [])) - - def _run_execution_chain( kind: str, - callbacks: List[Callable], terminal_call: Callable[[Any], Any], **kwargs: Any, ) -> Any: + from hermes_cli.plugins import get_plugin_manager + payload_key = "request" if "request" in kwargs else "args" + callbacks = list(get_plugin_manager()._middleware.get(kind, [])) + if not callbacks: + return terminal_call(kwargs[payload_key]) class _DownstreamExecutionError(Exception): def __init__(self, original: BaseException) -> None: @@ -314,14 +279,3 @@ def _run_execution_chain( return call_at(index + 1, payload) return call_at(0, kwargs[payload_key]) - - -def _trace_entry(result: Dict[str, Any]) -> Dict[str, Any]: - entry: Dict[str, Any] = {} - for key in ("source", "reason", "name"): - value = result.get(key) - if isinstance(value, str) and value: - entry[key] = value - if not entry: - entry["source"] = "plugin" - return entry diff --git a/hermes_cli/moa_cmd.py b/hermes_cli/moa_cmd.py index 8fcd8174e6..753fc5fda0 100644 --- a/hermes_cli/moa_cmd.py +++ b/hermes_cli/moa_cmd.py @@ -40,13 +40,7 @@ def _model_options() -> list[dict[str, Any]]: max_models=200, ) providers = payload.get("providers") or [] - return [ - p - for p in providers - if p.get("slug") - and str(p.get("slug")).strip().lower() != "moa" - and p.get("models") - ] + return [p for p in providers if p.get("slug") and str(p.get("slug")).strip().lower() != "moa" and p.get("models")] def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]: @@ -54,10 +48,7 @@ def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]: if not providers: raise RuntimeError("No configured model providers found. Run `hermes model` first.") current_provider = (current or {}).get("provider", "") - provider_default = next( - (idx for idx, p in enumerate(providers) if p.get("slug") == current_provider), - 0, - ) + provider_default = next((idx for idx, p in enumerate(providers) if p.get("slug") == current_provider), 0) provider_rows = [f"{p.get('name') or p.get('slug')} ({p.get('slug')})" for p in providers] provider = providers[_prompt_choice("Select provider", provider_rows, provider_default)] models = list(provider.get("models") or []) @@ -76,77 +67,90 @@ def _format_slot(slot: dict[str, Any]) -> str: def _print_config(config: dict[str, Any]) -> None: - cfg = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {}) + cfg = _moa_section(config) print("Mixture of Agents presets") print(f"Default: {cfg['default_preset']}") - active = cfg.get("active_preset") or "(off)" - print(f"Active in config: {active}") + print(f"Active in config: {cfg.get('active_preset') or '(off)'}") for name, preset in cfg["presets"].items(): marker = "*" if name == cfg["default_preset"] else " " print(f"\n{marker} {name}") print(" Reference models:") for idx, slot in enumerate(preset["reference_models"], start=1): print(f" {idx}. {_format_slot(slot)}") - agg = preset["aggregator"] - print(f" Aggregator: {_format_slot(agg)}") + print(f" Aggregator: {_format_slot(preset['aggregator'])}") + + +def _moa_section(cfg: Any) -> dict[str, Any]: + return normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {}) + + +def _save(cfg: dict, moa: dict[str, Any]) -> None: + cfg["moa"] = normalize_moa_config(moa) + save_config(cfg) + + +def _cmd_list(cfg: dict, args) -> None: + _print_config(cfg) + + +def _cmd_configure(cfg: dict, args) -> None: + moa = _moa_section(cfg) + preset_name = (getattr(args, "name", None) or moa.get("default_preset") or DEFAULT_MOA_PRESET_NAME).strip() + current = moa["presets"].get(preset_name, moa["presets"][moa["default_preset"]]) + print(f"Configure MoA preset: {preset_name}") + print("Pick at least one reference model; choose Done when finished.") + refs: list[dict[str, str]] = [] + existing = list(current.get("reference_models") or []) + while True: + base = existing[len(refs)] if len(refs) < len(existing) else None + picked = _pick_slot(base) + picked["enabled"] = bool((base or {}).get("enabled", True)) + refs.append(picked) + if _prompt_choice("Add another reference model?", ["Add another", "Done"], 1) == 1: + break + print("Configure aggregator model.") + current = dict(current) + current["reference_models"] = refs + current["aggregator"] = _pick_slot(current.get("aggregator")) + moa["presets"][preset_name] = current + moa.setdefault("default_preset", preset_name) + _save(cfg, moa) + print(f"Saved MoA preset: {preset_name}") + _print_config(cfg) + + +def _cmd_delete(cfg: dict, args) -> None: + moa = _moa_section(cfg) + preset_name = (getattr(args, "name", None) or "").strip() + if not preset_name: + raise SystemExit("Usage: hermes moa delete <name>") + if preset_name not in moa["presets"]: + raise SystemExit(f"Unknown MoA preset: {preset_name}") + if len(moa["presets"]) <= 1: + raise SystemExit("Cannot delete the only MoA preset") + del moa["presets"][preset_name] + if moa["default_preset"] == preset_name: + moa["default_preset"] = next(iter(moa["presets"])) + if moa.get("active_preset") == preset_name: + moa["active_preset"] = "" + _save(cfg, moa) + print(f"Deleted MoA preset: {preset_name}") + + +_SUBCOMMANDS = { + "list": _cmd_list, + "ls": _cmd_list, + "config": _cmd_configure, + "configure": _cmd_configure, + "delete": _cmd_delete, +} def cmd_moa(args) -> None: """Manage Mixture of Agents model presets.""" cfg = load_config() sub = getattr(args, "moa_command", None) or "list" - - if sub in {"list", "ls"}: - _print_config(cfg) - return - - if sub in {"config", "configure"}: - moa = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {}) - preset_name = (getattr(args, "name", None) or moa.get("default_preset") or DEFAULT_MOA_PRESET_NAME).strip() - current = moa["presets"].get(preset_name, moa["presets"][moa["default_preset"]]) - print(f"Configure MoA preset: {preset_name}") - print("Pick at least one reference model; choose Done when finished.") - refs: list[dict[str, str]] = [] - existing = list(current.get("reference_models") or []) - idx = 0 - while True: - base = existing[idx] if idx < len(existing) else None - picked = _pick_slot(base) - picked["enabled"] = bool((base or {}).get("enabled", True)) - refs.append(picked) - idx += 1 - choice = _prompt_choice("Add another reference model?", ["Add another", "Done"], 1) - if choice == 1: - break - print("Configure aggregator model.") - current = dict(current) - current["reference_models"] = refs - current["aggregator"] = _pick_slot(current.get("aggregator")) - moa["presets"][preset_name] = current - moa.setdefault("default_preset", preset_name) - cfg["moa"] = normalize_moa_config(moa) - save_config(cfg) - print(f"Saved MoA preset: {preset_name}") - _print_config(cfg) - return - - if sub == "delete": - moa = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {}) - preset_name = (getattr(args, "name", None) or "").strip() - if not preset_name: - raise SystemExit("Usage: hermes moa delete <name>") - if preset_name not in moa["presets"]: - raise SystemExit(f"Unknown MoA preset: {preset_name}") - if len(moa["presets"]) <= 1: - raise SystemExit("Cannot delete the only MoA preset") - del moa["presets"][preset_name] - if moa["default_preset"] == preset_name: - moa["default_preset"] = next(iter(moa["presets"])) - if moa.get("active_preset") == preset_name: - moa["active_preset"] = "" - cfg["moa"] = normalize_moa_config(moa) - save_config(cfg) - print(f"Deleted MoA preset: {preset_name}") - return - - raise SystemExit(f"Unknown moa subcommand: {sub}") + handler = _SUBCOMMANDS.get(sub) + if handler is None: + raise SystemExit(f"Unknown moa subcommand: {sub}") + handler(cfg, args) diff --git a/hermes_cli/observability/relay_runtime.py b/hermes_cli/observability/relay_runtime.py index bd6de0b837..0964d235aa 100644 --- a/hermes_cli/observability/relay_runtime.py +++ b/hermes_cli/observability/relay_runtime.py @@ -1,9 +1,4 @@ -"""Compatibility alias for the core Hermes Relay runtime. - -New code should import :mod:`agent.relay_runtime`. This module remains an -alias, rather than a copy, so existing plugins and tests share the same -profile registry and test-reset state during the migration. -""" +"""Compatibility alias for the core Hermes Relay runtime.""" from __future__ import annotations diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index 5a97c8a18d..d7cc71cc6a 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -62,6 +62,15 @@ _RUNTIMES: dict[str, _Runtime | object] = {} _RUNTIME_LOCK = threading.RLock() +def _session_pair(event: dict[str, Any], key: str) -> tuple[str, str] | None: + """(session_id, event[key]) when both are non-empty.""" + session_id = str(event.get("session_id") or "") + value = str(event.get(key) or "") + if not session_id or not value: + return None + return session_id, value + + def _retry_ordinal(event: dict[str, Any]) -> int | None: value = event.get("retry_count") if isinstance(value, int) and not isinstance(value, bool) and value >= 0: @@ -287,13 +296,9 @@ class _Runtime: def start_model_call(self, event: dict[str, Any]) -> None: task_id = str(event.get("task_id") or "") - session = self._task_session(event, allow_task_id_fallback=True) - task = session.tasks.get(task_id) if session is not None else None - if task is None: - task = self.start_task(event) - session = self._task_session(event) if task is not None else None - if task_id and task is None: - return + session, task = self._task_for(event, start=True) + if task_id and task is None: + return if session is None: session = self.ensure_session(event) if session is None: @@ -335,58 +340,39 @@ class _Runtime: # A real Hermes retry can advance api_request_id while # carrying the retry ordinal. Count that physical attempt. task.retry_count += 1 - handle = self._run_in_task( - task, - self.relay.llm.call, - MODEL_CALL_SCOPE, - self.relay.LLMRequest({}, {}), - handle=task.handle, - metadata=self._event_metadata(), - model_name=MODEL_CALL_PROFILE_MODEL, - ) - else: - handle = self._run_in_session( - session, - self.relay.llm.call, - MODEL_CALL_SCOPE, - self.relay.LLMRequest({}, {}), - handle=session.relay_session.handle, - metadata=self._event_metadata(), - model_name=MODEL_CALL_PROFILE_MODEL, - ) + handle = self._run_scoped( + session, + task, + self.relay.llm.call, + MODEL_CALL_SCOPE, + self.relay.LLMRequest({}, {}), + handle=task.handle if task is not None else session.relay_session.handle, + metadata=self._event_metadata(), + model_name=MODEL_CALL_PROFILE_MODEL, + ) session.model_calls[model_call_key] = _ModelCall( handle=handle, - task_id=str(event.get("task_id") or ""), + task_id=task_id, fields=fields, retry_ordinal=retry_ordinal, ) def record_model_call_error(self, event: dict[str, Any]) -> None: """Retain the latest attempt error without closing the logical call.""" - session = self._task_session(event, allow_task_id_fallback=True) - if session is None: - session = self._session(event) + session = self._any_session(event) if session is None: return with session.lock: if session.closing: return - model_call_key = self._existing_model_call_key(session, event) - if model_call_key is None: - return - model_call = session.model_calls.get(model_call_key) - if model_call is None: - return - model_call.fields = model_call_fields(event) + located = self._model_call_for(session, event) + if located is not None: + located[1].fields = model_call_fields(event) def start_tool_call(self, event: dict[str, Any]) -> None: """Open one privacy-safe Relay tool lifecycle under its task.""" task_id = str(event.get("task_id") or "") - session = self._task_session(event, allow_task_id_fallback=True) - task = session.tasks.get(task_id) if session is not None else None - if task is None: - task = self.start_task(event) - session = self._task_session(event) if task is not None else None + session, task = self._task_for(event, start=True) if session is None or task is None: return tool_call_id = str(event.get("tool_call_id") or "") @@ -422,15 +408,9 @@ class _Runtime: identity = self._tool_call_identity(event) tool_call = session.tool_calls.get((task.task_id, *identity)) if tool_call is None: - matching_keys = [ - key - for key in session.tool_calls - if key[0] == task.task_id - and self._tool_call_identities_are_compatible( - key[1:], - identity, - ) - ] + matching_keys = self._compatible_tool_call_keys( + session, task.task_id, identity + ) tool_call = ( session.tool_calls[matching_keys[0]] if len(matching_keys) == 1 @@ -451,8 +431,7 @@ class _Runtime: def record_tool_call(self, event: dict[str, Any]) -> None: """Close and count one unique privacy-safe tool lifecycle.""" task_id = str(event.get("task_id") or "") - session = self._task_session(event, allow_task_id_fallback=True) - task = session.tasks.get(task_id) if session is not None else None + session, task = self._task_for(event, start=False) if session is None or task is None: return tool_call_id = str(event.get("tool_call_id") or "") @@ -477,15 +456,9 @@ class _Runtime: for completed_identity in task.completed_tool_call_ids ): return - matching_keys = [ - key - for key in session.tool_calls - if key[0] == task_id - and self._tool_call_identities_are_compatible( - key[1:], - observed_identity, - ) - ] + matching_keys = self._compatible_tool_call_keys( + session, task_id, observed_identity + ) if len(matching_keys) > 1: # Partial context cannot safely choose between # concurrent calls that reused the provider-local ID. @@ -556,45 +529,23 @@ class _Runtime: ) def end_model_call(self, event: dict[str, Any]) -> None: - session = self._task_session(event, allow_task_id_fallback=True) - if session is None: - session = self._session(event) + session = self._any_session(event) if session is None: return with session.lock: if session.closing: return - model_call_key = self._existing_model_call_key(session, event) - if model_call_key is None: + located = self._model_call_for(session, event) + if located is None: return - model_call = session.model_calls.get(model_call_key) - if model_call is None: - return - fields = model_call_fields(event) - model_call.fields = fields - self._finish_model_call( - session, - model_call_key, - ) - - def end_pending_model_calls(self, event: dict[str, Any]) -> None: - session = self._task_session(event, allow_task_id_fallback=True) - if session is None: - session = self._session(event) - if session is None: - return - with session.lock: - if session.closing: - return - self._end_pending_model_calls(session, event) + model_call_key, model_call = located + model_call.fields = model_call_fields(event) + self._finish_model_call(session, model_call_key) def finish_task(self, event: dict[str, Any]) -> None: """Close one task scope exactly once with bounded terminal fields.""" task_id = str(event.get("task_id") or "") - session = self._task_session( - event, - allow_task_id_fallback=True, - ) or self._session(event) + session = self._any_session(event) if session is None: return with session.lock: @@ -602,15 +553,7 @@ class _Runtime: return finished = self._finish_task(session, task_id, event) if finished: - try: - self.relay.subscribers.flush() - except Exception: - logger.warning( - "Hermes shared-metrics task flush failed", - exc_info=True, - ) - else: - self._export() + self._flush_and_export("Hermes shared-metrics task flush failed") def close_session(self, event: dict[str, Any]) -> None: session = self._session(event) @@ -621,20 +564,16 @@ class _Runtime: if session.closing: return session.closing = True - for task_id in list(session.tasks): - self._finish_task( - session, - task_id, - { - **event, - "task_id": task_id, - "completed": False, - "failed": True, - "interrupted": False, - "turn_exit_reason": "system_aborted", - }, - ) - self._end_pending_model_calls(session, event) + self._abort_tasks( + session, + { + **event, + "completed": False, + "failed": True, + "interrupted": False, + "turn_exit_reason": "system_aborted", + }, + ) try: self.relay.subscribers.flush() except Exception as exc: @@ -659,28 +598,20 @@ class _Runtime: self._safe(self.close_session, {"session_id": session_id}) if not self._registered: return - try: - self.relay.subscribers.flush() - except Exception: - logger.warning( - "Hermes shared-metrics shutdown flush failed", - exc_info=True, - ) - else: - self._export() - self._safe(self.relay.subscribers.deregister, self._subscriber_name) - self.host.release_managed_execution(self._subscriber_name) - self._registered = False + self._flush_and_export("Hermes shared-metrics shutdown flush failed") + self._deregister() # The final export above may have started a send. Give it the same # bounded chance to finish that deactivate() gets — without this a # short-lived CLI process exits immediately and kills the daemon # thread mid-request, which is the common case for the one cadence # this feature has. self._join_send_thread() - try: - atexit.unregister(self.shutdown) - except Exception: - pass + self._unregister_atexit() + + def _deregister(self) -> None: + self._safe(self.relay.subscribers.deregister, self._subscriber_name) + self.host.release_managed_execution(self._subscriber_name) + self._registered = False def deactivate(self) -> None: """Stop collection without exporting locally aggregated metrics.""" @@ -688,9 +619,7 @@ class _Runtime: self._active = False self.subscriber.deactivate() if self._registered: - self._safe(self.relay.subscribers.deregister, self._subscriber_name) - self.host.release_managed_execution(self._subscriber_name) - self._registered = False + self._deregister() with self._sessions_lock: sessions = list(self._sessions.values()) for session in sessions: @@ -698,36 +627,27 @@ class _Runtime: if session.closing: continue session.closing = True - for task_id in list(session.tasks): - self._finish_task( - session, - task_id, - { - "session_id": session.session_id, - "task_id": task_id, - "failed": True, - "turn_exit_reason": "system_aborted", - }, - ) - self._end_pending_model_calls(session, {}) + self._abort_tasks( + session, + { + "session_id": session.session_id, + "failed": True, + "turn_exit_reason": "system_aborted", + }, + ) with self._sessions_lock: self._sessions.clear() with self._task_sessions_lock: self._task_sessions.clear() self._turn_sessions.clear() self._join_send_thread() - try: - atexit.unregister(self.shutdown) - except Exception: - pass + self._unregister_atexit() def _join_send_thread(self, timeout: float = 2.0) -> None: """Give an in-flight send a brief chance to finish at exit. - Bounded on purpose: the packages stay pending in SQLite and go out on - the next run, so blocking a user's shutdown for a slow network is the - wrong trade. The thread is a daemon, so an unfinished pass dies with - the process rather than holding it open. + Bounded on purpose: pending packages stay in SQLite and go out next run, so blocking + shutdown on a slow network is the wrong trade. The daemon thread dies with the process. """ with self._send_lock: thread = self._send_thread @@ -743,13 +663,76 @@ class _Runtime: with self._sessions_lock: return self._sessions.get(session_id) + def _any_session(self, event: dict[str, Any]) -> _MetricsSession | None: + """Owner session by task/turn correlation, else by session_id.""" + return self._task_session( + event, allow_task_id_fallback=True + ) or self._session(event) + + def _task_for( + self, + event: dict[str, Any], + *, + start: bool, + ) -> tuple[_MetricsSession | None, _TaskRun | None]: + """Resolve (session, task) for a task-scoped hook, optionally opening the task.""" + task_id = str(event.get("task_id") or "") + session = self._task_session(event, allow_task_id_fallback=True) + task = session.tasks.get(task_id) if session is not None else None + if task is None and start: + task = self.start_task(event) + session = self._task_session(event) if task is not None else None + return session, task + + def _model_call_for( + self, + session: _MetricsSession, + event: dict[str, Any], + ) -> tuple[tuple[str, str], _ModelCall] | None: + model_call_key = self._existing_model_call_key(session, event) + if model_call_key is None: + return None + model_call = session.model_calls.get(model_call_key) + if model_call is None: + return None + return model_call_key, model_call + + def _run_scoped( + self, + session: _MetricsSession, + task: _TaskRun | None, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + """Run under the task context when the call belongs to a task, else the session.""" + if task is not None: + return self._run_in_task(task, callback, *args, **kwargs) + return self._run_in_session(session, callback, *args, **kwargs) + + def _flush_and_export(self, failure_message: str) -> None: + try: + self.relay.subscribers.flush() + except Exception: + logger.warning(failure_message, exc_info=True) + else: + self._export() + + def _abort_tasks(self, session: _MetricsSession, base_event: dict[str, Any]) -> None: + """Close every open task of a closing session as system-aborted (caller holds the lock).""" + for task_id in list(session.tasks): + self._finish_task(session, task_id, {**base_event, "task_id": task_id}) + self._end_pending_model_calls(session, base_event) + + def _unregister_atexit(self) -> None: + try: + atexit.unregister(self.shutdown) + except Exception: + pass + @staticmethod def _task_key(event: dict[str, Any]) -> tuple[str, str] | None: - session_id = str(event.get("session_id") or "") - task_id = str(event.get("task_id") or "") - if not session_id or not task_id: - return None - return session_id, task_id + return _session_pair(event, "task_id") def _task_session( self, @@ -784,11 +767,7 @@ class _Runtime: @staticmethod def _turn_key(event: dict[str, Any]) -> tuple[str, str] | None: - session_id = str(event.get("session_id") or "") - turn_id = str(event.get("turn_id") or "") - if not session_id or not turn_id: - return None - return session_id, turn_id + return _session_pair(event, "turn_id") def _remember_turn( self, @@ -831,6 +810,20 @@ class _Runtime: ) ) + @classmethod + def _compatible_tool_call_keys( + cls, + session: _MetricsSession, + task_id: str, + identity: tuple[str, str, str], + ) -> list[tuple[str, str, str, str]]: + return [ + key + for key in session.tool_calls + if key[0] == task_id + and cls._tool_call_identities_are_compatible(key[1:], identity) + ] + @staticmethod def _event_matches_task_turn( task: _TaskRun, @@ -968,23 +961,14 @@ class _Runtime: if model_call is None: return try: - task = session.tasks.get(model_call.task_id) - if task is not None: - self._run_in_task( - task, - self.relay.llm.call_end, - model_call.handle, - model_call.fields, - metadata=self._event_metadata(), - ) - else: - self._run_in_session( - session, - self.relay.llm.call_end, - model_call.handle, - model_call.fields, - metadata=self._event_metadata(), - ) + self._run_scoped( + session, + session.tasks.get(model_call.task_id), + self.relay.llm.call_end, + model_call.handle, + model_call.fields, + metadata=self._event_metadata(), + ) except Exception: logger.warning( "Hermes shared-metrics model call close failed", exc_info=True @@ -1002,10 +986,7 @@ class _Runtime: if not task_id or model_call.task_id == task_id ] for model_call_key in model_call_keys: - self._finish_model_call( - session, - model_call_key, - ) + self._finish_model_call(session, model_call_key) @staticmethod def _new_model_call_key(event: dict[str, Any]) -> tuple[str, str] | None: @@ -1086,24 +1067,12 @@ class _Runtime: def _observe_send_consent(self, send_enabled: bool) -> None: """Reconcile consent windows with the observed config state. - Thin wrapper over the SINGLE consent writer. The old edge-detection - body (last-seen key, rising/falling branches) is gone: reconciliation - derives the correct window state from what it observes, so there is - no transition to miss and no ordering between callers to get wrong. - - Failures must never break the export hook, but they are logged at - warning rather than debug: silently failing to close a consent window - is a privacy-relevant event, not routine bookkeeping. + Failures must never break the export hook, but they are logged at warning rather than debug: + silently failing to close a consent window is a privacy-relevant event, not routine + bookkeeping. """ try: - from hermes_cli.observability.shared_metrics_sender import ( - reconcile_send_consent, - ) - from hermes_cli.sqlite_util import write_txn - - with self.subscriber.store._connection() as connection: - with write_txn(connection): - reconcile_send_consent(connection, send_enabled) + _reconcile_store_consent(self.subscriber.store, send_enabled) except Exception: logger.warning( "Unable to record a shared-metrics consent transition", @@ -1111,20 +1080,12 @@ class _Runtime: ) def _send_exported_packages(self) -> None: - from hermes_cli.observability.shared_metrics_send_config import ( - resolve_send_config, - ) - try: - from hermes_cli.config import read_raw_config_readonly - - config = read_raw_config_readonly() or {} + resolved = _resolved_send_config() except Exception: logger.debug("Unable to read shared-metrics send policy", exc_info=True) return - resolved = resolve_send_config(config) - # Observe the consent EDGE before deciding whether to send. Recording # revocation inside the send loop (as an earlier fix did) can never # work: the dominant case is the user turning sending off while no @@ -1157,12 +1118,7 @@ class _Runtime: def still_consented() -> bool: """Re-read consent so revoking `send` stops an in-flight pass.""" - from hermes_cli.config import read_raw_config_readonly - from hermes_cli.observability.shared_metrics_send_config import ( - resolve_send_config, - ) - - resolved = resolve_send_config(read_raw_config_readonly() or {}) + resolved = _resolved_send_config() return resolved.send and resolved.endpoint == endpoint try: @@ -1189,6 +1145,27 @@ class _Runtime: return None +def _resolved_send_config(): + """Resolve the opt-in send policy from the read-only config snapshot.""" + from hermes_cli.config import read_raw_config_readonly + from hermes_cli.observability.shared_metrics_send_config import ( + resolve_send_config, + ) + + return resolve_send_config(read_raw_config_readonly() or {}) + + +def _reconcile_store_consent(store: SharedMetricsStore, send_enabled: bool) -> None: + from hermes_cli.observability.shared_metrics_sender import ( + reconcile_send_consent, + ) + from hermes_cli.sqlite_util import write_txn + + with store._connection() as connection: + with write_txn(connection): + reconcile_send_consent(connection, send_enabled) + + def enabled() -> bool: """Return the shared-metrics policy for the active Hermes profile.""" profile_key = relay_runtime.current_profile_key() @@ -1209,9 +1186,7 @@ def enabled() -> bool: shared_metrics = ( telemetry.get("shared_metrics") if isinstance(telemetry, dict) else None ) - value = ( - isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True - ) + value = isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True if value: return True with _RUNTIME_LOCK: @@ -1231,34 +1206,24 @@ _consent_reconcile_done = False def _reconcile_send_consent_once() -> None: """Reconcile consent windows with config, once per process. - Runs BEFORE and INDEPENDENT of the collection gate — that placement is - the fix for the round-5 D1 leak, where the only idle-path consent - observer sat behind ``handles_hook()`` and became dead code the moment - ``enabled: false`` was set. A user with collection off still gets their - send-consent windows reconciled here. + Runs BEFORE and INDEPENDENT of the collection gate — that placement is the fix for the round-5 + D1 leak, where the only idle-path consent observer sat behind ``handles_hook()`` and became dead + code the moment ``enabled: false`` was set. A user with collection off still gets their send- + consent windows reconciled here. - Skipped only when there is no store on disk AND consent is off: with no - store there are no packages, so there is nothing a window could protect, - and creating ``~/.hermes/telemetry`` for every fully-disabled user would - be a behaviour change in the wrong direction. + Skipped only when there is no store on disk AND consent is off: with no store there are no + packages, so there is nothing a window could protect, and creating ``~/.hermes/telemetry`` for + every fully-disabled user would be a behaviour change in the wrong direction. """ global _consent_reconcile_done if _consent_reconcile_done: return _consent_reconcile_done = True try: - from hermes_cli.config import read_raw_config_readonly from hermes_cli.observability.shared_metrics import SharedMetricsStore - from hermes_cli.observability.shared_metrics_send_config import ( - resolve_send_config, - ) - from hermes_cli.observability.shared_metrics_sender import ( - reconcile_send_consent, - ) - from hermes_cli.sqlite_util import write_txn from hermes_constants import get_hermes_home - resolved = resolve_send_config(read_raw_config_readonly() or {}) + resolved = _resolved_send_config() # Probe for an existing store WITHOUT constructing one: the # constructor creates the directory and schema as a side effect, # which round 6 caught making this skip dead code — every @@ -1268,10 +1233,7 @@ def _reconcile_send_consent_once() -> None: ) if not resolved.send and not default_path.exists(): return - store = SharedMetricsStore() - with store._connection() as connection: - with write_txn(connection): - reconcile_send_consent(connection, resolved.send) + _reconcile_store_consent(SharedMetricsStore(), resolved.send) except Exception: logger.warning( "Unable to reconcile shared-metrics send consent", exc_info=True @@ -1289,32 +1251,7 @@ def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: if runtime is None: return try: - if hook_name == "on_session_start": - runtime.record_client_active(kwargs) - elif hook_name == "pre_llm_call": - runtime.start_task(kwargs) - elif hook_name == "pre_api_request": - runtime.start_model_call(kwargs) - elif hook_name == "pre_tool_call": - runtime.start_tool_call(_with_runtime_toolset(kwargs)) - elif hook_name == "post_tool_call": - runtime.record_tool_call(_with_runtime_toolset(kwargs)) - elif hook_name == "post_approval_response": - runtime.record_approval(kwargs) - elif hook_name == "on_skill_lifecycle": - runtime.record_skill_lifecycle(kwargs) - elif hook_name == "post_api_request": - runtime.end_model_call(kwargs) - elif hook_name == "api_request_error": - runtime.record_model_call_error(kwargs) - elif hook_name == "on_session_end": - runtime.finish_task(kwargs) - elif hook_name == "subagent_stop": - child_session_id = str(kwargs.get("child_session_id") or "") - if child_session_id: - runtime.close_session({"session_id": child_session_id}) - elif hook_name in {"on_session_finalize", "on_session_reset"}: - runtime.close_session(kwargs) + _HOOK_HANDLERS[hook_name](runtime, kwargs) except Exception: logger.warning( "Hermes shared metrics hook failed: %s", hook_name, exc_info=True @@ -1337,10 +1274,28 @@ def _with_runtime_toolset(event: dict[str, Any]) -> dict[str, Any]: return {**event, "toolset": toolset or "other"} -def prepare_session_start() -> None: - """Register the subscriber before any producer opens the session scope.""" - if enabled(): - _get_runtime(retry_failed=True) +def _close_child_session(runtime: _Runtime, kwargs: dict[str, Any]) -> None: + child_session_id = str(kwargs.get("child_session_id") or "") + if child_session_id: + runtime.close_session({"session_id": child_session_id}) + + +_HOOK_HANDLERS: dict[str, Callable[[_Runtime, dict[str, Any]], Any]] = { + "on_session_start": lambda rt, kw: rt.record_client_active(kw), + "pre_llm_call": lambda rt, kw: rt.start_task(kw), + "pre_api_request": lambda rt, kw: rt.start_model_call(kw), + "pre_tool_call": lambda rt, kw: rt.start_tool_call(_with_runtime_toolset(kw)), + "post_tool_call": lambda rt, kw: rt.record_tool_call(_with_runtime_toolset(kw)), + "post_approval_response": lambda rt, kw: rt.record_approval(kw), + "on_skill_lifecycle": lambda rt, kw: rt.record_skill_lifecycle(kw), + "post_api_request": lambda rt, kw: rt.end_model_call(kw), + "api_request_error": lambda rt, kw: rt.record_model_call_error(kw), + "on_session_end": lambda rt, kw: rt.finish_task(kw), + "subagent_stop": _close_child_session, + "on_session_finalize": lambda rt, kw: rt.close_session(kw), + "on_session_reset": lambda rt, kw: rt.close_session(kw), +} +assert frozenset(_HOOK_HANDLERS) == HANDLED_HOOKS def _prepare_core_session( @@ -1349,9 +1304,8 @@ def _prepare_core_session( ) -> None: """Prepare the profile subscriber before the coordinator opens a scope.""" del context - if host.profile_key == relay_runtime.current_profile_key(): - if enabled(): - _get_runtime(retry_failed=True, host=host) + if host.profile_key == relay_runtime.current_profile_key() and enabled(): + _get_runtime(retry_failed=True, host=host) def start_task_run( diff --git a/hermes_cli/observability/shared_metrics.py b/hermes_cli/observability/shared_metrics.py index 87094922d9..33e6552be2 100644 --- a/hermes_cli/observability/shared_metrics.py +++ b/hermes_cli/observability/shared_metrics.py @@ -352,13 +352,11 @@ class SharedMetricsStore: def _add_send_columns(connection: sqlite3.Connection) -> None: """Add transmission bookkeeping to ``package_outbox``, idempotently. - These columns are ADDITIVE and nullable, and the store schema version - is deliberately NOT bumped. ``_ensure_schema_in_transaction`` raises on - any version it does not recognise and has no forward-compatibility - branch, so bumping would make an older Hermes — a second profile on an - older build, or a rollback — hard-fail against the same database file. - Old readers select named columns and never ``SELECT *``, so extra - columns are invisible to them. + The columns are ADDITIVE and nullable, and the schema version is deliberately NOT bumped: + ``_ensure_schema_in_transaction`` raises on unknown versions with no forward-compat branch, + so a bump would hard-fail an older Hermes (second profile, rollback) against the same + database. Old readers select named columns, never ``SELECT *``, so extra columns are + invisible to them. """ existing = { str(row["name"]) @@ -395,32 +393,13 @@ class SharedMetricsStore: def _add_consent_tables(connection: sqlite3.Connection) -> None: """Create the consent-window tables, idempotently. - Additive like ``_add_send_columns`` — the schema version is - deliberately NOT bumped, and old readers never touch these tables. + Additive like ``_add_send_columns`` — the schema version is deliberately NOT bumped, and old + readers never touch these tables. - ``send_consent_windows`` records consent as explicit intervals rather - than a moving day-stamp: a window is opened when send consent is - observed, heartbeat-confirmed on every later observation, and closed - at the LAST CONFIRMED moment (never "now") when consent is observed - withdrawn. Consent is asserted only for time that was actually - observed, so unobserved gaps — a hand-edited config with no process - running — fail closed by construction. - - ``consent_marks`` holds two monotonic high-water marks with strictly - separated roles: - - - ``obs``: the latest observation stamp ever seen. Advanced only by - the reconciler. Confirms consent and clamps window closes. - - ``data``: the latest package ``period_end`` ever stored. Advanced - only by the package writer. Clamps window OPENS, so a rolled-back - clock can never open a window underneath packages that already - exist on disk. - - The separation is load-bearing: letting data stamps confirm consent - re-created a refused-window leak (packages stored during an off - window would vouch for it), and letting observation stamps clamp - opens is not enough on its own to stop a rollback sliding a window - under existing refused data. + ``send_consent_windows`` records consent as explicit intervals rather than a moving day- + stamp: a window is opened when send consent is observed, heartbeat-confirmed on every later + observation, and closed at the LAST CONFIRMED moment (never "now") when consent is observed + withdrawn. """ connection.execute( """ diff --git a/hermes_cli/observability/shared_metrics_contract.py b/hermes_cli/observability/shared_metrics_contract.py index eb1f858216..50ac76e69c 100644 --- a/hermes_cli/observability/shared_metrics_contract.py +++ b/hermes_cli/observability/shared_metrics_contract.py @@ -34,221 +34,105 @@ SKILL_LIFECYCLE_METRIC = "hermes.skill.lifecycle.count" SKILL_LOAD_METRIC = "hermes.skill.load.count" MODEL_IDENTIFIER_MAX_LENGTH = 256 PROVIDER_IDENTIFIER_MAX_LENGTH = 64 -_METRIC_IDENTIFIER_CHARACTERS = frozenset( - "abcdefghijklmnopqrstuvwxyz0123456789._:/@+-" -) -_METRIC_IDENTIFIER_START_CHARACTERS = frozenset( - "abcdefghijklmnopqrstuvwxyz0123456789" -) +_METRIC_IDENTIFIER_CHARACTERS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789._:/@+-") +_METRIC_IDENTIFIER_START_CHARACTERS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789") EXECUTION_SURFACES: frozenset[str] = frozenset({ - "api", - "batch", - "cli", - "desktop", - "gateway", - "python", - "scheduled_task", - "tui", - "other", - "unknown", + "api", "batch", "cli", "desktop", "gateway", "python", "scheduled_task", "tui", + "other", "unknown", }) TASK_OUTCOMES: frozenset[str] = frozenset({ - "cancelled", - "failed", - "success", - "timed_out", - "unknown", + "cancelled", "failed", "success", "timed_out", "unknown", }) TASK_END_REASONS: frozenset[str] = frozenset({ - "approval_denied", - "completed", - "failed", - "guardrail_blocked", - "iteration_limit", - "system_aborted", - "timed_out", - "unknown", - "user_cancelled", + "approval_denied", "completed", "failed", "guardrail_blocked", "iteration_limit", + "system_aborted", "timed_out", "unknown", "user_cancelled", }) TASK_TERMINATIONS: frozenset[str] = frozenset({ - "none", - "system_aborted", - "timed_out", - "unknown", - "user_cancelled", + "none", "system_aborted", "timed_out", "unknown", "user_cancelled", }) TASK_ENTRYPOINTS: frozenset[str] = frozenset({ - "api", - "background", - "batch", - "delegated", - "gateway_message", - "interactive", - "other", - "python", - "scheduled_task", - "unknown", + "api", "background", "batch", "delegated", "gateway_message", "interactive", "other", + "python", "scheduled_task", "unknown", }) DURATION_BUCKETS: frozenset[str] = frozenset({ - "1s_to_5s", - "2m_to_10m", - "30s_to_2m", - "5s_to_30s", - "gte_10m", - "lt_1s", -}) -COUNT_BUCKETS: frozenset[str] = frozenset({ - "0", - "1", - "2", - "3_to_5", - "6_to_10", - "gte_11", + "1s_to_5s", "2m_to_10m", "30s_to_2m", "5s_to_30s", "gte_10m", "lt_1s", }) +COUNT_BUCKETS: frozenset[str] = frozenset({"0", "1", "2", "3_to_5", "6_to_10", "gte_11"}) TOOL_CATEGORIES: frozenset[str] = frozenset({ - "browser", - "code_execution", - "communication", - "computer_use", - "delegation", - "file", - "home_automation", - "mcp", - "media", - "memory", - "other", - "planning", - "project", - "scheduler", - "skill", - "terminal", - "unknown", - "web", + "browser", "code_execution", "communication", "computer_use", "delegation", "file", + "home_automation", "mcp", "media", "memory", "other", "planning", "project", + "scheduler", "skill", "terminal", "unknown", "web", }) TOOL_OUTCOMES: frozenset[str] = frozenset({ - "blocked", - "cancelled", - "failed", - "success", - "timed_out", - "unknown", + "blocked", "cancelled", "failed", "success", "timed_out", "unknown", }) TOOL_APPROVAL_OUTCOMES: frozenset[str] = frozenset({ - "approved", - "denied", - "not_required", - "timed_out", - "unknown", -}) -TOOL_APPROVAL_ATTRIBUTIONS: frozenset[str] = frozenset({ - "tool_call", - "unattributed", + "approved", "denied", "not_required", "timed_out", "unknown", }) +TOOL_APPROVAL_ATTRIBUTIONS: frozenset[str] = frozenset({"tool_call", "unattributed"}) TOOL_LATENCY_BUCKETS: frozenset[str] = frozenset({ - "100ms_to_250ms", - "10s_to_30s", - "1s_to_2s", - "250ms_to_500ms", - "2s_to_5s", - "500ms_to_1s", - "5s_to_10s", - "gte_30s", - "lt_100ms", - "unknown", + "100ms_to_250ms", "10s_to_30s", "1s_to_2s", "250ms_to_500ms", "2s_to_5s", "500ms_to_1s", + "5s_to_10s", "gte_30s", "lt_100ms", "unknown", }) TOOL_RETRY_BUCKETS: frozenset[str] = COUNT_BUCKETS | frozenset({"unknown"}) SKILL_LIFECYCLE_ACTIONS: frozenset[str] = frozenset({ - "archived", - "created", - "edited", - "installed", - "patched", - "restored", - "stale", + "archived", "created", "edited", "installed", "patched", "restored", "stale", }) SKILL_PROVENANCES: frozenset[str] = frozenset({ - "agent_created", - "external", - "installed", - "local", - "unknown", + "agent_created", "external", "installed", "local", "unknown", }) SKILL_REUSE_STATES: frozenset[str] = frozenset({"first_use", "reused"}) SKILL_POST_PATCH_STATES: frozenset[str] = frozenset({ - "no_new_patch", - "not_applicable", - "reused_after_patch", -}) -CLIENT_OS_FAMILIES: frozenset[str] = frozenset({ - "linux", - "macos", - "unknown", - "windows", -}) -CLIENT_ARCHITECTURES: frozenset[str] = frozenset({ - "arm", - "arm64", - "unknown", - "x86", - "x86_64", + "no_new_patch", "not_applicable", "reused_after_patch", }) +CLIENT_OS_FAMILIES: frozenset[str] = frozenset({"linux", "macos", "unknown", "windows"}) +CLIENT_ARCHITECTURES: frozenset[str] = frozenset({"arm", "arm64", "unknown", "x86", "x86_64"}) CLIENT_INSTALL_METHODS: frozenset[str] = frozenset({ - "apt", - "docker", - "git", - "home-manager", - "homebrew", - "nixos", - "pip", - "unknown", + "apt", "docker", "git", "home-manager", "homebrew", "nixos", "pip", "unknown", }) CLIENT_RESOURCE_KEYS: frozenset[str] = frozenset({ - "architecture", - "hermes_version", - "install_method", - "os_family", + "architecture", "hermes_version", "install_method", "os_family", }) +_ARCHITECTURE_ALIASES = { + "amd64": "x86_64", "x64": "x86_64", "x86_64": "x86_64", + "aarch64": "arm64", "arm64": "arm64", + "i386": "x86", "i486": "x86", "i586": "x86", "i686": "x86", "x86": "x86", +} + + +def _norm(value: Any) -> str: + return str(value or "").strip().lower() + + +def _allowlisted(normalized: str, allowed: frozenset[str]) -> str: + return normalized if normalized in allowed else "unknown" + + def client_os_family(value: Any) -> str: """Map a platform system name to the shared-metrics OS taxonomy.""" - normalized = str(value or "").strip().lower() - return { - "darwin": "macos", - "linux": "linux", - "macos": "macos", - "windows": "windows", - }.get(normalized, "unknown") + return {"darwin": "macos", "linux": "linux", "macos": "macos", "windows": "windows"}.get( + _norm(value), "unknown" + ) def client_architecture(value: Any) -> str: """Map a machine architecture to the shared-metrics taxonomy.""" - normalized = str(value or "").strip().lower().replace("-", "_") - if normalized in {"amd64", "x64", "x86_64"}: - return "x86_64" - if normalized in {"aarch64", "arm64"}: - return "arm64" - if normalized in {"i386", "i486", "i586", "i686", "x86"}: - return "x86" - if normalized.startswith("armv"): - return "arm" - return "unknown" + normalized = _norm(value).replace("-", "_") + if normalized in _ARCHITECTURE_ALIASES: + return _ARCHITECTURE_ALIASES[normalized] + return "arm" if normalized.startswith("armv") else "unknown" def client_install_method(value: Any) -> str: """Return an allowlisted Hermes installation method.""" - normalized = str(value or "").strip().lower() - if normalized == "nix": - return "nixos" - return normalized if normalized in CLIENT_INSTALL_METHODS else "unknown" + normalized = _norm(value) + return _allowlisted("nixos" if normalized == "nix" else normalized, CLIENT_INSTALL_METHODS) def client_resource( - hermes_version: Any, - *, - os_name: Any, - architecture: Any, - install_method: Any, + hermes_version: Any, *, os_name: Any, architecture: Any, install_method: Any ) -> dict[str, str]: """Build the bounded client resource attached to aggregate packages.""" normalized_version = str(hermes_version or "").strip() @@ -276,36 +160,12 @@ def client_resource_is_valid(resource: Any) -> bool: ) -_LEGACY_PROVIDER_FAMILIES = frozenset({ - "aggregator", - "custom", - "direct", - "local", - "unknown", -}) +_LEGACY_PROVIDER_FAMILIES = frozenset({"aggregator", "custom", "direct", "local", "unknown"}) _LEGACY_MODEL_LOCALITIES = frozenset({"local", "remote", "unknown"}) _LEGACY_MODEL_OUTCOMES = frozenset({"cancelled", "failed", "success"}) _LEGACY_MODEL_FAMILIES = frozenset({ - "claude", - "deepseek", - "gemini", - "gemma", - "glm", - "gpt", - "grok", - "kimi", - "llama", - "minimax", - "mimo", - "mistral", - "nemotron", - "nova", - "o1", - "o3", - "o4", - "qwen", - "step", - "trinity", + "claude", "deepseek", "gemini", "gemma", "glm", "gpt", "grok", "kimi", "llama", "minimax", + "mimo", "mistral", "nemotron", "nova", "o1", "o3", "o4", "qwen", "step", "trinity", "unknown", }) @@ -356,36 +216,27 @@ _COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = { "use_count_bucket": COUNT_BUCKETS, }, } -COUNTER_METRICS: frozenset[str] = frozenset({ - CLIENT_ACTIVE_METRIC, - MODEL_ROUTE_METRIC, - SKILL_LIFECYCLE_METRIC, - SKILL_LOAD_METRIC, - TASK_FINISHED_METRIC, - TASK_STARTED_METRIC, - TOOL_APPROVAL_METRIC, - TOOL_CALL_METRIC, -}) +_MODEL_ROUTE_MAX_LENGTHS = { + "model": MODEL_IDENTIFIER_MAX_LENGTH, "provider": PROVIDER_IDENTIFIER_MAX_LENGTH, +} +# metric -> closed dimension field set (model-route fields are validated by shape, not allowlist) +_METRIC_FIELDS: dict[str, frozenset[str]] = { + **{name: frozenset(contract) for name, contract in _COUNTER_DIMENSION_VALUES.items()}, + MODEL_ROUTE_METRIC: frozenset(_MODEL_ROUTE_MAX_LENGTHS), +} +COUNTER_METRICS: frozenset[str] = frozenset(_METRIC_FIELDS) - {LEGACY_MODEL_CALL_METRIC} +_SKILL_MARK_METRICS = { + SKILL_LIFECYCLE_MARK: SKILL_LIFECYCLE_METRIC, + SKILL_LOAD_MARK: SKILL_LOAD_METRIC, +} -def counter_dimensions_are_valid( - metric_name: str, - dimensions: dict[str, Any], -) -> bool: +def counter_dimensions_are_valid(metric_name: str, dimensions: dict[str, Any]) -> bool: """Return whether dimensions match one closed shared-metric contract.""" if metric_name == MODEL_ROUTE_METRIC: - return ( - set(dimensions) == {"model", "provider"} - and dimensions["model"] - == _metric_identifier( - dimensions["model"], - max_length=MODEL_IDENTIFIER_MAX_LENGTH, - ) - and dimensions["provider"] - == _metric_identifier( - dimensions["provider"], - max_length=PROVIDER_IDENTIFIER_MAX_LENGTH, - ) + return set(dimensions) == _METRIC_FIELDS[metric_name] and all( + dimensions[field] == _metric_identifier(dimensions[field], max_length=max_length) + for field, max_length in _MODEL_ROUTE_MAX_LENGTHS.items() ) contract = _COUNTER_DIMENSION_VALUES.get(metric_name) if contract is None or set(dimensions) != set(contract): @@ -396,30 +247,97 @@ def counter_dimensions_are_valid( ) -def _event_metadata_is_valid(event: Any) -> bool: +def _relay_metadata( + event: Any, schema_key: str, schema_version: str, *extra_keys: str +) -> dict | None: + """Return the event metadata when it carries only the allowlisted Relay keys.""" metadata = getattr(event, "metadata", None) - if not isinstance(metadata, dict) or metadata.get(SCHEMA_KEY) != SCHEMA_VERSION: + if not isinstance(metadata, dict) or metadata.get(schema_key) != schema_version: + return None + allowed = {schema_key, RUNTIME_INSTANCE_KEY, "otel.status_code", *extra_keys} + if set(metadata) - allowed or metadata.get("otel.status_code", "OK") not in {"OK", "ERROR"}: + return None + return metadata + + +_ANY = object() + + +def _event_text(event: Any, attr: str) -> str: + return str(getattr(event, attr, "") or "") + + +def _event_shape_matches( + event: Any, + *, + kind: str, + name: Any = _ANY, + category: Any = _ANY, + scope_category: Any = _ANY, + category_profile: Any = _ANY, +) -> bool: + """Match the coarse Relay event shape. + + A ``str`` expectation compares against the stringified attribute; ``None`` requires the + attribute itself to be ``None``; ``_ANY`` skips the check. ``category_profile`` compares + with plain equality (marks carry ``None``, tool scopes ``{}``). + """ + if _event_text(event, "kind") != kind: return False - relay_metadata = set(metadata) - {SCHEMA_KEY, RUNTIME_INSTANCE_KEY} - return not relay_metadata - {"otel.status_code"} and metadata.get( - "otel.status_code", "OK" - ) in {"OK", "ERROR"} + for attr, expected, as_text in ( + ("name", name, True), + ("category", category, True), + ("scope_category", scope_category, True), + ("category_profile", category_profile, False), + ): + if expected is _ANY: + continue + actual = getattr(event, attr, None) + if expected is None: + if actual is not None: + return False + elif (_event_text(event, attr) if as_text else actual) != expected: + return False + return True + + +def _bounded_dimensions(metric_name: str, data: Any) -> dict[str, str] | None: + """Project ``data`` onto the metric's closed field set, or None when it does not fit.""" + expected_fields = _METRIC_FIELDS[metric_name] + if not isinstance(data, dict) or set(data) != expected_fields: + return None + dimensions = {field: data.get(field) for field in sorted(expected_fields)} + return dimensions if counter_dimensions_are_valid(metric_name, dimensions) else None + + +def _valid_shape(event: Any, **shape: Any) -> bool: + """Metadata allowlist check plus :func:`_event_shape_matches` in one step.""" + return ( + _relay_metadata(event, SCHEMA_KEY, SCHEMA_VERSION) is not None + and _event_shape_matches(event, **shape) + ) + + +def _bounded_counter(metric_name: str | None, event: Any) -> tuple[str, dict[str, str]] | None: + if metric_name is None: + return None + dimensions = _bounded_dimensions(metric_name, getattr(event, "data", None)) + return None if dimensions is None else (metric_name, dimensions) + + +_MARK_SHAPE = dict(kind="mark", category=None, scope_category=None, category_profile=None) + + +def _mark_counter(event: Any, metrics_by_mark: dict[str, str]) -> tuple[str, dict[str, str]] | None: + """Return the bounded counter for a safe Relay mark whose name is in *metrics_by_mark*.""" + if not _valid_shape(event, **_MARK_SHAPE): + return None + return _bounded_counter(metrics_by_mark.get(_event_text(event, "name")), event) def client_active_counter(event: Any) -> tuple[str, dict[str, str]] | None: """Return the active-install counter for one empty allowlisted mark.""" - if not _event_metadata_is_valid(event): - return None - if ( - str(getattr(event, "kind", "") or "") != "mark" - or str(getattr(event, "name", "") or "") != CLIENT_ACTIVE_MARK - or getattr(event, "category", None) is not None - or getattr(event, "scope_category", None) is not None - or getattr(event, "category_profile", None) is not None - or getattr(event, "data", None) != {} - ): - return None - return CLIENT_ACTIVE_METRIC, {} + return _mark_counter(event, {CLIENT_ACTIVE_MARK: CLIENT_ACTIVE_METRIC}) def model_call_dimensions(event: Any) -> dict[str, str] | None: @@ -427,223 +345,77 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: auxiliary = _auxiliary_model_call_dimensions(event) if auxiliary is not None: return auxiliary - if not _event_metadata_is_valid(event): - return None - if ( - str(getattr(event, "kind", "") or "") != "scope" - or str(getattr(event, "category", "") or "") != "llm" - or str(getattr(event, "name", "") or "") != MODEL_CALL_SCOPE - or str(getattr(event, "scope_category", "") or "") != "end" - ): - return None - category_profile = getattr(event, "category_profile", None) - if not isinstance(category_profile, dict) or set(category_profile) != { - "model_name" - }: - return None # The synthetic scope can span provider fallback. The accepted terminal # route is carried in the validated payload rather than this start profile. - if category_profile.get("model_name") != MODEL_CALL_PROFILE_MODEL: + if not _valid_shape( + event, kind="scope", category="llm", name=MODEL_CALL_SCOPE, scope_category="end", + category_profile={"model_name": MODEL_CALL_PROFILE_MODEL}, + ): return None - data = getattr(event, "data", None) - expected_fields = {"model", "provider"} - if not isinstance(data, dict) or set(data) != expected_fields: - return None - dimensions = {field: data.get(field) for field in sorted(expected_fields)} - if not counter_dimensions_are_valid(MODEL_ROUTE_METRIC, dimensions): - return None - return dimensions + return _bounded_dimensions(MODEL_ROUTE_METRIC, getattr(event, "data", None)) def _auxiliary_model_call_dimensions(event: Any) -> dict[str, str] | None: """Project a terminal auxiliary route from its Hermes logical scope.""" - metadata = getattr(event, "metadata", None) - if ( - not isinstance(metadata, dict) - or metadata.get(RUNTIME_SCHEMA_KEY) != RUNTIME_SCHEMA_VERSION - ): - return None - relay_metadata = set(metadata) - { - RUNTIME_INSTANCE_KEY, - RUNTIME_SCHEMA_KEY, - "hermes.call_role", - } - if relay_metadata - {"otel.status_code"} or metadata.get( - "otel.status_code", "OK" - ) not in {"OK", "ERROR"}: - return None - call_role = metadata.get("hermes.call_role") - if not isinstance(call_role, str) or not call_role.startswith("auxiliary:"): - return None - if ( - str(getattr(event, "kind", "") or "") != "scope" - or str(getattr(event, "category", "") or "") != "function" - or str(getattr(event, "name", "") or "") != LOGICAL_LLM_SCOPE - or str(getattr(event, "scope_category", "") or "") != "end" - or getattr(event, "category_profile", None) is not None - ): - return None + metadata = _relay_metadata( + event, RUNTIME_SCHEMA_KEY, RUNTIME_SCHEMA_VERSION, "hermes.call_role" + ) + call_role = (metadata or {}).get("hermes.call_role") data = getattr(event, "data", None) if ( - not isinstance(data, dict) - or set(data) - not in ( - {"model", "outcome", "provider"}, - {"model", "outcome", "provider", "response_model"}, + not isinstance(call_role, str) + or not call_role.startswith("auxiliary:") + or not _event_shape_matches( + event, kind="scope", category="function", name=LOGICAL_LLM_SCOPE, + scope_category="end", category_profile=None, ) - or data.get("outcome") not in {"cancelled", "failed", "success"} + or not isinstance(data, dict) + or set(data) - {"response_model"} != {"model", "outcome", "provider"} + or data.get("outcome") not in _LEGACY_MODEL_OUTCOMES ): return None dimensions = model_call_fields(data) - if not counter_dimensions_are_valid(MODEL_ROUTE_METRIC, dimensions): - return None - return dimensions + return dimensions if counter_dimensions_are_valid(MODEL_ROUTE_METRIC, dimensions) else None def task_counter(event: Any) -> tuple[str, dict[str, str]] | None: """Return one validated task counter from a task scope event.""" - if not _event_metadata_is_valid(event): - return None - if ( - str(getattr(event, "kind", "") or "") != "scope" - or str(getattr(event, "category", "") or "") != "function" - or str(getattr(event, "name", "") or "") != TASK_SCOPE + if not _valid_shape( + event, kind="scope", category="function", name=TASK_SCOPE, category_profile=None ): return None - if getattr(event, "category_profile", None) is not None: - return None - - scope_category = str(getattr(event, "scope_category", "") or "") - data = getattr(event, "data", None) - if scope_category == "start": - expected_fields = {"entrypoint", "execution_surface"} - if not isinstance(data, dict) or set(data) != expected_fields: - return None - dimensions = { - "entrypoint": data.get("entrypoint"), - "execution_surface": data.get("execution_surface"), - } - if not counter_dimensions_are_valid(TASK_STARTED_METRIC, dimensions): - return None - return TASK_STARTED_METRIC, dimensions - - expected_fields = { - "duration_bucket", - "end_reason", - "entrypoint", - "execution_surface", - "model_call_count_bucket", - "outcome", - "retry_count_bucket", - "termination", - "tool_call_count_bucket", - } - if ( - scope_category != "end" - or not isinstance(data, dict) - or set(data) != expected_fields - ): - return None - dimensions = {field: data.get(field) for field in sorted(expected_fields)} - if not counter_dimensions_are_valid(TASK_FINISHED_METRIC, dimensions): - return None - return TASK_FINISHED_METRIC, dimensions + metric_name = {"start": TASK_STARTED_METRIC, "end": TASK_FINISHED_METRIC}.get( + _event_text(event, "scope_category") + ) + return _bounded_counter(metric_name, event) def tool_call_dimensions(event: Any) -> dict[str, str] | None: """Return package dimensions for one allowlisted tool lifecycle end event.""" - if not _event_metadata_is_valid(event): - return None - if ( - str(getattr(event, "kind", "") or "") != "scope" - or str(getattr(event, "category", "") or "") != "tool" - or str(getattr(event, "name", "") or "") != TOOL_CALL_SCOPE - or str(getattr(event, "scope_category", "") or "") != "end" - or getattr(event, "category_profile", None) != {} + if not _valid_shape( + event, kind="scope", category="tool", name=TOOL_CALL_SCOPE, scope_category="end", + category_profile={}, ): return None - data = getattr(event, "data", None) - expected_fields = { - "approval_outcome", - "latency_bucket", - "outcome", - "retry_count_bucket", - "tool_category", - } - if not isinstance(data, dict) or set(data) != expected_fields: - return None - dimensions = {field: data.get(field) for field in sorted(expected_fields)} - if not counter_dimensions_are_valid(TOOL_CALL_METRIC, dimensions): - return None - return dimensions + return _bounded_dimensions(TOOL_CALL_METRIC, getattr(event, "data", None)) def tool_approval_counter(event: Any) -> tuple[str, dict[str, str]] | None: """Return one validated approval counter from a safe Relay mark event.""" - if not _event_metadata_is_valid(event): - return None - if ( - str(getattr(event, "kind", "") or "") != "mark" - or str(getattr(event, "name", "") or "") != TOOL_APPROVAL_MARK - or getattr(event, "category", None) is not None - or getattr(event, "scope_category", None) is not None - or getattr(event, "category_profile", None) is not None - ): - return None - data = getattr(event, "data", None) - expected_fields = {"attribution", "outcome"} - if not isinstance(data, dict) or set(data) != expected_fields: - return None - dimensions = {field: data.get(field) for field in sorted(expected_fields)} - if not counter_dimensions_are_valid(TOOL_APPROVAL_METRIC, dimensions): - return None - return TOOL_APPROVAL_METRIC, dimensions + return _mark_counter(event, {TOOL_APPROVAL_MARK: TOOL_APPROVAL_METRIC}) def skill_counter(event: Any) -> tuple[str, dict[str, str]] | None: """Return one validated skill lifecycle or load counter from a safe mark.""" - if not _event_metadata_is_valid(event): - return None - if ( - str(getattr(event, "kind", "") or "") != "mark" - or getattr(event, "category", None) is not None - or getattr(event, "scope_category", None) is not None - or getattr(event, "category_profile", None) is not None - ): - return None - - name = str(getattr(event, "name", "") or "") - data = getattr(event, "data", None) - if name == SKILL_LIFECYCLE_MARK: - metric_name = SKILL_LIFECYCLE_METRIC - expected_fields = {"action", "provenance"} - elif name == SKILL_LOAD_MARK: - metric_name = SKILL_LOAD_METRIC - expected_fields = { - "post_patch_state", - "provenance", - "reuse_state", - "use_count_bucket", - } - else: - return None - if not isinstance(data, dict) or set(data) != expected_fields: - return None - dimensions = {field: data.get(field) for field in sorted(expected_fields)} - if not counter_dimensions_are_valid(metric_name, dimensions): - return None - return metric_name, dimensions + return _mark_counter(event, _SKILL_MARK_METRICS) def skill_lifecycle_fields(kwargs: dict[str, Any]) -> dict[str, str] | None: """Build bounded fields for one successful non-load skill transition.""" - action = str(kwargs.get("action") or "").strip().lower() + action = _norm(kwargs.get("action")) if action not in SKILL_LIFECYCLE_ACTIONS: return None - return { - "action": action, - "provenance": skill_provenance(kwargs.get("provenance")), - } + return {"action": action, "provenance": skill_provenance(kwargs.get("provenance"))} def skill_load_fields(kwargs: dict[str, Any]) -> dict[str, str] | None: @@ -662,10 +434,8 @@ def skill_load_fields(kwargs: dict[str, Any]) -> dict[str, str] | None: return None return { "post_patch_state": ( - "not_applicable" - if not reused - else "reused_after_patch" - if reuse_after_patch + "not_applicable" if not reused + else "reused_after_patch" if reuse_after_patch else "no_new_patch" ), "provenance": skill_provenance(kwargs.get("provenance")), @@ -676,23 +446,22 @@ def skill_load_fields(kwargs: dict[str, Any]) -> dict[str, str] | None: def skill_provenance(value: Any) -> str: """Normalize producer provenance to the closed shared-metrics taxonomy.""" - normalized = str(value or "").strip().lower() - return normalized if normalized in SKILL_PROVENANCES else "unknown" + return _allowlisted(_norm(value), SKILL_PROVENANCES) + + +_SURFACE_ALIASES = { + "api_server": "api", + **dict.fromkeys(("cron", "scheduler", "scheduled"), "scheduled_task"), +} def execution_surface(kwargs: dict[str, Any]) -> str: """Normalize the safe session surface carried by the parent Relay scope.""" - value = ( - str(kwargs.get("execution_surface") or kwargs.get("platform") or "unknown") - .strip() - .lower() - ) + value = _norm(kwargs.get("execution_surface") or kwargs.get("platform") or "unknown") if value in EXECUTION_SURFACES: return value - if value == "api_server": - return "api" - if value in {"cron", "scheduler", "scheduled"}: - return "scheduled_task" + if value in _SURFACE_ALIASES: + return _SURFACE_ALIASES[value] try: from hermes_cli.platforms import get_all_platforms @@ -714,25 +483,22 @@ def task_start_fields(kwargs: dict[str, Any]) -> dict[str, str]: } +_SURFACE_ENTRYPOINTS = { + **dict.fromkeys(("cli", "desktop", "tui"), "interactive"), + **{s: s for s in ("api", "batch", "python", "scheduled_task", "unknown")}, + "gateway": "gateway_message", +} + + def task_entrypoint(kwargs: dict[str, Any], surface: str | None = None) -> str: """Normalize the task dispatch owner without exporting source strings.""" - declared = str(kwargs.get("entrypoint") or "").strip().lower() + declared = _norm(kwargs.get("entrypoint")) if declared in TASK_ENTRYPOINTS: return declared resolved_surface = surface or execution_surface(kwargs) if kwargs.get("parent_task_id") or kwargs.get("parent_session_id"): return "delegated" - return { - "api": "api", - "batch": "batch", - "cli": "interactive", - "desktop": "interactive", - "gateway": "gateway_message", - "python": "python", - "scheduled_task": "scheduled_task", - "tui": "interactive", - "unknown": "unknown", - }.get(resolved_surface, "other") + return _SURFACE_ENTRYPOINTS.get(resolved_surface, "other") def task_terminal_fields( @@ -744,10 +510,9 @@ def task_terminal_fields( retry_count: int, ) -> dict[str, str]: """Build the bounded terminal payload for one task scope.""" - start_fields = task_start_fields(kwargs) outcome, end_reason, termination = task_terminal_state(kwargs) return { - **start_fields, + **task_start_fields(kwargs), "duration_bucket": duration_bucket(duration_ms), "end_reason": end_reason, "model_call_count_bucket": count_bucket(model_call_count), @@ -760,7 +525,7 @@ def task_terminal_fields( def task_terminal_state(kwargs: dict[str, Any]) -> tuple[str, str, str]: """Map Hermes terminal state to bounded task outcome dimensions.""" - reason = str(kwargs.get("turn_exit_reason") or "").strip().lower() + reason = _norm(kwargs.get("turn_exit_reason")) if kwargs.get("interrupted") or "interrupt" in reason or "cancel" in reason: return "cancelled", "user_cancelled", "user_cancelled" if "timeout" in reason or "timed_out" in reason: @@ -780,91 +545,86 @@ def task_terminal_state(kwargs: dict[str, Any]) -> tuple[str, str, str]: return "unknown", "unknown", "unknown" +# (exclusive upper bound, label) — ascending; the trailing label catches the rest. +_DURATION_THRESHOLDS = ( + (1_000, "lt_1s"), (5_000, "1s_to_5s"), (30_000, "5s_to_30s"), + (120_000, "30s_to_2m"), (600_000, "2m_to_10m"), +) +_COUNT_THRESHOLDS = ((1, "0"), (2, "1"), (3, "2"), (6, "3_to_5"), (11, "6_to_10")) +_LATENCY_THRESHOLDS = ( + (100, "lt_100ms"), (250, "100ms_to_250ms"), (500, "250ms_to_500ms"), (1_000, "500ms_to_1s"), + (2_000, "1s_to_2s"), (5_000, "2s_to_5s"), (10_000, "5s_to_10s"), (30_000, "10s_to_30s"), +) + + +def _bucket(value: float, thresholds: tuple[tuple[float, str], ...], last: str) -> str: + for upper, label in thresholds: + if value < upper: + return label + return last + + def duration_bucket(duration_ms: int) -> str: """Bucket a non-negative task duration into a fixed low-cardinality range.""" - value = max(0, int(duration_ms)) - if value < 1_000: - return "lt_1s" - if value < 5_000: - return "1s_to_5s" - if value < 30_000: - return "5s_to_30s" - if value < 120_000: - return "30s_to_2m" - if value < 600_000: - return "2m_to_10m" - return "gte_10m" + return _bucket(max(0, int(duration_ms)), _DURATION_THRESHOLDS, "gte_10m") def count_bucket(count: int) -> str: """Bucket a non-negative per-task count into a fixed range.""" - value = max(0, int(count)) - if value <= 2: - return str(value) - if value <= 5: - return "3_to_5" - if value <= 10: - return "6_to_10" - return "gte_11" + return _bucket(max(0, int(count)), _COUNT_THRESHOLDS, "gte_11") + + +_TOOL_CATEGORY_EXACT = { + **{category: category for category in TOOL_CATEGORIES}, + "clarify": "planning", "kanban": "planning", "todo": "planning", + "session_search": "memory", + "cronjob": "scheduler", + "skills": "skill", + "x_search": "web", +} +_TOOL_CATEGORY_PREFIXES = ( + ("mcp", "mcp"), + ("browser", "browser"), + (("image", "tts", "video", "vision"), "media"), + ("homeassistant", "home_automation"), + (("discord", "email", "feishu", "hermes-yuanbao", "slack", "sms"), "communication"), +) def tool_category(kwargs: dict[str, Any]) -> str: """Map Hermes registry toolset metadata to a low-cardinality category.""" - toolset = str(kwargs.get("toolset") or "").strip().lower() + toolset = _norm(kwargs.get("toolset")) if not toolset: return "unknown" - if toolset in TOOL_CATEGORIES: - return toolset - if toolset.startswith("mcp"): - return "mcp" - if toolset.startswith("browser"): - return "browser" - if toolset.startswith(("image", "tts", "video", "vision")): - return "media" - if toolset.startswith("homeassistant"): - return "home_automation" - if toolset in {"clarify", "kanban", "todo"}: - return "planning" - if toolset == "session_search": - return "memory" - if toolset == "cronjob": - return "scheduler" - if toolset == "skills": - return "skill" - if toolset == "x_search": - return "web" - if toolset.startswith( - ("discord", "email", "feishu", "hermes-yuanbao", "slack", "sms") - ): - return "communication" + if toolset in _TOOL_CATEGORY_EXACT: + return _TOOL_CATEGORY_EXACT[toolset] + for prefixes, category in _TOOL_CATEGORY_PREFIXES: + if toolset.startswith(prefixes): + return category return "other" +_TOOL_STATUS_OUTCOMES = { + **{s: s for s in ("blocked", "cancelled", "failed", "success", "timed_out")}, + "error": "failed", "ok": "success", "timeout": "timed_out", +} + + def tool_outcome(kwargs: dict[str, Any]) -> str: """Normalize the terminal Hermes tool status without inspecting its result.""" - status = str(kwargs.get("status") or "").strip().lower() - return { - "blocked": "blocked", - "cancelled": "cancelled", - "error": "failed", - "failed": "failed", - "ok": "success", - "success": "success", - "timed_out": "timed_out", - "timeout": "timed_out", - }.get(status, "unknown") + return _TOOL_STATUS_OUTCOMES.get(_norm(kwargs.get("status")), "unknown") + + +_APPROVAL_CHOICES = { + **dict.fromkeys(("always", "approve", "approved", "once", "session", "smart_approve"), "approved"), + **dict.fromkeys(("deny", "denied", "smart_deny"), "denied"), + **dict.fromkeys(("timed_out", "timeout"), "timed_out"), +} def tool_approval_outcome(kwargs: dict[str, Any]) -> str: """Normalize a terminal approval choice to a bounded outcome.""" - choice = str(kwargs.get("choice") or "").strip().lower() - if choice in {"always", "approve", "approved", "once", "session", "smart_approve"}: - return "approved" - if choice in {"deny", "denied", "smart_deny"}: - return "denied" - if choice in {"timed_out", "timeout"}: - return "timed_out" - return "unknown" + return _APPROVAL_CHOICES.get(_norm(kwargs.get("choice")), "unknown") def tool_terminal_fields( @@ -877,50 +637,23 @@ def tool_terminal_fields( """Build one bounded tool-call terminal payload.""" return { "approval_outcome": ( - approval_outcome - if approval_outcome in TOOL_APPROVAL_OUTCOMES - else "unknown" + approval_outcome if approval_outcome in TOOL_APPROVAL_OUTCOMES else "unknown" ), "latency_bucket": tool_latency_bucket( - kwargs.get("duration_ms"), - fallback_duration_ms=fallback_duration_ms, + kwargs.get("duration_ms"), fallback_duration_ms=fallback_duration_ms ), "outcome": tool_outcome(kwargs), "retry_count_bucket": tool_retry_bucket(kwargs.get("retry_count")), - "tool_category": ( - category if category in TOOL_CATEGORIES else tool_category(kwargs) - ), + "tool_category": category if category in TOOL_CATEGORIES else tool_category(kwargs), } -def tool_latency_bucket( - value: Any, - *, - fallback_duration_ms: int | None = None, -) -> str: +def tool_latency_bucket(value: Any, *, fallback_duration_ms: int | None = None) -> str: """Bucket a tool duration reported in milliseconds.""" duration_ms = _non_negative_number(value) if duration_ms is None: duration_ms = _non_negative_number(fallback_duration_ms) - if duration_ms is None: - return "unknown" - if duration_ms < 100: - return "lt_100ms" - if duration_ms < 250: - return "100ms_to_250ms" - if duration_ms < 500: - return "250ms_to_500ms" - if duration_ms < 1_000: - return "500ms_to_1s" - if duration_ms < 2_000: - return "1s_to_2s" - if duration_ms < 5_000: - return "2s_to_5s" - if duration_ms < 10_000: - return "5s_to_10s" - if duration_ms < 30_000: - return "10s_to_30s" - return "gte_30s" + return "unknown" if duration_ms is None else _bucket(duration_ms, _LATENCY_THRESHOLDS, "gte_30s") def tool_retry_bucket(value: Any) -> str: @@ -942,22 +675,11 @@ def _non_negative_number(value: Any) -> float | None: def model_call_fields(kwargs: dict[str, Any]) -> dict[str, str]: """Return the terminal model identity and provider route known to Hermes.""" - model = _metric_identifier( - kwargs.get("response_model"), - max_length=MODEL_IDENTIFIER_MAX_LENGTH, - ) + model = _metric_identifier(kwargs.get("response_model"), max_length=MODEL_IDENTIFIER_MAX_LENGTH) if model == "unknown": - model = _metric_identifier( - kwargs.get("model"), - max_length=MODEL_IDENTIFIER_MAX_LENGTH, - ) - return { - "model": model, - "provider": _metric_identifier( - kwargs.get("provider"), - max_length=PROVIDER_IDENTIFIER_MAX_LENGTH, - ), - } + model = _metric_identifier(kwargs.get("model"), max_length=MODEL_IDENTIFIER_MAX_LENGTH) + provider = _metric_identifier(kwargs.get("provider"), max_length=PROVIDER_IDENTIFIER_MAX_LENGTH) + return {"model": model, "provider": provider} def _metric_identifier(value: Any, *, max_length: int) -> str: @@ -969,10 +691,7 @@ def _metric_identifier(value: Any, *, max_length: int) -> str: not identifier or len(identifier) > max_length or identifier[0] not in _METRIC_IDENTIFIER_START_CHARACTERS - or any( - character not in _METRIC_IDENTIFIER_CHARACTERS - for character in identifier - ) + or not _METRIC_IDENTIFIER_CHARACTERS.issuperset(identifier) ): return "unknown" return identifier diff --git a/hermes_cli/observability/shared_metrics_send_config.py b/hermes_cli/observability/shared_metrics_send_config.py index cb14027593..0d44906b19 100644 --- a/hermes_cli/observability/shared_metrics_send_config.py +++ b/hermes_cli/observability/shared_metrics_send_config.py @@ -1,10 +1,4 @@ -"""Configuration for shared-metrics transmission. - -Collection (``telemetry.shared_metrics.enabled``) and transmission -(``telemetry.shared_metrics.send``) are separate opt-ins. See -``docs/observability/relay-shared-metrics.md`` Appendix A for the consent, -identity, rotation, retention, and deletion decisions behind this module. -""" +"""Configuration for shared-metrics transmission.""" from __future__ import annotations @@ -46,8 +40,8 @@ class SendConfig: def _endpoint_is_safe(endpoint: str) -> bool: """Reject plaintext destinations unless they are loopback. - Telemetry must not leave a machine in clear text because of a typo in a - config file. Loopback stays allowed so tests can use a local HTTP server. + Telemetry must not leave a machine in clear text because of a typo in a config file. Loopback + stays allowed so tests can use a local HTTP server. """ try: parsed = urlparse(endpoint) @@ -65,8 +59,8 @@ def resolve_send_config(config: dict | None) -> SendConfig: Endpoint precedence: config > production default. - ``send`` is returned as False whenever transmission cannot legitimately - happen, so callers never have to re-check the combination. + ``send`` is returned as False whenever transmission cannot legitimately happen, so callers never + have to re-check the combination. """ global _warned_send_without_collection diff --git a/hermes_cli/observability/shared_metrics_sender.py b/hermes_cli/observability/shared_metrics_sender.py index 9418353c9b..5bcedba764 100644 --- a/hermes_cli/observability/shared_metrics_sender.py +++ b/hermes_cli/observability/shared_metrics_sender.py @@ -1,26 +1,10 @@ """Transmit exported shared-metrics packages to the Nous telemetry service. -Implements the sender side of the ingest contract (see the telemetry repo's -``CONTRACT.md``): +Implements the sender side of the ingest contract (see the telemetry repo's ``CONTRACT.md``): -* ``202`` — durably stored. Mark sent. -* ``400`` — permanently malformed. Never retry. -* ``429`` — keep, retry after ``Retry-After``. -* ``5xx`` / timeout / connection error — keep, retry with backoff. - -Two properties are load-bearing and easy to get wrong: - -**The outbox directory is the user's local history, not a queue.** Packages -are pruned by age; a ``202`` marks send state in SQLite and never deletes a -file. See Appendix A.7 of ``docs/observability/relay-shared-metrics.md``. - -**Consent is gated on the package's PERIOD, not its creation time.** One -period is split across packages created on different days, so a created-at -gate would send a period's tail while dropping its head and silently -undercount the first consented day. The gate itself is interval containment: -the period must fall entirely inside a recorded consent window -(``send_consent_windows``), maintained by the single ``reconcile_send_consent`` -writer below. +* ``202`` — durably stored. Mark sent. * ``400`` — permanently malformed. Never retry. * ``429`` — +keep, retry after ``Retry-After``. * ``5xx`` / timeout / connection error — keep, retry with +backoff. """ from __future__ import annotations @@ -186,31 +170,9 @@ def reconcile_send_consent( ) -> None: """Reconcile the consent-window table with the observed config state. - THE ONLY writer of consent state. Must run inside a write transaction. - A pure function of (config, now, store): call it from anywhere, any - number of times, in any order — the resulting windows are the same. This - replaces the previous edge-detection design, whose three partial - observers (wizard, relay, mid-pass) each covered a different subset of - transitions and repeatedly leaked the transitions between the subsets. - - Timestamp discipline (each rule is load-bearing; see the validation - harness in tests/hermes_cli/test_shared_metrics_consent_windows.py): - - - The 'obs' mark advances to every observation stamp, monotonically — - but by at most ``MAX_OBS_ADVANCE_SECONDS`` per call. Unbounded, the - mark is monotonic in the LEAK direction: one glitched-forward sample - would drag ``last_confirmed_at`` decades ahead, a later close would - stamp that horizon, and the closed window would contain every future - refused period (reproduced in round 6). Bounded, a poisoned sample - costs at most one cap's width, and real time overtakes it. - An open window's ``last_confirmed_at`` follows the mark: consent is - asserted only for time that was actually observed. - - A close is stamped at ``last_confirmed_at`` — never "now" — so an - unobserved gap (hand-edited config, machine off for 90 days) is never - inside a window and fails closed. - - An open clamps to ``max(now, obs, data)``: a rolled-back clock cannot - open a window underneath refused packages already on disk, and cannot - make the new window adjacent to the previous close. + THE ONLY writer of consent state. Must run inside a write transaction. A pure function of + (config, now, store): call it from anywhere, any number of times, in any order — the resulting + windows are the same. """ stamp = _isoformat(now or _utc_now()) raw_stamp = stamp # pre-cap observation time, used to clamp closes @@ -288,23 +250,6 @@ CONSENT_GATE_SQL = """EXISTS ( )""" -def _state_get(connection: sqlite3.Connection, key: str) -> str | None: - row = connection.execute( - "SELECT value FROM telemetry_state WHERE key = ?", (key,) - ).fetchone() - return str(row[0]) if row is not None else None - - -def _state_set(connection: sqlite3.Connection, key: str, value: str) -> None: - connection.execute( - """ - INSERT INTO telemetry_state(key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - """, - (key, value), - ) - - class SharedMetricsSender: """Sends exported packages, one bounded pass at a time.""" @@ -334,20 +279,11 @@ class SharedMetricsSender: def _claim_next(self, now: datetime, seen: set[str]) -> dict | None: """Claim exactly ONE package, immediately before it is sent. - Claiming a whole batch up front does not work: a single shared lease - has to cover the entire pass, and 20 retrying packages can legally run - far longer than any sane lease (three 30s timeouts plus backoff each). - The later rows' leases then expire while this pass still holds them in - memory, and another process re-sends them. Taking one row at a time - keeps the lease covering only the package actually in flight. - - ``seen`` holds packages this pass has already finished with. They are - excluded IN SQL rather than by rejecting the fetched row: with - ``LIMIT 1``, returning None for an already-seen row would make the - caller believe the queue was empty and abandon every healthy package - behind it. A row can legitimately become eligible again mid-pass (a - short Retry-After, or a pass that outlives the 15-minute failure - backoff), so this is reachable in normal operation, not just in tests. + Claiming a batch up front fails: one shared lease would have to cover the whole pass, and 20 + retrying packages can outlive any sane lease, so later rows expire and another process + re-sends them. ``seen`` (packages this pass finished with) is excluded IN SQL: with LIMIT 1, + returning None for a seen row would look like an empty queue and abandon everything behind + it, and rows can legitimately become eligible again mid-pass. """ with self._store._connection() as connection: with write_txn(connection): @@ -428,12 +364,10 @@ class SharedMetricsSender: ) -> str | None: """Record the transmitted id on the row, or reject an unusable one. - The stable install_id is transmitted as-is (product decision, - 2026-08-27 — see the doc's A.2). What remains of "freezing" is the - validation and the audit column: ``sent_install_id`` records exactly - what the wire will carry, and rejecting unusable rows here rather - than raising matters because an exception rolls back the claim - transaction and blocks every healthy package behind this one. + What remains of "freezing" is the validation and the audit column: ``sent_install_id`` + records exactly what the wire will carry, and rejecting unusable rows here rather than + raising matters because an exception rolls back the claim transaction and blocks every + healthy package behind this one. """ reason = None install_id = None @@ -476,11 +410,10 @@ class SharedMetricsSender: def _body(self, payload_json: str, transmitted_id: str) -> bytes: """Rebuild the exact bytes to send. - The payload is recomputed from the stored package rather than kept as - a second copy: json.dumps with these options is deterministic. The - install_id is written from the frozen ``sent_install_id`` column - rather than trusted implicitly, keeping "a resend is byte-identical" - anchored to one recorded value. + The payload is recomputed from the stored package rather than kept as a second copy: + json.dumps with these options is deterministic. The install_id is written from the frozen + ``sent_install_id`` column rather than trusted implicitly, keeping "a resend is byte- + identical" anchored to one recorded value. """ payload = json.loads(payload_json) payload = dict(payload) @@ -497,15 +430,14 @@ class SharedMetricsSender: ) -> None: """Write send state for one package. - Guarded on send_state so a pass whose lease lapsed cannot resurrect a - row another process has already finished: without this, a slow sender - could overwrite 'sent' back to 'pending' and cause a re-send. + Guarded on send_state so a pass whose lease lapsed cannot resurrect a row another process + has already finished: without this, a slow sender could overwrite 'sent' back to 'pending' + and cause a re-send. - When ``token`` is given, the write is additionally compare-and-set on - claim_token: it lands only if THIS claim is still the current one. A - claimant that lapsed and was superseded writes zero rows — its - settlement, backoff, and error strings all silently lose to the - newer claim's, which is the correct outcome. + When ``token`` is given, the write is additionally compare-and-set on claim_token: it lands + only if THIS claim is still the current one. A claimant that lapsed and was superseded + writes zero rows — its settlement, backoff, and error strings all silently lose to the newer + claim's, which is the correct outcome. """ assignments = ", ".join(f"{name} = ?" for name in columns) predicate = ( @@ -528,21 +460,14 @@ class SharedMetricsSender: def _renew_claim(self, package_id: str, token: str | None) -> bool: """Atomically re-assert ownership and extend the lease. CAS, one row. - A read-only ownership check is not enough: a claimant whose lease - expired while suspended can pass the check (its token is still in - the row if no one reclaimed yet) and then POST while another process - legitimately reclaims — the check-to-POST expiry race a seventh - review reproduced. Renewal closes it by requiring, in ONE statement: + A read-only ownership check is not enough: a claimant whose lease expired while suspended + can pass the check (its token is still in the row if no one reclaimed yet) and then POST + while another process legitimately reclaims — the check-to-POST expiry race a seventh review + reproduced. - - the token still matches (nobody reclaimed), AND - - the current lease is UNEXPIRED (this claimant is not stale), AND - - the row is still pending, - - and only then pushing next_attempt_at a fresh lease into the future, - so the upcoming POST (30s timeout, well under the 300s lease) runs - entirely inside renewed authority. rowcount == 1 is the only grant. - A claimant that wakes past its own lease fails the unexpired - condition and yields even though its token was never replaced. + and only then pushing next_attempt_at a fresh lease into the future, so the upcoming POST + (30s timeout, well under the 300s lease) runs entirely inside renewed authority. rowcount == + 1 is the only grant. """ if token is None: return False @@ -605,14 +530,10 @@ class SharedMetricsSender: def _send_one(self, package: dict) -> str: """Try one package. Returns 'sent', 'rejected', or 'deferred'. - Delivery is at-least-once. The pre-POST ownership check plus the - token-fenced writes close the claim->POST and settle-after-reclaim - gaps, but a suspension landing MID-POST (bytes already on the wire - when the machine sleeps) can still duplicate: no client-side check - can revoke a request in flight. The body is byte-identical across - retries by construction, so the residual duplicate is exactly one - redundant copy of identical content; collapsing it fully would need - package_id-keyed dedupe at the ingest service. + Delivery is at-least-once. The pre-POST ownership check plus the token-fenced writes close + the claim->POST and settle-after-reclaim gaps, but a suspension landing MID-POST (bytes + already on the wire when the machine sleeps) can still duplicate: no client-side check can + revoke a request in flight. """ package_id = package["package_id"] token = package.get("claim_token") @@ -711,9 +632,9 @@ class SharedMetricsSender: def send_pending(self) -> SendOutcome: """Run one bounded pass. Never raises. - Claims and sends ONE package at a time so each row's lease only has to - cover its own transmission, and re-checks consent before every send so - revoking `send` mid-pass stops the remaining packages. + Claims and sends ONE package at a time so each row's lease only has to cover its own + transmission, and re-checks consent before every send so revoking `send` mid-pass stops the + remaining packages. """ outcome = SendOutcome() seen: set[str] = set() @@ -774,10 +695,9 @@ class SharedMetricsSender: def _still_consented(self) -> bool: """Re-read profile-owned send consent. - Consent is a boundary, not cached configuration: the documentation - promises that setting `send: false` stops transmission immediately, - and a pass can run for minutes. Injected senders (tests, the staging - E2E) opt out by passing consent_check=None. + Consent is a boundary, not cached config: docs promise ``send: false`` stops transmission + immediately, and a pass can run for minutes. Injected senders opt out via + consent_check=None. """ if self._consent_check is None: return True diff --git a/hermes_cli/observability/shared_metrics_subscriber.py b/hermes_cli/observability/shared_metrics_subscriber.py index 54c05e5780..1104a8a0d3 100644 --- a/hermes_cli/observability/shared_metrics_subscriber.py +++ b/hermes_cli/observability/shared_metrics_subscriber.py @@ -53,6 +53,21 @@ class SharedMetricsSubscriber: with self._lock: self._active = False + @staticmethod + def _classify(event: Any) -> tuple[str, dict] | None: + """Return ``(metric_name, dimensions)`` for the first matching contract, else None.""" + metric = client_active_counter(event) + if metric is not None: + return metric + for metric_name, project in ( + (MODEL_ROUTE_METRIC, model_call_dimensions), + (TOOL_CALL_METRIC, tool_call_dimensions), + ): + dimensions = project(event) + if dimensions is not None: + return metric_name, dimensions + return task_counter(event) or tool_approval_counter(event) or skill_counter(event) + def __call__(self, event: Any) -> None: if self._runtime_id is not None: metadata = getattr(event, "metadata", None) @@ -61,26 +76,10 @@ class SharedMetricsSubscriber: or metadata.get(RUNTIME_INSTANCE_KEY) != self._runtime_id ): return - metric = client_active_counter(event) - dimensions = None - metric_name = CLIENT_ACTIVE_METRIC - if metric is not None: - metric_name, dimensions = metric - if dimensions is None: - dimensions = model_call_dimensions(event) - metric_name = MODEL_ROUTE_METRIC - if dimensions is None: - dimensions = tool_call_dimensions(event) - metric_name = TOOL_CALL_METRIC - if dimensions is None: - metric = ( - task_counter(event) - or tool_approval_counter(event) - or skill_counter(event) - ) - if metric is None: - return - metric_name, dimensions = metric + metric = self._classify(event) + if metric is None: + return + metric_name, dimensions = metric with self._lock: if not self._active: return diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index e2778d67d7..a10d863c42 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -1,22 +1,14 @@ """Oneshot (-z) mode: send a prompt, get the final content block, exit. -Bypasses cli.py entirely. No banner, no spinner, no session_id line, -no stderr chatter. Just the agent's final text to stdout. +Toolsets = explicit --toolsets when provided, otherwise whatever the user has configured for "cli" +in `hermes tools`. Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn. +Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call). Working directory = the user's +CWD (AGENTS.md etc. -Toolsets = explicit --toolsets when provided, otherwise whatever the user has -configured for "cli" in `hermes tools`. -Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn. -Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call). -Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual). - -Model / provider selection mirrors `hermes chat`: - - Both optional. If omitted, use the user's configured default. - - If both given, pair them exactly as given. - - If only --model given, auto-detect the provider that serves it. - - If only --provider given, error out (ambiguous — caller must pick a model). - -Env var fallbacks (used when the corresponding arg is not passed): - - HERMES_INFERENCE_MODEL +Model / provider selection mirrors `hermes chat`: - Both optional. If omitted, use the user's +configured default. - If both given, pair them exactly as given. - If only --model given, auto- +detect the provider that serves it. - If only --provider given, error out (ambiguous — caller must +pick a model). """ from __future__ import annotations @@ -159,8 +151,8 @@ def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | No def _write_usage_file(path: Optional[str], result: dict, failure: Optional[str] = None) -> None: """Best-effort JSON usage report for pipelines (``-z --usage-file``). - Written even on failure so callers can always account for spend. Never - raises — a broken usage write must not mask the run's own outcome. + Written even on failure so callers can always account for spend. Never raises — a broken usage + write must not mask the run's own outcome. """ if not path: return @@ -209,20 +201,9 @@ def run_oneshot( ) -> int: """Execute a single prompt and print only the final content block. - Args: - prompt: The user message to send. - model: Optional model override. Falls back to HERMES_INFERENCE_MODEL - env var, then config.yaml's model.default / model.model. - provider: Optional provider override. Falls back to config.yaml's - model.provider, then "auto". - toolsets: Optional comma-separated string or iterable of toolsets. - skills: Optional repeated/comma-separated skill identifiers to preload. - usage_file: Optional path; when set, a JSON usage report (estimated - cost, token counts, model, api_calls) is written there after the - run — even when the run fails — so pipelines can account for - spend per invocation. - - Returns the exit code. The caller owns process termination. + Model/provider fall back to ``HERMES_INFERENCE_MODEL`` and config.yaml. ``usage_file`` gets a + JSON usage report (cost, tokens, model, api_calls) even when the run fails, so pipelines can + account for spend per invocation. Returns the exit code; the caller owns process termination. """ # Silence every stdlib logger for the duration. AIAgent, tools, and # provider adapters all log to stderr through the root logger; file @@ -341,9 +322,9 @@ def run_oneshot( def _create_session_db_for_oneshot(): """Best-effort SessionDB for ``hermes -z`` / oneshot mode. - Oneshot bypasses ``HermesCLI._init_agent()``, so it must wire the SQLite - session store itself. Without this, the ``session_search``/recall tool is - advertised but every call returns "Session database not available.". + Oneshot bypasses ``HermesCLI._init_agent()``, so it must wire the SQLite session store itself. + Without this, the ``session_search``/recall tool is advertised but every call returns "Session + database not available.". """ try: from hermes_state import SessionDB diff --git a/hermes_cli/partial_compress.py b/hermes_cli/partial_compress.py index 129a9fc6b0..d097fd0bc2 100644 --- a/hermes_cli/partial_compress.py +++ b/hermes_cli/partial_compress.py @@ -1,42 +1,12 @@ """Boundary-aware partial compression — "summarize up to here". -Inspired by Claude Code's Rewind menu "Summarize up to here" action -(v2.1.139–v2.1.142, Week 20, May 2026): -https://code.claude.com/docs/en/whats-new/2026-w20 +* **Role alternation.** The compressed head ends with summary/handoff content (assistant- or user- +role, possibly a trailing todo snapshot). The verbatim tail must begin with a ``user`` message so +the rejoined history keeps the user↔assistant alternation that providers validate. -Hermes already has ``/compress`` (full-history compaction) and an -automatic token-budget tail-protection heuristic inside -``ContextCompressor``. What was missing is *user-chosen* boundary -control: "fold everything before this point into a summary, but keep -my most recent N exchanges exactly as they are." That is the value of -the Claude Code feature — the user decides the compression boundary -instead of leaving it to the token-budget heuristic. - -This module owns the pure, side-effect-free split logic so both the -CLI (``cli.py::_manual_compress``) and the gateway -(``gateway/run.py::_handle_compress_command``) share one -implementation. The slash-command surfaces handle compression of the -*head* via the existing ``_compress_context`` pipeline (preserving all -the session-rotation / lock / memory-notify machinery) and then -re-append the verbatim *tail* returned here. - -Design notes / invariants honored: - -* **Role alternation.** The compressed head ends with summary/handoff - content (assistant- or user-role, possibly a trailing todo snapshot). - The verbatim tail must begin with a ``user`` message so the rejoined - history keeps the user↔assistant alternation that providers validate. - :func:`split_history_for_partial_compress` snaps the tail boundary - backwards to the nearest ``user`` turn so the rejoin is always legal. - -* **No silent context mutation.** This is a manual, user-invoked - action. It rotates the session exactly like ``/compress`` does (via - the caller), so the prompt-cache reset is explicit and expected, not - silent. - -* **Conservative defaults.** ``keep_last`` counts *exchanges* (a user - turn plus its following assistant/tool turns), defaulting to 2. The - split never compresses if doing so would leave nothing in the head. +* **No silent context mutation.** This is a manual, user-invoked action. It rotates the session +exactly like ``/compress`` does (via the caller), so the prompt-cache reset is explicit and +expected, not silent. """ from __future__ import annotations @@ -57,26 +27,11 @@ def parse_partial_compress_args( ) -> Tuple[bool, int, Optional[str]]: """Parse the argument string after ``/compress``. - Recognizes the boundary-aware forms: + Anything else is treated as a focus topic for the existing full ``/compress <focus>`` behavior. - * ``here`` → partial compress, keep ``DEFAULT_KEEP_LAST`` - * ``here 4`` → partial compress, keep 4 exchanges - * ``--keep 4`` → partial compress, keep 4 exchanges - * ``up to here`` → alias for ``here`` (matches Claude Code's - menu label "Summarize up to here") - - Anything else is treated as a focus topic for the existing full - ``/compress <focus>`` behavior. - - Returns ``(partial, keep_last, focus_topic)``: - - * ``partial`` — True when a boundary-aware form was requested. - * ``keep_last`` — exchanges to preserve verbatim (only meaningful - when ``partial`` is True). - * ``focus_topic`` — focus string for full compression, or None. - Always None when ``partial`` is True (the two modes are exclusive; - a focused partial compress is not a documented Claude Code - behavior and would muddy the UX). + * ``partial`` — True when a boundary-aware form was requested. * ``keep_last`` — exchanges to + preserve verbatim (only meaningful when ``partial`` is True). * ``focus_topic`` — focus string + for full compression, or None. """ text = (raw_args or "").strip() if not text: @@ -90,43 +45,32 @@ def parse_partial_compress_args( text = text[len("up to ") :] tokens = lowered.split() + head = tokens[0] if tokens else "" # Form: here [N] - if tokens and tokens[0] == "here": - keep = DEFAULT_KEEP_LAST - if len(tokens) >= 2: - keep = _coerce_keep(tokens[1]) + if head == "here": + keep = _coerce_keep(tokens[1]) if len(tokens) >= 2 else DEFAULT_KEEP_LAST return True, keep, None # Form: --keep N (or --keep=N) - if tokens and tokens[0] in ("--keep", "-k") and len(tokens) >= 2: + if head in ("--keep", "-k") and len(tokens) >= 2: return True, _coerce_keep(tokens[1]), None - if tokens and tokens[0].startswith("--keep="): - return True, _coerce_keep(tokens[0].split("=", 1)[1]), None + if head.startswith("--keep="): + return True, _coerce_keep(head.split("=", 1)[1]), None # Otherwise: full compression with this as the focus topic. return False, DEFAULT_KEEP_LAST, text or None def extract_compress_flags(raw_args: str) -> Tuple[str, bool, bool]: - """Strip ``--preview``/``--dry-run``/``--aggressive`` flags from the - argument string after ``/compress`` (or its ``/compact`` alias). + """Strip ``--preview``/``--dry-run``/``--aggressive`` from the ``/compress`` argument string. - Flags may appear anywhere and coexist with the positional forms - (``here [N]``, ``--keep N``, or a focus topic); the returned - remainder is what :func:`parse_partial_compress_args` should see. - - Returns ``(remaining_args, preview, aggressive_requested)``: - - * ``preview`` — True when ``--preview`` or ``--dry-run`` was given. - The caller must report what WOULD be compressed (message counts, - token estimate, boundary) and make **no changes**. - * ``aggressive_requested`` — True when ``--aggressive`` was given. - The current surfaces do not implement an LLM-free hard-truncate - path (it would need its own transcript-persistence branch outside - the guarded ``_compress_context`` rotation machinery), so callers - surface a "not supported" note instead of silently treating the - flag as a focus topic. + Flags may appear anywhere alongside the positional forms (``here [N]``, ``--keep N``, focus + topic); the remainder is what :func:`parse_partial_compress_args` should see. Returns + ``(remaining_args, preview, aggressive_requested)``. ``preview`` (``--preview``/``--dry- + run``) means report what WOULD be compressed and change nothing. No surface implements an + LLM-free hard-truncate path, so callers surface "not supported" for ``--aggressive`` instead + of treating it as a focus topic. """ preview = False aggressive = False @@ -151,12 +95,9 @@ def summarize_compress_preview( ) -> Dict[str, Any]: """Build the ``/compress --preview`` report — pure, no side effects. - Shared by the CLI (``cli.py::_manual_compress``) and the gateway - (``gateway/slash_commands.py::_handle_compress_command``) so both - surfaces report the same numbers the real run would use. - - Returns a dict with ``head_count``/``tail_count``/``lines`` where - ``lines`` is a ready-to-print list of report strings. + Shared by the CLI and the gateway slash handler so both surfaces report the same numbers the + real run would use. Returns ``head_count``/``tail_count``/``lines`` (ready-to-print + strings). """ total = len(history) head = list(history) @@ -203,11 +144,7 @@ def _coerce_keep(value: str) -> int: n = int(value) except (TypeError, ValueError): return DEFAULT_KEEP_LAST - if n < 1: - return 1 - if n > MAX_KEEP_LAST: - return MAX_KEEP_LAST - return n + return max(1, min(n, MAX_KEEP_LAST)) def split_history_for_partial_compress( @@ -216,20 +153,11 @@ def split_history_for_partial_compress( ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Split ``history`` into ``(head, tail)`` for partial compression. - ``head`` is the earlier portion that will be summarized; ``tail`` is - the most recent ``keep_last`` exchanges, preserved verbatim. - - An *exchange* is counted by ``user``-role messages: keeping N - exchanges means keeping everything from the Nth-most-recent ``user`` - message onward. This guarantees the tail starts on a ``user`` turn, - so when the caller rejoins ``compressed_head + tail`` the - user↔assistant alternation stays valid (the compressed head's - trailing content is followed by a fresh user turn). - - Returns ``(head, tail)``. If the split would leave the head empty - (not enough history to compress meaningfully), returns - ``(history, [])`` — signaling the caller to fall back to full - compression or report "nothing to do". + ``head`` is summarized; ``tail`` is the last ``keep_last`` exchanges kept verbatim. + Exchanges are counted by ``user`` messages so the tail always starts on a user turn and + rejoining ``compressed_head + tail`` keeps user↔assistant alternation valid. Returns + ``(history, [])`` when the head would be empty, signaling the caller to fall back to full + compression or "nothing to do". """ if keep_last < 1: keep_last = 1 @@ -270,23 +198,14 @@ def rejoin_compressed_head_and_tail( compressed_head: List[Dict[str, Any]], tail: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: - """Concatenate a compressed head with the verbatim tail, defending - the seam against an illegal user→user / assistant→assistant adjacency. + """Concatenate a compressed head with the verbatim tail, defending the seam's role alternation. - In normal operation the compressed head ends with the head's own - protected verbatim tail (the ``ContextCompressor`` always preserves a - recent window), which terminates on an ``assistant``/``tool`` turn — - so ``assistant → user`` at the seam is already valid. But the head - compressor's exact output shape is not contractually guaranteed (a - plugin context engine could return something that ends on a ``user`` - turn, or a degenerate single-summary message). Rather than trust the - seam, this helper inspects the boundary and, if the last head message - and the first tail message share a ``user``/``assistant`` role, folds - the tail's first message content onto the head's last message so the - rejoined list never violates provider role-alternation rules. - - ``tool`` messages are left alone — consecutive ``tool`` entries are - the one legal repetition (parallel tool results). + The compressed head normally ends on an assistant/tool turn, but the head compressor's + output shape isn't contractually guaranteed (a plugin engine could end on a user turn). If + the last head message and first tail message share a user/assistant role, the tail's first + content is folded onto the head's last message so provider role-alternation rules hold. + ``tool`` messages are left alone — consecutive tool entries are the one legal repetition + (parallel results). """ if not tail: return list(compressed_head) diff --git a/hermes_cli/personality.py b/hermes_cli/personality.py index 8efdd93fc9..10ee2ac6f2 100644 --- a/hermes_cli/personality.py +++ b/hermes_cli/personality.py @@ -1,33 +1,11 @@ """Single owner for personality overlays. -Every surface (CLI ``/personality``, gateway ``/personality``, TUI + desktop -``config.set personality`` RPC, agent-startup overlay resolution) goes through -this module. Nothing else may: +The v34 config migration resets the selection once; this module ensures the split cannot happen +again. -* define built-in personalities, -* decide what counts as a "neutral" name, -* render a personality definition into prompt text, -* resolve the active overlay from config, or -* persist the selection. - -History: personality state used to be written differently per surface — the -old CLI/gateway wrote rendered personality TEXT into ``agent.system_prompt`` -while the TUI/desktop wrote the NAME to ``display.personality``. When -``display.personality`` became authoritative (PR #81946), years of stale -per-surface state resurrected personalities users had turned off. The v34 -config migration resets the selection once; this module ensures the split -cannot happen again. - -Contract: - -* ``display.personality`` holds the selected NAME (empty = no overlay). -* ``agent.system_prompt`` is the user-owned manual overlay. Personality code - never writes it. -* ``agent.personalities`` holds user-defined/overridden personalities; they - overlay the built-ins by name. - -This module deliberately has no module-level imports from ``hermes_cli.config`` -(that module imports us), keeping the import direction acyclic. +* ``display.personality`` holds the selected NAME (empty = no overlay). * ``agent.system_prompt`` is +the user-owned manual overlay. Personality code never writes it. * ``agent.personalities`` holds +user-defined/overridden personalities; they overlay the built-ins by name. """ from __future__ import annotations @@ -122,11 +100,7 @@ def available_personalities(cfg: Optional[Dict[str, Any]] = None) -> Dict[str, A def resolve_personality( value: Any, cfg: Optional[Dict[str, Any]] = None ) -> Tuple[str, str]: - """Resolve a requested personality to ``(canonical_name, prompt_text)``. - - Neutral names resolve to ``("", "")``. Unknown names raise ``ValueError`` - with an availability listing usable verbatim in user-facing errors. - """ + """Resolve a requested personality to ``(canonical_name, prompt_text)``.""" name = normalize_personality_name(value) if not name: return "", "" @@ -150,9 +124,9 @@ def active_personality_name(cfg: Optional[Dict[str, Any]]) -> str: def resolve_ephemeral_system_prompt(cfg: Optional[Dict[str, Any]]) -> str: """Resolve the session overlay from config. - ``display.personality`` wins when it names a known personality; otherwise - the user-owned ``agent.system_prompt`` applies. Callers should still - prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` when that env var is set. + ``display.personality`` wins when it names a known personality; otherwise the user-owned + ``agent.system_prompt`` applies. Callers should still prefer ``HERMES_EPHEMERAL_SYSTEM_PROMPT`` + when that env var is set. """ name = active_personality_name(cfg) if name: @@ -163,9 +137,9 @@ def resolve_ephemeral_system_prompt(cfg: Optional[Dict[str, Any]]) -> str: def persist_personality(value: Any) -> bool: """Persist the personality selection — the ONLY sanctioned write path. - Writes the canonical name (or '') to ``display.personality`` in the active - HERMES_HOME config.yaml atomically, preserving comments and ordering. - Never touches ``agent.system_prompt``. Returns True on success. + Writes the canonical name (or '') to ``display.personality`` in the active HERMES_HOME + config.yaml atomically, preserving comments and ordering. Never touches ``agent.system_prompt``. + Returns True on success. """ name = normalize_personality_name(value) try: diff --git a/hermes_cli/pets.py b/hermes_cli/pets.py index e840a03bdd..5c2c703ea7 100644 --- a/hermes_cli/pets.py +++ b/hermes_cli/pets.py @@ -1,12 +1,4 @@ -"""CLI subcommand: ``hermes pets <subcommand>``. - -Thin shell around :mod:`agent.pet`. Browses the public petdex gallery, -installs pets into the profile's ``pets/`` directory, selects the active -mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check. - -No side effects at import time — ``main.py`` wires the argparse subparsers on -demand via :func:`register_cli`. -""" +"""CLI subcommand: ``hermes pets <subcommand>``.""" from __future__ import annotations @@ -16,10 +8,6 @@ import sys from utils import is_truthy_value -def _print(msg: str = "") -> None: - print(msg) - - def _err(msg: str) -> None: print(msg, file=sys.stderr) @@ -31,11 +19,11 @@ def _cmd_list(args) -> int: if getattr(args, "installed", False): pets = store.installed_pets() if not pets: - _print("No pets installed. Try: hermes pets install boba") + print("No pets installed. Try: hermes pets install boba") return 0 - _print(f"Installed pets ({len(pets)}):") + print(f"Installed pets ({len(pets)}):") for pet in pets: - _print(f" {pet.slug:<24} {pet.display_name}") + print(f" {pet.slug:<24} {pet.display_name}") return 0 from agent.pet.manifest import ManifestError, fetch_manifest @@ -48,23 +36,19 @@ def _cmd_list(args) -> int: query = (getattr(args, "query", "") or "").strip().lower() if query: - entries = [ - e - for e in entries - if query in e.slug.lower() or query in e.display_name.lower() - ] + entries = [e for e in entries if query in e.slug.lower() or query in e.display_name.lower()] limit = getattr(args, "limit", 0) or 0 shown = entries[:limit] if limit > 0 else entries installed = {p.slug for p in store.installed_pets()} - _print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:") + print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:") for entry in shown: mark = "✓" if entry.slug in installed else " " - _print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})") + print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})") if limit and len(entries) > limit: - _print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)") - _print("\nInstall one with: hermes pets install <slug>") + print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)") + print("\nInstall one with: hermes pets install <slug>") return 0 @@ -79,13 +63,13 @@ def _cmd_install(args) -> int: _err(f"✗ install failed: {exc}") return 1 - _print(f"✓ installed {pet.display_name} → {pet.directory}") + print(f"✓ installed {pet.display_name} → {pet.directory}") if getattr(args, "select", False) or not _has_active_pet(): _set_active(slug) - _print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)") + print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)") else: - _print(f" Make it active with: hermes pets select {slug}") + print(f" Make it active with: hermes pets select {slug}") return 0 @@ -94,7 +78,7 @@ def _cmd_remove(args) -> int: slug = args.slug.strip() if store.remove_pet(slug): - _print(f"✓ removed {slug}") + print(f"✓ removed {slug}") return 0 _err(f"✗ '{slug}' is not installed") return 1 @@ -119,13 +103,13 @@ def _cmd_select(args) -> int: return 1 _set_active(slug) - _print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)") + print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)") return 0 def _cmd_off(args) -> int: _set_enabled(False) - _print("✓ pet disabled (display.pet.enabled=false)") + print("✓ pet disabled (display.pet.enabled=false)") return 0 @@ -135,16 +119,16 @@ def _cmd_scale(args) -> int: if err: _err(f"✗ {err}") return 1 - _print(f"✓ pet scale set to {scale:g} (display.pet.scale)") + print(f"✓ pet scale set to {scale:g} (display.pet.scale)") return 0 def _cmd_show(args) -> int: """Animate the active (or named) pet in the terminal. - Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics - protocol (kitty/iTerm2/sixel) when the terminal supports it, else a - truecolor Unicode half-block fallback. Ctrl+C to stop. + Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics protocol + (kitty/iTerm2/sixel) when the terminal supports it, else a truecolor Unicode half-block + fallback. Ctrl+C to stop. """ import time @@ -170,10 +154,7 @@ def _cmd_show(args) -> int: unicode_cols=cols, ) if not renderer.available: - _err( - "✗ cannot render here (no TTY / graphics disabled). " - f"Effective mode: {renderer.mode}." - ) + _err(f"✗ cannot render here (no TTY / graphics disabled). Effective mode: {renderer.mode}.") return 1 # Which states to play: one named state, or cycle the driveable rows. @@ -194,20 +175,15 @@ def _cmd_show(args) -> int: import shutil term_cols = shutil.get_terminal_size((80, 24)).columns - indent = "" - g_indent = "" - if is_unicode: - indent = " " * max(0, term_cols - cols - 1) - else: - cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8) - g_indent = " " * max(0, term_cols - cell_cols - 1) + sprite_cols = cols if is_unicode else max(1, int(renderer.frame_w * renderer.scale) // 8) + indent = " " * max(0, term_cols - sprite_cols - 1) out = sys.stdout out.write("\x1b[?25l") # hide cursor out.flush() prev_lines = 0 try: - _print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)") + print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)") loops = 0 while True: for state in states: @@ -227,10 +203,7 @@ def _cmd_show(args) -> int: else: out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols out.write(f"{pet.display_name} [{state}]\n") - if g_indent: - out.write(g_indent) - out.write(encoded) - out.write("\n") + out.write(indent + encoded + "\n") out.flush() time.sleep(frame_delay) loops += 1 @@ -251,40 +224,40 @@ def _cmd_doctor(args) -> int: from agent.pet.render import detect_terminal_graphics, resolve_mode cfg = _pet_config() - enabled = is_truthy_value(cfg.get("enabled"), default=False) + enabled = _pet_enabled(cfg) configured_slug = str(cfg.get("slug", "") or "") mode_cfg = str(cfg.get("render_mode", "auto") or "auto") pets = store.installed_pets() active = store.resolve_active_pet(configured_slug) - _print("petdex doctor") - _print(f" pets dir: {store.pets_dir()}") - _print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})") - _print(f" display.pet.enabled: {enabled}") - _print(f" display.pet.slug: {configured_slug or '(unset)'}") - _print(f" active (resolved): {active.slug if active else '(none)'}") - _print(f" display.pet.render_mode: {mode_cfg}") - _print(f" detected graphics: {detect_terminal_graphics()}") - _print(f" effective mode (TTY): {resolve_mode(mode_cfg)}") + print("petdex doctor") + print(f" pets dir: {store.pets_dir()}") + print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})") + print(f" display.pet.enabled: {enabled}") + print(f" display.pet.slug: {configured_slug or '(unset)'}") + print(f" active (resolved): {active.slug if active else '(none)'}") + print(f" display.pet.render_mode: {mode_cfg}") + print(f" detected graphics: {detect_terminal_graphics()}") + print(f" effective mode (TTY): {resolve_mode(mode_cfg)}") ok = True if not pets: - _print(" → no pets installed. Run: hermes pets install boba") + print(" → no pets installed. Run: hermes pets install boba") ok = False elif active is None: - _print(" → active pet unresolved. Run: hermes pets select <slug>") + print(" → active pet unresolved. Run: hermes pets select <slug>") ok = False elif not enabled: - _print(" → pet display is disabled. Run: hermes pets select " + active.slug) + print(" → pet display is disabled. Run: hermes pets select " + active.slug) try: import PIL # noqa: F401 except ImportError: - _print(" ✗ Pillow not importable — sprite decoding will be unavailable") + print(" ✗ Pillow not importable — sprite decoding will be unavailable") ok = False - _print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)") + print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)") return 0 @@ -302,48 +275,48 @@ def _pet_config() -> dict: def _has_active_pet() -> bool: - return is_truthy_value(_pet_config().get("enabled"), default=False) and bool( - _pet_config().get("slug") - ) + cfg = _pet_config() + return _pet_enabled(cfg) and bool(cfg.get("slug")) + + +def _pet_enabled(cfg: dict) -> bool: + return is_truthy_value(cfg.get("enabled"), default=False) + + +def _update_pet_config(when_slug: str | None = None, **values) -> bool: + """Write ``display.pet.*`` keys and save config. + + With ``when_slug`` the write only happens when the currently configured slug equals it (used + by remove/rename so an inactive pet never disturbs the active one). Returns whether anything + was written. + """ + from hermes_cli.config import load_config, save_config + + cfg = load_config() + pet = cfg.setdefault("display", {}).setdefault("pet", {}) + if when_slug is not None and ( + not isinstance(pet, dict) or str(pet.get("slug", "") or "") != when_slug + ): + return False + pet.update(values) + save_config(cfg) + return True def _set_active(slug: str) -> None: - from hermes_cli.config import load_config, save_config - - cfg = load_config() - display = cfg.setdefault("display", {}) - pet = display.setdefault("pet", {}) - pet["slug"] = slug - pet["enabled"] = True - save_config(cfg) + _update_pet_config(slug=slug, enabled=True) def _set_enabled(enabled: bool) -> None: - from hermes_cli.config import load_config, save_config - - cfg = load_config() - display = cfg.setdefault("display", {}) - pet = display.setdefault("pet", {}) - pet["enabled"] = enabled - save_config(cfg) - - -def _set_scale(scale: float) -> None: - from hermes_cli.config import load_config, save_config - - cfg = load_config() - display = cfg.setdefault("display", {}) - pet = display.setdefault("pet", {}) - pet["scale"] = scale - save_config(cfg) + _update_pet_config(enabled=enabled) def set_pet_scale(value: float | str) -> tuple[float, str | None]: """Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``. - The single write path behind ``/pet scale`` and the desktop slider, so every - surface that resolves scale from config picks it up identically. *error* is - set (and nothing written) only when *value* isn't a number. + The single write path behind ``/pet scale`` and the desktop slider, so every surface that + resolves scale from config picks it up identically. *error* is set (and nothing written) only + when *value* isn't a number. """ from agent.pet.constants import clamp_scale @@ -352,23 +325,19 @@ def set_pet_scale(value: float | str) -> tuple[float, str | None]: except (TypeError, ValueError): return 0.0, f"not a number: {value!r} — try a value like 0.5" - _set_scale(scale) + _update_pet_config(scale=scale) return scale, None def toggle_pet_display() -> tuple[bool, str | None, str | None]: - """Toggle ``display.pet.enabled``. - - Returns ``(enabled, display_name, error_message)``. *error_message* is set - when turning on but nothing is installed to show. - """ + """Toggle ``display.pet.enabled``.""" from agent.pet import store cfg = _pet_config() slug = str(cfg.get("slug", "") or "") pet = store.resolve_active_pet(slug) - if is_truthy_value(cfg.get("enabled"), default=False): + if _pet_enabled(cfg): _set_enabled(False) return False, pet.display_name if pet else None, None @@ -408,46 +377,28 @@ def _clear_active_if(slug: str) -> bool: Returns whether anything changed, so callers don't write config needlessly. """ - from hermes_cli.config import load_config, save_config - - cfg = load_config() - pet = cfg.setdefault("display", {}).setdefault("pet", {}) - if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug: - return False - pet["slug"] = "" - pet["enabled"] = False - save_config(cfg) - return True + return _update_pet_config(when_slug=slug, slug="", enabled=False) def _rename_active_if(old_slug: str, new_slug: str) -> bool: """Repoint the active pet from ``old_slug`` to ``new_slug`` iff it's active. - Used when a rename realigns a pet's slug/dir: if the renamed pet was the - active one, the config must follow or surfaces point at a now-missing dir. - Preserves the ``enabled`` flag. Returns whether anything changed. + Used when a rename realigns a pet's slug/dir: if the renamed pet was the active one, the config + must follow or surfaces point at a now-missing dir. Preserves the ``enabled`` flag. Returns + whether anything changed. """ if not new_slug or old_slug == new_slug: return False - from hermes_cli.config import load_config, save_config - - cfg = load_config() - pet = cfg.setdefault("display", {}).setdefault("pet", {}) - if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != old_slug: - return False - pet["slug"] = new_slug - save_config(cfg) - return True + return _update_pet_config(when_slug=old_slug, slug=new_slug) def _interactive_pick(pets) -> str: """Minimal numbered picker (avoids curses dep for a tiny list).""" - _print("Installed pets:") + print("Installed pets:") for i, pet in enumerate(pets, 1): - _print(f" {i}. {pet.slug:<24} {pet.display_name}") + print(f" {i}. {pet.slug:<24} {pet.display_name}") try: - choice = input("Select a pet [1]: ").strip() or "1" - idx = int(choice) - 1 + idx = int(input("Select a pet [1]: ").strip() or "1") - 1 except (EOFError, KeyboardInterrupt, ValueError): _err("✗ cancelled") return "" @@ -461,46 +412,47 @@ def _interactive_pick(pets) -> str: # argparse wiring # ───────────────────────────────────────────────────────────────────────── +# (name, help, handler, [((flags...), add_argument kwargs), ...]) — registration order is menu order. +_SUBCOMMANDS = ( + ("list", "Browse the petdex gallery", _cmd_list, ( + (("query",), dict(nargs="?", default="", help="Filter by slug/name substring")), + (("--installed",), dict(action="store_true", help="Only show installed pets")), + (("--limit",), dict(type=int, default=40, help="Max rows (0 = all)")), + )), + ("install", "Install a pet from the gallery", _cmd_install, ( + (("slug",), dict(help="Pet slug (e.g. boba)")), + (("--force",), dict(action="store_true", help="Re-download even if present")), + (("--select",), dict(action="store_true", help="Make it the active pet")), + )), + ("select", "Set the active pet (writes display.pet.*)", _cmd_select, ( + (("slug",), dict(nargs="?", default="", help="Pet slug (omit for picker)")), + )), + ("show", "Animate the active pet in the terminal", _cmd_show, ( + (("slug",), dict(nargs="?", default="", help="Pet slug (default: active)")), + (("--state",), dict(default="", help="Single state: idle/run/review/failed/wave/jump")), + (("--cycle",), dict(action="store_true", help="Cycle through all states")), + (("--once",), dict(action="store_true", help="Play once instead of looping")), + (("--mode",), dict(default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)")), + (("--scale",), dict(type=float, default=0, help="Override scale (0 = config)")), + )), + ("off", "Disable the pet display", _cmd_off, ()), + ("scale", "Resize the pet everywhere (display.pet.scale)", _cmd_scale, ( + (("factor",), dict(help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)")), + )), + ("remove", "Delete an installed pet", _cmd_remove, ( + (("slug",), dict(help="Pet slug")), + )), + ("doctor", "Check pet setup + terminal graphics support", _cmd_doctor, ()), +) + + def register_cli(parent: argparse.ArgumentParser) -> None: """Attach ``pets`` subcommands to *parent* (called by main.py).""" parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1]) subs = parent.add_subparsers(dest="pets_command") - p_list = subs.add_parser("list", help="Browse the petdex gallery") - p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring") - p_list.add_argument("--installed", action="store_true", help="Only show installed pets") - p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)") - p_list.set_defaults(func=_cmd_list) - - p_install = subs.add_parser("install", help="Install a pet from the gallery") - p_install.add_argument("slug", help="Pet slug (e.g. boba)") - p_install.add_argument("--force", action="store_true", help="Re-download even if present") - p_install.add_argument("--select", action="store_true", help="Make it the active pet") - p_install.set_defaults(func=_cmd_install) - - p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)") - p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)") - p_select.set_defaults(func=_cmd_select) - - p_show = subs.add_parser("show", help="Animate the active pet in the terminal") - p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)") - p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump") - p_show.add_argument("--cycle", action="store_true", help="Cycle through all states") - p_show.add_argument("--once", action="store_true", help="Play once instead of looping") - p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)") - p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)") - p_show.set_defaults(func=_cmd_show) - - subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off) - - p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)") - p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)") - p_scale.set_defaults(func=_cmd_scale) - - p_remove = subs.add_parser("remove", help="Delete an installed pet") - p_remove.add_argument("slug", help="Pet slug") - p_remove.set_defaults(func=_cmd_remove) - - subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults( - func=_cmd_doctor - ) + for name, help_text, func, arguments in _SUBCOMMANDS: + sub = subs.add_parser(name, help=help_text) + for flags, kwargs in arguments: + sub.add_argument(*flags, **kwargs) + sub.set_defaults(func=func) diff --git a/hermes_cli/platform_actions.py b/hermes_cli/platform_actions.py index de52a591e7..9df3d86b33 100644 --- a/hermes_cli/platform_actions.py +++ b/hermes_cli/platform_actions.py @@ -1,32 +1,7 @@ """Capability-gated platform action facade for plugins (#64176, action half). -``ctx.platform_actions`` gives a plugin a *minimal*, versioned verb set for -acting on connected chat platforms through the live gateway adapter registry — -no adapter handles, bot clients, or raw SDK objects are ever exposed. - -Gating (fail closed, default OFF) ---------------------------------- -Every verb checks ``plugin_capability_granted(plugin_id, -"gateway.platform_actions")`` at call time. The capability maps to the -``plugins.entries.<id>.allow_platform_actions`` legacy key and the #64228 -consent registry (``granted_capabilities``). No grant → structured -``capability_not_granted`` error, never an exception. - -v1 verb set ------------ -* ``add_reaction(platform, chat_id, message_id, emoji)`` -* ``set_thread_title(platform, chat_id, thread_id, title)`` - -Both return a structured result dict — ``{"ok": True, ...}`` on success, -``{"ok": False, "error": <code>, "detail": <str>}`` on failure — and never -raise into hook dispatch. Error codes are part of the v1 contract: -``capability_not_granted``, ``invalid_argument``, ``gateway_unavailable``, -``unknown_platform``, ``adapter_not_registered``, ``adapter_disconnected``, -``unsupported_platform_action``, ``action_failed``. - -Raw SDK payload/handle access is deliberately NOT part of this surface; per -the #64176 round-2 correction it requires its own capability -(``gateway.raw_events``, #64228) and design. +Both return a structured result dict — ``{"ok": True, ...}`` on success, ``{"ok": False, "error": +<code>, "detail": <str>}`` on failure — and never raise into hook dispatch. """ from __future__ import annotations @@ -57,10 +32,9 @@ def _ok(**fields: Any) -> Dict[str, Any]: class PlatformActions: """Per-plugin facade over the live gateway adapter registry. - Instances are cheap and hold only the owning plugin id; the gateway - runner and adapters are resolved at call time so a facade created - before the gateway starts (plugin ``register()`` runs first) still - works once adapters connect. + Instances are cheap and hold only the owning plugin id; the gateway runner and adapters are + resolved at call time so a facade created before the gateway starts (plugin ``register()`` runs + first) still works once adapters connect. """ def __init__(self, plugin_id: str): @@ -166,11 +140,7 @@ class PlatformActions: async def add_reaction( self, platform: str, chat_id: str, message_id: str, emoji: str ) -> Dict[str, Any]: - """Add/set an emoji reaction on a platform message. - - Telegram note: the Bot API *sets* the bot's reaction (replacing a - previous one) rather than stacking, per ``set_message_reaction``. - """ + """Add/set an emoji reaction on a platform message.""" adapter, error = self._gate( platform, chat_id=chat_id, message_id=message_id, emoji=emoji ) @@ -202,11 +172,7 @@ class PlatformActions: async def set_thread_title( self, platform: str, chat_id: str, thread_id: str, title: str ) -> Dict[str, Any]: - """Rename a thread / forum topic. - - Discord ignores ``chat_id`` (thread ids are globally addressable); - Telegram requires it (``edit_forum_topic`` is chat-scoped). - """ + """Rename a thread / forum topic.""" adapter, error = self._gate( platform, chat_id=chat_id, thread_id=thread_id, title=title ) diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py index 730dbed8a1..11d0cf8699 100644 --- a/hermes_cli/platforms.py +++ b/hermes_cli/platforms.py @@ -1,11 +1,4 @@ -""" -Shared platform registry for Hermes Agent. - -Single source of truth for platform metadata consumed by both -skills_config (label display) and tools_config (default toolset -resolution). Import ``PLATFORMS`` from here instead of maintaining -duplicate dicts in each module. -""" +"""Shared platform registry for Hermes Agent.""" from collections import OrderedDict from typing import NamedTuple @@ -44,12 +37,12 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([ ]) -def platform_label(key: str, default: str = "") -> str: - """Return the display label for a platform key, or *default*. +def _plugin_label(entry) -> str: + return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label - Checks the static PLATFORMS dict first, then the plugin platform - registry for dynamically registered platforms. - """ + +def platform_label(key: str, default: str = "") -> str: + """Return the display label for a platform key, or *default*.""" info = PLATFORMS.get(key) if info is not None: return info.label @@ -58,7 +51,7 @@ def platform_label(key: str, default: str = "") -> str: from gateway.platform_registry import platform_registry entry = platform_registry.get(key) if entry: - return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label + return _plugin_label(entry) except Exception: pass return default @@ -67,18 +60,15 @@ def platform_label(key: str, default: str = "") -> str: def get_all_platforms() -> "OrderedDict[str, PlatformInfo]": """Return PLATFORMS merged with any plugin-registered platforms. - Plugin platforms are appended after builtins. This is the function - that tools_config and skills_config should use for platform menus. + Plugin platforms are appended after builtins. This is the function that tools_config and + skills_config should use for platform menus. """ merged = OrderedDict(PLATFORMS) try: from gateway.platform_registry import platform_registry for entry in platform_registry.plugin_entries(): if entry.name not in merged: - merged[entry.name] = PlatformInfo( - label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label, - default_toolset=f"hermes-{entry.name}", - ) + merged[entry.name] = PlatformInfo(_plugin_label(entry), f"hermes-{entry.name}") except Exception: pass return merged diff --git a/hermes_cli/plugin_capabilities.py b/hermes_cli/plugin_capabilities.py index c474ea037d..cb655d5d22 100644 --- a/hermes_cli/plugin_capabilities.py +++ b/hermes_cli/plugin_capabilities.py @@ -1,54 +1,8 @@ """Plugin capability declarations + consent state (#64228). -Unifies the scattered per-plugin trust gates (``plugins.entries.<id>.allow_*``) -into one declared, diffable **capability model** with install/update-time -consent. - -**This is NOT a sandbox.** In-process Python plugins remain trusted code — a -malicious plugin can import anything, monkey-patch core, and ignore all of -this. Capabilities govern the *host API surfaces* Hermes hands out (which -registrations succeed, which ``ctx`` methods are live) and give the user an -honest consent + audit trail. Actual isolation is a separate research track. - -Canonical registry ------------------- -Every capability id maps 1:1 to a trust gate that **already exists** on the -enforcing surface. We deliberately do not mint capability ids without an -enforcing gate: - -=========================== ================================================== -Capability id Legacy config gate (``plugins.entries.<id>.…``) -=========================== ================================================== -``tools.override`` ``allow_tool_override`` -``llm.provider_override`` ``llm.allow_provider_override`` -``llm.model_override`` ``llm.allow_model_override`` -``llm.agent_id_override`` ``llm.allow_agent_id_override`` -``llm.profile_override`` ``llm.allow_profile_override`` -``llm.task_override`` ``llm.allow_task_override`` -``gateway.platform_actions`` ``allow_platform_actions`` -=========================== ================================================== - -The legacy ``allow_*`` keys keep working verbatim (deprecated but honored): -a gate is open when the legacy key is true **or** the capability is granted. - -Consent state -------------- -Stored under the plugin's config entry:: - - plugins: - entries: - <plugin_id>: - granted_capabilities: [tools.override] - capabilities_consent: - hash: "<sha256 of the declared capability set at consent time>" - granted_at: "2026-08-12T00:00:00+00:00" - -The hash records *what the user saw* when they consented. When an update -declares capabilities whose set hash differs, the additions stay ungranted -until the user re-consents (``hermes plugins update`` surfaces the diff). - -Ground rule: everything defaults OFF. Any failure to read consent state -(missing config, corrupt YAML, wrong types) means **not granted**. +Canonical registry ------------------ Every capability id maps 1:1 to a trust gate that **already +exists** on the enforcing surface. We deliberately do not mint capability ids without an enforcing +gate: """ from __future__ import annotations @@ -75,60 +29,30 @@ class CapabilitySpec: # Canonical registry — ONLY capabilities with an existing enforcing surface. +# (id, legacy_path, description) +_CAPABILITY_ROWS = ( + ("tools.override", ("allow_tool_override",), + "Replace built-in tools (e.g. shell_exec, write_file) — an " + "override can intercept everything routed through that tool"), + ("llm.provider_override", ("llm", "allow_provider_override"), + "Run host-owned LLM calls against a provider other than your " + "active one (uses your credentials)"), + ("llm.model_override", ("llm", "allow_model_override"), + "Choose which model host-owned LLM calls use (spend follows " + "the chosen model)"), + ("llm.agent_id_override", ("llm", "allow_agent_id_override"), + "Attribute its LLM calls to a different agent id"), + ("llm.profile_override", ("llm", "allow_profile_override"), + "Run LLM calls under a different auth profile"), + ("llm.task_override", ("llm", "allow_task_override"), + "Route its LLM calls through the host's built-in auxiliary " + "task lanes"), + ("gateway.platform_actions", ("allow_platform_actions",), + "Act on connected chat platforms as the gateway bot " + "(add reactions, rename threads) via ctx.platform_actions"), +) CAPABILITY_REGISTRY: Dict[str, CapabilitySpec] = { - spec.id: spec - for spec in ( - CapabilitySpec( - id="tools.override", - legacy_path=("allow_tool_override",), - description=( - "Replace built-in tools (e.g. shell_exec, write_file) — an " - "override can intercept everything routed through that tool" - ), - ), - CapabilitySpec( - id="llm.provider_override", - legacy_path=("llm", "allow_provider_override"), - description=( - "Run host-owned LLM calls against a provider other than your " - "active one (uses your credentials)" - ), - ), - CapabilitySpec( - id="llm.model_override", - legacy_path=("llm", "allow_model_override"), - description=( - "Choose which model host-owned LLM calls use (spend follows " - "the chosen model)" - ), - ), - CapabilitySpec( - id="llm.agent_id_override", - legacy_path=("llm", "allow_agent_id_override"), - description="Attribute its LLM calls to a different agent id", - ), - CapabilitySpec( - id="llm.profile_override", - legacy_path=("llm", "allow_profile_override"), - description="Run LLM calls under a different auth profile", - ), - CapabilitySpec( - id="llm.task_override", - legacy_path=("llm", "allow_task_override"), - description=( - "Route its LLM calls through the host's built-in auxiliary " - "task lanes" - ), - ), - CapabilitySpec( - id="gateway.platform_actions", - legacy_path=("allow_platform_actions",), - description=( - "Act on connected chat platforms as the gateway bot " - "(add reactions, rename threads) via ctx.platform_actions" - ), - ), - ) + cid: CapabilitySpec(id=cid, legacy_path=path, description=desc) for cid, path, desc in _CAPABILITY_ROWS } VALID_CAPABILITY_IDS = frozenset(CAPABILITY_REGISTRY) @@ -145,10 +69,9 @@ CONSENT_KEY = "capabilities_consent" def parse_declared_capabilities(raw: Any, plugin_name: str = "?") -> List[str]: """Normalize a manifest ``capabilities:`` value into known capability ids. - Unknown ids are dropped with a warning (forward compat: a plugin built - for a newer Hermes may declare ids this build doesn't know; they can - never be granted here, so hiding them from the consent screen is the - fail-closed choice — the plugin must degrade gracefully). + Unknown ids are dropped with a warning (forward compat: a plugin built for a newer Hermes may + declare ids this build doesn't know; they can never be granted here, so hiding them from the + consent screen is the fail-closed choice — the plugin must degrade gracefully). """ if not raw: return [] @@ -161,23 +84,24 @@ def parse_declared_capabilities(raw: Any, plugin_name: str = "?") -> List[str]: out: List[str] = [] for item in raw: if not isinstance(item, str): - logger.warning( - "Plugin %s: ignoring non-string capability entry %r", - plugin_name, item, - ) + logger.warning("Plugin %s: ignoring non-string capability entry %r", plugin_name, item) continue cap = item.strip() - if cap in VALID_CAPABILITY_IDS: - if cap not in out: - out.append(cap) - else: + if cap not in VALID_CAPABILITY_IDS: logger.warning( "Plugin %s: unknown capability %r (known: %s) — ignoring", plugin_name, cap, ", ".join(sorted(VALID_CAPABILITY_IDS)), ) + elif cap not in out: + out.append(cap) return out +def _known(capabilities: Iterable[str]) -> List[str]: + """Deduplicated (order-preserving) subset of *capabilities* with a registry entry.""" + return [c for c in dict.fromkeys(capabilities) if c in VALID_CAPABILITY_IDS] + + def capability_set_hash(capabilities: Iterable[str]) -> str: """Deterministic sha256 over a capability set (order-insensitive).""" canon = "\n".join(sorted(set(capabilities))) @@ -203,21 +127,10 @@ def _plugin_entry(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> return {} -def granted_capabilities( - plugin_id: str, config: Optional[Mapping[str, Any]] = None -) -> frozenset: - """Return the set of capabilities the user has granted this plugin. - - Fail-closed: missing/corrupt state yields the empty set. - """ - entry = _plugin_entry(plugin_id, config) - raw = entry.get(GRANTED_KEY) - if not isinstance(raw, list): - return frozenset() - return frozenset( - c.strip() for c in raw - if isinstance(c, str) and c.strip() in VALID_CAPABILITY_IDS - ) +def granted_capabilities(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> frozenset: + """Return the set of capabilities the user has granted this plugin.""" + raw = _plugin_entry(plugin_id, config).get(GRANTED_KEY) + return frozenset(_known(c.strip() for c in raw if isinstance(c, str))) if isinstance(raw, list) else frozenset() def _legacy_gate_set(entry: Mapping[str, Any], spec: CapabilitySpec) -> bool: @@ -227,118 +140,76 @@ def _legacy_gate_set(entry: Mapping[str, Any], spec: CapabilitySpec) -> bool: if not isinstance(node, Mapping): return False node = node.get(part) - return bool(node) and node is not None + return bool(node) -def plugin_capability_granted( - plugin_id: str, - capability: str, - config: Optional[Mapping[str, Any]] = None, -) -> bool: +def plugin_capability_granted(plugin_id: str, capability: str, config: Optional[Mapping[str, Any]] = None) -> bool: """Canonical check: is *capability* live for *plugin_id*? - True when EITHER: - - * the capability appears in ``granted_capabilities`` (consent flow), OR - * the legacy ``allow_*`` config key is set (deprecated, still honored so - existing configs keep working). - - Unknown capability ids and any failure to read state return ``False`` - (ground rule 4: fail closed). + True when the capability is in ``granted_capabilities`` (consent flow) OR the legacy + ``allow_*`` key is set (deprecated, still honored so existing configs keep working). Unknown + ids and any failure to read state return ``False`` (fail closed). """ spec = CAPABILITY_REGISTRY.get(capability) if spec is None: - logger.debug( - "capability check for unknown id %r (plugin %s) — denied", - capability, plugin_id, - ) + logger.debug("capability check for unknown id %r (plugin %s) — denied", capability, plugin_id) return False entry = _plugin_entry(plugin_id, config) if capability in granted_capabilities(plugin_id, config={"plugins": {"entries": {plugin_id: entry}}}): - _log_capability_decision(plugin_id, capability, True, "granted_capabilities") - return True - if _legacy_gate_set(entry, spec): - _log_capability_decision( - plugin_id, capability, True, - f"legacy key plugins.entries.{plugin_id}.{'.'.join(spec.legacy_path)} (deprecated)", - ) - return True - _log_capability_decision(plugin_id, capability, False, "not granted") - return False - - -def _log_capability_decision( - plugin_id: str, capability: str, allowed: bool, evidence: str -) -> None: - """Audit line for capability gate decisions (the ``checked_by`` trail).""" + allowed, evidence = True, "granted_capabilities" + elif _legacy_gate_set(entry, spec): + allowed, evidence = True, f"legacy key plugins.entries.{plugin_id}.{'.'.join(spec.legacy_path)} (deprecated)" + else: + allowed, evidence = False, "not granted" + # Audit line for capability gate decisions (the ``checked_by`` trail). logger.info( "capability_check plugin=%s capability=%s decision=%s checked_by=plugin_capability_granted evidence=%s", plugin_id, capability, "allow" if allowed else "deny", evidence, ) + return allowed # --------------------------------------------------------------------------- # Consent state (write side) # --------------------------------------------------------------------------- -def record_consent( - plugin_id: str, - granted: Iterable[str], - declared: Iterable[str], -) -> None: +def _child_dict(parent: dict, key: str) -> dict: + """``parent[key]`` as a dict, replacing any non-dict value in place.""" + child = parent.setdefault(key, {}) + if not isinstance(child, dict): + child = {} + parent[key] = child + return child + + +def record_consent(plugin_id: str, granted: Iterable[str], declared: Iterable[str]) -> None: """Persist a consent decision for *plugin_id*. - Writes ``granted_capabilities`` (union with any previously granted set), - the consent record (hash of the *declared* set the user saw + UTC - timestamp), and — so every existing enforcement site keeps working - without changes — the corresponding legacy ``allow_*`` keys for each - newly granted capability. + Writes ``granted_capabilities`` (union with prior grants), the consent record (hash of the + declared set the user saw + UTC timestamp), and the legacy ``allow_*`` keys for each new grant + so every existing enforcement site keeps working unchanged. """ from hermes_cli.config import load_config, save_config - granted_list = [c for c in dict.fromkeys(granted) if c in VALID_CAPABILITY_IDS] - declared_list = [c for c in dict.fromkeys(declared) if c in VALID_CAPABILITY_IDS] - config = load_config() - plugins_cfg = config.setdefault("plugins", {}) - if not isinstance(plugins_cfg, dict): - plugins_cfg = {} - config["plugins"] = plugins_cfg - entries = plugins_cfg.setdefault("entries", {}) - if not isinstance(entries, dict): - entries = {} - plugins_cfg["entries"] = entries - entry = entries.setdefault(plugin_id, {}) - if not isinstance(entry, dict): - entry = {} - entries[plugin_id] = entry + entry = _child_dict(_child_dict(_child_dict(config, "plugins"), "entries"), plugin_id) previous = entry.get(GRANTED_KEY) - merged = list(previous) if isinstance(previous, list) else [] - for cap in granted_list: - if cap not in merged: - merged.append(cap) - entry[GRANTED_KEY] = sorted( - c for c in dict.fromkeys(merged) - if isinstance(c, str) and c in VALID_CAPABILITY_IDS - ) + merged = (list(previous) if isinstance(previous, list) else []) + _known(granted) + entry[GRANTED_KEY] = sorted(_known(c for c in merged if isinstance(c, str))) entry[CONSENT_KEY] = { - "hash": capability_set_hash(declared_list), + "hash": capability_set_hash(_known(declared)), "granted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } # Bridge: mirror each granted capability into its legacy gate so the # existing enforcement sites (which still read allow_*) honor the grant. for cap in entry[GRANTED_KEY]: - spec = CAPABILITY_REGISTRY[cap] + *parents, leaf = CAPABILITY_REGISTRY[cap].legacy_path node = entry - for part in spec.legacy_path[:-1]: - child = node.setdefault(part, {}) - if not isinstance(child, dict): - child = {} - node[part] = child - node = child - node[spec.legacy_path[-1]] = True + for part in parents: + node = _child_dict(node, part) + node[leaf] = True save_config(config) logger.info( @@ -350,44 +221,31 @@ def record_consent( def consent_hash(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> Optional[str]: """Return the stored consent hash, or None when absent/corrupt.""" - entry = _plugin_entry(plugin_id, config) - consent = entry.get(CONSENT_KEY) - if not isinstance(consent, dict): - return None - h = consent.get("hash") + consent = _plugin_entry(plugin_id, config).get(CONSENT_KEY) + h = consent.get("hash") if isinstance(consent, dict) else None return h if isinstance(h, str) and h else None def pending_capabilities( - plugin_id: str, - declared: Iterable[str], - config: Optional[Mapping[str, Any]] = None, + plugin_id: str, declared: Iterable[str], config: Optional[Mapping[str, Any]] = None ) -> List[str]: """Capabilities declared by the plugin but not yet granted. - Used both at first consent (everything is pending) and on update - re-consent: when a new version declares capabilities the granted set - lacks, those additions are returned and must be re-consented before - they go live. The stored consent hash tells whether the *declared* set - changed since the user last saw it. + Used both at first consent (everything is pending) and on update re-consent: when a new version + declares capabilities the granted set lacks, those additions are returned and must be re- + consented before they go live. The stored consent hash tells whether the *declared* set changed + since the user last saw it. """ - declared_list = [c for c in dict.fromkeys(declared) if c in VALID_CAPABILITY_IDS] granted = granted_capabilities(plugin_id, config) - return [c for c in declared_list if c not in granted] + return [c for c in _known(declared) if c not in granted] def declared_set_changed( - plugin_id: str, - declared: Iterable[str], - config: Optional[Mapping[str, Any]] = None, + plugin_id: str, declared: Iterable[str], config: Optional[Mapping[str, Any]] = None ) -> bool: """True when the declared set differs from what the user consented to. No stored consent at all counts as changed (never consented). """ stored = consent_hash(plugin_id, config) - if stored is None: - return True - return stored != capability_set_hash( - c for c in declared if c in VALID_CAPABILITY_IDS - ) + return stored is None or stored != capability_set_hash(_known(declared)) diff --git a/hermes_cli/plugin_dev.py b/hermes_cli/plugin_dev.py index 2687fd7489..3994ef9f04 100644 --- a/hermes_cli/plugin_dev.py +++ b/hermes_cli/plugin_dev.py @@ -1,9 +1,7 @@ """Runtime-backed validation behind ``hermes plugins doctor``. -The Doctor originated in #46456 / contributor PR #46457 by 峯岸 亮 -(@zapabob). This core command keeps that contribution's manifest/import/ -registration validation intent while routing every check through the current -runtime contracts instead of maintaining a parallel scanner. +This core command keeps that contribution's manifest/import/ registration validation intent while +routing every check through the current runtime contracts instead of maintaining a parallel scanner. """ from __future__ import annotations @@ -36,9 +34,8 @@ def _deny_network(*_args: Any, **_kwargs: Any) -> None: def _doctor_runtime(plugin_path: Path): """Load one plugin through the real runtime and restore global state. - This is deliberately private Doctor machinery, not a standalone plugin - test framework. Registration code executes under a temporary HERMES_HOME - with outbound socket connects blocked. + Deliberately private Doctor machinery, not a plugin test framework. Registration code runs + under a temporary HERMES_HOME with outbound socket connects blocked. """ stack = ExitStack() try: @@ -200,14 +197,10 @@ def _has_manifest(path: Path) -> bool: def _holds_plugin(path: Path) -> bool: """True when plugin discovery would find a manifest under *path*. - Mirrors ``PluginManager._scan_directory``: a manifest in *path* itself - (flat layout) or in one immediate subdirectory (category layout, where - the category directory carries no manifest of its own). - - Doctor copies the resolved directory wholesale before the runtime gets - to reject it, so an unvalidated resolve is a disk-usage bug, not just a - confusing error: ``hermes plugins doctor`` with no argument defaults to - ``.``, and any directory used to satisfy that. + Mirrors ``PluginManager._scan_directory``: a manifest in *path* itself or in one immediate + subdirectory (category layout). Doctor copies the resolved directory wholesale before the + runtime can reject it, so an unvalidated resolve is a disk-usage bug: with no argument + Doctor defaults to ``.``, and any directory used to satisfy that. """ if not path.is_dir(): return False @@ -223,10 +216,9 @@ def _holds_plugin(path: Path) -> bool: def _is_plugin_id(raw: str) -> bool: """True when *raw* can name an installed plugin rather than a path. - Ids are relative and may carry one category segment - (``image_gen/openai``). Dot components are excluded: joining ``.`` - onto a plugins root yields the root itself, which would hand Doctor - every installed plugin at once instead of one. + Ids are relative and may carry one category segment (``image_gen/openai``). Dot components + are excluded: joining ``.`` onto a plugins root yields the root itself, handing Doctor every + installed plugin at once instead of one. """ if not raw or PurePath(raw).is_absolute(): return False diff --git a/hermes_cli/plugin_index.py b/hermes_cli/plugin_index.py index 2cc92a76b7..de814abeea 100644 --- a/hermes_cli/plugin_index.py +++ b/hermes_cli/plugin_index.py @@ -1,16 +1,10 @@ """Community plugin index — fetch, cache, search, and name resolution. -Mirrors the Skills Hub catalog pattern (``tools/skills_hub.py``): a static -machine-readable JSON index hosted at a canonical URL, cached locally under -``HERMES_HOME/cache/`` with a TTL, with a bundled seed file as the offline -fallback and format reference. +Mirrors the Skills Hub catalog pattern (``tools/skills_hub.py``): a static machine-readable JSON +index hosted at a canonical URL, cached locally under ``HERMES_HOME/cache/`` with a TTL, with a +bundled seed file as the offline fallback and format reference. Fallback chain: remote index → cached copy (fresh or stale) → bundled seed. - -The index is discovery metadata ONLY. **Indexed ≠ audited** — inclusion in -the index means the entry's metadata was reviewed, not that the plugin's code -was audited. Install keeps its existing consent/review flow, and index -entries pin an immutable ref (tag or commit SHA). """ from __future__ import annotations @@ -70,23 +64,17 @@ class PluginIndexEntry: def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { - "name": self.name, - "description": self.description, - "author": self.author, - "tags": list(self.tags), - "repo": self.repo, - "ref": self.ref, + "name": self.name, "description": self.description, "author": self.author, + "tags": list(self.tags), "repo": self.repo, "ref": self.ref, } - if self.subdir: - d["subdir"] = self.subdir - if self.homepage: - d["homepage"] = self.homepage - if self.capabilities: - d["capabilities"] = list(self.capabilities) - if self.api_version is not None: - d["api_version"] = self.api_version - if self.added_at: - d["added_at"] = self.added_at + # Optional keys are emitted only when set (api_version: only when not None). + for key, value in ( + ("subdir", self.subdir), ("homepage", self.homepage), + ("capabilities", list(self.capabilities)), + ("api_version", self.api_version), ("added_at", self.added_at), + ): + if value or (key == "api_version" and value is not None): + d[key] = value return d @@ -110,13 +98,12 @@ def get_index_url() -> str: def _parse_entries(raw: Any) -> List[PluginIndexEntry]: """Parse a decoded index document into entries, skipping malformed items.""" if isinstance(raw, dict): - items = raw.get("plugins", []) - elif isinstance(raw, list): # bare-list form also accepted - items = raw - else: + raw = raw.get("plugins", []) + if not isinstance(raw, list): + raise ValueError("Plugin index 'plugins' field must be a list.") + elif not isinstance(raw, list): # bare-list form also accepted raise ValueError("Plugin index must be a JSON object or list.") - if not isinstance(items, list): - raise ValueError("Plugin index 'plugins' field must be a list.") + items = raw entries: List[PluginIndexEntry] = [] for item in items: @@ -163,10 +150,8 @@ def _read_cache(*, max_age: Optional[float]) -> Optional[List[PluginIndexEntry]] try: if not cache.is_file(): return None - if max_age is not None: - age = time.time() - cache.stat().st_mtime - if age > max_age: - return None + if max_age is not None and time.time() - cache.stat().st_mtime > max_age: + return None return _parse_entries(json.loads(cache.read_text(encoding="utf-8"))) except (OSError, ValueError) as exc: logger.debug("plugin index: cache read failed: %s", exc) @@ -206,11 +191,10 @@ def _fetch_remote() -> Optional[List[PluginIndexEntry]]: def load_index(*, refresh: bool = False, offline: bool = False) -> tuple[List[PluginIndexEntry], str]: """Load the plugin index. - Returns ``(entries, source)`` where *source* is one of ``"remote"``, - ``"cache"``, or ``"seed"``. + Returns ``(entries, source)`` where *source* is one of ``"remote"``, ``"cache"``, or ``"seed"``. - Order: fresh cache (unless *refresh*) → remote → stale cache → bundled seed. - ``offline=True`` skips the network entirely. + Order: fresh cache (unless *refresh*) → remote → stale cache → bundled seed. ``offline=True`` + skips the network entirely. """ if not refresh: cached = _read_cache(max_age=INDEX_CACHE_TTL) @@ -229,57 +213,44 @@ def load_index(*, refresh: bool = False, offline: bool = False) -> tuple[List[Pl return _load_seed_entries(), "seed" -# --------------------------------------------------------------------------- # Search -# --------------------------------------------------------------------------- def _score_entry(entry: PluginIndexEntry, term: str) -> float: """Fuzzy relevance score for *entry* against lowercase *term* (0 = no match).""" import difflib name = entry.name.lower() - desc = entry.description.lower() tags = [t.lower() for t in entry.tags] if term == name: return 100.0 - score = 0.0 - if term in name: - score = max(score, 80.0) - if any(term == t for t in tags): - score = max(score, 70.0) - if any(term in t for t in tags): - score = max(score, 55.0) - if term in desc: - score = max(score, 50.0) - if term in entry.author.lower(): - score = max(score, 40.0) # Fuzzy close-match on the name for typo tolerance. ratio = difflib.SequenceMatcher(None, term, name).ratio() - if ratio >= 0.6: - score = max(score, ratio * 60.0) - return score + signals = ( + (term in name, 80.0), + (term in tags, 70.0), + (any(term in t for t in tags), 55.0), + (term in entry.description.lower(), 50.0), + (term in entry.author.lower(), 40.0), + (ratio >= 0.6, ratio * 60.0), + ) + return max((points for hit, points in signals if hit), default=0.0) def search_index( entries: List[PluginIndexEntry], term: str, *, capability: Optional[str] = None ) -> List[PluginIndexEntry]: - """Rank *entries* against *term* (fuzzy on name/description/tags/author). - - An empty *term* matches everything (browse mode). ``capability`` filters - entries by declared capability. - """ + """Rank *entries* against *term* (fuzzy on name/description/tags/author).""" pool = entries if capability: cap = capability.lower() - pool = [e for e in pool if any(cap == c.lower() for c in e.capabilities)] + pool = [e for e in entries if any(cap == c.lower() for c in e.capabilities)] term = (term or "").strip().lower() if not term: return sorted(pool, key=lambda e: e.name) - scored = [(e, _score_entry(e, term)) for e in pool] - matched = [(e, s) for e, s in scored if s > 0] + matched = [(e, s) for e in pool if (s := _score_entry(e, term)) > 0] matched.sort(key=lambda pair: (-pair[1], pair[0].name)) return [e for e, _s in matched] @@ -289,17 +260,11 @@ def resolve_name( ) -> tuple[Optional[PluginIndexEntry], List[PluginIndexEntry]]: """Resolve a bare plugin *name* against the index. - Returns ``(entry, candidates)``: an exact (case-insensitive) unique match - in ``entry``, otherwise ``entry is None`` and ``candidates`` holds any - partial matches (empty = nothing similar, >1 on exact = ambiguous). + Returns ``(entry, candidates)``: an exact (case-insensitive) unique match in ``entry``, + otherwise ``entry is None`` and ``candidates`` holds any partial matches (empty = nothing + similar, >1 on exact = ambiguous). """ lowered = name.strip().lower() exact = [e for e in entries if e.name.lower() == lowered] - if len(exact) == 1: - return exact[0], exact - if len(exact) > 1: - return None, exact - partial = [e for e in entries if lowered in e.name.lower()] - if len(partial) == 1: - return partial[0], partial - return None, partial + matches = exact or [e for e in entries if lowered in e.name.lower()] + return (matches[0] if len(matches) == 1 else None), matches diff --git a/hermes_cli/plugin_packs.py b/hermes_cli/plugin_packs.py index 12f1ebe914..1ba39efcd8 100644 --- a/hermes_cli/plugin_packs.py +++ b/hermes_cli/plugin_packs.py @@ -1,41 +1,7 @@ """Plugin packs — declarative, shareable plugin sets (#64166). -A pack is a single YAML file (``hermes-pack.yaml``) that pins a set of -plugins (source + exact commit SHA + optional non-secret config seeds). -Installing a pack is nothing new at runtime: it fans out to N ordinary -plugin installs through the existing pinned-ref install path, then seeds -``plugins.entries.<id>`` config keys. - -Format (canonical):: - - name: voice-assistant-pack - description: STT + streaming TTS + approval relay - author: hyper - version: 1.0.0 - plugins: - - name: hermes-media-studio # bare community-index name… - ref: e8d59971d2b7901405b39dac7b03bdd616272d0d - - repo: owner/approval-relay # …or explicit owner/repo / git URL - ref: 8f3c2d1a9b4e5f6071829304a5b6c7d8e9f00112 - subdir: plugins/relay # optional path within the repo - config: # optional plugins.entries seeds - hermes-media-studio: - default_model: flux-3 - skills: [] # declared seam — NOT auto-installed - -Supply-chain posture: - -* Every plugin entry MUST pin an exact 40-character commit SHA in ``ref``. - Tags and branch names are rejected with an error naming the entry. -* ``config`` seeds are limited to ``plugins.entries.<id>.*`` keys and may - never carry secrets (secret-shaped key names are rejected) nor - capability-grant keys (a pack cannot pre-consent capabilities). -* Capability consent is NEVER bulk-granted: after each plugin installs, - its declared capabilities ride the exact same per-plugin consent flow - as a normal ``hermes plugins install`` (#64228). - -``skills:`` is parsed and displayed but not installed — wiring skill-hub -ids into the skills installer is a documented follow-up seam. +* Every plugin entry MUST pin an exact 40-character commit SHA in ``ref``. Tags and branch names are +rejected with an error naming the entry. """ from __future__ import annotations @@ -146,11 +112,10 @@ def _entry_label(item: Any, index: int) -> str: def validate_config_seed(plugin_id: str, seed: Any) -> dict[str, Any]: - """Validate one plugin's config seed mapping. + """Validate one plugin's config seed mapping and return it. - Rejects non-dict seeds, reserved consent/capability keys, deprecated - ``allow_*`` trust gates, and secret-shaped key names. Returns the - validated dict. + Rejects non-dict seeds, reserved consent/capability keys, deprecated ``allow_*`` trust gates, + and secret-shaped key names -- a pack must not be able to grant itself trust or ship secrets. """ if not isinstance(seed, dict): raise PackError( @@ -178,11 +143,7 @@ def validate_config_seed(plugin_id: str, seed: Any) -> dict[str, Any]: def parse_pack(text: str, *, source: str = "<pack>") -> PluginPack: - """Parse and validate a pack YAML document. - - Raises :class:`PackError` with an actionable message on any problem — - including refs that are not exact 40-character commit SHAs. - """ + """Parse and validate a pack YAML document.""" import yaml try: @@ -307,8 +268,8 @@ class ResolvedPackPlugin: def resolve_pack_plugins(pack: PluginPack) -> List[ResolvedPackPlugin]: """Resolve every entry; bare names go through the community index. - Resolution failures do not raise — they are carried per-entry so the - review screen can show them and install can report partial failure. + Resolution failures do not raise — they are carried per-entry so the review screen can show them + and install can report partial failure. """ resolved: List[ResolvedPackPlugin] = [] index_entries = None @@ -454,11 +415,9 @@ def install_pack_plugins( ) -> List[PackInstallResult]: """Fan a pack out to N ordinary pinned installs; never raises per-plugin. - Each plugin goes through the existing exact-ref install path, then its - declared capabilities go through the SAME per-plugin consent flow as a - single install (:func:`hermes_cli.plugins_cmd._run_capability_consent`). - Successful installs are enabled (the user consented via the review - screen) and their pack config seed is applied. + Each plugin uses the existing exact-ref install path and then the SAME per-plugin capability + consent flow as a single install. Successful installs are enabled (the user consented via the + review screen) and their pack config seed is applied. """ from hermes_cli.plugins_cmd import ( PluginOperationError, @@ -573,9 +532,7 @@ def _sanitized_entry_config(plugin_id: str) -> dict[str, Any]: continue if _SECRET_KEY_RE.search(key): continue - if isinstance(value, (str, int, float, bool)) or value is None: - out[key] = value - elif isinstance(value, (list, dict)): + if isinstance(value, (str, int, float, bool)) or value is None or isinstance(value, (list, dict)): out[key] = value return out @@ -583,9 +540,9 @@ def _sanitized_entry_config(plugin_id: str) -> dict[str, Any]: def export_pack(*, enabled_only: bool = False, pack_name: str = "my-hermes-pack") -> tuple[str, List[str]]: """Build pack YAML from the current install. - Returns ``(yaml_text, warnings)``. Plugins whose Git provenance is - unknown (local-only, no install metadata) are listed in the warnings - and included as comments in the YAML, never as installable entries. + Returns ``(yaml_text, warnings)``. Plugins whose Git provenance is unknown (local-only, no + install metadata) are listed in the warnings and included as comments in the YAML, never as + installable entries. """ import yaml @@ -674,10 +631,9 @@ def cmd_pack_show(source: str) -> None: def cmd_pack_install(source: str, *, force: bool = False) -> None: """``hermes plugins pack install <path-or-url>``. - Mandatory review screen → one summary consent for the pack contents → - fan-out installs with pinned refs → per-plugin capability consent via - the standard flow. Partial failures are reported per plugin; exits - non-zero when any plugin failed. + Mandatory review screen -> one summary consent for the pack -> pinned fan-out installs -> + per-plugin capability consent via the standard flow. Partial failures are reported per plugin + and the exit code is non-zero when any plugin failed. """ from rich.console import Console diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 912d93208d..d11b517eda 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1,10 +1,7 @@ """``hermes plugins`` CLI subcommand — install, update, remove, and list plugins. -Plugins are installed from Git repositories into ``~/.hermes/plugins/``. -Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub). - -After install, if the plugin ships an ``after-install.md`` file it is -rendered with Rich Markdown. Otherwise a default confirmation is shown. +After install, if the plugin ships an ``after-install.md`` file it is rendered with Rich Markdown. +Otherwise a default confirmation is shown. """ from __future__ import annotations @@ -35,31 +32,21 @@ logger = logging.getLogger(__name__) @functools.lru_cache(maxsize=1) def _resolve_git_executable() -> Optional[str]: - """Resolve a git binary for subprocess use when ``PATH`` may be minimal. - - Matches other Hermes subprocess resolution: :func:`shutil.which` first, - then common Git for Windows install paths and POSIX defaults. - """ + """Resolve a git binary for subprocess use when ``PATH`` may be minimal.""" found = shutil.which("git") if found: return found if os.name == "nt": - prog = os.environ.get("ProgramFiles", r"C:\Program Files") - prog_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") - local = os.environ.get("LOCALAPPDATA", "") - candidates = [ - os.path.join(prog, "Git", "cmd", "git.exe"), - os.path.join(prog, "Git", "bin", "git.exe"), - os.path.join(prog_x86, "Git", "cmd", "git.exe"), - os.path.join(prog_x86, "Git", "bin", "git.exe"), + roots = [ + os.environ.get("ProgramFiles", r"C:\Program Files"), + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), ] + local = os.environ.get("LOCALAPPDATA", "") if local: - candidates.extend( - ( - os.path.join(local, "Programs", "Git", "cmd", "git.exe"), - os.path.join(local, "Programs", "Git", "bin", "git.exe"), - ) - ) + roots.append(os.path.join(local, "Programs")) + candidates = [ + os.path.join(root, "Git", sub, "git.exe") for root in roots for sub in ("cmd", "bin") + ] else: candidates = ["/usr/bin/git", "/usr/local/bin/git", "/bin/git"] for c in candidates: @@ -73,38 +60,88 @@ class PluginOperationError(Exception): class PluginScanBlocked(PluginOperationError): - """Plugin failed the security scan and was not installed. - - Carries the ScanResult so callers (CLI, dashboard) can render the - findings report alongside the error message. - """ + """Plugin failed the security scan and was not installed.""" def __init__(self, message: str, scan_result=None): super().__init__(message) self.scan_result = scan_result +def _console(): + """A fresh Rich console (imported lazily; rich is not needed at import time).""" + from rich.console import Console + + return Console() + + +def _is_tty() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _ask_yes(prompt: str, reader=input) -> bool: + """One y/N question; EOF / Ctrl-C count as "no".""" + try: + answer = reader(prompt).strip().lower() + except (EOFError, KeyboardInterrupt): + return False + return answer in {"y", "yes"} + + +def _sub_dict(parent: dict, key: str) -> dict: + """``parent[key]`` as a dict, replacing a missing or non-dict value with ``{}``.""" + child = parent.get(key) + if not isinstance(child, dict): + child = {} + parent[key] = child + return child + + +def _config_value(*keys: str, default: Any) -> Any: + """Read ``keys`` from config.yaml; *default* on a missing key or any load failure.""" + try: + from hermes_cli.config import load_config + return cfg_get(load_config(), *keys, default=default) + except Exception: + return default + + +def _config_name_set(*keys: str) -> set: + """Read a list-valued config key as a set (empty on any failure or non-list).""" + value = _config_value(*keys, default=[]) + return set(value) if isinstance(value, list) else set() + + +def _config_str(*keys: str, default: str) -> str: + """Read a string config key, coercing empty/missing/failed reads to *default*.""" + return _config_value(*keys, default=default) or default + + +def _write_config_value(section: str, key: str, value: Any) -> None: + """Persist ``config[section][key] = value`` to config.yaml (creating the section).""" + from hermes_cli.config import load_config, save_config + config = load_config() + if section not in config: + config[section] = {} + config[section][key] = value + save_config(config) + + def _scan_on_install_enabled() -> bool: """Whether install/update-time plugin security scanning is enabled. - On by default (inspired by Claude Cowork's skill & plugin security - scanning). Disable via ``plugins.scan_on_install: false`` in config.yaml. + On by default (inspired by Claude Cowork's skill & plugin security scanning). Disable via + ``plugins.scan_on_install: false`` in config.yaml. """ - try: - from hermes_cli.config import load_config - config = load_config() - return bool(cfg_get(config, "plugins", "scan_on_install", default=True)) - except Exception: - return True + return bool(_config_value("plugins", "scan_on_install", default=True)) def _scan_plugin_tree(plugin_dir: Path, identifier: str, *, force: bool, scan_decision_cb=None): """Scan *plugin_dir* and enforce the install policy. - Verdicts: safe → proceed; caution → needs confirmation (``force=True`` - or a truthy ``scan_decision_cb(result)``); dangerous → always blocked. - Raises :class:`PluginScanBlocked` when the plugin may not be installed. - Returns the ScanResult (or None when scanning is disabled). + Verdicts: safe → proceed; caution → needs confirmation (``force=True`` or a truthy + ``scan_decision_cb(result)``); dangerous → always blocked. Raises :class:`PluginScanBlocked` + when the plugin may not be installed. Returns the ScanResult (or None when scanning is + disabled). """ if not _scan_on_install_enabled(): return None @@ -160,26 +197,16 @@ def _sanitize_plugin_name( ) -> Path: """Validate a plugin name and return the safe target path inside *plugins_dir*. - Raises ``ValueError`` if the name contains path-traversal sequences or would - resolve outside the plugins directory. - - ``allow_subdir=True`` permits a single forward slash inside *name* so - category-namespaced plugin keys like ``observability/langfuse`` or - ``image_gen/openai`` (the registry keys emitted by ``_discover_all_plugins``) - can be looked up. ``..`` and backslash are still rejected, leading and - trailing slashes are stripped, and the resolved target must still live - inside *plugins_dir*. Install paths leave this at the default ``False`` - because a freshly-cloned plugin always lands top-level under - ``~/.hermes/plugins/<name>/``. + Raises ``ValueError`` on path-traversal sequences or a target outside the plugins directory. + ``allow_subdir=True`` permits one forward slash so category-namespaced registry keys like + ``observability/langfuse`` can be looked up; ``..`` and backslashes are still rejected. + Install paths keep the default ``False`` because a fresh clone always lands top-level. """ + if allow_subdir and name: + name = name.strip("/") if not name: raise ValueError("Plugin name must not be empty.") - if allow_subdir: - name = name.strip("/") - if not name: - raise ValueError("Plugin name must not be empty.") - if name in {".", ".."}: raise ValueError( f"Invalid plugin name '{name}': must not reference the plugins directory itself." @@ -198,14 +225,10 @@ def _sanitize_plugin_name( raise ValueError( f"Invalid plugin name '{name}': resolves to the plugins directory itself." ) - - try: - target.relative_to(plugins_resolved) - except ValueError: + if plugins_resolved not in target.parents: raise ValueError( f"Invalid plugin name '{name}': resolves outside the plugins directory." ) - return target @@ -226,28 +249,8 @@ _GITHUB_BROWSER_SEGMENTS = { def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]: """Turn an identifier into a cloneable Git URL and optional subdirectory. - Returns ``(git_url, subdir)`` where ``subdir`` is the path within the - cloned repository that contains the plugin (``None`` when the plugin lives - at the repo root). - - Accepted formats: - - Full URL: https://github.com/owner/repo.git - - Full URL: git@github.com:owner/repo.git - - Full URL: ssh://git@github.com/owner/repo.git - - Browser URL: https://github.com/owner/repo/tree/main/path - → (https://github.com/owner/repo.git, "path") - - Shorthand: owner/repo → https://github.com/owner/repo.git - - Shorthand w/ subdir: owner/repo/path/to/plugin - → (https://github.com/owner/repo.git, "path/to/plugin") - - Full URL w/ subdir (``.git`` boundary): - https://github.com/owner/repo.git/path/to/plugin - → (https://github.com/owner/repo.git, "path/to/plugin") - - Any URL w/ explicit subdir fragment (works for every scheme, incl. - ``file://`` and ssh): <url>#path/to/plugin - → (<url>, "path/to/plugin") - - NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a - security warning at install time. + NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a security warning at + install time. """ # Already a URL. if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")): @@ -267,12 +270,9 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]: git_url, _, frag = identifier.partition("#") return git_url, (frag.strip("/") or None) # Natural ``.git/`` boundary (GitHub-style URLs). - marker = ".git/" - idx = identifier.find(marker) - if idx != -1: - git_url = identifier[: idx + len(".git")] - subdir = identifier[idx + len(marker) :].strip("/") - return git_url, (subdir or None) + git_url, marker, subdir = identifier.partition(".git/") + if marker: + return git_url + ".git", (subdir.strip("/") or None) return identifier, None # owner/repo[/subdir...] shorthand @@ -293,10 +293,8 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]: def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path: """Resolve ``subdir`` inside ``clone_root``, rejecting path traversal. - Guards against ``..`` segments, absolute paths, and symlinks that would - escape the cloned repository. Returns the resolved directory path. - Raises ``PluginOperationError`` if the path escapes the clone, doesn't - exist, or is not a directory. + Guards against ``..`` segments, absolute paths, and symlinks that would escape the clone. + Raises ``PluginOperationError`` if the path escapes, doesn't exist, or is not a directory. """ clone_root = clone_root.resolve() candidate = (clone_root / subdir).resolve() @@ -321,90 +319,124 @@ def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path: def _repo_name_from_url(url: str) -> str: """Extract the repo name from a Git URL for the plugin directory name.""" - # Strip trailing .git and slashes - name = url.rstrip("/") - if name.endswith(".git"): - name = name[:-4] - # Get last path component - name = name.rsplit("/", 1)[-1] - # Handle ssh-style urls: git@github.com:owner/repo + # Last path component after stripping trailing slashes and ``.git``; ssh-style + # ``git@host:owner/repo`` has no slash to split on, hence the colon fallback. + name = url.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1] if ":" in name: name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1] return name +def _native_manifest_file(plugin_dir: Path) -> Optional[Path]: + """``plugin.yaml`` (or ``plugin.yml``) under *plugin_dir*, or None when neither exists.""" + for name in ("plugin.yaml", "plugin.yml"): + candidate = plugin_dir / name + if candidate.exists(): + return candidate + return None + + +def _has_portable_manifest(plugin_dir: Path) -> bool: + """True when ``plugin.json`` exists (or is a symlink, even dangling) under *plugin_dir*.""" + portable_file = plugin_dir / "plugin.json" + return portable_file.exists() or portable_file.is_symlink() + + +def _load_yaml_manifest(manifest_file: Path): + """``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error.""" + import yaml + + with open(manifest_file, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def _read_portable_manifest(plugin_dir: Path) -> dict: + """Validated Agent Plugins v1 ``plugin.json`` manifest (diagnostics dropped); raises on failure.""" + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, _ = read_agent_plugin_manifest(plugin_dir) + return manifest + + def _read_manifest(plugin_dir: Path) -> dict: """Read a native or portable manifest, preferring native YAML.""" - manifest_file = plugin_dir / "plugin.yaml" - if not manifest_file.exists(): - manifest_file = plugin_dir / "plugin.yml" - if not manifest_file.exists(): - portable_file = plugin_dir / "plugin.json" - if not portable_file.exists() and not portable_file.is_symlink(): + manifest_file = _native_manifest_file(plugin_dir) + if manifest_file is None: + if not _has_portable_manifest(plugin_dir): return {} try: - from hermes_cli.agent_plugins import read_agent_plugin_manifest - - manifest, _ = read_agent_plugin_manifest(plugin_dir) - return manifest + return _read_portable_manifest(plugin_dir) except Exception as e: logger.warning("Failed to read plugin.json in %s: %s", plugin_dir, e) return {} try: - import yaml - - with open(manifest_file, encoding="utf-8") as f: - return yaml.safe_load(f) or {} + return _load_yaml_manifest(manifest_file) except Exception as e: logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e) return {} +def _looks_like_plugin_dir(target: Path) -> bool: + """True when *target* has a native/portable manifest or a package ``__init__.py``.""" + return ( + _native_manifest_file(target) is not None + or (target / "plugin.json").exists() + or (target / "__init__.py").exists() + ) + + def _copy_example_files(plugin_dir: Path, console) -> None: """Copy any .example files to their real names if they don't already exist. - For example, ``config.yaml.example`` becomes ``config.yaml``. - Skips files that already exist to avoid overwriting user config on reinstall. + For example, ``config.yaml.example`` becomes ``config.yaml``. Skips files that already exist to + avoid overwriting user config on reinstall. """ for example_file in plugin_dir.glob("*.example"): real_name = example_file.stem # e.g. "config.yaml" from "config.yaml.example" real_path = plugin_dir / real_name - if not real_path.exists(): - try: - shutil.copy2(example_file, real_path) - console.print( - f"[dim] Created {real_name} from {example_file.name}[/dim]" - ) - except OSError as e: - console.print( - f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}" - ) + if real_path.exists(): + continue + try: + shutil.copy2(example_file, real_path) + console.print(f"[dim] Created {real_name} from {example_file.name}[/dim]") + except OSError as e: + console.print(f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}") -def _missing_requires_env_names(manifest: dict) -> list[str]: - """Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``.""" - requires_env = manifest.get("requires_env") or [] - if not requires_env: - return [] - - from hermes_cli.config import get_env_value - +def _requires_env_specs(manifest: dict) -> list[dict]: + """Normalise ``requires_env`` (plain names or ``{name, description, url, secret}`` dicts) + to a list of dicts; entries without a name are dropped.""" env_specs: list[dict] = [] - for entry in requires_env: + for entry in manifest.get("requires_env") or []: if isinstance(entry, str): env_specs.append({"name": entry}) elif isinstance(entry, dict) and entry.get("name"): env_specs.append(entry) + return env_specs - return [s["name"] for s in env_specs if s.get("name") and not get_env_value(s["name"])] + +def _missing_env_specs(manifest: dict) -> list[dict]: + """Declared ``requires_env`` specs whose variable is unset in ``~/.hermes/.env``.""" + env_specs = _requires_env_specs(manifest) + if not env_specs: + return [] + + from hermes_cli.config import get_env_value + + return [s for s in env_specs if not get_env_value(s["name"])] + + +def _missing_requires_env_names(manifest: dict) -> list[str]: + """Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``.""" + return [s["name"] for s in _missing_env_specs(manifest)] def _print_python_dependencies(manifest: dict, console) -> None: """Surface declared python_dependencies at install time (#64165). - Declaration seam ONLY — Hermes never auto-installs plugin pip - dependencies (isolation design deferred; see #64165 / #15220). We print - the declared requirements with a copy-pasteable install hint. + Declaration seam ONLY — Hermes never auto-installs plugin pip dependencies (isolation design + deferred; see #64165 / #15220). We print the declared requirements with a copy-pasteable install + hint. """ deps = manifest.get("python_dependencies") or [] if not isinstance(deps, list): @@ -428,43 +460,17 @@ def _print_python_dependencies(manifest: dict, console) -> None: def _prompt_plugin_env_vars(manifest: dict, console) -> None: """Prompt for required environment variables declared in plugin.yaml. - ``requires_env`` accepts two formats: - - Simple list (backwards-compatible):: - - requires_env: - - MY_API_KEY - - Rich list with metadata:: - - requires_env: - - name: MY_API_KEY - description: "API key for Acme service" - url: "https://acme.com/keys" - secret: true - - Already-set variables are skipped. Values are saved to the user's ``.env``. + ``requires_env`` accepts either a plain list of names or rich entries with ``name``, + ``description``, ``url`` and ``secret``. Already-set variables are skipped; values are saved + to the user's ``.env``. """ - requires_env = manifest.get("requires_env") or [] - if not requires_env: - return - - from hermes_cli.config import get_env_value, save_env_value # noqa: F811 - from hermes_constants import display_hermes_home - - # Normalise to list-of-dicts - env_specs: list[dict] = [] - for entry in requires_env: - if isinstance(entry, str): - env_specs.append({"name": entry}) - elif isinstance(entry, dict) and entry.get("name"): - env_specs.append(entry) - - # Filter to only vars that aren't already set - missing = [s for s in env_specs if not get_env_value(s["name"])] + missing = _missing_env_specs(manifest) if not missing: return + from hermes_cli.config import save_env_value + from hermes_constants import display_hermes_home + plugin_name = manifest.get("name", "this plugin") console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n") @@ -482,10 +488,7 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None: console.print(f" [dim]Get yours at: {url}[/dim]") try: - if secret: - value = masked_secret_prompt(f" {name}: ").strip() - else: - value = line_input(f" {name}: ").strip() + value = (masked_secret_prompt if secret else line_input)(f" {name}: ").strip() except (EOFError, KeyboardInterrupt): console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]") return @@ -502,46 +505,30 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None: def _display_after_install(plugin_dir: Path, identifier: str) -> None: """Show after-install.md if it exists, otherwise a default message.""" - from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel - console = Console() + console = _console() after_install = plugin_dir / "after-install.md" if after_install.exists(): - content = after_install.read_text(encoding="utf-8") - md = Markdown(content) - console.print() - console.print(Panel(md, border_style="green", expand=False)) - console.print() + body, title = Markdown(after_install.read_text(encoding="utf-8")), None else: - console.print() - console.print( - Panel( - f"[green bold]Plugin installed:[/] {identifier}\n" - f"[dim]Location:[/] {plugin_dir}", - border_style="green", - title="✓ Installed", - expand=False, - ) - ) - console.print() - - -def _display_removed(name: str, plugins_dir: Path) -> None: - """Show confirmation after removing a plugin.""" - from rich.console import Console - - console = Console() + body = f"[green bold]Plugin installed:[/] {identifier}\n[dim]Location:[/] {plugin_dir}" + title = "✓ Installed" console.print() - console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}") + console.print(Panel(body, border_style="green", title=title, expand=False)) console.print() def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path: - """Return the plugin path if it exists, or exit with an error listing installed plugins.""" - target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True) + """Return the plugin path if it exists, or exit with an error (invalid name, or a listing of + installed plugins when missing).""" + try: + target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True) + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + sys.exit(1) if not target.exists(): installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)" console.print( @@ -604,68 +591,43 @@ def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") - return redact_sensitive_text(error) -def _git_head_revision(repo: Path, git_exe: str) -> str: - result = subprocess.run( - [git_exe, "rev-parse", "HEAD"], - cwd=str(repo), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=15, - stdin=subprocess.DEVNULL, - env=noninteractive_git_env(), - ) +def _git_or_raise( + git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "" +) -> subprocess.CompletedProcess: + """Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error).""" + result = _run_plugin_git(git_exe, repo, *args, timeout=timeout) if result.returncode != 0: - err = _safe_git_error(result) - raise PluginOperationError(f"Could not determine installed Git revision:\n{err}") + raise PluginOperationError(failure_prefix + _safe_git_error(result, source_url)) + return result + + +def _git_head_revision(repo: Path, git_exe: str) -> str: + result = _git_or_raise( + git_exe, repo, "rev-parse", "HEAD", timeout=15, + failure_prefix="Could not determine installed Git revision:\n", + ) return result.stdout.strip().lower() def _checkout_exact_revision(repo: Path, git_exe: str, revision: str) -> None: """Fetch and detach at one immutable commit, then verify the resulting HEAD.""" - try: - fetched = subprocess.run( - [git_exe, "fetch", "--depth", "1", "origin", revision], - cwd=str(repo), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=60, - stdin=subprocess.DEVNULL, - env=noninteractive_git_env(), - ) - except subprocess.TimeoutExpired as exc: - raise PluginOperationError( - f"Git fetch of commit '{revision}' timed out after 60 seconds." - ) from exc - if fetched.returncode != 0: - err = _safe_git_error(fetched) - raise PluginOperationError( - f"Git commit '{revision}' could not be fetched:\n{err}" - ) - try: - checked_out = subprocess.run( - [git_exe, "checkout", "--detach", revision], - cwd=str(repo), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=60, - stdin=subprocess.DEVNULL, - env=noninteractive_git_env(), - ) - except subprocess.TimeoutExpired as exc: - raise PluginOperationError( - f"Git checkout of commit '{revision}' timed out after 60 seconds." - ) from exc - if checked_out.returncode != 0: - err = _safe_git_error(checked_out) - raise PluginOperationError( - f"Git checkout of commit '{revision}' failed:\n{err}" - ) + steps = ( + ( + ("fetch", "--depth", "1", "origin", revision), + f"Git fetch of commit '{revision}' timed out after 60 seconds.", + f"Git commit '{revision}' could not be fetched:\n", + ), + ( + ("checkout", "--detach", revision), + f"Git checkout of commit '{revision}' timed out after 60 seconds.", + f"Git checkout of commit '{revision}' failed:\n", + ), + ) + for args, timeout_msg, failure_prefix in steps: + try: + _git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix) + except subprocess.TimeoutExpired as exc: + raise PluginOperationError(timeout_msg) from exc actual = _git_head_revision(repo, git_exe) if actual != revision: raise PluginOperationError( @@ -696,20 +658,98 @@ def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None: scrubbed = _scrub_git_url(git_url) if scrubbed == git_url: return - result = subprocess.run( - [git_exe, "remote", "set-url", "origin", scrubbed], - cwd=str(repo), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=15, - stdin=subprocess.DEVNULL, - env=noninteractive_git_env(), + _git_or_raise( + git_exe, repo, "remote", "set-url", "origin", scrubbed, timeout=15, + failure_prefix="Could not sanitize installed Git remote:\n", source_url=git_url, ) + + +def _check_manifest_version(manifest: dict, plugin_name: str) -> None: + """Reject manifests declaring a newer ``manifest_version`` than this installer supports.""" + mv = manifest.get("manifest_version") + if mv is None: + return + try: + mv_int = int(mv) + except (ValueError, TypeError): + raise PluginOperationError( + f"Plugin '{plugin_name}' has invalid manifest_version " + f"'{mv}' (expected an integer).", + ) from None + if mv_int > _SUPPORTED_MANIFEST_VERSION: + from hermes_cli.config import recommended_update_command + + raise PluginOperationError( + f"Plugin '{plugin_name}' requires manifest_version {mv}, " + f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. " + f"Run {recommended_update_command()} to update Hermes.", + ) from None + + +def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str]) -> str: + """Shallow-clone *git_url* into *tmp_clone* (detached at *revision* when given), scrub any + credentials from the recorded origin, and return the installed HEAD SHA.""" + git_exe = _resolve_git_executable() + if not git_exe: + raise PluginOperationError("git is not installed or not in PATH.") + + clone_args = ["clone", "--depth", "1"] + if revision: + clone_args.append("--no-checkout") + clone_args.extend([git_url, str(tmp_clone)]) + try: + result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args) + except FileNotFoundError as e: + raise PluginOperationError("git is not installed or not in PATH.") from e + except subprocess.TimeoutExpired as e: + raise PluginOperationError("Git clone timed out after 60 seconds.") from e if result.returncode != 0: err = _safe_git_error(result, git_url) - raise PluginOperationError(f"Could not sanitize installed Git remote:\n{err}") + raise PluginOperationError(f"Git clone failed:\n{err}") + + _scrub_cloned_origin(tmp_clone, git_exe, git_url) + if revision: + _checkout_exact_revision(tmp_clone, git_exe, revision) + return _git_head_revision(tmp_clone, git_exe) + + +def _read_manifest_for_install(plugin_dir: Path) -> dict: + """Manifest of a freshly cloned tree. Unlike :func:`_read_manifest`, a broken portable + ``plugin.json`` is an install error (not a silent ``{}``) and its diagnostics are logged.""" + if _native_manifest_file(plugin_dir) is not None or not _has_portable_manifest(plugin_dir): + return _read_manifest(plugin_dir) + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, diagnostics = read_agent_plugin_manifest(plugin_dir) + except Exception as exc: + raise PluginOperationError( + f"Portable plugin manifest validation failed: {exc}" + ) from exc + for diagnostic in diagnostics: + logger.warning("Agent Plugin install: %s", diagnostic.message) + return manifest + + +def _swap_in_plugin(tmp_target: Path, target: Path, backup: Path, old_metadata: dict, new_metadata: dict) -> None: + """Move the validated clone into place and persist metadata; on any failure restore the + previous tree (if one was replaced) and the previous metadata sidecar, then re-raise.""" + replaced_existing = target.exists() + if replaced_existing: + os.replace(target, backup) + try: + os.replace(tmp_target, target) + _write_install_metadata(new_metadata) + except Exception: + if target.exists(): + shutil.rmtree(target) + if replaced_existing and backup.exists(): + os.replace(backup, target) + if old_metadata: + _write_install_metadata(old_metadata) + else: + _install_metadata_path().unlink(missing_ok=True) + raise def _install_plugin_core( @@ -738,65 +778,16 @@ def _install_plugin_core( for entry in old_metadata.values() if entry.get("source") == source and entry.get("pinned") is True ] - if len(matching_pins) == 1: - revision = matching_pins[0].get("revision") - if isinstance(revision, str): - requested_revision = _normalize_exact_revision(revision) + if len(matching_pins) == 1 and isinstance(matching_pins[0].get("revision"), str): + requested_revision = _normalize_exact_revision(matching_pins[0]["revision"]) with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp: tmp_clone = Path(tmp) / "plugin" - git_exe = _resolve_git_executable() - if not git_exe: - raise PluginOperationError("git is not installed or not in PATH.") - - clone_args = [git_exe, "clone", "--depth", "1"] - if requested_revision: - clone_args.append("--no-checkout") - clone_args.extend([git_url, str(tmp_clone)]) - try: - result = subprocess.run( - clone_args, - capture_output=True, - text=True, encoding='utf-8', errors='replace', - timeout=60, - stdin=subprocess.DEVNULL, - env=noninteractive_git_env(), - ) - except FileNotFoundError as e: - raise PluginOperationError("git is not installed or not in PATH.") from e - except subprocess.TimeoutExpired as e: - raise PluginOperationError("Git clone timed out after 60 seconds.") from e - if result.returncode != 0: - err = _safe_git_error(result, git_url) - raise PluginOperationError(f"Git clone failed:\n{err}") - - _scrub_cloned_origin(tmp_clone, git_exe, git_url) - if requested_revision: - _checkout_exact_revision(tmp_clone, git_exe, requested_revision) - installed_revision = _git_head_revision(tmp_clone, git_exe) - + installed_revision = _clone_plugin_repo(tmp_clone, git_url, requested_revision) tmp_target = ( _resolve_subdir_within(tmp_clone, subdir) if subdir else tmp_clone ) - has_native_manifest = (tmp_target / "plugin.yaml").exists() or ( - tmp_target / "plugin.yml" - ).exists() - has_portable_manifest = (tmp_target / "plugin.json").exists() or ( - tmp_target / "plugin.json" - ).is_symlink() - if not has_native_manifest and has_portable_manifest: - try: - from hermes_cli.agent_plugins import read_agent_plugin_manifest - - manifest, diagnostics = read_agent_plugin_manifest(tmp_target) - for diagnostic in diagnostics: - logger.warning("Agent Plugin install: %s", diagnostic.message) - except Exception as exc: - raise PluginOperationError( - f"Portable plugin manifest validation failed: {exc}" - ) from exc - else: - manifest = _read_manifest(tmp_target) + manifest = _read_manifest_for_install(tmp_target) plugin_name = manifest.get("name") or ( subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url) ) @@ -804,24 +795,7 @@ def _install_plugin_core( target = _sanitize_plugin_name(plugin_name, plugins_dir) except ValueError as e: raise PluginOperationError(str(e)) from e - - mv = manifest.get("manifest_version") - if mv is not None: - try: - mv_int = int(mv) - except (ValueError, TypeError): - raise PluginOperationError( - f"Plugin '{plugin_name}' has invalid manifest_version " - f"'{mv}' (expected an integer).", - ) from None - if mv_int > _SUPPORTED_MANIFEST_VERSION: - from hermes_cli.config import recommended_update_command - - raise PluginOperationError( - f"Plugin '{plugin_name}' requires manifest_version {mv}, " - f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. " - f"Run {recommended_update_command()} to update Hermes.", - ) from None + _check_manifest_version(manifest, plugin_name) # Security scan the clone BEFORE anything is moved into place # (see ``tools/plugin_guard.py``; inspired by Claude Cowork's skill @@ -858,35 +832,15 @@ def _install_plugin_core( "revision": installed_revision, "source": source, } - backup = Path(tmp) / "previous-plugin" - replaced_existing = target.exists() - if replaced_existing: - os.replace(target, backup) - try: - os.replace(tmp_target, target) - _write_install_metadata(new_metadata) - except Exception: - if target.exists(): - shutil.rmtree(target) - if replaced_existing and backup.exists(): - os.replace(backup, target) - if old_metadata: - _write_install_metadata(old_metadata) - else: - _install_metadata_path().unlink(missing_ok=True) - raise + _swap_in_plugin(tmp_target, target, Path(tmp) / "previous-plugin", old_metadata, new_metadata) - has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists() - has_portable = (target / "plugin.json").exists() - if not has_yaml and not has_portable and not (target / "__init__.py").exists(): + if not _looks_like_plugin_dir(target): logger.warning( "%s has no plugin.yaml / __init__.py; may not be a valid plugin", plugin_name, ) - from rich.console import Console - - _copy_example_files(target, Console()) + _copy_example_files(target, _console()) installed_manifest = _read_manifest(target) installed_name = installed_manifest.get("name") or target.name return target, installed_manifest, installed_name @@ -895,8 +849,8 @@ def _install_plugin_core( def _looks_like_bare_index_name(identifier: str) -> bool: """True when *identifier* is a bare plugin name (no slash, not a URL). - Bare names are resolved through the community plugin index; anything with - a slash or URL scheme keeps the existing owner/repo / Git URL semantics. + Bare names are resolved through the community plugin index; anything with a slash or URL scheme + keeps the existing owner/repo / Git URL semantics. """ if "/" in identifier or "\\" in identifier: return False @@ -906,10 +860,9 @@ def _looks_like_bare_index_name(identifier: str) -> bool: def _resolve_index_name(identifier: str, console) -> tuple[str, Optional[str]]: """Resolve a bare plugin name to ``(install_identifier, pinned_ref)``. - Exits with an error when the name is unknown, or lists candidates and - exits when the name is ambiguous. The returned ref is only used when it - is an exact 40-character commit SHA (the pin format the installer - accepts); tag refs are surfaced as advisory output instead. + Exits with an error when the name is unknown, or lists candidates and exits when the name is + ambiguous. The returned ref is only used when it is an exact 40-character commit SHA (the pin + format the installer accepts); tag refs are surfaced as advisory output instead. """ from hermes_cli.plugin_index import SECURITY_FOOTER, load_index, resolve_name @@ -957,16 +910,13 @@ def cmd_install( ) -> None: """Install a plugin from a Git URL, owner/repo shorthand, or index name. - Bare names (no slash, no URL scheme) are resolved through the community - plugin index to ``owner/repo`` plus the index-pinned ref. An explicit - ``--ref`` always wins over the index pin. + Bare names (no slash, no URL scheme) are resolved through the community plugin index to + ``owner/repo`` plus the index-pinned ref. An explicit ``--ref`` always wins over the index pin. - After install, prompt "Enable now? [y/N]" unless *enable* is provided - (True = auto-enable without prompting, False = install disabled). + After install, prompt "Enable now? [y/N]" unless *enable* is provided (True = auto-enable + without prompting, False = install disabled). """ - from rich.console import Console - - console = Console() + console = _console() if _looks_like_bare_index_name(identifier): identifier, index_ref = _resolve_index_name(identifier, console) @@ -985,10 +935,7 @@ def cmd_install( "Consider using https:// or git@ for production installs.", ) - if _subdir: - console.print(f"[dim]Cloning {git_url} (subdir: {_subdir})...[/dim]") - else: - console.print(f"[dim]Cloning {git_url}...[/dim]") + console.print(f"[dim]Cloning {git_url}{f' (subdir: {_subdir})' if _subdir else ''}...[/dim]") def _interactive_scan_decision(scan_result) -> bool: """Prompt the user to accept a caution-verdict plugin (Cowork 'warn').""" @@ -997,15 +944,9 @@ def cmd_install( console.print() console.print("[yellow]⚠ Security scan flagged this plugin:[/yellow]") console.print(format_scan_report(scan_result)) - if not (sys.stdin.isatty() and sys.stdout.isatty()): - return False - try: - answer = input( - " Install anyway? Only continue if you trust the source. [y/N]: ", - ).strip().lower() - except (EOFError, KeyboardInterrupt): - return False - return answer in {"y", "yes"} + return _is_tty() and _ask_yes( + " Install anyway? Only continue if you trust the source. [y/N]: " + ) try: target, installed_manifest, installed_name = _install_plugin_core( @@ -1014,16 +955,12 @@ def cmd_install( ref=ref, scan_decision_cb=_interactive_scan_decision, ) - except PluginScanBlocked as e: - console.print(f"[red]Blocked:[/red] {e}") - sys.exit(1) except PluginOperationError as e: - console.print(f"[red]Error:[/red] {e}") + label = "Blocked" if isinstance(e, PluginScanBlocked) else "Error" + console.print(f"[red]{label}:[/red] {e}") sys.exit(1) - if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (target / "plugin.json").exists() and not ( - target / "__init__.py" - ).exists(): + if not _looks_like_plugin_dir(target): console.print( f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, " f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.", @@ -1037,24 +974,10 @@ def cmd_install( should_enable = enable if should_enable is None: - if sys.stdin.isatty() and sys.stdout.isatty(): - try: - answer = input( - f" Enable '{installed_name}' now? [y/N]: ", - ).strip().lower() - should_enable = answer in {"y", "yes"} - except (EOFError, KeyboardInterrupt): - should_enable = False - else: - should_enable = False + should_enable = _is_tty() and _ask_yes(f" Enable '{installed_name}' now? [y/N]: ") if should_enable: - enabled = _get_enabled_set() - disabled = _get_disabled_set() - enabled.add(installed_name) - disabled.discard(installed_name) - _save_enabled_set(enabled) - _save_disabled_set(disabled) + _set_plugin_enabled(installed_name, enable=True) console.print( f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.", ) @@ -1067,110 +990,82 @@ def cmd_install( # Capability consent (#64228): if the manifest declares capabilities, # show the list once and record consent. Non-interactive installs (and # declines) proceed with capabilities ungranted — fail closed. - declared_caps = _declared_capabilities_from_manifest( - installed_manifest, installed_name - ) + declared_caps = _declared_capabilities_from_manifest(installed_manifest, installed_name) if declared_caps: - _run_capability_consent( - console, installed_name, declared_caps, context="install" - ) + _run_capability_consent(console, installed_name, declared_caps, context="install") console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]") console.print("[dim] hermes gateway restart[/dim]") console.print() +def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None) -> str: + """Shared ``update`` core: refuse pinned / non-git checkouts, ``git pull``, record the new + revision. Returns the pull output; raises :class:`PluginOperationError` on any refusal. + *pinned_msg(install_record)* / *not_git_msg()* build the caller-specific error text.""" + metadata = _read_install_metadata() + install_record = metadata.get(target.name, {}) + if install_record.get("pinned") is True: + raise PluginOperationError(pinned_msg(install_record)) + if not (target / ".git").exists(): + raise PluginOperationError(not_git_msg()) + if before_pull is not None: + before_pull() + ok, output = _git_pull_plugin_dir(target) + if not ok: + raise PluginOperationError(output) + _record_pulled_revision(target, metadata, install_record) + return output + + def cmd_update(name: str) -> None: """Update an installed plugin by pulling latest from its git remote.""" - from rich.console import Console from rich.markup import escape - console = Console() + console = _console() plugins_dir = _plugins_dir() + target = _require_installed_plugin(name, plugins_dir, console) try: - target = _require_installed_plugin(name, plugins_dir, console) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - try: - metadata = _read_install_metadata() + output = _pull_plugin_update( + target, + lambda rec: ( + f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run " + f"`hermes plugins install {escape(str(rec.get('source', '<source>')))} --force " + "--ref <40-character commit SHA>`." + ), + lambda: ( + f"Plugin '{name}' was not installed from git (no .git directory). Cannot update." + ), + before_pull=lambda: console.print(f"[dim]Updating {name}...[/dim]"), + ) except PluginOperationError as exc: console.print(f"[red]Error:[/red] {exc}") sys.exit(1) - install_record = metadata.get(target.name, {}) - if install_record.get("pinned") is True: - recorded_source = escape(str(install_record.get("source", "<source>"))) - console.print( - f"[red]Error:[/red] Plugin '{name}' is pinned to " - f"{install_record.get('revision')}. To move it, run " - f"`hermes plugins install {recorded_source} --force " - "--ref <40-character commit SHA>`." - ) - sys.exit(1) - - if not (target / ".git").exists(): - console.print( - f"[red]Error:[/red] Plugin '{name}' was not installed from git " - f"(no .git directory). Cannot update." - ) - sys.exit(1) - - console.print(f"[dim]Updating {name}...[/dim]") - - ok, output = _git_pull_plugin_dir(target) - if not ok: - console.print(f"[red]Error:[/red] {output}") - sys.exit(1) - - if install_record: - git_exe = _resolve_git_executable() - if git_exe: - install_record["revision"] = _git_head_revision(target, git_exe) - metadata[target.name] = install_record - _write_install_metadata(metadata) # Re-scan after update — Cowork re-scans skills/plugins on edit, and an # update can introduce malicious content into a previously clean plugin. # The pull has already mutated the tree, so a dangerous verdict disables # the plugin rather than leaving it active. if _scan_on_install_enabled(): - from tools.plugin_guard import ( - format_scan_report, - scan_plugin, - should_allow_plugin_install, - ) + from tools.plugin_guard import format_scan_report, scan_plugin, should_allow_plugin_install scan_result = scan_plugin(target, source=name) allowed, reason = should_allow_plugin_install(scan_result) if allowed is not True: console.print() - console.print( - f"[yellow]⚠ Security scan flagged the updated plugin:[/yellow] {reason}", - ) + console.print(f"[yellow]⚠ Security scan flagged the updated plugin:[/yellow] {reason}") console.print(format_scan_report(scan_result)) if scan_result.verdict == "dangerous": - enabled = _get_enabled_set() - disabled = _get_disabled_set() - if name in enabled or name not in disabled: - enabled.discard(name) - disabled.add(name) - _save_enabled_set(enabled) - _save_disabled_set(disabled) + if name in _get_enabled_set() or name not in _get_disabled_set(): + _set_plugin_enabled(name, enable=False) console.print( f"[red]Plugin '{name}' has been disabled.[/red] Review the " f"findings, then re-enable with `hermes plugins enable {name}` " f"if you trust them.", ) - # Same stale-bytecode class as the main checkout (#6207/#60242): the - # pull just changed .py files under this plugin dir, so drop any - # __pycache__ compiled from the previous revision. - _clear_plugin_bytecode(target) - - # Copy any new .example files - _copy_example_files(target, console) + _post_pull_housekeeping(target, console) # Update-time re-consent (#64228): if the new version declares # capabilities the granted set lacks, surface the diff and require @@ -1179,20 +1074,14 @@ def cmd_update(name: str) -> None: # (non-interactive updates leave them ungranted — fail closed). updated_manifest = _read_manifest(target) plugin_id = updated_manifest.get("name") or target.name - declared_caps = _declared_capabilities_from_manifest( - updated_manifest, plugin_id - ) + declared_caps = _declared_capabilities_from_manifest(updated_manifest, plugin_id) if declared_caps: - from hermes_cli.plugin_capabilities import ( - declared_set_changed, - pending_capabilities, - ) + from hermes_cli.plugin_capabilities import declared_set_changed, pending_capabilities + if pending_capabilities(plugin_id, declared_caps) or declared_set_changed( plugin_id, declared_caps ): - _run_capability_consent( - console, plugin_id, declared_caps, context="update" - ) + _run_capability_consent(console, plugin_id, declared_caps, context="update") out = output.strip() if "Already up to date" in out: @@ -1204,6 +1093,24 @@ def cmd_update(name: str) -> None: console.print(f"[dim]{out}[/dim]") +def _post_pull_housekeeping(target: Path, console) -> None: + """After ``git pull``: drop ``__pycache__`` compiled from the previous revision (same stale- + bytecode class as the main checkout, #6207/#60242) and copy any new ``.example`` files.""" + _clear_plugin_bytecode(target) + _copy_example_files(target, console) + + +def _record_pulled_revision(target: Path, metadata: dict, install_record: dict) -> None: + """After a pull, store the new HEAD in the plugin's install-metadata record (if it has one).""" + if not install_record: + return + git_exe = _resolve_git_executable() + if git_exe: + install_record["revision"] = _git_head_revision(target, git_exe) + metadata[target.name] = install_record + _write_install_metadata(metadata) + + def _remove_plugin_core(target: Path) -> None: """Remove one plugin and its metadata without splitting their state.""" metadata = _read_install_metadata() @@ -1235,48 +1142,32 @@ def _remove_plugin_core(target: Path) -> None: def cmd_remove(name: str) -> None: """Remove an installed plugin by name.""" - from rich.console import Console - - console = Console() + console = _console() plugins_dir = _plugins_dir() - - try: - target = _require_installed_plugin(name, plugins_dir, console) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) + target = _require_installed_plugin(name, plugins_dir, console) try: _remove_plugin_core(target) except (OSError, PluginOperationError) as exc: console.print(f"[red]Error:[/red] Could not remove plugin '{name}': {exc}") sys.exit(1) - _display_removed(name, plugins_dir) + console.print() + console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}") + console.print() def _get_disabled_set() -> set: """Read the disabled plugins set from config.yaml. - An explicit deny-list. A plugin name here never loads, even if also - listed in ``plugins.enabled``. + An explicit deny-list. A plugin name here never loads, even if also listed in + ``plugins.enabled``. """ - try: - from hermes_cli.config import load_config - config = load_config() - disabled = cfg_get(config, "plugins", "disabled", default=[]) - return set(disabled) if isinstance(disabled, list) else set() - except Exception: - return set() + return _config_name_set("plugins", "disabled") def _save_disabled_set(disabled: set) -> None: """Write the disabled plugins list to config.yaml.""" - from hermes_cli.config import load_config, save_config - config = load_config() - if "plugins" not in config: - config["plugins"] = {} - config["plugins"]["disabled"] = sorted(disabled) - save_config(config) + _write_config_value("plugins", "disabled", sorted(disabled)) _BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"}) @@ -1285,94 +1176,85 @@ _BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"}) def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool: """Re-enable the bundled basic dashboard-auth plugin in *cfg*. - ``hermes setup`` / ``hermes plugins disable basic`` can park the plugin - in ``plugins.disabled`` while ``dashboard.basic_auth`` is configured. - The basic provider is a bundled backend that still respects the - deny-list, so password auth silently fails until the block is removed. - - Returns True when ``plugins.disabled`` was modified. + ``hermes setup`` / ``hermes plugins disable basic`` can park the plugin in + ``plugins.disabled`` while ``dashboard.basic_auth`` is configured; the bundled provider + still respects the deny-list, so password auth silently fails until the block is removed. + Returns True when modified. """ plugins_cfg = cfg.get("plugins") - if not isinstance(plugins_cfg, dict): + disabled = plugins_cfg.get("disabled") if isinstance(plugins_cfg, dict) else None + if not isinstance(disabled, list) or not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS): return False - disabled = plugins_cfg.get("disabled") - if not isinstance(disabled, list): - return False - if not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS): - return False - plugins_cfg["disabled"] = sorted( - set(disabled) - _BASIC_AUTH_PLUGIN_KEYS - ) + plugins_cfg["disabled"] = sorted(set(disabled) - _BASIC_AUTH_PLUGIN_KEYS) return True def _get_enabled_set() -> set: """Read the enabled plugins allow-list from config.yaml. - Plugins are opt-in: only names here are loaded. Returns ``set()`` if - the key is missing (same behaviour as "nothing enabled yet"). + Plugins are opt-in: only names here are loaded. Returns ``set()`` if the key is missing (same + behaviour as "nothing enabled yet"). """ - try: - from hermes_cli.config import load_config - config = load_config() - plugins_cfg = config.get("plugins", {}) - if not isinstance(plugins_cfg, dict): - return set() - enabled = plugins_cfg.get("enabled", []) - return set(enabled) if isinstance(enabled, list) else set() - except Exception: - return set() + return _config_name_set("plugins", "enabled") def _save_enabled_set(enabled: set) -> None: """Write the enabled plugins list to config.yaml.""" - from hermes_cli.config import load_config, save_config - config = load_config() - if "plugins" not in config: - config["plugins"] = {} - config["plugins"]["enabled"] = sorted(enabled) - save_config(config) + _write_config_value("plugins", "enabled", sorted(enabled)) + + +def _discard_key_and_leaf(names: set, key: str) -> None: + """Drop *key* and its bare leaf (``observability/langfuse`` -> ``langfuse``) from *names*, so a + stale legacy bare-name entry can't keep vetoing the canonical key.""" + names.discard(key) + names.discard(key.split("/")[-1]) + + +def _set_plugin_enabled(name: str, *, enable: bool) -> None: + """Move *name* between the enabled allow-list and the disabled deny-list and persist both.""" + enabled = _get_enabled_set() + disabled = _get_disabled_set() + (enabled.add if enable else enabled.discard)(name) + (disabled.discard if enable else disabled.add)(name) + _save_enabled_set(enabled) + _save_disabled_set(disabled) def _resolve_plugin_key(name: str) -> Optional[str]: """Resolve a user-supplied plugin identifier to its canonical registry key. - Accepts either the bare manifest name (``langfuse``), the directory - name, or the full path-derived key (``observability/langfuse``) and - returns the canonical key the loader gates on (``manifest.key`` or, for a - flat plugin, the bare name). Returns ``None`` when no plugin matches. - - This is the single normalization point so ``hermes plugins enable`` / - ``disable`` write the same key that ``PluginManager`` matches against — - nested category plugins (e.g. ``observability/langfuse``) included. + Accepts the bare manifest name, the directory name, or the path-derived key + (``observability/langfuse``) and returns the key the loader gates on; ``None`` when nothing + matches. Single normalization point so ``enable``/``disable`` write the same key that + ``PluginManager`` matches against, nested category plugins included. """ - entries = _discover_all_plugins() - # 1. Exact match on canonical key or manifest name — always unambiguous. - for entry in entries: - # entry = (name, version, description, source, dir_path, key) - if name == entry[5] or name == entry[0]: - return entry[5] - # 2. Fall back to a bare leaf-name match (e.g. "langfuse" -> - # "observability/langfuse"), but only when it resolves to exactly one - # plugin so we never silently pick the wrong same-named nested plugin. - leaf_matches = [entry[5] for entry in entries if name == entry[5].split("/")[-1]] - if len(leaf_matches) == 1: - return leaf_matches[0] - return None + resolved = _resolve_plugin_key_and_source(name) + return resolved[0] if resolved else None + + +def _find_plugin_entry(name: str) -> Optional[tuple]: + """First discovered ``(name, version, description, source, dir_path, key)`` entry whose + manifest name or canonical key equals *name*.""" + return next( + (entry for entry in _discover_all_plugins() if name in (entry[0], entry[5])), None + ) def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]: """Resolve *name* to ``(canonical_key, source)`` or ``None`` if no match. - Mirrors :func:`_resolve_plugin_key`'s normalization but also returns the - plugin's source (``"bundled"``, ``"user"``, ``"project"``, ...) so the - enable path can tell whether a built-in-override consent prompt is needed. + Same normalization as :func:`_resolve_plugin_key` but also returns the plugin's source + (``"bundled"``, ``"user"``, ``"project"``, ...) so the enable path can tell whether a built- + in-override consent prompt is needed. """ entries = _discover_all_plugins() + # 1. Exact match on canonical key or manifest name — always unambiguous. for entry in entries: - # entry = (name, version, description, source, dir_path, key) - if name == entry[5] or name == entry[0]: + if name in (entry[0], entry[5]): return (entry[5], entry[3]) + # 2. Fall back to a bare leaf-name match (e.g. "langfuse" -> + # "observability/langfuse"), but only when it resolves to exactly one + # plugin so we never silently pick the wrong same-named nested plugin. leaf_matches = [ (entry[5], entry[3]) for entry in entries if name == entry[5].split("/")[-1] @@ -1386,18 +1268,7 @@ def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None: """Write ``plugins.entries.<plugin_id>.<key> = value`` into config.yaml.""" from hermes_cli.config import load_config, save_config config = load_config() - plugins_cfg = config.setdefault("plugins", {}) - if not isinstance(plugins_cfg, dict): - plugins_cfg = {} - config["plugins"] = plugins_cfg - entries = plugins_cfg.setdefault("entries", {}) - if not isinstance(entries, dict): - entries = {} - plugins_cfg["entries"] = entries - entry = entries.setdefault(plugin_id, {}) - if not isinstance(entry, dict): - entry = {} - entries[plugin_id] = entry + entry = _sub_dict(_sub_dict(_sub_dict(config, "plugins"), "entries"), plugin_id) entry[key] = bool(value) save_config(config) @@ -1405,26 +1276,27 @@ def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None: def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: """Add a plugin to the enabled allow-list (and remove it from disabled). - For non-bundled plugins, prompt the operator about granting the - privileged ``allow_tool_override`` capability (replacing built-in tools - like ``shell_exec`` / ``write_file``). ``allow_tool_override`` is a - tri-state: ``True`` grants without prompting, ``False`` declines without - prompting, ``None`` (default) asks interactively. Bundled plugins are + Non-bundled plugins are prompted about the privileged ``allow_tool_override`` capability + (replacing built-in tools). ``allow_tool_override`` is tri-state: ``True`` grants and + ``False`` declines without prompting, ``None`` asks interactively. Bundled plugins are trusted and never prompted. """ - from rich.console import Console from hermes_cli.relay_plugin_cutover import ( LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV, ) - console = Console() - if name in LEGACY_RELAY_PLUGIN_KEYS: - console.print( - f"[red]Plugin '{name}' was removed.[/red] Relay lifecycle is owned " - f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead." - ) - sys.exit(1) + console = _console() + + def _refuse_legacy_relay(plugin: str) -> None: + if plugin in LEGACY_RELAY_PLUGIN_KEYS: + console.print( + f"[red]Plugin '{plugin}' was removed.[/red] Relay lifecycle is owned " + f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead." + ) + sys.exit(1) + + _refuse_legacy_relay(name) # Discover the plugin — check installed (user) AND bundled, including # nested category plugins — and normalize to its canonical registry key. @@ -1433,13 +1305,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]") sys.exit(1) key, source = resolved - - if key in LEGACY_RELAY_PLUGIN_KEYS: - console.print( - f"[red]Plugin '{key}' was removed.[/red] Relay lifecycle is owned " - f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead." - ) - sys.exit(1) + _refuse_legacy_relay(key) enabled = _get_enabled_set() disabled = _get_disabled_set() @@ -1448,7 +1314,6 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: if not already_enabled: enabled.add(key) - disabled.discard(key) # Drop every alias of this plugin from the disabled list so an # explicit disable under a different form can't keep it off. The # loader's disable check matches on BOTH the canonical key @@ -1456,9 +1321,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: # a stale entry under either form makes "explicit disable wins" # (plugins.py) silently veto this enable. Discard the key, its # bare leaf, and the manifest name. (#40190 follow-up.) - bare = key.split("/")[-1] - if bare != key: - disabled.discard(bare) + _discard_key_and_leaf(disabled, key) for entry in _discover_all_plugins(): # entry = (name, version, description, source, dir_path, key) if entry[5] == key: @@ -1506,22 +1369,20 @@ def _declared_capabilities_from_manifest(manifest: dict, plugin_name: str = "?") def _declared_capabilities_for_key(key: str) -> list: """Read the declared capabilities for an installed/bundled plugin by key.""" - for entry in _discover_all_plugins(): - # entry = (name, version, description, source, dir_path, key) - if entry[5] == key or entry[0] == key: - if entry[3] == "entrypoint": - from hermes_cli.plugins import discover_entrypoint_manifests + entry = _find_plugin_entry(key) + if entry is None: + return [] + if entry[3] == "entrypoint": + from hermes_cli.plugins import discover_entrypoint_manifests - for manifest in discover_entrypoint_manifests(): - if key in (manifest.key, manifest.name): - return list(manifest.capabilities) - return [] - dir_path = entry[4] - if not dir_path: - return [] - manifest = _read_manifest(Path(dir_path)) - return _declared_capabilities_from_manifest(manifest, entry[0]) - return [] + for manifest in discover_entrypoint_manifests(): + if key in (manifest.key, manifest.name): + return list(manifest.capabilities) + return [] + dir_path = entry[4] + if not dir_path: + return [] + return _declared_capabilities_from_manifest(_read_manifest(Path(dir_path)), entry[0]) def _print_capability_list(console, capabilities: list) -> None: @@ -1543,18 +1404,12 @@ def _run_capability_consent( ) -> bool: """Show the capability consent screen and record the decision. - Prints the declared capability list with one-line risk descriptions and - asks a single Y/n. On consent, the *pending* capabilities are granted - (recorded under ``plugins.entries.<plugin_id>.granted_capabilities`` with - a consent hash of the declared set). On decline — or in ANY - non-interactive context — capabilities stay ungranted (fail closed) and - the plugin must degrade gracefully via ``ctx.has_capability()``. - - The consent wording deliberately does not imply a code audit: granting a - capability trusts the plugin author. This is consent + audit, NOT a - sandbox — an in-process plugin can run arbitrary Python regardless. - - Returns True when consent was granted. + Lists declared capabilities with risk descriptions and asks one Y/n. On consent the pending + capabilities are granted under ``plugins.entries.<id>.granted_capabilities`` with a hash of + the declared set. On decline — or in ANY non-interactive context — they stay ungranted (fail + closed) and the plugin must degrade via ``ctx.has_capability()``. This is consent + audit, + NOT a sandbox: an in-process plugin can run arbitrary Python regardless. Returns True when + granted. """ from hermes_cli.plugin_capabilities import ( pending_capabilities, @@ -1581,7 +1436,7 @@ def _run_capability_consent( "in-process.[/dim]" ) - if not (sys.stdin.isatty() and sys.stdout.isatty()): + if not _is_tty(): console.print( " [yellow]Non-interactive session: capabilities NOT granted " "(fail closed).[/yellow] Run " @@ -1590,11 +1445,7 @@ def _run_capability_consent( ) return False - try: - answer = console.input(" Grant these capabilities? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - if answer in {"y", "yes"}: + if _ask_yes(" Grant these capabilities? [y/N] ", console.input): record_consent(plugin_id, pending, declared) console.print( f" [green]✓[/green] Granted: {', '.join(pending)} " @@ -1612,11 +1463,13 @@ def _run_capability_consent( def cmd_capabilities(name: Optional[str] = None) -> None: """``hermes plugins capabilities [<id>]`` — declared vs granted.""" - from rich.console import Console + from hermes_cli.plugin_capabilities import ( + CAPABILITY_REGISTRY, + granted_capabilities, + plugin_capability_granted, + ) - from hermes_cli.plugin_capabilities import granted_capabilities - - console = Console() + console = _console() rows = [] for entry in _discover_all_plugins(): @@ -1628,10 +1481,6 @@ def cmd_capabilities(name: Optional[str] = None) -> None: granted = granted_capabilities(key) # Legacy grants surface too: report capabilities live via deprecated # allow_* keys so `capabilities` shows the true effective state. - from hermes_cli.plugin_capabilities import ( - CAPABILITY_REGISTRY, - plugin_capability_granted, - ) effective = { cap for cap in CAPABILITY_REGISTRY if plugin_capability_granted(key, cap) @@ -1670,11 +1519,7 @@ def cmd_capabilities(name: Optional[str] = None) -> None: def _resolve_tool_override_grant( console, key: str, allow_tool_override: Optional[bool] ) -> None: - """Resolve and persist the ``allow_tool_override`` grant for a plugin. - - ``allow_tool_override`` tri-state: True grants, False declines, None - prompts interactively (defaulting to deny on a non-interactive stdin). - """ + """Resolve and persist the ``allow_tool_override`` grant for a plugin.""" if allow_tool_override is None: # Interactive consent. Default to NO so a blind Enter doesn't grant # a privileged capability, and a non-interactive stdin denies safely. @@ -1685,19 +1530,14 @@ def _resolve_tool_override_grant( "everything the agent routes through that tool.\n" " Grant it? [y/N] " ) - try: - answer = console.input(prompt).strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - allow_tool_override = answer in {"y", "yes"} + allow_tool_override = _ask_yes(prompt, console.input) - plugin_id = key - _set_plugin_entry_flag(plugin_id, "allow_tool_override", allow_tool_override) + _set_plugin_entry_flag(key, "allow_tool_override", allow_tool_override) if allow_tool_override: console.print( f"[green]✓[/green] Granted [bold]{key}[/bold] permission to " "override built-in tools " - f"([dim]plugins.entries.{plugin_id}.allow_tool_override: true[/dim])." + f"([dim]plugins.entries.{key}.allow_tool_override: true[/dim])." ) else: console.print( @@ -1709,9 +1549,7 @@ def _resolve_tool_override_grant( def cmd_disable(name: str) -> None: """Remove a plugin from the enabled allow-list (and add to disabled).""" - from rich.console import Console - - console = Console() + console = _console() key = _resolve_plugin_key(name) if key is None: console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]") @@ -1724,12 +1562,9 @@ def cmd_disable(name: str) -> None: console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]") return - enabled.discard(key) # Drop any legacy bare-name entry from the allow-list too, so a stale # bare name can't keep a nested plugin loading after an explicit disable. - bare = key.split("/")[-1] - if bare != key: - enabled.discard(bare) + _discard_key_and_leaf(enabled, key) disabled.add(key) _save_enabled_set(enabled) _save_disabled_set(disabled) @@ -1739,55 +1574,27 @@ def cmd_disable(name: str) -> None: ) -def _plugin_exists(name: str) -> bool: - """Return True if a plugin with *name* (bare name or key) exists.""" - return _resolve_plugin_key(name) is not None - - def _read_manifest_info(d: Path, prefix: str): - """Read a native or portable manifest and return display metadata. - - Returns None if no manifest file exists. - """ - manifest_file = d / "plugin.yaml" - if not manifest_file.exists(): - manifest_file = d / "plugin.yml" - if not manifest_file.exists(): - portable_file = d / "plugin.json" - if not portable_file.exists() and not portable_file.is_symlink(): + """Read a native or portable manifest and return display metadata.""" + manifest_file = _native_manifest_file(d) + if manifest_file is None: + if not _has_portable_manifest(d): return None try: - from hermes_cli.agent_plugins import read_agent_plugin_manifest - - manifest, _ = read_agent_plugin_manifest(d) + manifest = _read_portable_manifest(d) name = manifest["name"] - key = f"{prefix}/{d.name}" if prefix else name - return ( - name, - manifest.get("version", ""), - manifest.get("description", ""), - key, - ) except Exception: return None - try: - import yaml - except ImportError: - yaml = None - name = d.name - version = "" - description = "" - if yaml: + else: + # Unreadable YAML (or no yaml module) degrades to the directory name, silently. try: - with open(manifest_file, encoding="utf-8") as f: - manifest = yaml.safe_load(f) or {} - name = manifest.get("name", d.name) - version = manifest.get("version", "") - description = manifest.get("description", "") + manifest = _load_yaml_manifest(manifest_file) except Exception: - pass + manifest = {} + manifest = manifest if isinstance(manifest, dict) else {} + name = manifest.get("name", d.name) key = f"{prefix}/{d.name}" if prefix else name - return name, version, description, key + return name, manifest.get("version", ""), manifest.get("description", ""), key def _is_portable_plugin_dir(dir_path) -> bool: @@ -1795,12 +1602,7 @@ def _is_portable_plugin_dir(dir_path) -> bool: only — a native ``plugin.yaml`` takes precedence, matching the loader).""" try: d = Path(dir_path) - if not d.is_dir(): - return False - if (d / "plugin.yaml").exists() or (d / "plugin.yml").exists(): - return False - portable_file = d / "plugin.json" - return portable_file.exists() or portable_file.is_symlink() + return d.is_dir() and _native_manifest_file(d) is None and _has_portable_manifest(d) except OSError: return False @@ -1815,17 +1617,11 @@ def _bundled_default_on(dir_path) -> bool: """True when a bundled plugin at *dir_path* is active without an explicit ``plugins.enabled`` entry. Standalone/exclusive kinds stay opt-in, and portable packages (``plugin.json``) have no kind at all.""" - manifest_file = Path(dir_path) / "plugin.yaml" - if not manifest_file.exists(): - manifest_file = Path(dir_path) / "plugin.yml" - if not manifest_file.exists(): + manifest_file = _native_manifest_file(Path(dir_path)) + if manifest_file is None: return False try: - import yaml - - with open(manifest_file, encoding="utf-8") as f: - manifest = yaml.safe_load(f) or {} - kind = str(manifest.get("kind", "standalone")).strip().lower() + kind = str(_load_yaml_manifest(manifest_file).get("kind", "standalone")).strip().lower() return kind in _BUNDLED_DEFAULT_ON_KINDS except Exception: return False @@ -1839,10 +1635,7 @@ def _scan_level( depth: int, seen: dict, ) -> None: - """Recursive directory scan matching PluginManager._scan_directory_level. - - Populates *seen* with key -> (name, version, description, source, dir, key). - """ + """Recursive directory scan matching PluginManager._scan_directory_level.""" if not base.is_dir(): return for d in sorted(base.iterdir()): @@ -1851,28 +1644,22 @@ def _scan_level( if depth == 0 and skip_names and d.name in skip_names: continue info = _read_manifest_info(d, prefix) - if info is not None: - name, version, description, key = info - if key in seen and source == "bundled": - continue - src_label = source - if source == "user" and (d / ".git").exists(): - src_label = "git" - seen[key] = (name, version, description, src_label, d, key) + if info is None: + if depth == 0: + _scan_level(d, source, set(), f"{prefix}/{d.name}" if prefix else d.name, 1, seen) continue - if depth >= 1: + name, version, description, key = info + if key in seen and source == "bundled": continue - sub_prefix = f"{prefix}/{d.name}" if prefix else d.name - _scan_level(d, source, set(), sub_prefix, depth + 1, seen) + src_label = "git" if source == "user" and (d / ".git").exists() else source + seen[key] = (name, version, description, src_label, d, key) def _discover_all_plugins() -> list: - """Return a list of (name, version, description, source, dir_path, key) for - every plugin the loader can see — user + bundled + project + entry point. + """Return (name, version, description, source, dir_path, key) for every plugin the loader sees. - Matches the ordering/dedup of ``PluginManager.discover_and_load``: - bundled first, then user, then project, then entry points. Later sources - override earlier ones on key collision. + Matches the ordering/dedup of ``PluginManager.discover_and_load``: bundled, then user, then + project, then entry points; later sources override earlier ones on key collision. """ seen: dict = {} # key -> (name, version, description, source, path, key) @@ -1897,9 +1684,9 @@ def _discover_all_plugins() -> list: def _discover_entrypoint_plugins() -> list[tuple[str, str, str, str]]: """Return plugin entries advertised through ``hermes_agent.plugins``. - Entry-point plugins are installed as Python packages, so they do not have a - plugin directory under ``~/.hermes/plugins``. Include package metadata here - so ``hermes plugins list`` can show and enable them. + Entry-point plugins are installed as Python packages, so they do not have a plugin directory + under ``~/.hermes/plugins``. Include package metadata here so ``hermes plugins list`` can show + and enable them. """ from hermes_cli.plugins import ENTRY_POINTS_GROUP @@ -1917,14 +1704,17 @@ def _discover_entrypoint_plugins() -> list[tuple[str, str, str, str]]: entries: list[tuple[str, str, str, str]] = [] for ep in group_eps: - version = "" - description = "" dist = getattr(ep, "dist", None) metadata = getattr(dist, "metadata", None) - if metadata is not None: - version = str(getattr(dist, "version", "") or "") - description = str(metadata.get("Summary", "") or "") - entries.append((ep.name, version, description, ep.value)) + if metadata is None: + entries.append((ep.name, "", "", ep.value)) + else: + entries.append(( + ep.name, + str(getattr(dist, "version", "") or ""), + str(metadata.get("Summary", "") or ""), + ep.value, + )) return entries @@ -1950,12 +1740,14 @@ def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set return filtered +_STATUS_MARKUP = {"disabled": "[red]disabled[/red]", "enabled": "[green]enabled[/green]"} + + def cmd_list(args: Any | None = None) -> None: """List all plugins (bundled + user) with enabled/disabled state.""" - from rich.console import Console from rich.table import Table - console = Console() + console = _console() entries = _discover_all_plugins() if not entries: console.print("[dim]No plugins installed.[/dim]") @@ -1966,24 +1758,20 @@ def cmd_list(args: Any | None = None) -> None: disabled = _get_disabled_set() entries = _filter_plugin_entries(entries, args, enabled, disabled) + # (name, status, version, description, source) per entry + rows = [ + (name, _plugin_status(name, enabled, disabled, key=key), str(version), description, source) + for name, version, description, source, _dir, key in entries + ] + if getattr(args, "json", False): - payload = [ - { - "name": name, - "status": _plugin_status(name, enabled, disabled, key=key), - "version": str(version), - "description": description, - "source": source, - } - for name, version, description, source, _dir, key in entries - ] - print(json.dumps(payload, indent=2)) + keys = ("name", "status", "version", "description", "source") + print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2)) return if getattr(args, "plain", False): - for name, version, _description, source, _dir, key in entries: - status = _plugin_status(name, enabled, disabled, key=key) - print(f"{status:12} {source:8} {str(version):8} {name}") + for name, status, version, _description, source in rows: + print(f"{status:12} {source:8} {version:8} {name}") return if not entries: @@ -1997,15 +1785,9 @@ def cmd_list(args: Any | None = None) -> None: table.add_column("Description") table.add_column("Source", style="dim") - for name, version, description, source, _dir, key in entries: - status_name = _plugin_status(name, enabled, disabled, key=key) - if status_name == "disabled": - status = "[red]disabled[/red]" - elif status_name == "enabled": - status = "[green]enabled[/green]" - else: - status = "[yellow]not enabled[/yellow]" - table.add_row(name, status, str(version), description, source) + for name, status_name, version, description, source in rows: + status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]") + table.add_row(name, status, version, description, source) console.print() console.print(table) @@ -2033,20 +1815,15 @@ def _discover_memory_providers() -> list[tuple[str, str]]: def _discover_context_engines() -> list[tuple[str, str]]: """Return [(name, description), ...] for available context engines. - Includes repo-shipped engines from ``plugins/context_engine/`` AND - plugin-registered engines (third-party engines installed as Hermes - plugins via ``ctx.register_context_engine``). Repo-shipped descriptions - win when a plugin-registered engine collides on name. + Includes repo-shipped engines from ``plugins/context_engine/`` AND plugin-registered engines + (via ``ctx.register_context_engine``). Repo-shipped descriptions win on a name collision. """ - engines: list[tuple[str, str]] = [] - seen: set[str] = set() + engines: dict[str, str] = {} try: from plugins.context_engine import discover_context_engines for name, desc, _avail in discover_context_engines(): - if name not in seen: - engines.append((name, desc)) - seen.add(name) + engines.setdefault(name, desc) except Exception: pass @@ -2054,128 +1831,67 @@ def _discover_context_engines() -> list[tuple[str, str]]: from hermes_cli.plugins import discover_plugins, get_plugin_context_engine discover_plugins() plugin_engine = get_plugin_context_engine() - if plugin_engine and getattr(plugin_engine, "name", None) and plugin_engine.name not in seen: - engines.append((plugin_engine.name, "installed plugin")) + if plugin_engine and getattr(plugin_engine, "name", None): + engines.setdefault(plugin_engine.name, "installed plugin") except Exception: pass - return engines + return list(engines.items()) -def _get_current_memory_provider() -> str: - """Return the current memory.provider from config (empty = built-in).""" - try: - from hermes_cli.config import load_config - config = load_config() - return cfg_get(config, "memory", "provider", default="") or "" - except Exception: - return "" +# memory.provider ("" = built-in) and context.engine config accessors. +_get_current_memory_provider = functools.partial(_config_str, "memory", "provider", default="") +_get_current_context_engine = functools.partial(_config_str, "context", "engine", default="compressor") +_save_memory_provider = functools.partial(_write_config_value, "memory", "provider") +_save_context_engine = functools.partial(_write_config_value, "context", "engine") -def _get_current_context_engine() -> str: - """Return the current context.engine from config.""" - try: - from hermes_cli.config import load_config - config = load_config() - return cfg_get(config, "context", "engine", default="compressor") or "compressor" - except Exception: - return "compressor" - - -def _save_memory_provider(name: str) -> None: - """Persist memory.provider to config.yaml.""" - from hermes_cli.config import load_config, save_config - config = load_config() - if "memory" not in config: - config["memory"] = {} - config["memory"]["provider"] = name - save_config(config) - - -def _save_context_engine(name: str) -> None: - """Persist context.engine to config.yaml.""" - from hermes_cli.config import load_config, save_config - config = load_config() - if "context" not in config: - config["context"] = {} - config["context"]["engine"] = name - save_config(config) - - -def _configure_memory_provider() -> bool: - """Launch a radio picker for memory providers. Returns True if changed.""" +def _configure_provider_category( + title: str, default_label: str, default_name: str, current: str, choices, save +) -> bool: + """Radio picker: the built-in default first, then *choices*; a current value not among + them is appended as ``(not found)``. Calls *save* and returns True when the choice changed.""" from hermes_cli.curses_ui import curses_radiolist - current = _get_current_memory_provider() - providers = _discover_memory_providers() - - # Build items: "built-in" first, then discovered providers - items = ["built-in (default)"] - names = [""] # empty string = built-in - selected = 0 - - for name, desc in providers: - names.append(name) - label = f"{name} \u2014 {desc}" if desc else name - items.append(label) - if name == current: - selected = len(items) - 1 - - # If current provider isn't in discovered list, add it - if current and current not in names: + names = [default_name] + [name for name, _desc in choices] + items = [default_label] + [f"{name} \u2014 {desc}" if desc else name for name, desc in choices] + if current not in names: names.append(current) items.append(f"{current} (not found)") - selected = len(items) - 1 + selected = max(i for i, name in enumerate(names) if name == current) - choice = curses_radiolist( - title="Memory Provider (select one)", - items=items, - selected=selected, - ) - - new_provider = names[choice] - if new_provider != current: - _save_memory_provider(new_provider) + new_value = names[curses_radiolist(title=title, items=items, selected=selected)] + if new_value != current: + save(new_value) return True return False -def _configure_context_engine() -> bool: - """Launch a radio picker for context engines. Returns True if changed.""" - from hermes_cli.curses_ui import curses_radiolist +# (title, default label, default name, current-value reader, discovery fn, saver) per provider +# category. Readers/savers are looked up at call time so module-level patching still applies. +_PROVIDER_CATEGORY_SPECS = ( + ("Memory Provider", "built-in", "", lambda: _get_current_memory_provider(), + lambda: _discover_memory_providers(), lambda v: _save_memory_provider(v)), + ("Context Engine", "compressor", "compressor", lambda: _get_current_context_engine(), + lambda: _discover_context_engines(), lambda v: _save_context_engine(v)), +) - current = _get_current_context_engine() - engines = _discover_context_engines() - # Build items: "compressor" first (built-in), then discovered engines - items = ["compressor (default)"] - names = ["compressor"] - selected = 0 - - for name, desc in engines: - names.append(name) - label = f"{name} \u2014 {desc}" if desc else name - items.append(label) - if name == current: - selected = len(items) - 1 - - # If current engine isn't in discovered list and isn't compressor, add it - if current != "compressor" and current not in names: - names.append(current) - items.append(f"{current} (not found)") - selected = len(items) - 1 - - choice = curses_radiolist( - title="Context Engine (select one)", - items=items, - selected=selected, +def _configure_category_spec(spec) -> bool: + """Launch the radio picker for one ``_PROVIDER_CATEGORY_SPECS`` row. Returns True if changed.""" + title, default_label, default_name, current, discover, save = spec + return _configure_provider_category( + f"{title} (select one)", f"{default_label} (default)", default_name, + current(), discover(), save, ) - new_engine = names[choice] - if new_engine != current: - _save_context_engine(new_engine) - return True - return False + +def _provider_categories() -> list: + """``[(title, current_label, configure_fn), ...]`` rows for the composite UI.""" + return [ + (spec[0], spec[3]() or spec[1], functools.partial(_configure_category_spec, spec)) + for spec in _PROVIDER_CATEGORY_SPECS + ] # --------------------------------------------------------------------------- @@ -2184,24 +1900,9 @@ def _configure_context_engine() -> bool: def cmd_show(name: str) -> None: - """Show details for a single plugin, including declared emits/listens. - - Resolves *name* against every discoverable plugin (bundled + user + - entrypoint) by either its display name or its registry key, then reads - its ``plugin.yaml`` to surface the advisory event-bus declarations - (``emits`` / ``listens``) alongside the basic metadata. - """ - from rich.console import Console - - console = Console() - entries = _discover_all_plugins() - match = None - for entry in entries: - # entry = (name, version, description, source, dir_path, key) - if entry[0] == name or entry[5] == name: - match = entry - break - + """Show details for a single plugin, including declared emits/listens.""" + console = _console() + match = _find_plugin_entry(name) if match is None: console.print(f"[red]Plugin '{name}' not found.[/red]") console.print("[dim]List installed plugins:[/dim] hermes plugins list") @@ -2234,9 +1935,7 @@ def cmd_show(name: str) -> None: def cmd_toggle() -> None: """Interactive composite UI — general plugins + provider plugin categories.""" - from rich.console import Console - - console = Console() + console = _console() # -- General plugins discovery (bundled + user) -- entries = _discover_all_plugins() @@ -2251,42 +1950,23 @@ def cmd_toggle() -> None: # plugins.disabled, but ``hermes plugins enable web/firecrawl`` cleared # only the key — so "explicit disable wins" kept a bundled backend off # forever (pi314's #40190 symptom). Keys keep every surface aligned. - plugin_keys = [] - plugin_labels = [] - plugin_selected = set() - - for i, (name, _version, description, source, _d, key) in enumerate(entries): - label = f"{name} \u2014 {description}" if description else name - if source == "bundled": - label = f"{label} [bundled]" - plugin_keys.append(key) - plugin_labels.append(label) - # Selected (enabled) when in enabled-set AND not in disabled-set. - # Accept the legacy bare name on either side for back-compat with - # existing configs written before this normalization. - is_on = ( - (key in enabled_set or name in enabled_set) - and key not in disabled_set - and name not in disabled_set - ) - if is_on: - plugin_selected.add(i) - - # -- Provider categories -- - current_memory = _get_current_memory_provider() or "built-in" - current_context = _get_current_context_engine() - categories = [ - ("Memory Provider", current_memory, _configure_memory_provider), - ("Context Engine", current_context, _configure_context_engine), + plugin_keys = [entry[5] for entry in entries] + plugin_labels = [ + (f"{name} \u2014 {description}" if description else name) + + (" [bundled]" if source == "bundled" else "") + for name, _version, description, source, _d, _key in entries ] + # Selected (enabled) when in enabled-set AND not in disabled-set. Accept the + # legacy bare name on either side for back-compat with configs written + # before this normalization. + plugin_selected = { + i for i, (name, _v, _desc, _src, _d, key) in enumerate(entries) + if (key in enabled_set or name in enabled_set) + and key not in disabled_set + and name not in disabled_set + } - has_plugins = bool(plugin_keys) - has_categories = bool(categories) - - if not has_plugins and not has_categories: - console.print("[dim]No plugins installed and no provider categories available.[/dim]") - console.print("[dim]Install with:[/dim] hermes plugins install owner/repo") - return + categories = _provider_categories() # Non-TTY fallback if not sys.stdin.isatty(): @@ -2303,6 +1983,31 @@ def cmd_toggle() -> None: disabled_set, categories, console) +def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set]: + """Save the composite UI's checkbox state; returns ``(changed, new_enabled)``. + + Persist by canonical key. Unchecked plugins are written to the disabled-list so they stay off + even if a future plugin auto-enables itself — but we ONLY ever write the canonical key (never + the bare manifest name), so the disabled-list can't drift out of sync with what ``cmd_enable`` + clears or what PluginManager gates on (#40190). Re-checking a plugin also drops any stale + legacy bare-leaf disable so it is fully cleared from the disabled-list. + """ + new_enabled: set = set() + new_disabled: set = set(disabled) # preserve existing disabled state for unseen plugins + for i, key in enumerate(plugin_keys): + if i in chosen: + new_enabled.add(key) + _discard_key_and_leaf(new_disabled, key) + else: + new_disabled.add(key) + + changed = new_enabled != _get_enabled_set() or new_disabled != disabled + if changed: + _save_enabled_set(new_enabled) + _save_disabled_set(new_disabled) + return changed, new_enabled + + def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled, categories, console): """Custom curses screen with checkboxes + category action rows.""" @@ -2315,10 +2020,9 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, n_categories = len(categories) total_items = n_plugins + n_categories # navigable items - result_holder = {"plugins_changed": False, "providers_changed": False} + providers_changed = False - def _draw(stdscr): - curses.curs_set(0) + def _init_colors(): if curses.has_colors(): curses.start_color() curses.use_default_colors() @@ -2326,6 +2030,49 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, curses.init_pair(2, curses.COLOR_YELLOW, -1) curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray + + def _attr(base, pair): + attr = base + if curses.has_colors(): + attr |= curses.color_pair(pair) + return attr + + def _put(stdscr, y, x, text, max_x, attr): + try: + stdscr.addnstr(y, x, text, max_x - 1, attr) + except curses.error: + pass + + def _configure_category(ci): + """Leave curses, run the category's picker, refresh its row, re-enter curses.""" + curses.endwin() + nonlocal providers_changed + cat_name, _cat_cur, cat_fn = categories[ci] + if cat_fn(): + providers_changed = True + categories[ci] = (cat_name, _provider_categories()[ci][1], cat_fn) + stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + stdscr.keypad(True) + _init_colors() + curses.curs_set(0) + return stdscr + + def _draw(stdscr): + curses.curs_set(0) + _init_colors() + # key -> new cursor, given (cursor, page_size) + nav = {} + for keys, move in ( + ((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items), + ((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items), + ((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)), + ((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)), + ((curses.KEY_HOME,), lambda c, p: 0), + ((curses.KEY_END,), lambda c, p: total_items - 1), + ): + nav.update(dict.fromkeys(keys, move)) cursor = 0 scroll_offset = 0 @@ -2334,222 +2081,66 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, max_y, max_x = stdscr.getmaxyx() # Header - try: - hattr = curses.A_BOLD - if curses.has_colors(): - hattr |= curses.color_pair(2) - stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr) - stdscr.addnstr( - 1, 0, - " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done", - max_x - 1, curses.A_DIM, - ) - except curses.error: - pass - - # Build display rows - # Row layout: - # [plugins section header] (not navigable, skipped in scroll math) - # plugin checkboxes (navigable, indices 0..n_plugins-1) - # [separator] (not navigable) - # [categories section header] (not navigable) - # category action rows (navigable, indices n_plugins..total_items-1) + _put(stdscr, 0, 0, "Plugins", max_x, _attr(curses.A_BOLD, 2)) + _put( + stdscr, 1, 0, + " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done", + max_x, curses.A_DIM, + ) + # Navigable indices: plugins 0..n_plugins-1, categories n_plugins..total_items-1; + # section headers / separator are drawn but skipped in scroll math. visible_rows = max_y - 4 if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: scroll_offset = cursor - visible_rows + 1 - y = 3 # start drawing after header - - # Determine which items are visible based on scroll - # We need to map logical cursor positions to screen rows - # accounting for non-navigable separator/headers - - - # --- General Plugins section --- + # Body rows as (text, attr); "" is a blank separator. Drawn from y=3 + # and truncated at the last screen line. + lines = [] if n_plugins > 0: - # Section header - if y < max_y - 1: - try: - sattr = curses.A_BOLD - if curses.has_colors(): - sattr |= curses.color_pair(2) - stdscr.addnstr(y, 0, " General Plugins", max_x - 1, sattr) - except curses.error: - pass - y += 1 - - plugin_start = scroll_offset - plugin_stop = min(n_plugins, scroll_offset + max(visible_rows, 0)) - for i in range(plugin_start, plugin_stop): - if y >= max_y - 1: - break + lines.append((" General Plugins", _attr(curses.A_BOLD, 2))) + for i in range(scroll_offset, min(n_plugins, scroll_offset + max(visible_rows, 0))): check = "\u2713" if i in chosen else " " arrow = "\u2192" if i == cursor else " " - line = f" {arrow} [{check}] {plugin_labels[i]}" - attr = curses.A_NORMAL - if i == cursor: - attr = curses.A_BOLD - if curses.has_colors(): - attr |= curses.color_pair(1) - try: - stdscr.addnstr(y, 0, line, max_x - 1, attr) - except curses.error: - pass - y += 1 - - # --- Separator --- - if y < max_y - 1: - y += 1 # blank line - - # --- Provider Plugins section --- - if n_categories > 0 and y < max_y - 1: - try: - sattr = curses.A_BOLD - if curses.has_colors(): - sattr |= curses.color_pair(2) - stdscr.addnstr(y, 0, " Provider Plugins", max_x - 1, sattr) - except curses.error: - pass - y += 1 - + attr = _attr(curses.A_BOLD, 1) if i == cursor else curses.A_NORMAL + lines.append((f" {arrow} [{check}] {plugin_labels[i]}", attr)) + lines.append(("", curses.A_NORMAL)) + if n_categories > 0: + lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2))) for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories): - if y >= max_y - 1: - break cat_idx = n_plugins + ci arrow = "\u2192" if cat_idx == cursor else " " - line = f" {arrow} {cat_name:<24} \u25b8 {cat_current}" - attr = curses.A_NORMAL - if cat_idx == cursor: - attr = curses.A_BOLD - if curses.has_colors(): - attr |= curses.color_pair(3) - try: - stdscr.addnstr(y, 0, line, max_x - 1, attr) - except curses.error: - pass - y += 1 + attr = _attr(curses.A_BOLD, 3) if cat_idx == cursor else curses.A_NORMAL + lines.append((f" {arrow} {cat_name:<24} \u25b8 {cat_current}", attr)) + for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3): + if text: + _put(stdscr, y, 0, text, max_x, attr) stdscr.refresh() key = stdscr.getch() - if key in {curses.KEY_UP, ord("k")}: - if total_items > 0: - cursor = (cursor - 1) % total_items - elif key in {curses.KEY_DOWN, ord("j")}: - if total_items > 0: - cursor = (cursor + 1) % total_items - elif key in {curses.KEY_NPAGE, ord("f")}: - if total_items > 0: - cursor = min(total_items - 1, cursor + max(1, max_y - 5)) - elif key in {curses.KEY_PPAGE, ord("b")}: - if total_items > 0: - cursor = max(0, cursor - max(1, max_y - 5)) - elif key == curses.KEY_HOME: - cursor = 0 - elif key == curses.KEY_END: - cursor = max(0, total_items - 1) - elif key == ord(" "): - if cursor < n_plugins: - # Toggle general plugin + if key in nav: + if total_items > 0: # (with no rows, every motion leaves cursor at 0) + cursor = nav[key](cursor, max(1, max_y - 5)) + elif key == ord(" ") or key in {curses.KEY_ENTER, 10, 13}: + if cursor >= n_plugins: + # Provider category — launch sub-screen (SPACE and ENTER alike) + if cursor - n_plugins < n_categories: + stdscr = _configure_category(cursor - n_plugins) + elif key == ord(" "): chosen.symmetric_difference_update({cursor}) else: - # Provider category — launch sub-screen - ci = cursor - n_plugins - if 0 <= ci < n_categories: - curses.endwin() - _cat_name, _cat_cur, cat_fn = categories[ci] - changed = cat_fn() - if changed: - result_holder["providers_changed"] = True - # Refresh current values - categories[ci] = ( - _cat_name, - _get_current_memory_provider() or "built-in" if ci == 0 - else _get_current_context_engine(), - cat_fn, - ) - # Re-enter curses - stdscr = curses.initscr() - curses.noecho() - curses.cbreak() - stdscr.keypad(True) - if curses.has_colors(): - curses.start_color() - curses.use_default_colors() - curses.init_pair(1, curses.COLOR_GREEN, -1) - curses.init_pair(2, curses.COLOR_YELLOW, -1) - curses.init_pair(3, curses.COLOR_CYAN, -1) - curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) - curses.curs_set(0) - elif key in {curses.KEY_ENTER, 10, 13}: - if cursor < n_plugins: - # ENTER on a plugin checkbox — confirm and exit - result_holder["plugins_changed"] = True - return - else: - # ENTER on a category — same as SPACE, launch sub-screen - ci = cursor - n_plugins - if 0 <= ci < n_categories: - curses.endwin() - _cat_name, _cat_cur, cat_fn = categories[ci] - changed = cat_fn() - if changed: - result_holder["providers_changed"] = True - categories[ci] = ( - _cat_name, - _get_current_memory_provider() or "built-in" if ci == 0 - else _get_current_context_engine(), - cat_fn, - ) - stdscr = curses.initscr() - curses.noecho() - curses.cbreak() - stdscr.keypad(True) - if curses.has_colors(): - curses.start_color() - curses.use_default_colors() - curses.init_pair(1, curses.COLOR_GREEN, -1) - curses.init_pair(2, curses.COLOR_YELLOW, -1) - curses.init_pair(3, curses.COLOR_CYAN, -1) - curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) - curses.curs_set(0) + return # ENTER on a plugin checkbox — confirm and exit elif key in {27, ord("q")}: - # Save plugin changes on exit - result_holder["plugins_changed"] = True - return + return # plugin changes are saved on exit curses.wrapper(_draw) flush_stdin() - # Persist by canonical key. Unchecked plugins are written to the - # disabled-list so they stay off even if a future plugin auto-enables - # itself — but we ONLY ever write the canonical key (never the bare - # manifest name), so the disabled-list can't drift out of sync with - # what ``cmd_enable`` clears or what PluginManager gates on (#40190). - new_enabled: set = set() - new_disabled: set = set(disabled) # preserve existing disabled state for unseen plugins - for i, key in enumerate(plugin_keys): - bare = key.split("/")[-1] - if i in chosen: - new_enabled.add(key) - new_disabled.discard(key) - # Drop any stale legacy bare-leaf disable so re-enabling here - # fully clears the plugin from the disabled-list. - if bare != key: - new_disabled.discard(bare) - else: - new_disabled.add(key) - - prev_enabled = _get_enabled_set() - enabled_changed = new_enabled != prev_enabled - disabled_changed = new_disabled != disabled - - if enabled_changed or disabled_changed: - _save_enabled_set(new_enabled) - _save_disabled_set(new_disabled) + changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled) + if changed: console.print( f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, " f"{len(plugin_keys) - len(new_enabled)} disabled." @@ -2557,7 +2148,7 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, elif n_plugins > 0: console.print("\n[dim]General plugins unchanged.[/dim]") - if result_holder["providers_changed"]: + if providers_changed: new_memory = _get_current_memory_provider() or "built-in" new_context = _get_current_context_engine() console.print( @@ -2565,7 +2156,7 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, f"Context engine: [bold]{new_context}[/bold]" ) - if n_plugins > 0 or result_holder["providers_changed"]: + if n_plugins > 0 or providers_changed: console.print("[dim]Changes take effect on next session.[/dim]") console.print() @@ -2599,24 +2190,7 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, return print() - # Persist by canonical key only — never the bare manifest name — so - # the disabled-list stays aligned with cmd_enable / PluginManager - # (#40190). - new_enabled: set = set() - new_disabled: set = set(disabled) - for i, key in enumerate(plugin_keys): - bare = key.split("/")[-1] - if i in chosen: - new_enabled.add(key) - new_disabled.discard(key) - if bare != key: - new_disabled.discard(bare) - else: - new_disabled.add(key) - prev_enabled = _get_enabled_set() - if new_enabled != prev_enabled or new_disabled != disabled: - _save_enabled_set(new_enabled) - _save_disabled_set(new_disabled) + _persist_plugin_selection(plugin_keys, chosen, disabled) # Provider categories if categories: @@ -2645,13 +2219,11 @@ def dashboard_install_plugin( """Non-interactive install for the web dashboard. Returns a JSON-serializable dict.""" warnings: list[str] = [] try: - git_url, _subdir = _resolve_git_url(identifier) - if git_url.startswith(("http://", "file://")): - warnings.append( - "Insecure URL scheme; prefer https:// or git@ for production installs.", - ) + insecure = _resolve_git_url(identifier)[0].startswith(("http://", "file://")) except ValueError: - pass + insecure = False + if insecure: + warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.") try: target, installed_manifest, installed_name = _install_plugin_core( @@ -2659,19 +2231,11 @@ def dashboard_install_plugin( force=force, ) except PluginScanBlocked as exc: - findings = [] - if exc.scan_result is not None: - findings = [ - { - "pattern_id": f.pattern_id, - "severity": f.severity, - "category": f.category, - "file": f.file, - "line": f.line, - "description": f.description, - } - for f in exc.scan_result.findings - ] + fields = ("pattern_id", "severity", "category", "file", "line", "description") + findings = [ + {k: getattr(f, k) for k in fields} + for f in (exc.scan_result.findings if exc.scan_result is not None else ()) + ] return { "ok": False, "error": str(exc), @@ -2684,17 +2248,10 @@ def dashboard_install_plugin( missing_env = _missing_requires_env_names(installed_manifest) if enable: - en = _get_enabled_set() - dis = _get_disabled_set() - en.add(installed_name) - dis.discard(installed_name) - _save_enabled_set(en) - _save_disabled_set(dis) + _set_plugin_enabled(installed_name, enable=True) - hint: str | None = None ap = target / "after-install.md" - if ap.exists(): - hint = str(ap) + hint = str(ap) if ap.exists() else None return { "ok": True, @@ -2709,46 +2266,47 @@ def dashboard_install_plugin( def _get_plugin_toolset_key(name: str) -> Optional[str]: """Return the toolset key a plugin registers its tools under, or None. - Queries the live tool registry — the plugin must already be loaded. - Falls back to reading ``provides_tools`` from plugin.yaml and looking - up the toolset from the registry for the first tool name found. + Queries the live tool registry — the plugin must already be loaded. Falls back to reading + ``provides_tools`` from plugin.yaml and looking up the toolset from the registry for the first + tool name found. """ try: from tools.registry import registry except Exception: return None - # Check the plugin manager for tools this plugin registered - try: + def _first_toolset(tool_names) -> Optional[str]: + for tool_name in tool_names: + entry = registry.get_entry(tool_name) + if entry and entry.toolset: + return entry.toolset + return None + + def _from_loaded_plugin() -> Optional[str]: from hermes_cli.plugins import discover_plugins, get_plugin_manager discover_plugins() # idempotent — ensures plugins are loaded - manager = get_plugin_manager() - for _key, loaded in manager._plugins.items(): + for _key, loaded in get_plugin_manager()._plugins.items(): if loaded.manifest.name == name or _key == name: - for tool_name in loaded.tools_registered: - entry = registry.get_entry(tool_name) - if entry and entry.toolset: - return entry.toolset - break - except Exception: - pass + return _first_toolset(loaded.tools_registered) + return None - # Fallback: read provides_tools from manifest on disk and query registry - try: + def _from_manifest_on_disk() -> Optional[str]: from hermes_cli.plugins import get_bundled_plugins_dir for base in (get_bundled_plugins_dir(), _plugins_dir()): - if not base.is_dir(): - continue candidate = base / name - if candidate.is_dir(): - manifest = _read_manifest(candidate) - for tool_name in manifest.get("provides_tools") or []: - entry = registry.get_entry(tool_name) - if entry and entry.toolset: - return entry.toolset - except Exception: - pass + if base.is_dir() and candidate.is_dir(): + toolset = _first_toolset(_read_manifest(candidate).get("provides_tools") or []) + if toolset: + return toolset + return None + for lookup in (_from_loaded_plugin, _from_manifest_on_disk): + try: + toolset = lookup() + except Exception: + continue + if toolset: + return toolset return None @@ -2764,10 +2322,7 @@ def _toggle_plugin_toolset(name: str, *, enable: bool) -> None: from hermes_cli.config import load_config, save_config config = load_config() - platform_toolsets = config.get("platform_toolsets") - if not isinstance(platform_toolsets, dict): - platform_toolsets = {} - config["platform_toolsets"] = platform_toolsets + platform_toolsets = _sub_dict(config, "platform_toolsets") changed = False for platform, ts_list in platform_toolsets.items(): @@ -2791,35 +2346,17 @@ def _toggle_plugin_toolset(name: str, *, enable: bool) -> None: def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]: - """Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists). - - For plugins that provide tools (toolsets), also toggles the toolset in - ``platform_toolsets`` so the agent actually sees the tools in sessions. - """ - if not _plugin_exists(name): + """Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists).""" + if _resolve_plugin_key(name) is None: return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."} en = _get_enabled_set() dis = _get_disabled_set() - - if enabled: - if name in en and name not in dis: - return {"ok": True, "name": name, "unchanged": True} - en.add(name) - dis.discard(name) - _save_enabled_set(en) - _save_disabled_set(dis) - _toggle_plugin_toolset(name, enable=True) - return {"ok": True, "name": name, "unchanged": False} - - if name not in en and name in dis: + already = (name in en and name not in dis) if enabled else (name not in en and name in dis) + if already: return {"ok": True, "name": name, "unchanged": True} - - en.discard(name) - dis.add(name) - _save_enabled_set(en) - _save_disabled_set(dis) - _toggle_plugin_toolset(name, enable=False) + _set_plugin_enabled(name, enable=enabled) + _toggle_plugin_toolset(name, enable=enabled) return {"ok": True, "name": name, "unchanged": False} @@ -2843,45 +2380,19 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]: } try: - metadata = _read_install_metadata() - except PluginOperationError as exc: - return {"ok": False, "error": str(exc)} - install_record = metadata.get(target.name, {}) - if install_record.get("pinned") is True: - recorded_source = install_record.get("source", "<source>") - return { - "ok": False, - "error": ( - f"Plugin '{name}' is pinned to {install_record.get('revision')}; " - f"run `hermes plugins install {recorded_source} --force " + msg = _pull_plugin_update( + target, + lambda rec: ( + f"Plugin '{name}' is pinned to {rec.get('revision')}; " + f"run `hermes plugins install {rec.get('source', '<source>')} --force " "--ref <40-character commit SHA>` to move it." ), - } + lambda: f"Plugin '{name}' is not a git checkout; cannot pull updates.", + ) + except PluginOperationError as exc: + return {"ok": False, "error": str(exc)} - if not (target / ".git").exists(): - return { - "ok": False, - "error": f"Plugin '{name}' is not a git checkout; cannot pull updates.", - } - - ok, msg = _git_pull_plugin_dir(target) - if not ok: - return {"ok": False, "error": msg} - - if install_record: - git_exe = _resolve_git_executable() - if git_exe: - install_record["revision"] = _git_head_revision(target, git_exe) - metadata[target.name] = install_record - _write_install_metadata(metadata) - - # Sibling of the CLI ``hermes plugins update`` path: drop bytecode - # compiled from the pre-pull plugin revision. - _clear_plugin_bytecode(target) - - from rich.console import Console - - _copy_example_files(target, Console()) + _post_pull_housekeeping(target, _console()) unchanged = "Already up to date" in msg return {"ok": True, "name": name, "output": msg, "unchanged": unchanged} @@ -2889,22 +2400,20 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]: def _clear_plugin_bytecode(target: Path) -> int: """Remove ``__pycache__`` dirs under a just-updated plugin checkout. - Plugin dirs live outside the main repo, so the launch-time checkout - fingerprint sweep in ``hermes_cli.main`` never covers them. After a - ``git pull`` changes a plugin's ``.py`` files, stale bytecode here can - produce the same ImportError class as #6207/#60242 in whichever - process imports the plugin next. Never raises. + Plugin dirs live outside the main repo, so the launch-time checkout fingerprint sweep in + ``hermes_cli.main`` never covers them. After a ``git pull`` changes a plugin's ``.py`` files, + stale bytecode here can produce the same ImportError class as #6207/#60242 in whichever process + imports the plugin next. Never raises. """ removed = 0 try: for cache_dir in target.rglob("__pycache__"): - if not cache_dir.is_dir(): - continue - try: - shutil.rmtree(cache_dir) - removed += 1 - except OSError: - pass + if cache_dir.is_dir(): + try: + shutil.rmtree(cache_dir) + removed += 1 + except OSError: + pass except OSError: pass return removed @@ -2931,22 +2440,23 @@ def _stash_ref(git_exe: str, target: Path) -> str: return probe.stdout.strip() if probe.returncode == 0 else "" +def _reapply_stash(git_exe: str, target: Path) -> bool: + """``stash apply`` the autostash; drop it on a clean apply. False when it applied with + errors or left unmerged paths (the stash entry is kept in that case).""" + restore = _run_plugin_git(git_exe, target, "stash", "apply", "stash@{0}") + unmerged = _run_plugin_git(git_exe, target, "diff", "--name-only", "--diff-filter=U") + if restore.returncode != 0 or unmerged.stdout.strip(): + return False + _run_plugin_git(git_exe, target, "stash", "drop", "stash@{0}") + return True + + def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]: """``git pull --ff-only`` a plugin checkout, autostashing local edits. - Users tweak installed plugins in place (config constants, small patches), - and a plain ``pull --ff-only`` then aborts with "Your local changes ... - would be overwritten by merge" — making the plugin permanently - un-updatable until they hand-run git. Same UX class Factory Droid fixed - in v0.188 ("Updating a plugin marketplace now succeeds when its checkout - has local changes"), and the same autostash approach ``hermes update`` - already uses for the main checkout (PR #70161). - - Flow: clean tree → plain pull (unchanged). Dirty tree → stash push - (ref-compared, so "nothing saved" is distinguished from "saved but exit - 1"), pull, stash apply. A clean re-apply drops the entry; a conflicted - re-apply resets the tree to the updated revision and KEEPS the stash so - the plugin still imports and no local work is lost. + Users tweak installed plugins in place (config constants, small patches), and a plain ``pull + --ff-only`` then aborts with "Your local changes ... would be overwritten by merge" — making the + plugin permanently un-updatable until they hand-run git. """ git_exe = _resolve_git_executable() if not git_exe: @@ -2983,33 +2493,23 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]: result = _run_plugin_git(git_exe, target, "pull", "--ff-only") if result.returncode != 0: - err = _safe_git_error(result) + err = _safe_git_error(result) or "git pull failed." if stash_created: # Put the user's edits back before reporting the failure. - restore = _run_plugin_git(git_exe, target, "stash", "apply", "stash@{0}") - if restore.returncode == 0: - _run_plugin_git(git_exe, target, "stash", "drop", "stash@{0}") + if _reapply_stash(git_exe, target): note = "Local changes were restored." else: note = ( "Local changes are preserved in git stash " "(restore with: git stash pop)." ) - return False, (err or "git pull failed.") + f"\n{note}" - return False, err or "git pull failed." + return False, f"{err}\n{note}" + return False, err pulled = result.stdout.strip() if not stash_created: return True, pulled - - restore = _run_plugin_git(git_exe, target, "stash", "apply", "stash@{0}") - unmerged = _run_plugin_git( - git_exe, target, "diff", "--name-only", "--diff-filter=U" - ) - has_conflicts = bool(unmerged.stdout.strip()) - - if restore.returncode == 0 and not has_conflicts: - _run_plugin_git(git_exe, target, "stash", "drop", "stash@{0}") + if _reapply_stash(git_exe, target): return True, pulled + "\nLocal changes were re-applied on top of the update." # Conflicted re-apply: leave the plugin importable on the updated @@ -3050,12 +2550,10 @@ def dashboard_remove_user_plugin(name: str) -> dict[str, Any]: def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None: """Validate one plugin through runtime discovery and registration.""" - from rich.console import Console - from hermes_cli.plugin_dev import doctor_plugin report = doctor_plugin(target) - Console().print(report.format_text()) + _console().print(report.format_text()) if ci and not report.ok: raise SystemExit(1) @@ -3068,15 +2566,13 @@ def cmd_search( refresh: bool = False, ) -> None: """Search the community plugin index (fuzzy on name/description/tags).""" - from rich.console import Console - from hermes_cli.plugin_index import ( SECURITY_FOOTER, load_index, search_index, ) - console = Console() + console = _console() entries, source = load_index(refresh=refresh) results = search_index(entries, term, capability=capability) @@ -3118,61 +2614,60 @@ def cmd_search( console.print(f"[dim]{SECURITY_FOOTER}[/dim]") +def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]: + """Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given).""" + if getattr(args, yes_attr, False): + return True + if getattr(args, no_attr, False): + return False + return None + + +def _action_pack(args): + from hermes_cli.plugin_packs import pack_command + + pack_command(args) + + +# Tri-state flags: neither --x nor --no-x given == None == interactive prompt. +_PLUGIN_ACTIONS = { + "install": lambda args: cmd_install( + args.identifier, + force=getattr(args, "force", False), + enable=_tri_state_flag(args, "enable", "no_enable"), + ref=getattr(args, "ref", None), + ), + "search": lambda args: cmd_search( + getattr(args, "term", "") or "", + json_output=getattr(args, "json", False), + capability=getattr(args, "capability", None), + refresh=getattr(args, "refresh", False), + ), + "update": lambda args: cmd_update(args.name), + "remove": lambda args: cmd_remove(args.name), + "rm": lambda args: cmd_remove(args.name), + "uninstall": lambda args: cmd_remove(args.name), + "enable": lambda args: cmd_enable( + args.name, + allow_tool_override=_tri_state_flag(args, "allow_tool_override", "no_allow_tool_override"), + ), + "disable": lambda args: cmd_disable(args.name), + "capabilities": lambda args: cmd_capabilities(getattr(args, "name", None)), + "list": lambda args: cmd_list(args), + "ls": lambda args: cmd_list(args), + "doctor": lambda args: cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)), + "pack": _action_pack, + "show": lambda args: cmd_show(args.name), + "info": lambda args: cmd_show(args.name), + None: lambda args: cmd_toggle(), +} + + def plugins_command(args) -> None: """Dispatch hermes plugins subcommands.""" action = getattr(args, "plugins_action", None) - - if action == "install": - # Map argparse tri-state: --enable=True, --no-enable=False, neither=None (prompt) - enable_arg = None - if getattr(args, "enable", False): - enable_arg = True - elif getattr(args, "no_enable", False): - enable_arg = False - cmd_install( - args.identifier, - force=getattr(args, "force", False), - enable=enable_arg, - ref=getattr(args, "ref", None), - ) - elif action == "search": - cmd_search( - getattr(args, "term", "") or "", - json_output=getattr(args, "json", False), - capability=getattr(args, "capability", None), - refresh=getattr(args, "refresh", False), - ) - elif action == "update": - cmd_update(args.name) - elif action in {"remove", "rm", "uninstall"}: - cmd_remove(args.name) - elif action == "enable": - # Tri-state: --allow-tool-override=True, --no-allow-tool-override=False, - # neither=None (interactive prompt for non-bundled plugins). - allow_override = None - if getattr(args, "allow_tool_override", False): - allow_override = True - elif getattr(args, "no_allow_tool_override", False): - allow_override = False - cmd_enable(args.name, allow_tool_override=allow_override) - elif action == "disable": - cmd_disable(args.name) - elif action == "capabilities": - cmd_capabilities(getattr(args, "name", None)) - elif action in {"list", "ls"}: - cmd_list(args) - elif action == "doctor": - cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)) - elif action == "pack": - from hermes_cli.plugin_packs import pack_command - - pack_command(args) - elif action in {"show", "info"}: - cmd_show(args.name) - elif action is None: - cmd_toggle() - else: - from rich.console import Console - - Console().print(f"[red]Unknown plugins action: {action}[/red]") + handler = _PLUGIN_ACTIONS.get(action) + if handler is None: + _console().print(f"[red]Unknown plugins action: {action}[/red]") sys.exit(1) + handler(args) diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py index c84015d7c7..951f5468d5 100644 --- a/hermes_cli/process_identity.py +++ b/hermes_cli/process_identity.py @@ -1,35 +1,12 @@ -"""Process identity: spawn tags, the machine-wide spawn ledger, and the -Windows job-object self-attach. +"""Process identity: spawn tags, the machine-wide spawn ledger, and the Windows job-object self-attach. -Three layers that make every long-lived Hermes process positively -identifiable, so reapers (``hermes update``, Desktop startup sweeps) never -have to guess lineage from PPID archaeology or cmdline pattern-matching: +Three layers that make every long-lived Hermes process positively identifiable, so reapers (``hermes +update``, Desktop startup sweeps) never have to guess lineage from PPID archaeology or cmdline +pattern-matching: -1. **Spawn tag** (``HERMES_SPAWN`` env var): every spawner stamps its children - with ``v1:<install_id>:<purpose>:<spawner_pid>:<spawner_create>``. A scanner - that can read the child's environment classifies it instantly: which - install, what it is, who spawned it, and when. - -2. **Spawn ledger** (``spawn-ledger.json`` at the machine Hermes root): every - long-lived process (serve/dashboard backend, gateway) self-registers - ``pid + create_time + purpose + spawner`` at startup. ``pid`` alone is - forgeable by reuse; the ``(pid, create_time)`` pair is not. Reapers - cross-check live processes against the ledger for positive identification - even when environment reads are denied (Windows frequently denies - ``Process.environ()`` cross-session). - -3. **Job object self-attach** (Windows): a backend places itself in a job with - ``KILL_ON_JOB_CLOSE`` so its whole child tree dies atomically with it — - no launcher→worker two-hop chains left holding ``.pyd`` locks after the - visible root is killed. ``BREAKAWAY_OK`` is set so the existing - ``CREATE_BREAKAWAY_FROM_JOB`` spawns (gateway relaunch during update, - watchers that must outlive their spawner) keep working unchanged. - -All of it is best-effort and fail-safe: identity failures degrade to the -legacy heuristics, they never block startup or updates. The ledger tolerates -corruption the same way ``backend-ownership.json`` does post-#89298: -an unreadable ledger is quarantined aside (``.corrupt``), never rewritten -blind. +3. **Job object self-attach** (Windows): a backend places itself in a job with ``KILL_ON_JOB_CLOSE`` +so its whole child tree dies atomically with it — no launcher→worker two-hop chains left holding +``.pyd`` locks after the visible root is killed. """ from __future__ import annotations @@ -68,11 +45,7 @@ _LEDGER_LOCK = threading.Lock() # --------------------------------------------------------------------------- def install_id(project_root: Optional[Path] = None) -> str: - """Stable 12-hex identifier for THIS install (derived from its path). - - Lets a reaper reject processes from a different Hermes install on the - same machine without path comparisons at scan time. - """ + """Stable 12-hex identifier for THIS install (derived from its path).""" if project_root is None: try: from hermes_constants import PROJECT_ROOT as _root @@ -87,15 +60,20 @@ def install_id(project_root: Optional[Path] = None) -> str: return hashlib.sha256(canonical.encode("utf-8", "replace")).hexdigest()[:12] -def _own_create_time() -> Optional[float]: +def _process_create_time(pid: int) -> Optional[float]: + """``psutil`` create time for ``pid``; ``None`` when psutil can't say.""" try: import psutil - return float(psutil.Process(os.getpid()).create_time()) + return float(psutil.Process(pid).create_time()) except Exception: return None +def _own_create_time() -> Optional[float]: + return _process_create_time(os.getpid()) + + # --------------------------------------------------------------------------- # Layer 1 — spawn tags # --------------------------------------------------------------------------- @@ -112,9 +90,7 @@ def build_spawn_tag(purpose: str, *, project_root: Optional[Path] = None) -> str """Value for the child's ``HERMES_SPAWN`` env var, stamped by the spawner.""" create = _own_create_time() create_part = f"{create:.3f}" if create is not None else "-" - return ":".join( - (_TAG_VERSION, install_id(project_root), purpose, str(os.getpid()), create_part) - ) + return ":".join((_TAG_VERSION, install_id(project_root), purpose, str(os.getpid()), create_part)) def spawn_env(purpose: str, *, project_root: Optional[Path] = None) -> dict[str, str]: @@ -134,16 +110,11 @@ def parse_spawn_tag(raw: object) -> Optional[SpawnTag]: return None try: pid = int(pid_s) + create = None if create_s == "-" else float(create_s) except ValueError: return None if pid <= 0: return None - create: Optional[float] = None - if create_s != "-": - try: - create = float(create_s) - except ValueError: - return None return SpawnTag(install=install, purpose=purpose, spawner_pid=pid, spawner_create=create) @@ -185,8 +156,8 @@ def _ledger_path() -> Path: def _read_ledger(path: Path) -> Optional[list[dict]]: """Entries list, ``[]`` for empty/missing, ``None`` for CORRUPT. - Mirrors the #89298 contract: corrupt is a distinct state that must never - be silently treated as an empty roster. + Mirrors the #89298 contract: corrupt is a distinct state that must never be silently treated as + an empty roster. """ try: text = path.read_text(encoding="utf-8") @@ -205,13 +176,22 @@ def _read_ledger(path: Path) -> Optional[list[dict]]: return [e for e in parsed if isinstance(e, dict)] -def _quarantine_ledger(path: Path) -> None: - parked = path.with_suffix(path.suffix + ".corrupt") - try: - os.replace(path, parked) - logger.warning("spawn ledger was unreadable; moved to %s", parked) - except OSError: - pass +def _read_ledger_or_quarantine(path: Path) -> Optional[list[dict]]: + """Ledger entries; ``None`` after parking a corrupt file. Caller holds ``_LEDGER_LOCK``.""" + entries = _read_ledger(path) + if entries is None: + parked = path.with_suffix(path.suffix + ".corrupt") + try: + os.replace(path, parked) + logger.warning("spawn ledger was unreadable; moved to %s", parked) + except OSError: + pass + return entries + + +def _same_incarnation(proc, create_time: Optional[float]) -> bool: + """Does the live ``proc`` match a recorded ``create_time`` (2 s tolerance; ``None`` matches)?""" + return create_time is None or abs(float(proc.create_time()) - float(create_time)) < 2.0 def _pid_alive_matches(pid: int, create_time: Optional[float]) -> Optional[bool]: @@ -221,10 +201,7 @@ def _pid_alive_matches(pid: int, create_time: Optional[float]) -> Optional[bool] except Exception: return None try: - proc = psutil.Process(int(pid)) - if create_time is None: - return True - return abs(float(proc.create_time()) - float(create_time)) < 2.0 + return _same_incarnation(psutil.Process(int(pid)), create_time) except psutil.NoSuchProcess: return False except Exception: @@ -239,15 +216,10 @@ def register_self( ) -> bool: """Record this process in the machine spawn ledger. Best-effort. - Called at the top of every long-lived entry point (serve/dashboard - backend, gateway run loop). Dead entries — ``(pid, create_time)`` no - longer live — are pruned on every write so the ledger tracks reality - instead of growing forever. - - ``detail`` optionally carries structured launch identity (#63206) — - ``host``/``port``/``profile`` — so the update pipeline can relaunch a - manually-started serve with its real bind address instead of guessing - from argv. + Called at the top of every long-lived entry point. Dead entries — ``(pid, create_time)`` no + longer live — are pruned on every write so the ledger tracks reality instead of growing + forever. ``detail`` may carry ``host``/``port``/``profile`` so the update pipeline can + relaunch a manually-started serve with its real bind address instead of guessing from argv. """ tag = parse_spawn_tag(os.environ.get(SPAWN_ENV_VAR)) spawner_pid: Optional[int] = tag.spawner_pid if tag else None @@ -269,16 +241,7 @@ def register_self( spawner_create = float(marker.split(":", 1)[1]) / 1000.0 except (ValueError, IndexError): spawner_create = None - entry = LedgerEntry( - pid=os.getpid(), - create_time=_own_create_time(), - purpose=purpose, - install=install_id(project_root), - spawner_pid=spawner_pid, - spawner_create=spawner_create, - registered_at=time.time(), - argv="", - ) + entry = _new_entry(os.getpid(), _own_create_time(), purpose, project_root, spawner_pid, spawner_create) if detail: try: entry.host = str(detail.get("host") or "") @@ -301,20 +264,36 @@ def register_self( return _append_entry(entry) +def _new_entry( + pid: int, + create_time: Optional[float], + purpose: str, + project_root: Optional[Path], + spawner_pid: Optional[int], + spawner_create: Optional[float], +) -> LedgerEntry: + return LedgerEntry( + pid=pid, + create_time=create_time, + purpose=purpose, + install=install_id(project_root), + spawner_pid=spawner_pid, + spawner_create=spawner_create, + registered_at=time.time(), + argv="", + ) + + def _append_entry(entry: LedgerEntry) -> bool: """Prune dead entries and append ``entry`` — the ONLY ledger write path. - Serialized under ``_LEDGER_LOCK`` with an atomic tmp+replace, exactly as - ``register_self`` has always written (kept single so #91660's lock- - serialization guarantees hold: no writer ever touches the file outside - this function). + Serialized under ``_LEDGER_LOCK`` with an atomic tmp+replace, exactly as ``register_self`` has + always written (kept single so #91660's lock- serialization guarantees hold: no writer ever + touches the file outside this function). """ path = _ledger_path() with _LEDGER_LOCK: - entries = _read_ledger(path) - if entries is None: - _quarantine_ledger(path) - entries = [] + entries = _read_ledger_or_quarantine(path) or [] pruned: list[dict] = [] for e in entries: pid = e.get("pid") @@ -344,20 +323,13 @@ def register_child( ) -> bool: """Record a CHILD process this process just spawned. Best-effort. - Mirror of :func:`register_self` for children that cannot register - themselves (stdio MCP helper subprocesses, #61514: arbitrary - ``npx``/binary servers never import Hermes code). The entry records the - child's ``(pid, create_time)`` with THIS process as the spawner, so - reapers get the same positive-identity contract: + Mirror of :func:`register_self` for children that cannot register themselves (stdio MCP helper + subprocesses, #61514: arbitrary ``npx``/binary servers never import Hermes code). The entry + records the child's ``(pid, create_time)`` with THIS process as the spawner, so reapers get the + same positive-identity contract: - - a live helper whose spawner is still alive is never reaped - (``spawner_is_dead`` → ``False``); - - a helper whose spawner ``(pid, create_time)`` is provably gone is a - reapable orphan. - - Never raises; returns ``False`` when the child already exited (no - provable ``create_time`` means no forge-proof identity — don't record a - pid-only entry a reuse could impersonate) or the write failed. + - a live helper whose spawner is still alive is never reaped (``spawner_is_dead`` → ``False``); + - a helper whose spawner ``(pid, create_time)`` is provably gone is a reapable orphan. """ try: pid = int(pid) @@ -365,22 +337,10 @@ def register_child( return False if pid <= 0: return False - try: - import psutil - - child_create: Optional[float] = float(psutil.Process(pid).create_time()) - except Exception: + child_create = _process_create_time(pid) + if child_create is None: return False - entry = LedgerEntry( - pid=pid, - create_time=child_create, - purpose=purpose, - install=install_id(project_root), - spawner_pid=os.getpid(), - spawner_create=_own_create_time(), - registered_at=time.time(), - argv="", - ) + entry = _new_entry(pid, child_create, purpose, project_root, os.getpid(), _own_create_time()) try: import psutil @@ -393,45 +353,31 @@ def register_child( def ledger_entries(*, project_root: Optional[Path] = None) -> list[dict]: """Live-verified ledger entries for THIS install. - Entries whose ``(pid, create_time)`` no longer matches a live process are - excluded (PID reuse reads as dead, thanks to the create-time pair). A - corrupt ledger is quarantined and read as empty — identical philosophy to - the backend-ownership fix (#89298): never let corruption erase or fake - a roster; never let it block the caller either. + A corrupt ledger is quarantined and read as empty — identical philosophy to the backend- + ownership fix (#89298): never let corruption erase or fake a roster; never let it block the + caller either. """ want_install = install_id(project_root) - path = _ledger_path() with _LEDGER_LOCK: - entries = _read_ledger(path) - if entries is None: - _quarantine_ledger(path) - return [] - out: list[dict] = [] - for e in entries: - if e.get("install") != want_install: - continue - pid = e.get("pid") - if not isinstance(pid, int): - continue - if _pid_alive_matches(pid, e.get("create_time")) is False: - continue - out.append(e) - return out + entries = _read_ledger_or_quarantine(_ledger_path()) + if entries is None: + return [] + return [ + e + for e in entries + if e.get("install") == want_install + and isinstance(e.get("pid"), int) + and _pid_alive_matches(e["pid"], e.get("create_time")) is not False + ] def spawner_is_dead(entry: dict) -> Optional[bool]: - """Is the recorded spawner of this entry provably gone? - - ``True`` → owner gone (orphaned by identity, not by PPID guessing). - ``False`` → owner still alive. ``None`` → no spawner recorded / unprovable. - """ + """Is the recorded spawner of this entry provably gone?""" spawner_pid = entry.get("spawner_pid") if not isinstance(spawner_pid, int) or spawner_pid <= 0: return None alive = _pid_alive_matches(spawner_pid, entry.get("spawner_create")) - if alive is None: - return None - return not alive + return None if alive is None else not alive def reap_orphaned_mcp_helpers( @@ -441,18 +387,13 @@ def reap_orphaned_mcp_helpers( ) -> list[int]: """Kill ledger-registered stdio MCP helpers whose spawner is provably dead. - Startup-sweep rung mirroring ``_reap_orphaned_desktop_local_serves`` - (dashboard_procs.py), but ledger-driven instead of cmdline-heuristic: - a helper is reaped ONLY when + Startup-sweep rung mirroring ``_reap_orphaned_desktop_local_serves`` (dashboard_procs.py), but + ledger-driven instead of cmdline-heuristic: a helper is reaped ONLY when - - it has a live ``(pid, create_time)`` ledger entry for THIS install with - purpose ``mcp-helper`` (``ledger_entries`` already excludes dead/ - foreign entries), and - - its recorded spawner is **provably dead** (``spawner_is_dead`` is - ``True`` — never ``None``/unprovable, never a live spawner). - - Best-effort, never raises; returns the PIDs it terminated. ``kill_fn`` - is injectable for tests (defaults to psutil terminate→wait→kill). + - it has a live ``(pid, create_time)`` ledger entry for THIS install with purpose ``mcp-helper`` + (``ledger_entries`` already excludes dead/ foreign entries), and - its recorded spawner is + **provably dead** (``spawner_is_dead`` is ``True`` — never ``None``/unprovable, never a live + spawner). """ reaped: list[int] = [] try: @@ -476,10 +417,7 @@ def reap_orphaned_mcp_helpers( proc = psutil.Process(pid) # Re-verify identity at the moment of kill (PID-reuse guard). - create = entry.get("create_time") - if create is not None and abs( - float(proc.create_time()) - float(create) - ) >= 2.0: + if not _same_incarnation(proc, entry.get("create_time")): continue proc.terminate() try: @@ -501,11 +439,9 @@ def reap_orphaned_mcp_helpers( def attach_self_to_kill_on_close_job() -> bool: """Place this process in a job that dies (whole tree) when we die. - Windows-only, best-effort, idempotent. ``BREAKAWAY_OK`` is included so - children spawned with ``CREATE_BREAKAWAY_FROM_JOB`` (gateway relaunch - during update, detached watchers) keep escaping exactly as they do today. - Nested jobs are supported since Windows 8, so being inside another job - (Terminal, CI runners) does not prevent the attach on any supported OS. + Windows-only, best-effort, idempotent. ``BREAKAWAY_OK`` is included so children spawned with + ``CREATE_BREAKAWAY_FROM_JOB`` (gateway relaunch during update, detached watchers) keep escaping + exactly as they do today. """ global _JOB_HANDLE if not _IS_WINDOWS or _JOB_HANDLE is not None: @@ -543,10 +479,8 @@ def attach_self_to_kill_on_close_job() -> bool: _fields_ = [ ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), ("IoInfo", IO_COUNTERS), - ("ProcessMemoryLimit", ctypes.c_size_t), - ("JobMemoryLimit", ctypes.c_size_t), - ("PeakProcessMemoryUsed", ctypes.c_size_t), - ("PeakJobMemoryUsed", ctypes.c_size_t), + *((n, ctypes.c_size_t) for n in ( + "ProcessMemoryLimit", "JobMemoryLimit", "PeakProcessMemoryUsed", "PeakJobMemoryUsed")), ] job = kernel32.CreateJobObjectW(None, None) @@ -561,10 +495,7 @@ def attach_self_to_kill_on_close_job() -> bool: ok = kernel32.SetInformationJobObject( job, JobObjectExtendedLimitInformation, ctypes.byref(info), ctypes.sizeof(info) ) - if not ok: - kernel32.CloseHandle(job) - return False - if not kernel32.AssignProcessToJobObject(job, kernel32.GetCurrentProcess()): + if not ok or not kernel32.AssignProcessToJobObject(job, kernel32.GetCurrentProcess()): kernel32.CloseHandle(job) return False _JOB_HANDLE = job # keep alive for the life of the process — never close diff --git a/hermes_cli/profile_describer.py b/hermes_cli/profile_describer.py index c6af27ae22..c6094c8f8f 100644 --- a/hermes_cli/profile_describer.py +++ b/hermes_cli/profile_describer.py @@ -1,27 +1,8 @@ """Profile describer — auto-generate ``description`` for a profile. -Used by ``hermes profile describe <name> --auto`` and the dashboard's -"auto-generate description" button. Reads the profile's installed -skills, model+provider, name, and optionally a small slice of memory, -then asks the auxiliary LLM to produce a 1-2 sentence description of -what the profile is good at. - -Result is written to ``<profile_dir>/profile.yaml`` with -``description_auto: true`` so the dashboard can surface a "review" -badge. User can edit afterward to confirm. - -Design notes ------------- -- Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux - client import inside the function, lenient response parse, never - raises on expected failure modes. -- Reads at most ``MAX_SKILLS_FOR_PROMPT`` skill names to keep the - prompt bounded. No skill body — names + categories are enough - signal and avoid blowing context on profiles with 100+ skills. -- Memory is intentionally NOT read here. Memories are personal and - the orchestrator routes work to a *role* not a *biography*. If we - find later that memory adds signal we can wire it; for now, - skills + name + model is plenty. +Design notes ------------ - Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux client +import inside the function, lenient response parse, never raises on expected failure modes. - Reads +at most ``MAX_SKILLS_FOR_PROMPT`` skill names to keep the prompt bounded. """ from __future__ import annotations @@ -98,11 +79,10 @@ class DescribeOutcome: def _collect_skills(profile_dir: Path) -> list[str]: - """Return a stable, capped list of skill names for the prompt. + """Return every (non-excluded) skill name in a profile, sorted. - Format: ``category/skill_name`` where category is the immediate - subdir under ``skills/`` (e.g. ``devops``, ``research``). Skills - that live directly under ``skills/`` show as bare ``skill_name``. + Format ``category/skill_name`` (category = immediate subdir under ``skills/``); skills + directly under ``skills/`` show as bare ``skill_name``. """ skills_dir = profile_dir / "skills" if not skills_dir.is_dir(): @@ -118,21 +98,22 @@ def _collect_skills(profile_dir: Path) -> list[str]: parts = rel.parts[:-1] # drop SKILL.md filename if not parts: continue - # parts[-1] is the skill dir name; parts[:-1] is the category path - if len(parts) == 1: - names.append(parts[0]) - else: - names.append(f"{parts[0]}/{parts[-1]}") + # parts[-1] is the skill dir name; parts[0] is the top-level category + names.append(parts[0] if len(parts) == 1 else f"{parts[0]}/{parts[-1]}") names.sort() - # Keep within prompt budget. Skills earlier in alphabet aren't more - # important — we'll let the LLM see a sample. Pick evenly-spaced - # entries instead of just the head so a profile with skills A..Z - # doesn't get described as "starts with A". + return names + + +def _sample_skills(names: list[str]) -> list[str]: + """Cap *names* to the prompt budget with evenly-spaced picks. + + Skills earlier in the alphabet aren't more important, so sample across the whole list rather + than taking the head — a profile with skills A..Z must not be described as "starts with A". + """ if len(names) <= MAX_SKILLS_FOR_PROMPT: return names step = len(names) / MAX_SKILLS_FOR_PROMPT - sampled = [names[int(i * step)] for i in range(MAX_SKILLS_FOR_PROMPT)] - return sampled + return [names[int(i * step)] for i in range(MAX_SKILLS_FOR_PROMPT)] def _extract_json_blob(raw: str) -> Optional[dict]: @@ -143,14 +124,11 @@ def _extract_json_blob(raw: str) -> Optional[dict]: last = stripped.rfind("}") if first == -1 or last == -1 or last <= first: return None - candidate = stripped[first : last + 1] try: - val = json.loads(candidate) - except (ValueError, json.JSONDecodeError): + val = json.loads(stripped[first : last + 1]) + except ValueError: return None - if not isinstance(val, dict): - return None - return val + return val if isinstance(val, dict) else None def describe_profile( @@ -161,15 +139,13 @@ def describe_profile( ) -> DescribeOutcome: """Auto-generate a description for one profile. - Returns an outcome describing what happened. Never raises for - expected failure modes (profile missing, no aux client configured, - API error, malformed response) — those surface via ``ok=False`` so - a sweep can continue past individual failures. + Returns an outcome describing what happened. Never raises for expected failure modes (profile + missing, no aux client configured, API error, malformed response) — those surface via + ``ok=False`` so a sweep can continue past individual failures. - ``overwrite`` controls whether an existing user-authored description - is replaced. By default we refuse to overwrite a description with - ``description_auto: false`` to protect curated text. Auto-generated - descriptions (``description_auto: true``) are always replaceable. + ``overwrite`` controls whether an existing user-authored description is replaced. By default we + refuse to overwrite a description with ``description_auto: false`` to protect curated text. + Auto-generated descriptions (``description_auto: true``) are always replaceable. """ canon = profiles_mod.normalize_profile_name(profile_name) if not profiles_mod.profile_exists(canon): @@ -196,12 +172,10 @@ def describe_profile( "(use --overwrite to replace)", ) - skill_names = _collect_skills(profile_dir) + all_skills = _collect_skills(profile_dir) + skill_count = len(all_skills) + skill_names = _sample_skills(all_skills) skill_list = "\n".join(f" - {n}" for n in skill_names) or " (no skills installed)" - skill_count = sum( - 1 for _ in (profile_dir / "skills").rglob("SKILL.md") - if not is_excluded_skill_path(_) - ) if (profile_dir / "skills").is_dir() else 0 # Read model + provider from the profile's config. try: @@ -277,12 +251,10 @@ def describe_profile( def list_describable_profiles(*, missing_only: bool = True) -> list[str]: """Return profile names that can be described. - ``missing_only=True`` (default) returns only profiles without a - description. ``missing_only=False`` returns every profile. + ``missing_only=True`` (default) returns only profiles without a description. + ``missing_only=False`` returns every profile. """ - out: list[str] = [] - for p in profiles_mod.list_profiles(): - if missing_only and (p.description or "").strip() and not p.description_auto: - continue - out.append(p.name) - return out + return [ + p.name for p in profiles_mod.list_profiles() + if not (missing_only and (p.description or "").strip() and not p.description_auto) + ] diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index 3fee24a9f1..c4ad3589fb 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -1,66 +1,13 @@ """Profile distributions — shareable, packaged Hermes profiles via git. -A distribution is a Hermes profile published as a git repository (or -installed from a local directory for development). Install with one command -from a git URL, update in place, and keep your local memories / sessions / -credentials untouched. - -Where this fits relative to the existing pieces: - -* ``hermes profile export/import`` — local backup / restore for a profile - on your own machine. NOT a distribution format. Stays as-is. -* ``hermes skills install <url>`` — the URL install pattern we're mirroring, - but at the profile granularity. - -Subcommands (all live under ``hermes profile``, not a parallel tree): - - hermes profile install <source> [--name N] [--alias] [--force] [--yes] - hermes profile update <name> [--force-config] [--yes] - hermes profile info <name> - -``<source>`` is one of: - -* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``, - ``ssh://``, ``git://``), optionally with ``#<ref>`` to pin a tag / branch / - commit SHA. -* A local directory that already contains ``distribution.yaml`` — used - during profile development before the first push. - -Manifest format (``distribution.yaml`` at the profile root):: - - name: telemetry - version: 0.1.0 - description: "Compliance monitoring harness" - hermes_requires: ">=0.12.0" - author: "..." - license: "..." - env_requires: - - name: OPENAI_API_KEY - description: "OpenAI API key" - required: true - - name: GRAPHITI_MCP_URL - description: "Memory graph URL" - required: false - default: "http://127.0.0.1:8000/sse" - distribution_owned: # optional; sensible defaults apply - - SOUL.md - - skills/ - - cron/ - - mcp.json - -Update semantics: - -* Distribution-owned paths (SOUL.md, mcp.json, skills/, cron/, - distribution.yaml) are replaced from the new source. -* ``config.yaml`` is distribution-owned but preserved on update unless - ``--force-config`` is passed (user overrides typically live here). -* User-owned paths (memories/, sessions/, state.db, auth.json, .env, - logs/, workspace/, home/, plans/, *_cache/, and anything under - ``local/``) are never touched. +* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``, ``ssh://``, +``git://``), optionally with ``#<ref>`` to pin a tag / branch / commit SHA. * A local directory that +already contains ``distribution.yaml`` — used during profile development before the first push. """ from __future__ import annotations +import operator import re import shutil import subprocess @@ -70,6 +17,8 @@ from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any, Dict, List, Optional, Tuple +import yaml + from agent.skill_utils import is_excluded_skill_path from hermes_cli._subprocess_compat import noninteractive_git_env @@ -214,42 +163,21 @@ class DistributionManifest: ) def to_dict(self) -> Dict[str, Any]: - out: Dict[str, Any] = { - "name": self.name, - "version": self.version, - } - if self.description: - out["description"] = self.description - if self.hermes_requires: - out["hermes_requires"] = self.hermes_requires - if self.author: - out["author"] = self.author - if self.license: - out["license"] = self.license - if self.env_requires: - out["env_requires"] = [e.to_dict() for e in self.env_requires] - if self.distribution_owned: - out["distribution_owned"] = self.distribution_owned - if self.source: - out["source"] = self.source - if self.installed_at: - out["installed_at"] = self.installed_at + out: Dict[str, Any] = {"name": self.name, "version": self.version} + # Key order is the on-disk YAML order (write_manifest uses sort_keys=False). + optional = ( + ("description", self.description), + ("hermes_requires", self.hermes_requires), + ("author", self.author), + ("license", self.license), + ("env_requires", [e.to_dict() for e in self.env_requires]), + ("distribution_owned", self.distribution_owned), + ("source", self.source), + ("installed_at", self.installed_at), + ) + out.update((k, v) for k, v in optional if v) return out - def owned_paths(self) -> List[str]: - """Resolve which paths count as distribution-owned.""" - if self.distribution_owned: - return list(self.distribution_owned) - return list(DEFAULT_DIST_OWNED) - - -def _load_yaml(text: str) -> Any: - try: - import yaml - except ImportError as exc: # pragma: no cover — pyyaml is a hard dep - raise DistributionError("PyYAML is required for distribution manifests") from exc - return yaml.safe_load(text) - def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]: """Return the manifest for *profile_dir*, or None if it isn't a distribution.""" @@ -257,7 +185,7 @@ def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]: if not mf_path.is_file(): return None try: - data = _load_yaml(mf_path.read_text(encoding="utf-8")) + data = yaml.safe_load(mf_path.read_text(encoding="utf-8")) except Exception as exc: raise DistributionError(f"Failed to parse {mf_path}: {exc}") from exc return DistributionManifest.from_dict(data or {}) @@ -296,16 +224,14 @@ def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path: _VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$") +_VERSION_OPS = {">=": operator.ge, "<=": operator.le, "==": operator.eq, "!=": operator.ne, ">": operator.gt, "<": operator.lt} def _parse_semver(v: str) -> Tuple[int, int, int]: """Very small semver parser — major.minor.patch only. Extra labels stripped.""" - s = str(v).strip().lstrip("v") # Strip any pre-release / build metadata (e.g. "0.12.0-rc1+abc") - s = re.split(r"[-+]", s, 1)[0] - parts = s.split(".") - while len(parts) < 3: - parts.append("0") + parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".") + parts += ["0"] * (3 - len(parts)) try: return (int(parts[0]), int(parts[1]), int(parts[2])) except ValueError as exc: @@ -313,30 +239,13 @@ def _parse_semver(v: str) -> Tuple[int, int, int]: def check_hermes_requires(spec: str, current_version: str) -> None: - """Raise DistributionError if ``current_version`` does not satisfy ``spec``. - - ``spec`` accepts a single comparator (``>=0.12.0``, ``==0.12.0``, etc.). - Empty or blank spec is a no-op — no requirement. - """ + """Raise DistributionError if ``current_version`` does not satisfy ``spec``.""" if not spec or not spec.strip(): return m = _VERSION_OP_RE.match(spec) - if not m: - # Bare version → treat as ``>=`` - op, target = ">=", spec.strip() - else: - op, target = m.group(1), m.group(2) - cur = _parse_semver(current_version) - tgt = _parse_semver(target) - ok = { - ">=": cur >= tgt, - "<=": cur <= tgt, - "==": cur == tgt, - "!=": cur != tgt, - ">": cur > tgt, - "<": cur < tgt, - }[op] - if not ok: + # Bare version → treat as ``>=`` + op, target = m.groups() if m else (">=", spec.strip()) + if not _VERSION_OPS[op](_parse_semver(current_version), _parse_semver(target)): raise DistributionError( f"This distribution requires Hermes {op}{target}, " f"but you have {current_version}." @@ -358,12 +267,11 @@ def _env_template_from_manifest(manifest: DistributionManifest) -> str: for req in manifest.env_requires: if req.description: lines.append(f"# {req.description}") - status = "required" if req.required else "optional" - lines.append(f"# ({status})") default_val = req.default if req.default is not None else "" - prefix = "" if req.required else "# " - lines.append(f"{prefix}{req.name}={default_val}") - lines.append("") + if req.required: + lines += ["# (required)", f"{req.name}={default_val}", ""] + else: + lines += ["# (optional)", f"# {req.name}={default_val}", ""] return "\n".join(lines).rstrip() + "\n" @@ -372,25 +280,24 @@ def _env_template_from_manifest(manifest: DistributionManifest) -> str: # --------------------------------------------------------------------------- +# Bare github.com/user/repo shorthand +_GITHUB_SHORTHAND_RE = re.compile(r"^github\.com/[\w.-]+/[\w.-]+/?$") + + def _looks_like_git_url(s: str) -> bool: + # Any http(s) URL is treated as a git repo. We no longer accept + # tar.gz URLs — git is the only remote transport. s = s.strip() - if s.endswith(".git"): - return True - if s.startswith(("git@", "ssh://", "git://")): - return True - if s.startswith(("http://", "https://")): - # Any http(s) URL is treated as a git repo. We no longer accept - # tar.gz URLs — git is the only remote transport. - return True - # Bare github.com/user/repo shorthand - if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", s): - return True - return False + return ( + s.endswith(".git") + or s.startswith(("git@", "ssh://", "git://", "http://", "https://")) + or bool(_GITHUB_SHORTHAND_RE.match(s)) + ) def _git_clone(url: str, dest: Path) -> None: # Normalize github.com/user/repo shorthand - if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", url): + if _GITHUB_SHORTHAND_RE.match(url): url = f"https://{url.rstrip('/')}" try: subprocess.run( @@ -410,44 +317,37 @@ def _git_clone(url: str, dest: Path) -> None: def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]: """Resolve *source* to a local directory containing distribution.yaml. - Returns ``(staged_dir, provenance)`` where ``provenance`` is stored in the - installed manifest's ``source:`` field so ``hermes profile update`` can - re-pull from the same place. - - Accepts: - * A git URL (https / ssh / git@ / bare github.com shorthand) — cloned - into a temp directory; ``.git`` removed after clone. - * A local directory already containing ``distribution.yaml``. + Accepts: * A git URL (https / ssh / git@ / bare github.com shorthand) — cloned into a temp + directory; ``.git`` removed after clone. * A local directory already containing + ``distribution.yaml``. """ src_str = source.strip() # Git URL if _looks_like_git_url(src_str): - cloned = workdir / "clone" - _git_clone(src_str, cloned) + staged, provenance = workdir / "clone", src_str + _git_clone(src_str, staged) # Remove .git to keep the staged tree clean - shutil.rmtree(cloned / ".git", ignore_errors=True) - if not (cloned / MANIFEST_FILENAME).is_file(): - raise DistributionError( - f"No {MANIFEST_FILENAME} at the root of {src_str!r}. " - "This repository is not a Hermes profile distribution." - ) - return cloned, src_str - - # Local directory - path_guess = Path(src_str).expanduser() - if path_guess.is_dir(): - if not (path_guess / MANIFEST_FILENAME).is_file(): - raise DistributionError( - f"No {MANIFEST_FILENAME} in {path_guess}. " - "A local-directory source must contain a distribution.yaml at its root." - ) - return path_guess.resolve(), str(path_guess.resolve()) - - raise DistributionError( + shutil.rmtree(staged / ".git", ignore_errors=True) + missing = ( + f"No {MANIFEST_FILENAME} at the root of {src_str!r}. " + "This repository is not a Hermes profile distribution." + ) + elif (path_guess := Path(src_str).expanduser()).is_dir(): + staged = path_guess.resolve() + provenance = str(staged) + missing = ( + f"No {MANIFEST_FILENAME} in {path_guess}. " + "A local-directory source must contain a distribution.yaml at its root." + ) + else: + raise DistributionError( f"Cannot resolve distribution source: {source!r}. " "Expected a git URL (e.g. github.com/user/repo) or a local directory." ) + if not (staged / MANIFEST_FILENAME).is_file(): + raise DistributionError(missing) + return staged, provenance def _reject_distribution_symlinks(staged: Path) -> None: @@ -484,24 +384,16 @@ class InstallPlan: def _has_cron_jobs(staged: Path) -> bool: cron_dir = staged / "cron" - if not cron_dir.is_dir(): - return False - for _ in cron_dir.rglob("*.json"): - return True - for _ in cron_dir.rglob("*.yaml"): - return True - return False - - -def _count_skills(staged: Path) -> int: - skills_dir = staged / "skills" - if not skills_dir.is_dir(): - return 0 - return sum( - 1 for p in skills_dir.rglob("SKILL.md") if not is_excluded_skill_path(p) + return cron_dir.is_dir() and ( + any(cron_dir.rglob("*.json")) or any(cron_dir.rglob("*.yaml")) ) +def _has_skills(staged: Path) -> bool: + skills_dir = staged / "skills" + return skills_dir.is_dir() and any(not is_excluded_skill_path(p) for p in skills_dir.rglob("SKILL.md")) + + def plan_install( source: str, workdir: Path, @@ -545,9 +437,6 @@ def plan_install( target_dir = get_profile_dir(canon) existing = target_dir.is_dir() - has_cron = _has_cron_jobs(staged) - skill_count = _count_skills(staged) - return InstallPlan( manifest=manifest, staged_dir=staged, @@ -555,11 +444,36 @@ def plan_install( target_dir=target_dir, existing=existing, preserves_config=existing, - has_cron=has_cron, - has_skills=skill_count > 0, + has_cron=_has_cron_jobs(staged), + has_skills=_has_skills(staged), ) +def _owned_entries(staged: Path, manifest: DistributionManifest): + """Yield ``(src, rel_parts)`` for every staged path the distribution owns.""" + explicit_owned = [p.strip().strip("/") for p in manifest.distribution_owned] + explicit_owned = [p for p in explicit_owned if p] + if not explicit_owned: + # Legacy behaviour: no explicit allowlist means the whole staged + # payload (minus USER_OWNED_EXCLUDE) is distribution-owned. Do NOT + # narrow to DEFAULT_DIST_OWNED here — existing distributions ship + # arbitrary extra top-level paths without declaring them. + for entry in staged.iterdir(): + if entry.name not in USER_OWNED_EXCLUDE: + yield entry, (entry.name,) + return + # Path-aware allowlist: copy exactly the declared paths. + for rel in explicit_owned: + rel_parts = PurePosixPath(rel).parts + if not rel_parts or rel_parts[0] in USER_OWNED_EXCLUDE: + continue + if ".." in rel_parts or PurePosixPath(rel).is_absolute(): + continue + src = staged.joinpath(*rel_parts) + if src.exists(): + yield src, rel_parts + + def _copy_dist_payload( staged: Path, target: Path, @@ -568,79 +482,39 @@ def _copy_dist_payload( ) -> None: """Copy distribution-owned files from *staged* into *target*. - User-owned paths are never touched. ``config.yaml`` is replaced only when - ``preserve_config`` is False (fresh install or ``--force-config`` update). - ``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid - shadowing a real ``.env``. + User-owned paths are never touched. ``config.yaml`` is replaced only when ``preserve_config`` is + False (fresh install or ``--force-config`` update). ``.env.template`` is renamed to + ``.env.EXAMPLE`` in the target to avoid shadowing a real ``.env``. - When the manifest declares an explicit ``distribution_owned`` list, only - those paths are copied (path-aware: nested entries such as - ``skills/research`` or ``cron/digest.json`` are honoured). When the list - is omitted the legacy behaviour is preserved: every staged entry outside + When the manifest declares an explicit ``distribution_owned`` list, only those paths are copied + (path-aware: nested entries such as ``skills/research`` or ``cron/digest.json`` are honoured). + When the list is omitted the legacy behaviour is preserved: every staged entry outside ``USER_OWNED_EXCLUDE`` is copied. """ target.mkdir(parents=True, exist_ok=True) + staged_resolved = staged.resolve() - def _copy_entry(entry: Path, dest: Path) -> None: - if entry.is_dir(): - if dest.exists(): - shutil.rmtree(dest) - staged_resolved = staged.resolve() - shutil.copytree( - entry, - dest, - ignore=lambda d, names: ( - [n for n in names if n in USER_OWNED_EXCLUDE] - if Path(d).resolve() == staged_resolved - else [] - ), - ) - else: - shutil.copy2(entry, dest) + def _ignore_user_owned(d, names): + # Only the staged root's direct children are filtered. + return [n for n in names if n in USER_OWNED_EXCLUDE] if Path(d).resolve() == staged_resolved else [] - explicit_owned = [p.strip().strip("/") for p in manifest.distribution_owned] - explicit_owned = [p for p in explicit_owned if p] - - if explicit_owned: - # Path-aware allowlist: copy exactly the declared paths. - for rel in explicit_owned: - rel_parts = PurePosixPath(rel).parts - if not rel_parts or rel_parts[0] in USER_OWNED_EXCLUDE: - continue - if ".." in rel_parts or PurePosixPath(rel).is_absolute(): - continue - src = staged.joinpath(*rel_parts) - if not src.exists(): - continue - if len(rel_parts) == 1: - name = rel_parts[0] - if name == ENV_TEMPLATE_FILENAME: - shutil.copy2(src, target / ENV_EXAMPLE_FILENAME) - continue - if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): - # Leave user's config.yaml alone on update - continue - dest = target.joinpath(*rel_parts) - dest.parent.mkdir(parents=True, exist_ok=True) - _copy_entry(src, dest) - else: - # Legacy behaviour: no explicit allowlist means the whole staged - # payload (minus USER_OWNED_EXCLUDE) is distribution-owned. Do NOT - # narrow to DEFAULT_DIST_OWNED here — existing distributions ship - # arbitrary extra top-level paths without declaring them. - for entry in staged.iterdir(): - name = entry.name - - if name in USER_OWNED_EXCLUDE: - continue + for src, rel_parts in _owned_entries(staged, manifest): + if len(rel_parts) == 1: + name = rel_parts[0] if name == ENV_TEMPLATE_FILENAME: - shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME) + shutil.copy2(src, target / ENV_EXAMPLE_FILENAME) continue if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): # Leave user's config.yaml alone on update continue - - _copy_entry(entry, target / name) + dest = target.joinpath(*rel_parts) + dest.parent.mkdir(parents=True, exist_ok=True) + if src.is_dir(): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest, ignore=_ignore_user_owned) + else: + shutil.copy2(src, dest) # Emit .env.EXAMPLE from manifest if the staged tree didn't ship one if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists(): @@ -667,8 +541,8 @@ def install_distribution( ) -> InstallPlan: """Install a distribution from *source* into a new profile. - Returns the resolved :class:`InstallPlan`. Use :func:`plan_install` - first if you want to preview + prompt the user before calling this. + Returns the resolved :class:`InstallPlan`. Use :func:`plan_install` first if you want to preview + + prompt the user before calling this. """ from hermes_cli.profiles import ( check_alias_collision, @@ -694,25 +568,14 @@ def install_distribution( preserve_config=False, ) - if create_alias: - collision = check_alias_collision(plan.manifest.name) - if collision is None: - create_wrapper_script(plan.manifest.name) + if create_alias and check_alias_collision(plan.manifest.name) is None: + create_wrapper_script(plan.manifest.name) return plan -def update_distribution( - profile_name: str, - force_config: bool = False, -) -> InstallPlan: - """Re-pull the distribution for an existing profile and apply updates. - - The source is read from the installed profile's ``distribution.yaml`` - ``source:`` field. Distribution-owned files are overwritten; user-owned - data (memories, sessions, auth) is never touched. ``config.yaml`` is - preserved unless ``force_config`` is True. - """ +def _existing_profile(profile_name: str) -> Tuple[str, Path]: + """Return ``(canonical_name, profile_dir)`` or raise if the profile doesn't exist.""" from hermes_cli.profiles import ( get_profile_dir, normalize_profile_name, @@ -724,7 +587,20 @@ def update_distribution( target = get_profile_dir(canon) if not target.is_dir(): raise DistributionError(f"Profile '{canon}' does not exist.") + return canon, target + +def update_distribution( + profile_name: str, + force_config: bool = False, +) -> InstallPlan: + """Re-pull the distribution for an existing profile and apply updates. + + The source is read from the installed profile's ``distribution.yaml`` ``source:`` field. + Distribution-owned files are overwritten; user-owned data (memories, sessions, auth) is never + touched. ``config.yaml`` is preserved unless ``force_config`` is True. + """ + canon, target = _existing_profile(profile_name) existing_manifest = read_manifest(target) if existing_manifest is None: raise DistributionError( @@ -760,23 +636,6 @@ def update_distribution( def describe_distribution(profile_name: str) -> Dict[str, Any]: - """Return a structured view of a profile's distribution metadata. - - Returns an empty dict if the profile exists but has no manifest. - Raises DistributionError if the profile itself doesn't exist. - """ - from hermes_cli.profiles import ( - get_profile_dir, - normalize_profile_name, - validate_profile_name, - ) - - canon = normalize_profile_name(profile_name) - validate_profile_name(canon) - target = get_profile_dir(canon) - if not target.is_dir(): - raise DistributionError(f"Profile '{canon}' does not exist.") - manifest = read_manifest(target) - if manifest is None: - return {} - return manifest.to_dict() + """Return a structured view of a profile's distribution metadata.""" + manifest = read_manifest(_existing_profile(profile_name)[1]) + return {} if manifest is None else manifest.to_dict() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 39ad55555c..99ef669d45 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1,23 +1,4 @@ -""" -Profile management for multiple isolated Hermes instances. - -Each profile is a fully independent HERMES_HOME directory with its own -config.yaml, .env, memory, sessions, skills, gateway, cron, and logs. -Profiles live under ``~/.hermes/profiles/<name>/`` by default. - -The "default" profile is ``~/.hermes`` itself — backward compatible, -zero migration needed. - -Usage:: - - hermes profile create coder # fresh profile + bundled skills - hermes profile create coder --clone # also copy config, .env, SOUL.md, skills - hermes profile create coder --clone-all # full copy of source profile - coder chat # use via wrapper alias - hermes -p coder chat # or via flag - hermes profile use coder # set as sticky default - hermes profile delete coder # remove profile + alias + service -""" +"""Profile management for multiple isolated Hermes instances.""" import json import logging @@ -104,10 +85,8 @@ _CLONE_ALL_STRIP: list[str] = [ # bin — installed binaries (tirith etc., ~10 MB) shared per-host # node_modules — npm packages (hundreds of MB) # -# See ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` below for the broader export-side -# exclusion list (export also drops logs / caches because the archive is a -# portable snapshot; clone-all keeps those because the cloned profile is -# meant to keep working immediately). +# Export uses a root allow-list instead (``_DEFAULT_EXPORT_INCLUDE_ROOT``): the +# archive is a portable snapshot, while a clone must keep working immediately. _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ "hermes-agent", ".worktrees", @@ -148,97 +127,52 @@ _CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({ # Delete the marker file to opt back in. NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills" - -def has_bundled_skills_opt_out(profile_dir: Path) -> bool: - """Return True if the profile opted out of bundled-skill seeding.""" - try: - return (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists() - except OSError: - return False +# Header seeded into a profile's empty .env so it owns a credentials file from day one. +_PLACEHOLDER_ENV = ( + "# Per-profile secrets for this Hermes profile.\n" + "# API keys and tokens set here override the shell environment.\n" + "# Behavioral settings belong in config.yaml, not here.\n" +) def _clone_all_copytree_ignore(source_dir: Path): """Exclude infrastructure artifacts when cloning a profile via --clone-all. - Three categories: - 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session - history, backups, and snapshots that belong to the SOURCE profile - and should never carry into a fresh clone. Applies to any source. - 2. Root-level entries in ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` — known - Hermes infrastructure directories that only the default profile - (``~/.hermes``) ever contains. Gated on ``source_dir`` actually - being the default profile so a named-profile source never has its - own data silently dropped. - 3. Universal exclusions at any depth — Python bytecode caches that - are stale or regenerable (``__pycache__``, ``*.pyc``, ``*.pyo``) - and runtime sockets / temp files (``*.sock``, ``*.tmp``). + Three categories: 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session + history, backups, and snapshots that belong to the SOURCE profile and should never carry into a + fresh clone. Applies to any source. 2. - The export-side ignore (``_default_export_ignore``) uses the same - two-tier pattern with the broader ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` set - because the export archive is a portable snapshot rather than a live - clone. + The export-side ignore (``_default_export_ignore``) uses a root-level allow-list instead + because the export archive is a portable snapshot rather than a live clone. """ source_resolved = source_dir.resolve() is_default_source = source_resolved == _get_default_hermes_home().resolve() + # History artifacts are excluded for ANY source; infrastructure only + # when the source is the default profile (named profiles never have it). + root_exclude = set(_CLONE_ALL_HISTORY_EXCLUDE_ROOT) + if is_default_source: + root_exclude |= _CLONE_ALL_DEFAULT_EXCLUDE_ROOT + def _ignore(directory: str, names: List[str]) -> List[str]: - ignored: list[str] = [] - for entry in names: + try: + at_root = Path(directory).resolve() == source_resolved + except (OSError, ValueError): + # ``resolve()`` can fail on unusual FS layouts (broken + # symlinks, missing parents). Fail open — better to + # over-copy than silently drop user data. + at_root = False + return [ + entry for entry in names # Universal exclusions at any depth. - if ( - entry == "__pycache__" - or entry.endswith((".pyc", ".pyo", ".sock", ".tmp")) - ): - ignored.append(entry) - continue - try: - at_root = Path(directory).resolve() == source_resolved - except (OSError, ValueError): - # ``resolve()`` can fail on unusual FS layouts (broken - # symlinks, missing parents). Fail open — better to - # over-copy than silently drop user data. - at_root = False - if at_root: - # History artifacts: excluded for ANY source profile. - if entry in _CLONE_ALL_HISTORY_EXCLUDE_ROOT: - ignored.append(entry) - continue - # Infrastructure: only the default profile contains these. - if is_default_source and entry in _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: - ignored.append(entry) - return ignored + if entry == "__pycache__" + or entry.endswith((".pyc", ".pyo", ".sock", ".tmp")) + or (at_root and entry in root_exclude) + ] return _ignore -# Directories/files to exclude when exporting the default (~/.hermes) profile. -# The default profile contains infrastructure (repo checkout, worktrees, DBs, -# caches, binaries) that named profiles don't have. We exclude those so the -# export is a portable, reasonable-size archive of actual profile data. -_DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({ - # Infrastructure - "hermes-agent", # repo checkout (multi-GB) - ".worktrees", # git worktrees - "profiles", # other profiles — never recursive-export - "bin", # installed binaries (tirith, etc.) - "node_modules", # npm packages - # Databases & runtime state - "state.db", "state.db-shm", "state.db-wal", - "hermes_state.db", - "response_store.db", "response_store.db-shm", "response_store.db-wal", - "gateway.pid", "gateway_state.json", "processes.json", - "auth.json", # API keys, OAuth tokens, credential pools - ".env", # API keys (dotenv) - "auth.lock", "active_profile", ".update_check", - "errors.log", - ".hermes_history", - # Caches (regenerated on use) - "image_cache", "audio_cache", "document_cache", - "browser_screenshots", "checkpoints", - "sandboxes", - "logs", # gateway logs -}) - # Allow-list for ``export_profile("default")``: when HERMES_HOME equals the # cwd (Docker/custom deployments), the default profile home is the working # directory and contains arbitrary user files that should NOT be bundled @@ -282,13 +216,8 @@ _HERMES_SUBCOMMANDS = frozenset({ def _get_profiles_root() -> Path: """Return the directory where named profiles are stored. - Anchored to the hermes root, NOT to the current HERMES_HOME - (which may itself be a profile). This ensures ``coder profile list`` - can see all profiles. - - In Docker/custom deployments where HERMES_HOME points outside - ``~/.hermes``, profiles live under ``HERMES_HOME/profiles/`` so - they persist on the mounted volume. + Anchored to the hermes root, NOT to the current HERMES_HOME (which may itself be a profile). + This ensures ``coder profile list`` can see all profiles. """ return _get_default_hermes_home() / "profiles" @@ -296,9 +225,8 @@ def _get_profiles_root() -> Path: def _get_default_hermes_home() -> Path: """Return the default (pre-profile) HERMES_HOME path. - In standard deployments this is ``~/.hermes``. - In Docker/custom deployments where HERMES_HOME is outside ``~/.hermes`` - (e.g. ``/opt/data``), returns HERMES_HOME directly. + Normally ``~/.hermes``; in Docker/custom deployments where HERMES_HOME lives elsewhere + (e.g. ``/opt/data``) returns HERMES_HOME itself. """ from hermes_constants import get_default_hermes_root return get_default_hermes_root() @@ -314,6 +242,19 @@ def _get_wrapper_dir() -> Path: return Path.home() / ".local" / "bin" +def _wrapper_path(alias: str) -> Path: + """Wrapper script path for *alias*: ``<alias>.bat`` on Windows, bare name elsewhere.""" + return _get_wrapper_dir() / (f"{alias}.bat" if sys.platform == "win32" else alias) + + +def _is_our_wrapper(path: Path) -> bool: + """True when *path* reads as a Hermes-generated wrapper (contains ``hermes -p``).""" + try: + return "hermes -p" in path.read_text(encoding="utf-8") + except Exception: + return False + + # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- @@ -321,10 +262,9 @@ def _get_wrapper_dir() -> Path: def normalize_profile_name(name: str) -> str: """Return the canonical profile id used on disk and in CLI ``-p`` argv. - Named profiles are stored lowercase under ``profiles/<id>/``. The special - alias ``default`` is matched case-insensitively (``Default`` → ``default``). - Dashboards and tools may pass title-cased display labels; normalize before - validation, assignment, and subprocess spawn (see issue #18498). + Named profiles are stored lowercase under ``profiles/<id>/``; ``default`` matches + case-insensitively. Dashboards/tools may pass title-cased labels, so normalize before + validation, assignment, and subprocess spawn. """ if not isinstance(name, str): name = str(name) @@ -339,17 +279,10 @@ def normalize_profile_name(name: str) -> str: def validate_profile_name(name: str) -> None: """Raise ``ValueError`` if *name* is not a valid profile identifier. - Validates the input as-given — strict lowercase match. Callers that accept - mixed-case or title-cased input from users (dashboard UI, CLI args) should - call :func:`normalize_profile_name` first. This separation keeps validate - honest about what the on-disk directory name must look like, while - ingress-point normalization handles UX flexibility (see #18498). - - Also rejects names in :data:`_RESERVED_NAMES` (``hermes``, ``test``, - ``tmp``, ``root``, ``sudo``) that would create confusing on-disk - collisions (a ``hermes`` profile inside ``~/.hermes/``) or get refused - at alias-creation time anyway. ``default`` is a special pass-through — - it's a valid alias for the built-in root profile. + Strict lowercase match as-given -- callers taking mixed-case user input must call + ``normalize_profile_name`` first, so this stays honest about the on-disk directory name. + Also rejects ``_RESERVED_NAMES`` (``hermes``, ``test``, ``tmp``, ``root``, ``sudo``) that + would collide on disk or be refused at alias creation; ``default`` passes through. """ if name == "default": return # special alias for ~/.hermes @@ -369,11 +302,10 @@ def validate_profile_name(name: str) -> None: def validate_alias_name(name: str) -> None: """Raise ``ValueError`` if *name* is not a safe wrapper-alias identifier. - The alias is used verbatim as a filename under :func:`_get_wrapper_dir` - (``~/.local/bin``), so it must be a single safe command name with no path - separators or traversal segments — otherwise a value like ``../../.bashrc`` - would escape the wrapper directory and clobber arbitrary user files. We - reuse the profile id regex, which already forbids ``/``, ``.``, and ``..``. + The alias is used verbatim as a filename under :func:`_get_wrapper_dir` (``~/.local/bin``), so + it must be a single safe command name with no path separators or traversal segments — otherwise + a value like ``../../.bashrc`` would escape the wrapper directory and clobber arbitrary user + files. """ if not _PROFILE_ID_RE.match(name): raise ValueError( @@ -402,27 +334,16 @@ def profile_exists(name: str) -> bool: def profile_matches_home(name: str, home: "Path | None" = None) -> bool: """Return True when *name* refers to the profile served from *home*. - ``home`` defaults to the process's current Hermes home - (:func:`hermes_constants.get_hermes_home`). Used by single-profile - gateways to decide whether a ``/p/<profile>/`` URL prefix is - self-referential (safe to serve on the bare route) or names a *different* - profile — in which case the request must fail closed rather than silently - resolve config/toolsets from the gateway owner (#91583 defect 2). - - Invalid profile names return False (fail closed). + Lets single-profile gateways decide whether a ``/p/<profile>/`` URL prefix is + self-referential (safe on the bare route) or names a different profile, which must fail + closed rather than silently resolve the owner's config. Invalid names return False. """ try: target = get_profile_dir(name) - except Exception: - return False - if home is None: - try: + if home is None: from hermes_constants import get_hermes_home home = get_hermes_home() - except Exception: - return False - try: return ( Path(target).expanduser().resolve(strict=False) == Path(home).expanduser().resolve(strict=False) @@ -431,20 +352,32 @@ def profile_matches_home(name: str, home: "Path | None" = None) -> bool: return False +def _iter_named_profile_dirs(*, live_only: bool = True) -> List[Path]: + """Sorted named-profile dirs under the profiles root (valid ids, never ``default``). + + ``live_only`` additionally skips tombstoned (deleted) profiles. + """ + profiles_root = _get_profiles_root() + if not profiles_root.is_dir(): + return [] + return [ + entry for entry in sorted(profiles_root.iterdir()) + if entry.is_dir() + and entry.name != "default" + and _PROFILE_ID_RE.match(entry.name) + and not (live_only and named_profile_is_deleted(entry)) + ] + + def list_profile_names() -> List[str]: """Cheap name-only profile listing: ``default`` plus profile dirs. - Unlike :func:`list_profiles` this reads NO per-profile config/metadata — - it is a directory scan, safe to call from hot paths (cron delivery-target - listings, create-time validation). + Unlike :func:`list_profiles` this reads NO per-profile config/metadata — it is a directory scan, + safe to call from hot paths (cron delivery-target listings, create-time validation). """ names = ["default"] - profiles_root = _get_profiles_root() try: - if profiles_root.is_dir(): - for entry in sorted(profiles_root.iterdir()): - if entry.is_dir() and entry.name != "default" and _PROFILE_ID_RE.match(entry.name): - names.append(entry.name) + names.extend(entry.name for entry in _iter_named_profile_dirs(live_only=False)) except OSError: pass return names @@ -455,11 +388,7 @@ def list_profile_names() -> List[str]: # --------------------------------------------------------------------------- def check_alias_collision(name: str) -> Optional[str]: - """Return a human-readable collision message, or None if the name is safe. - - Checks: alias-name validity, reserved names, hermes subcommands, existing - binaries in PATH. - """ + """Return a human-readable collision message, or None if the name is safe.""" canon = normalize_profile_name(name) try: validate_alias_name(canon) @@ -471,24 +400,17 @@ def check_alias_collision(name: str) -> Optional[str]: return f"'{canon}' conflicts with a hermes subcommand" # Check existing commands in PATH - wrapper_dir = _get_wrapper_dir() - is_windows = sys.platform == "win32" try: result = subprocess.run( - ["where" if is_windows else "which", canon], + ["where" if sys.platform == "win32" else "which", canon], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, ) if result.returncode == 0: existing_path = result.stdout.strip().splitlines()[0] # Allow overwriting our own wrappers - expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon) - if existing_path == str(expected): - try: - content = expected.read_text(encoding="utf-8") - if "hermes -p" in content: - return None # it's our wrapper, safe to overwrite - except Exception: - pass + expected = _wrapper_path(canon) + if existing_path == str(expected) and _is_our_wrapper(expected): + return None # it's our wrapper, safe to overwrite return f"'{canon}' conflicts with an existing command ({existing_path})" except (FileNotFoundError, subprocess.TimeoutExpired): pass @@ -505,12 +427,9 @@ def _is_wrapper_dir_in_path() -> bool: def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]: """Create a shell wrapper script at ~/.local/bin/<name>. - The wrapper file is named after ``name`` (the alias). The profile it - activates is ``target`` if given, otherwise ``name`` — this lets a custom - alias name point at a differently-named profile without a post-hoc rewrite. - - On Windows, creates a ``.bat`` file instead of a POSIX shell script. - Returns the path to the created wrapper, or None if creation failed. + The wrapper file is named after ``name`` (the alias). The profile it activates is ``target`` if + given, otherwise ``name`` — this lets a custom alias name point at a differently-named profile + without a post-hoc rewrite. """ canon = normalize_profile_name(name) profile = normalize_profile_name(target) if target else canon @@ -524,30 +443,22 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P print(f"⚠ Could not create {wrapper_dir}: {e}") return None - is_windows = sys.platform == "win32" - if is_windows: - wrapper_path = wrapper_dir / f"{canon}.bat" - try: + wrapper_path = _wrapper_path(canon) + try: + if sys.platform == "win32": wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n", encoding="utf-8") - return wrapper_path - except OSError as e: - print(f"⚠ Could not create wrapper at {wrapper_path}: {e}") - return None - else: - wrapper_path = wrapper_dir / canon - try: + else: hermes_exe = shutil.which("hermes") or "hermes" wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n', encoding="utf-8") wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - return wrapper_path - except OSError as e: - print(f"⚠ Could not create wrapper at {wrapper_path}: {e}") - return None + return wrapper_path + except OSError as e: + print(f"⚠ Could not create wrapper at {wrapper_path}: {e}") + return None def remove_wrapper_script(name: str) -> bool: """Remove the wrapper script for a profile. Returns True if removed.""" - wrapper_dir = _get_wrapper_dir() canon = normalize_profile_name(name) # A traversal-shaped name could point unlink() at a file outside the # wrapper dir; refuse it rather than acting on an arbitrary path. @@ -555,21 +466,18 @@ def remove_wrapper_script(name: str) -> bool: validate_alias_name(canon) except ValueError: return False - is_windows = sys.platform == "win32" # Check both the extensionless path (POSIX) and .bat (Windows) - candidates = [wrapper_dir / canon] - if is_windows: - candidates.insert(0, wrapper_dir / f"{canon}.bat") + candidates = [_get_wrapper_dir() / canon] + if sys.platform == "win32": + candidates.insert(0, _get_wrapper_dir() / f"{canon}.bat") for wrapper_path in candidates: - if wrapper_path.exists(): + # Verify it's our wrapper before removing + if wrapper_path.exists() and _is_our_wrapper(wrapper_path): try: - # Verify it's our wrapper before removing - content = wrapper_path.read_text(encoding="utf-8") - if "hermes -p" in content: - wrapper_path.unlink() - return True + wrapper_path.unlink() + return True except Exception: pass return False @@ -578,11 +486,9 @@ def remove_wrapper_script(name: str) -> bool: def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: """Bring a copied profile config.yaml up to the current schema. - Profile creation can clone a config file that predates schema tracking (no - ``_config_version``) or that is simply older than the running Hermes. If we - leave it untouched, the first desktop/doctor view of the new profile shows a - scary ``v0 → latest`` warning even though we just created the profile. Scope - the normal migration pipeline to the new profile and keep it non-interactive. + A cloned config may predate schema tracking or be older than the running Hermes; left alone, + the first desktop/doctor view of the new profile shows a scary ``v0 -> latest`` warning. + Runs the normal migration pipeline scoped to the new profile, non-interactively. """ config_path = profile_dir / "config.yaml" if not config_path.exists(): @@ -609,20 +515,13 @@ def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: def find_alias_for_profile(profile_name: str) -> Optional[str]: """Return the alias name of the wrapper that activates *profile_name*, or None. - A wrapper created by :func:`create_wrapper_script` is a file named after the - alias whose body invokes ``hermes -p <profile>``. When the alias name equals - the profile name this is trivial, but a custom alias (``hermes profile alias - <profile> --name <custom>``) produces a differently-named file — so the - display side cannot assume ``wrapper == profile`` and must reverse-look-up. + A wrapper created by :func:`create_wrapper_script` is a file named after the alias whose body + invokes ``hermes -p <profile>``. - A custom alias (name != profile) is preferred over the profile-named wrapper - so ``profile list``/``show`` surface the command the user actually typed. - Results are sorted for deterministic output when several aliases match. - - For listing ALL profiles at once, prefer :func:`build_alias_map` — calling - this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper - dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg - etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts. + For listing ALL profiles at once, prefer :func:`build_alias_map` — calling this per-profile re- + reads every wrapper file N times (O(N*M)); on a wrapper dir like ``~/.local/bin`` that also + holds large unrelated binaries (ffmpeg etc.) that meant multi-second ``list_profiles`` latency + and desktop timeouts. """ return build_alias_map().get(normalize_profile_name(profile_name)) @@ -639,11 +538,10 @@ _WRAPPER_READ_LIMIT = 8192 def build_alias_map() -> dict[str, str]: """Single-pass reverse map ``{canonical_profile -> alias_name}``. - Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile) - and reads only a small head slice of each candidate wrapper, skipping - binaries. A custom alias (file name != profile) wins over the profile-named - wrapper, matching ``find_alias_for_profile``'s preference; deterministic via - sorted iteration. + Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile) and reads only a + small head slice of each candidate wrapper, skipping binaries. A custom alias (file name != + profile) wins over the profile-named wrapper, matching ``find_alias_for_profile``'s preference; + deterministic via sorted iteration. """ wrapper_dir = _get_wrapper_dir() result: dict[str, str] = {} @@ -725,29 +623,27 @@ class ProfileInfo: display_name: str = "" -def _read_distribution_meta(profile_dir: Path) -> tuple: - """Return ``(name, version, source)`` from the profile's ``distribution.yaml`` - if present; ``(None, None, None)`` otherwise. - - Failures (missing file, bad YAML) are swallowed — a bad manifest should - never break ``hermes profile list`` for an unrelated profile. - """ - mf_path = profile_dir / "distribution.yaml" - if not mf_path.is_file(): - return None, None, None +def _load_yaml_dict(path: Path) -> Optional[dict]: + """Return the mapping in a YAML file, or None when missing/unreadable/not a mapping.""" + if not path.is_file(): + return None try: import yaml - with open(mf_path, "r", encoding="utf-8") as f: + with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} - if not isinstance(data, dict): - return None, None, None - return ( - data.get("name"), - data.get("version"), - data.get("source"), - ) except Exception: + return None + return data if isinstance(data, dict) else None + + +def _read_distribution_meta(profile_dir: Path) -> tuple: + """Return ``(name, version, source)`` from the profile's ``distribution.yaml`` if present; ``(None, + None, None)`` otherwise. + """ + data = _load_yaml_dict(profile_dir / "distribution.yaml") + if data is None: return None, None, None + return data.get("name"), data.get("version"), data.get("source") def _read_config_model(profile_dir: Path) -> tuple: @@ -773,14 +669,8 @@ def _read_config_model(profile_dir: Path) -> tuple: def _seed_model_config(profile_dir: Path) -> None: """Give a profile created without a clone source a usable model block. - Such a profile gets its directory tree but no ``config.yaml`` at all, so it - resolves no provider and its first turn dies with "No LLM provider - configured" — created, but unable to run. Copy the active profile's - ``model`` block over at creation time. - - This is a copy, not a link: profiles remain independent islands, and - editing either one afterwards never touches the other. "Fresh" means fresh - skills and SOUL, not unreachable. + This is a copy, not a link: profiles remain independent islands, and editing either one + afterwards never touches the other. "Fresh" means fresh skills and SOUL, not unreachable. """ config_path = profile_dir / "config.yaml" if config_path.exists(): @@ -808,30 +698,23 @@ def _seed_model_config(profile_dir: Path) -> None: def _check_gateway_running(profile_dir: Path) -> bool: """Check if a gateway is running for a given profile directory. - Primary signal is the profile's ``gateway.pid`` (verified against the - runtime lock). That check fails closed whenever the lock isn't held by - *this* reader — which is exactly the case for a dashboard process that is - a separate s6 service from the gateway it's reporting on (Docker), or any - launch-service-managed gateway that left a fresh ``gateway_state.json`` but - no live PID file. In those cases fall back to validating the PID recorded - in the profile's own ``gateway_state.json`` against the live process table, - mirroring the ``/api/status`` sidebar's liveness logic so the two surfaces - agree. Parameterized by ``profile_dir`` so it never mutates ``HERMES_HOME``. + Primary signal is ``gateway.pid`` verified against the runtime lock, which fails closed when + the lock isn't held by *this* reader (dashboard as a separate s6 service, launch-service + gateways with no live PID file). Then fall back to validating the PID in the profile's + ``gateway_state.json`` against the process table, matching ``/api/status``. Never mutates + ``HERMES_HOME``. """ try: - from gateway.status import get_running_pid - if ( - get_running_pid(profile_dir / "gateway.pid", cleanup_stale=False) - is not None - ): + from gateway.status import ( + get_running_pid, + get_runtime_status_running_pid, + read_runtime_status, + ) + if get_running_pid(profile_dir / "gateway.pid", cleanup_stale=False) is not None: return True except Exception: pass try: - from gateway.status import ( - get_runtime_status_running_pid, - read_runtime_status, - ) runtime = read_runtime_status(profile_dir / "gateway_state.json") return get_runtime_status_running_pid(runtime, expected_home=profile_dir) is not None except Exception: @@ -841,10 +724,9 @@ def _check_gateway_running(profile_dir: Path) -> bool: def _served_by_running_multiplexer(profile_name: str) -> bool: """True when the live default gateway multiplexes ``profile_name``. - A served named profile has no gateway.pid of its own, so - ``_check_gateway_running`` alone reports it stopped while the default - multiplexer is actually its inbound process. Single shared lookup with the - named-profile start guard and cron liveness (#97120). + A served named profile has no gateway.pid of its own, so ``_check_gateway_running`` alone + reports it stopped while the default multiplexer is really its inbound process. Shared by the + named-profile start guard and cron liveness. """ try: from hermes_cli.gateway import named_profile_served_by_running_multiplexer @@ -869,10 +751,9 @@ _SKILL_COUNT_TTL_SECONDS = 30.0 def _skills_dir_signature(skills_dir: Path) -> float: """Cheap change-signature for a skills tree. - Max mtime of ``skills_dir`` and its immediate children (category dirs). - Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a - skill inside a category bumps that category dir's mtime. One ``scandir`` - (not a recursive walk) keeps this O(#categories), not O(#files). + Max mtime of ``skills_dir`` and its immediate children: adding/removing a category bumps the + root, adding/removing a skill bumps its category dir. One ``scandir`` keeps this + O(#categories), not O(#files). """ try: sig = skills_dir.stat().st_mtime @@ -933,30 +814,16 @@ def _count_skills(profile_dir: Path) -> int: # tolerates empty descriptions and just falls back to the profile name. -def _profile_yaml_path(profile_dir: Path) -> Path: - return profile_dir / "profile.yaml" - - def read_profile_meta(profile_dir: Path) -> dict: """Read ``<profile_dir>/profile.yaml`` and return a dict. - Returns ``{"description": "", "description_auto": False, - "display_name": ""}`` when the file is missing or unreadable. Never - raises — a corrupt profile.yaml on an unrelated profile must not + Returns ``{"description": "", "description_auto": False, "display_name": ""}`` when the file is + missing or unreadable. Never raises — a corrupt profile.yaml on an unrelated profile must not break ``hermes profile list``. """ - empty = {"description": "", "description_auto": False, "display_name": ""} - path = _profile_yaml_path(profile_dir) - if not path.is_file(): - return empty - try: - import yaml - with open(path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - except Exception: - return empty - if not isinstance(data, dict): - return empty + data = _load_yaml_dict(profile_dir / "profile.yaml") + if data is None: + return {"description": "", "description_auto": False, "display_name": ""} return { "description": str(data.get("description") or "").strip(), "description_auto": bool(data.get("description_auto", False)), @@ -973,23 +840,13 @@ def write_profile_meta( ) -> None: """Update ``<profile_dir>/profile.yaml`` in place. - Only the explicitly passed fields are overwritten; unspecified - fields preserve existing values. Creates the file if missing. - Profile directory itself must exist. + Only the explicitly passed fields are overwritten; unspecified fields preserve existing values. + Creates the file if missing. Profile directory itself must exist. """ if not profile_dir.is_dir(): raise FileNotFoundError(f"profile directory does not exist: {profile_dir}") - import yaml - path = _profile_yaml_path(profile_dir) - existing: dict = {} - if path.is_file(): - try: - with open(path, "r", encoding="utf-8") as f: - loaded = yaml.safe_load(f) or {} - if isinstance(loaded, dict): - existing = loaded - except Exception: - existing = {} + path = profile_dir / "profile.yaml" + existing: dict = _load_yaml_dict(path) or {} if description is not None: existing["description"] = description.strip() if description_auto is not None: @@ -1011,10 +868,9 @@ def write_profile_meta( def format_profile_label(name: str, display_name: Optional[str]) -> str: """Render a profile for display: ``display_name (canonical_id)``. - Falls back to the bare canonical id when no display name is set (or it - equals the id) — byte-for-byte the pre-feature rendering. Display names - are presentation-only free text (Unicode fine); they are never a - directory name, wrapper filename, or argv token. + Falls back to the bare canonical id when no display name is set (or it equals the id) — byte- + for-byte the pre-feature rendering. Display names are presentation-only free text (Unicode + fine); they are never a directory name, wrapper filename, or argv token. """ dn = (display_name or "").strip() return f"{dn} ({name})" if dn and dn != name else name @@ -1023,8 +879,8 @@ def format_profile_label(name: str, display_name: Optional[str]) -> str: def set_profile_display_name(profile_name: str, display_name: str) -> str: """Set (or clear, with ``""``) a profile's user-facing display name. - Presentation-only: the canonical profile id is untouched. Returns the - stored value. Raises ``ValueError`` for names over 64 chars. + Presentation-only: the canonical profile id is untouched. Returns the stored value. Raises + ``ValueError`` for names over 64 chars. """ canon = normalize_profile_name(profile_name) validate_profile_name(canon) @@ -1042,82 +898,52 @@ def set_profile_display_name(profile_name: str, display_name: str) -> str: # CRUD operations # --------------------------------------------------------------------------- +def _profile_info(name: str, path: Path, *, is_default: bool, alias_name: Optional[str] = None) -> ProfileInfo: + """Build one :class:`ProfileInfo` from a profile directory.""" + model, provider = _read_config_model(path) + dist_name, dist_version, dist_source = _read_distribution_meta(path) + meta = read_profile_meta(path) + alias_path = _wrapper_path(alias_name) if alias_name else None + if alias_path is not None and not alias_path.exists(): + alias_path = None + gateway_running = _check_gateway_running(path) + if not is_default: + gateway_running = gateway_running or _served_by_running_multiplexer(name) + return ProfileInfo( + name=name, + path=path, + is_default=is_default, + gateway_running=gateway_running, + model=model, + provider=provider, + has_env=(path / ".env").exists(), + skill_count=_count_skills(path), + alias_path=alias_path, + alias_name=alias_name, + distribution_name=dist_name, + distribution_version=dist_version, + distribution_source=dist_source, + description=meta.get("description", ""), + description_auto=meta.get("description_auto", False), + display_name=meta.get("display_name", ""), + ) + + def list_profiles() -> List[ProfileInfo]: """Return info for all profiles, including the default.""" profiles = [] - wrapper_dir = _get_wrapper_dir() - - # Default profile default_home = _get_default_hermes_home() if default_home.is_dir(): - model, provider = _read_config_model(default_home) - dist_name, dist_version, dist_source = _read_distribution_meta(default_home) - meta = read_profile_meta(default_home) - profiles.append(ProfileInfo( - name="default", - path=default_home, - is_default=True, - gateway_running=_check_gateway_running(default_home), - model=model, - provider=provider, - has_env=(default_home / ".env").exists(), - skill_count=_count_skills(default_home), - distribution_name=dist_name, - distribution_version=dist_version, - distribution_source=dist_source, - description=meta.get("description", ""), - description_auto=meta.get("description_auto", False), - display_name=meta.get("display_name", ""), - )) + profiles.append(_profile_info("default", default_home, is_default=True)) - # Named profiles - profiles_root = _get_profiles_root() - if profiles_root.is_dir(): - # Build the {profile -> alias} map ONCE here instead of calling - # find_alias_for_profile() per profile (which re-scanned the whole - # wrapper dir each time — O(N*M), the dominant cost in this function). + named = _iter_named_profile_dirs() + if named: + # Build the {profile -> alias} map ONCE instead of per profile + # (re-scanning the wrapper dir N times was the dominant cost here). alias_map = build_alias_map() - for entry in sorted(profiles_root.iterdir()): - if not entry.is_dir(): - continue - name = entry.name - if name == "default": - continue # already added as the built-in default above - if not _PROFILE_ID_RE.match(name): - continue - if named_profile_is_deleted(entry): - continue - model, provider = _read_config_model(entry) - alias_name = alias_map.get(normalize_profile_name(name)) - if alias_name: - is_windows = sys.platform == "win32" - alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name) - else: - alias_path = None - dist_name, dist_version, dist_source = _read_distribution_meta(entry) - meta = read_profile_meta(entry) - profiles.append(ProfileInfo( - name=name, - path=entry, - is_default=False, - gateway_running=( - _check_gateway_running(entry) - or _served_by_running_multiplexer(name) - ), - model=model, - provider=provider, - has_env=(entry / ".env").exists(), - skill_count=_count_skills(entry), - alias_path=alias_path if (alias_path and alias_path.exists()) else None, - alias_name=alias_name, - distribution_name=dist_name, - distribution_version=dist_version, - distribution_source=dist_source, - description=meta.get("description", ""), - description_auto=meta.get("description_auto", False), - display_name=meta.get("display_name", ""), - )) - + for entry in named: + alias_name = alias_map.get(normalize_profile_name(entry.name)) + profiles.append(_profile_info(entry.name, entry, is_default=False, alias_name=alias_name)) return profiles @@ -1127,24 +953,12 @@ def profiles_to_serve( ) -> List[Tuple[str, Path]]: """Return the ``(profile_name, hermes_home)`` pairs a gateway should serve. - This is the single chokepoint for "which profiles does the inbound gateway - handle" so later multiplexing phases never re-derive the set. + This is the single chokepoint for "which profiles does the inbound gateway handle" so later + multiplexing phases never re-derive the set. - - ``multiplex=False`` (default): returns exactly one entry for the *active* - profile — byte-for-byte the single-profile behavior the gateway has - always had. The name is ``"default"`` for the default profile or the - active named profile's id. - - ``multiplex=True``: returns the default profile plus every valid named - profile under ``profiles/``, each paired with its own HERMES_HOME. When - ``profile_allowlist`` is provided, only selected named profiles are - included; the default profile is always served. - - Intentionally lightweight (a directory scan + name validation only): no - per-profile config reads, gateway-running probes, or skill counts like - :func:`list_profiles`. It runs on gateway startup and must stay cheap. - - The returned ``hermes_home`` is the path to pass to - ``set_hermes_home_override`` when scoping a turn to that profile. + - ``multiplex=False`` (default): returns exactly one entry for the *active* profile — byte-for- + byte the single-profile behavior the gateway has always had. The name is ``"default"`` for the + default profile or the active named profile's id. """ active = get_active_profile_name() or "default" if not multiplex: @@ -1165,21 +979,9 @@ def profiles_to_serve( if name != "default": allowed.add(name) - profiles_root = _get_profiles_root() - if profiles_root.is_dir(): - for entry in sorted(profiles_root.iterdir()): - if not entry.is_dir(): - continue - name = entry.name - if name == "default": - continue # default is the built-in entry already added above - if not _PROFILE_ID_RE.match(name): - continue - if named_profile_is_deleted(entry): - continue - if allowed is not None and name not in allowed: - continue - serve.append((name, entry)) + for entry in _iter_named_profile_dirs(): + if allowed is None or entry.name in allowed: + serve.append((entry.name, entry)) if allowed is not None: missing = tuple(sorted(allowed - {name for name, _ in serve})) @@ -1193,6 +995,53 @@ def profiles_to_serve( return serve +def _resolve_clone_source(clone_from: Optional[str]) -> Path: + """Directory to clone from: the named profile, or the active profile when ``None``.""" + if clone_from is None: + from hermes_constants import get_hermes_home + source_dir = get_hermes_home() + else: + clone_from = normalize_profile_name(clone_from) + validate_profile_name(clone_from) + source_dir = get_profile_dir(clone_from) + if not source_dir.is_dir(): + raise FileNotFoundError( + f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}" + ) + return source_dir + + +def _seed_file_if_missing(path: Path, text: str, mode: Optional[int] = None) -> None: + """Best-effort: write *text* to *path* unless it already exists; never raises.""" + if path.exists(): + return + try: + path.write_text(text, encoding="utf-8") + if mode is not None: + os.chmod(str(path), mode) + except OSError: + pass + + +def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None: + """Copy one profile-relative file if it exists; ``.env`` is tightened to owner-only. + + ``shutil.copy2`` preserves source mode bits, so a loose source ``.env`` (host umask 0o022 + leaving 0o644) would otherwise hand the clone weak perms. + """ + src = source_dir / relpath + if not src.exists(): + return + dst = profile_dir / relpath + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + if relpath == ".env": + try: + os.chmod(str(dst), 0o600) + except OSError: + pass + + def create_profile( name: str, clone_from: Optional[str] = None, @@ -1202,32 +1051,12 @@ def create_profile( no_skills: bool = False, description: Optional[str] = None, ) -> Path: - """Create a new profile directory. + """Create a new profile directory and return its path. - Parameters - ---------- - name: - Profile identifier (lowercase, alphanumeric, hyphens, underscores). - clone_from: - Source profile to clone from. If ``None`` and clone_config/clone_all - is True, defaults to the currently active profile. - clone_all: - If True, do a full copytree of the source (all state). - clone_config: - If True, copy config files (config.yaml, .env, SOUL.md), installed - skills, and selected profile identity files from the source profile. - no_alias: - If True, skip wrapper script creation. - no_skills: - If True, create an empty profile with no bundled skills, and write - a marker file so ``hermes update`` skips re-seeding this profile's - skills. Mutually exclusive with ``clone_config``/``clone_all`` (those - explicitly copy skills from the source). - - Returns - ------- - Path - The newly created profile directory. + ``clone_from`` defaults to the active profile when cloning. ``clone_all`` copies all state; + ``clone_config`` copies config.yaml/.env/SOUL.md, installed skills, and identity files. + ``no_skills`` creates an empty profile and writes a marker so ``hermes update`` skips + re-seeding its skills; it is mutually exclusive with the clone options, which copy skills. """ if no_skills and (clone_from is not None or clone_config or clone_all): raise ValueError( @@ -1253,21 +1082,9 @@ def create_profile( raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}") clear_named_profile_deleted(profile_dir) - # Resolve clone source source_dir = None if clone_from is not None or clone_all or clone_config: - if clone_from is None: - # Default: clone from active profile - from hermes_constants import get_hermes_home - source_dir = get_hermes_home() - else: - clone_from = normalize_profile_name(clone_from) - validate_profile_name(clone_from) - source_dir = get_profile_dir(clone_from) - if not source_dir.is_dir(): - raise FileNotFoundError( - f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}" - ) + source_dir = _resolve_clone_source(clone_from) if clone_all and source_dir: # Full copy of source profile (exclude sibling ~/.hermes/profiles/) @@ -1301,80 +1118,45 @@ def create_profile( if source_dir is None: _seed_model_config(profile_dir) - # Clone config files from source + # Clone config files, then installed skills (the dashboard's "clone from + # default" flow must preserve bundled AND user-installed skills), then + # memory/identity files from the source profile. if source_dir is not None: - for filename in _CLONE_CONFIG_FILES: - src = source_dir / filename - if src.exists(): - dst = profile_dir / filename - shutil.copy2(src, dst) - # Tighten .env to owner-only after copy. shutil.copy2 - # preserves source mode bits, but if the source's .env - # was loose (host umask 0o022 leaving 0o644), tighten - # explicitly so the clone doesn't inherit weak perms. - if filename == ".env": - try: - os.chmod(str(dst), 0o600) - except OSError: - pass - - # Clone installed skills from the source profile. The dashboard's - # "clone from default" flow is expected to preserve both bundled - # and user-installed skills so the new profile immediately has the - # same agent capabilities as the source profile. + for relpath in _CLONE_CONFIG_FILES: + _clone_file(source_dir, profile_dir, relpath) source_skills = source_dir / "skills" if source_skills.is_dir(): shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True) - - # Clone memory and other subdirectory files for relpath in _CLONE_SUBDIR_FILES: - src = source_dir / relpath - if src.exists(): - dst = profile_dir / relpath - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dst) + _clone_file(source_dir, profile_dir, relpath) # Seed an empty .env so the profile has its own credentials file from # day one. Without it, profile-scoped env writes (dashboard Channels / # Keys pages, `hermes -p <name> auth add`) had no file until first # write, and the profile silently inherited API keys from the shell # environment — users reasonably read that as "the new profile reads - # the root .env". Skipped when --clone/--clone-all already copied one. - env_path = profile_dir / ".env" - if not env_path.exists(): - try: - env_path.write_text( - "# Per-profile secrets for this Hermes profile.\n" - "# API keys and tokens set here override the shell environment.\n" - "# Behavioral settings belong in config.yaml, not here.\n", - encoding="utf-8", - ) - os.chmod(str(env_path), 0o600) - except OSError: - pass # best-effort — save_env_value creates the file on demand + # the root .env". Skipped when --clone/--clone-all already copied one + # (save_env_value creates the file on demand if this fails). + _seed_file_if_missing(profile_dir / ".env", _PLACEHOLDER_ENV, 0o600) # Seed a default SOUL.md so the user has a file to customize immediately. # Skipped when the profile already has one (from --clone / --clone-all). - soul_path = profile_dir / "SOUL.md" - if not soul_path.exists(): - try: - from hermes_cli.default_soul import DEFAULT_SOUL_MD - soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") - except Exception: - pass # best-effort — don't fail profile creation over this + try: + from hermes_cli.default_soul import DEFAULT_SOUL_MD + _seed_file_if_missing(profile_dir / "SOUL.md", DEFAULT_SOUL_MD) + except Exception: + pass # best-effort — don't fail profile creation over this # Write the opt-out marker so seed_profile_skills() and `hermes update`'s - # all-profile sync loop both skip this profile for bundled-skill seeding. + # all-profile sync loop both skip this profile for bundled-skill seeding + # (the feature still works via the empty skills/ dir if this fails). if no_skills: - try: - (profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text( - "This profile opted out of bundled-skill seeding " - "(`hermes profile create --no-skills`).\n" - "Delete this file to re-enable sync on the next `hermes update`.\n", - encoding="utf-8", - ) - except OSError: - pass # best-effort — the feature still works via the empty skills/ dir + _seed_file_if_missing( + profile_dir / NO_BUNDLED_SKILLS_MARKER, + "This profile opted out of bundled-skill seeding " + "(`hermes profile create --no-skills`).\n" + "Delete this file to re-enable sync on the next `hermes update`.\n", + ) # Cloned configs can be older than the running Hermes (or predate schema # tracking entirely). Migrate config-only clones immediately so @@ -1411,14 +1193,12 @@ def create_profile( def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict]: """Seed bundled skills into a profile via subprocess. - Uses subprocess because sync_skills() caches HERMES_HOME at module level. - Returns the sync result dict, or None on failure. + Uses subprocess because sync_skills() caches HERMES_HOME at module level. Returns the sync + result dict, or None on failure. - Profiles that opted out of bundled skills (via ``hermes profile create - --no-skills`` — which writes ``.no-bundled-skills`` to the profile root) - still run the sync: ``sync_skills()`` detects the marker itself and seeds - only the essential skills (e.g. ``hermes-agent``), reporting - ``skipped_opt_out`` so callers can say "opted out" instead of "failed". + Profiles that opted out of bundled skills (via ``hermes profile create --no-skills`` — which + writes ``.no-bundled-skills`` to the profile root) still run the sync: ``sync_skills()`` detects + the marker itself and seeds only the essential skills (e.g. """ project_root = Path(__file__).parent.parent.resolve() try: @@ -1450,35 +1230,13 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict def backfill_profile_envs(quiet: bool = False) -> List[str]: """Give every named profile that predates per-profile ``.env`` files one. - Profiles created before the dashboard/CLI started seeding a ``.env`` - (PR #44792) have none, so once the Channels/Keys endpoints became - profile-scoped those profiles stopped inheriting the root install's - credentials and showed everything as unconfigured. To avoid breaking - anyone on update, copy the DEFAULT install's ``.env`` into each named - profile that lacks one — that preserves the effective credentials those - profiles were already running with (they previously read the root - ``.env`` via the process environment). Users can then diverge per - profile from there. - - Falls back to the placeholder header when the default install has no - ``.env`` itself. Never overwrites an existing profile ``.env``. - - Returns the list of profile names that received a backfilled ``.env``. + Falls back to the placeholder header when the default install has no ``.env`` itself. Never + overwrites an existing profile ``.env``. """ backfilled: List[str] = [] - profiles_root = _get_profiles_root() - if not profiles_root.is_dir(): - return backfilled - default_env = _get_default_hermes_home() / ".env" - for entry in sorted(profiles_root.iterdir()): - if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name): - continue - if entry.name == "default": - continue - if named_profile_is_deleted(entry): - continue + for entry in _iter_named_profile_dirs(): env_path = entry / ".env" if env_path.exists(): continue @@ -1486,12 +1244,7 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: if default_env.is_file(): shutil.copy2(default_env, env_path) else: - env_path.write_text( - "# Per-profile secrets for this Hermes profile.\n" - "# API keys and tokens set here override the shell environment.\n" - "# Behavioral settings belong in config.yaml, not here.\n", - encoding="utf-8", - ) + env_path.write_text(_PLACEHOLDER_ENV, encoding="utf-8") os.chmod(str(env_path), 0o600) backfilled.append(entry.name) except OSError as e: @@ -1501,23 +1254,53 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: return backfilled +_BACKEND_TOKENS = frozenset({"serve", "dashboard", "gateway"}) +_HERMES_ARGV_MARKERS = ("hermes_cli.main", "hermes-gateway", "tui_gateway") +# Matches python / python3 / python3.12 / pythonw(.exe) — the interpreter +# basenames a `#!/…/python3` console-script shim gets exec'd through when +# something (e.g. Electron's `findOnPath('hermes')` resolution) spawns the +# shim by handing the interpreter its path explicitly. In that shape the +# OS-reported argv[0] is the interpreter, not "hermes". +_PYTHON_INTERPRETER_RE = re.compile(r"^python[\d.]*w?(\.exe)?$") +# Console-script entry points this project ships (pyproject.toml +# [project.scripts]). argv[1] is validated against these exact names rather +# than a loose ``startswith("hermes")``: when argv[0] is a bare interpreter, +# argv[1] can be ANY user script ("hermes-notes.py") and a prefix match would +# make it killable by profile delete. +_HERMES_CONSOLE_SCRIPT_NAMES = frozenset({"hermes", "hermes-agent", "hermes-acp"}) + + +def _is_hermes_argv(argv: list) -> bool: + """True when *argv* is a Hermes process: an entrypoint marker in argv, an executable named + ``hermes*``, or a python interpreter directly exec'ing a known ``hermes`` console-script shim. + """ + joined = " ".join(argv) + exe_name = os.path.basename(argv[0]).lower() + if any(marker in joined for marker in _HERMES_ARGV_MARKERS) or exe_name.startswith("hermes"): + return True + if len(argv) >= 2 and _PYTHON_INTERPRETER_RE.match(exe_name): + script_name = os.path.basename(str(argv[1])).lower() + return script_name.rsplit(".", 1)[0] in _HERMES_CONSOLE_SCRIPT_NAMES + return False + + +def _argv_profile_selectors(argv: list): + """Yield every profile name selected via ``-p X`` / ``--profile X`` / ``--profile=X``.""" + for i, tok in enumerate(argv): + if tok in {"--profile", "-p"} and i + 1 < len(argv): + yield argv[i + 1] + elif tok.startswith("--profile="): + yield tok.split("=", 1)[1] + + def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: """PIDs of running Hermes *backends* bound to this profile. - The ``gateway.pid`` file only tracks the messaging gateway. A Desktop app - spawns a headless ``serve`` (or legacy ``dashboard --no-open``) backend per - profile that holds the profile's SQLite connection open and keeps writing - sessions/WAL/sandbox files — the writer that makes ``rmtree`` hit - ``ENOTEMPTY`` (and, pre-fix, resurrected the tree). ``gateway.pid`` never - names it, so find it by inspection: a Hermes backend subcommand - (``serve``/``dashboard``/``gateway``) that is bound to *this* profile either - by a ``--profile <canon>`` / ``-p <canon>`` selector or by a ``HERMES_HOME`` - that resolves to ``profile_dir``. + The ``gateway.pid`` file only tracks the messaging gateway. - Best-effort and tightly scoped: current-user processes only, backend - subcommands only (never an interactive ``chat``/``tui``), and never this - process or its ancestors. Returns an empty list if ``psutil`` can't - inspect anything. + Best-effort and tightly scoped: current-user processes only, backend subcommands only (never an + interactive ``chat``/``tui``), and never this process or its ancestors. Returns an empty list if + ``psutil`` can't inspect anything. """ try: import psutil # type: ignore @@ -1545,21 +1328,6 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: except Exception: current_user = None - backend_tokens = {"serve", "dashboard", "gateway"} - hermes_markers = ("hermes_cli.main", "hermes-gateway", "tui_gateway") - # Matches python / python3 / python3.12 / pythonw(.exe) — the interpreter - # basenames a `#!/…/python3` console-script shim gets exec'd through when - # something (e.g. Electron's `findOnPath('hermes')` resolution) spawns the - # shim by handing the interpreter its path explicitly instead of running - # the shim directly. In that shape the OS-reported argv[0] is the - # interpreter, not "hermes", so the checks below would otherwise miss it. - _python_interpreter_re = re.compile(r"^python[\d.]*w?(\.exe)?$") - # The actual console-script entry points this project ships (see - # pyproject.toml [project.scripts]) -- used to validate argv[1] against - # a known shim identity rather than a loose prefix match, since argv[1] - # can be ANY user-invoked python script path when argv[0] is a bare - # interpreter. - _HERMES_CONSOLE_SCRIPT_NAMES = frozenset({"hermes", "hermes-agent", "hermes-acp"}) pids: list[int] = [] for proc in psutil.process_iter(["pid", "name", "username", "cmdline"]): @@ -1572,54 +1340,18 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: continue argv = info.get("cmdline") or [] - if not argv: - continue - - # Must be a Hermes process: either an entrypoint marker in argv, a - # resolved executable named `hermes`, or a python interpreter - # directly exec'ing a `hermes`-named console-script shim (argv[0] - # is the interpreter, argv[1] is the shim's path). - joined = " ".join(argv) - exe_name = os.path.basename(argv[0]).lower() - is_hermes = ( - any(marker in joined for marker in hermes_markers) - or exe_name == "hermes" - or exe_name.startswith("hermes") - ) - if not is_hermes and len(argv) >= 2 and _python_interpreter_re.match(exe_name): - # Match against the actual known console-script entry points - # (pyproject.toml [project.scripts]: hermes, hermes-agent, - # hermes-acp) rather than a bare `startswith("hermes")` -- - # that looser check is fine for a directly-resolved executable - # name (argv[0] IS the interpreter there, so a false match is - # rare), but here argv[1] can be ANY user-invoked python - # script path, and a bare prefix match would misidentify an - # unrelated script the user happens to name e.g. - # "hermes-notes.py" or "hermes-unrelated-tool" as the shim, - # making it killable by profile delete. - script_name = os.path.basename(str(argv[1])).lower() - script_stem = script_name.rsplit(".", 1)[0] if "." in script_name else script_name - is_hermes = script_stem in _HERMES_CONSOLE_SCRIPT_NAMES - if not is_hermes: + if not argv or not _is_hermes_argv(argv): continue # Restrict to backend subcommands so we never kill an interactive # session the user is deliberately running. - tokens = {tok.lower() for tok in argv} - if not (tokens & backend_tokens): + if not ({tok.lower() for tok in argv} & _BACKEND_TOKENS): continue # Bound to THIS profile — by selector flag in argv... - bound = False - for i, tok in enumerate(argv): - if tok in {"--profile", "-p"} and i + 1 < len(argv): - if normalize_profile_name(argv[i + 1]) == canon: - bound = True - break - elif tok.startswith("--profile="): - if normalize_profile_name(tok.split("=", 1)[1]) == canon: - bound = True - break + bound = any( + normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv) + ) # ...or by HERMES_HOME env pointing at this profile dir. if not bound: @@ -1642,64 +1374,88 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: return pids +def _wait_then_force_kill(pids: List[int], start_times: dict, *, wait: float = 10.0) -> bool: + """After a graceful ``terminate_pid``, wait up to *wait* seconds (0.5s polls) for *pids* to + exit, then force-kill stragglers. Returns True when every pid exited gracefully. + + ``start_times`` pins each force kill to the same process incarnation (PID reuse guard); + force-kill errors are swallowed. + """ + from gateway.status import _pid_exists, get_process_start_time, terminate_pid + + for _ in range(int(wait / 0.5)): + time.sleep(0.5) + if not any(_pid_exists(pid) for pid in pids): + return True + for pid in pids: + if _pid_exists(pid): + try: + terminate_pid( + pid, + force=True, + expected_start_time=start_times.get(pid, get_process_start_time(pid)), + ) + except (ProcessLookupError, PermissionError, OSError): + pass + return False + + def _stop_profile_backends(canon: str, profile_dir: Path) -> None: """Terminate any Desktop-spawned / stray backends bound to this profile. - Complements ``_stop_gateway_process`` (which only knows ``gateway.pid``): - without this, a live ``serve``/``dashboard`` backend keeps creating files - under the profile dir while ``rmtree`` walks it, so the final ``rmdir`` - fails with ``ENOTEMPTY`` and the delete doesn't converge. Best-effort: - any failure is reported and swallowed so it never makes delete worse. + Complements ``_stop_gateway_process`` (which only knows ``gateway.pid``): without this, a live + ``serve``/``dashboard`` backend keeps creating files under the profile dir while ``rmtree`` + walks it, so the final ``rmdir`` fails with ``ENOTEMPTY`` and the delete doesn't converge. """ pids = _profile_bound_backend_pids(canon, profile_dir) if not pids: return - try: - from gateway.status import ( - _pid_exists, - get_process_start_time, - terminate_pid as _terminate_pid, - ) + from gateway.status import terminate_pid except Exception: return for pid in pids: try: - _terminate_pid(pid) # graceful first + terminate_pid(pid) # graceful first except (ProcessLookupError, PermissionError, OSError): continue - - # Wait up to 10s for graceful exit, then force-kill stragglers. - deadline = time.time() + 10.0 - while time.time() < deadline: - if not any(_pid_exists(pid) for pid in pids): - break - time.sleep(0.5) - - for pid in pids: - if _pid_exists(pid): - try: - _terminate_pid( - pid, - force=True, - expected_start_time=get_process_start_time(pid), - ) - except (ProcessLookupError, PermissionError, OSError): - pass + _wait_then_force_kill(pids, {}) print(f"✓ Stopped {len(pids)} profile backend process(es)") +def _rmtree_make_writable(func, path, exc): + """onexc/onerror handler: add +w on PermissionError so rmtree can proceed. + + Handles two cases on NixOS (and other systems with read-only copies from immutable + stores): 1. The path itself isn't writable (e.g. a file with mode 0444) 2. The *parent* + directory isn't writable (e.g. mode 0555) + """ + # Normalise the two callback signatures: + # onexc(func, path, exc_instance) — 3.12+ + # onerror(func, path, exc_info_tuple) — 3.11 + if isinstance(exc, tuple): + exc = exc[1] # exc_info → actual exception object + if not isinstance(exc, PermissionError): + raise + # Make the path and its parent writable (parent needed for unlink/rmdir). + for target in (path, os.path.dirname(path)): + if target: + try: + os.chmod(target, os.stat(target).st_mode | stat.S_IWUSR) + except OSError: + pass + func(path) + + def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None: """``shutil.rmtree`` with a short retry loop for transient races. - Even after stopping the gateway and profile backends, a just-terminated - process can leave in-flight writes (SQLite ``-wal``/``-shm`` checkpoints, - sandbox temp files) that land after ``rmtree`` has walked past a directory, - surfacing as ``ENOTEMPTY`` (POSIX) or a transient ``PermissionError`` - (Windows file lock still releasing). A few spaced retries let those settle - instead of failing the whole delete on a race the next attempt would win. + Even after stopping the gateway and profile backends, a just-terminated process can leave in- + flight writes (SQLite ``-wal``/``-shm`` checkpoints, sandbox temp files) that land after + ``rmtree`` has walked past a directory, surfacing as ``ENOTEMPTY`` (POSIX) or a transient + ``PermissionError`` (Windows file lock still releasing). """ attempts = 3 last_exc: OSError | None = None @@ -1721,30 +1477,9 @@ def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None: raise last_exc -def delete_profile(name: str, yes: bool = False) -> Path: - """Delete a profile, its wrapper script, and its gateway service. - - Stops the gateway if running. Disables systemd/launchd service first - to prevent auto-restart. - - Returns the path that was removed. - """ - canon = normalize_profile_name(name) - validate_profile_name(canon) - - if canon == "default": - raise ValueError( - "Cannot delete the default profile (~/.hermes).\n" - "To remove everything, use: hermes uninstall" - ) - - profile_dir = get_profile_dir(canon) - if not profile_dir.is_dir(): - raise FileNotFoundError(f"Profile '{canon}' does not exist.") - - # Show what will be deleted +def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapper_path: Optional[Path]) -> None: + """Show what ``delete_profile`` is about to remove.""" model, provider = _read_config_model(profile_dir) - gw_running = _check_gateway_running(profile_dir) skill_count = _count_skills(profile_dir) dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) @@ -1759,30 +1494,45 @@ def delete_profile(name: str, yes: bool = False) -> Path: if dist_source: print(f"Installed from: {dist_source}") - items = [ - "All config, API keys, memories, sessions, skills, cron jobs", - ] - - # Check for service - wrapper_path = _get_wrapper_dir() / canon - has_wrapper = wrapper_path.exists() - if has_wrapper: - items.append(f"Command alias ({wrapper_path})") - print("\nThis will permanently delete:") - for item in items: - print(f" • {item}") + print(" • All config, API keys, memories, sessions, skills, cron jobs") + if wrapper_path is not None: + print(f" • Command alias ({wrapper_path})") if gw_running: print(" ⚠ Gateway is running — it will be stopped.") + +def delete_profile(name: str, yes: bool = False) -> Path: + """Delete a profile, its wrapper script, and its gateway service. + + Stops the gateway if running. Disables systemd/launchd service first to prevent auto-restart. + """ + canon = normalize_profile_name(name) + validate_profile_name(canon) + + if canon == "default": + raise ValueError( + "Cannot delete the default profile (~/.hermes).\n" + "To remove everything, use: hermes uninstall" + ) + + profile_dir = get_profile_dir(canon) + if not profile_dir.is_dir(): + raise FileNotFoundError(f"Profile '{canon}' does not exist.") + + gw_running = _check_gateway_running(profile_dir) + wrapper_path = _get_wrapper_dir() / canon + has_wrapper = wrapper_path.exists() + _print_delete_summary(canon, profile_dir, gw_running, wrapper_path if has_wrapper else None) + # Confirmation if not yes: print() try: confirm = input(f"Type '{canon}' to confirm: ").strip() except (KeyboardInterrupt, EOFError): - print("\nCancelled.") - return profile_dir + confirm = None + print() if confirm != canon: print("Cancelled.") return profile_dir @@ -1826,64 +1576,20 @@ def delete_profile(name: str, yes: bool = False) -> Path: pass # best-effort: never block the delete on the release path # 3. Remove wrapper script - if has_wrapper: - if remove_wrapper_script(canon): - print(f"✓ Removed {wrapper_path}") + if has_wrapper and remove_wrapper_script(canon): + print(f"✓ Removed {wrapper_path}") # 4. Remove profile directory remove_error: Exception | None = None try: - def _make_writable(func, path, exc): - """onexc/onerror handler: add +w on PermissionError so rmtree can proceed. - - Handles two cases on NixOS (and other systems with read-only - copies from immutable stores): - 1. The path itself isn't writable (e.g. a file with mode 0444) - 2. The *parent* directory isn't writable (e.g. mode 0555) - - Compatible with both the ``onexc`` API (3.12+, receives an - exception instance) and the ``onerror`` API (3.11-, receives - ``sys.exc_info()`` tuple). - """ - import stat as _stat - - # Normalise the two callback signatures: - # onexc(func, path, exc_instance) — 3.12+ - # onerror(func, path, exc_info_tuple) — 3.11 - if isinstance(exc, tuple): - exc = exc[1] # exc_info → actual exception object - - if isinstance(exc, PermissionError): - # Make the path writable - try: - os.chmod(path, os.stat(path).st_mode | _stat.S_IWUSR) - except OSError: - pass - # Also make the parent writable (needed for unlink/rmdir) - parent = os.path.dirname(path) - if parent: - try: - os.chmod(parent, os.stat(parent).st_mode | _stat.S_IWUSR) - except OSError: - pass - func(path) - else: - raise - - _rmtree_with_retry(profile_dir, _make_writable) + _rmtree_with_retry(profile_dir, _rmtree_make_writable) print(f"✓ Removed {profile_dir}") except Exception as e: print(f"⚠ Could not remove {profile_dir}: {e}") remove_error = e # 5. Clear active_profile if it pointed to this profile - try: - active = get_active_profile() - if active == canon: - set_active_profile("default") - print("✓ Active profile reset to default") - except Exception: - pass + _retarget_active_profile(canon, "default", "✓ Active profile reset to default") if remove_error is not None: raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error @@ -1892,55 +1598,33 @@ def delete_profile(name: str, yes: bool = False) -> Path: return profile_dir +def _s6_runtime_manager(): + """Return the s6 service manager when running inside the container, else None. + + Silent on host: a failing/absent detector must never print a confusing s6 warning to users + who have never touched the container. + """ + try: + from hermes_cli.service_manager import detect_service_manager, get_service_manager + if detect_service_manager() != "s6": + return None + mgr = get_service_manager() + except Exception: + return None + return mgr if mgr.supports_runtime_registration() else None + + def _maybe_register_gateway_service(profile_name: str) -> None: """Register a profile's gateway with s6 inside the container. - No-op on host (systemd/launchd/windows) — those backends raise - ``NotImplementedError`` on ``register_profile_gateway`` and the - existing per-profile unit-generation paths handle lifecycle. - - Best-effort: any error (no backend detected, s6 not yet ready, - etc.) is logged and swallowed so profile creation doesn't fail - because the s6 supervision tree is in a weird state. The user - can re-register manually later via the gateway start command, - which goes through the same dispatch path. - - Port selection: each supervised profile gateway loads its own - ``HERMES_HOME`` and binds the port resolved by ``gateway/config.py`` - from that profile's environment — ``API_SERVER_PORT`` (or - ``platforms.api_server.extra.port`` in the profile's - ``config.yaml``), defaulting to 8642. There is no ``[gateway] port`` - key and no Python-side allocator (PR #30136 review item I5 retired - the SHA-256-derived range [9200, 9800) as dead code), so two - profiles that both leave the port at its default will both try to - bind 8642 — give each profile a distinct ``API_SERVER_PORT`` in its - ``.env``. - - Host short-circuit: check ``detect_service_manager()`` first and - return immediately if it isn't ``"s6"``. This keeps host - (systemd/launchd/windows) profile creation completely silent — - no ``get_service_manager()`` call, no exception path, no chance - of the ``⚠ Could not register s6 gateway service`` warning ever - rendering on a non-container machine. The earlier - ``supports_runtime_registration()`` check still catches the case - where detection somehow returns ``"s6"`` but the backend isn't - actually the S6 one. + Best-effort: any error (no backend detected, s6 not yet ready, etc.) is logged and swallowed so + profile creation doesn't fail because the s6 supervision tree is in a weird state. The user can + re-register manually later via the gateway start command, which goes through the same dispatch + path. """ - try: - from hermes_cli.service_manager import detect_service_manager - if detect_service_manager() != "s6": - return # host path — silent, no registration needed - from hermes_cli.service_manager import get_service_manager - mgr = get_service_manager() - except RuntimeError: - return # no backend on this host — nothing to do - except Exception: - # Defensive: detect_service_manager failed for some other - # reason. Stay silent on host rather than printing a confusing - # s6 warning to users who have never touched the container. + mgr = _s6_runtime_manager() + if mgr is None: return - if not mgr.supports_runtime_registration(): - return # host backend; no-op try: mgr.register_profile_gateway(profile_name, start_now=False) except ValueError: @@ -1955,23 +1639,11 @@ def _maybe_register_gateway_service(profile_name: str) -> None: def _maybe_unregister_gateway_service(profile_name: str) -> None: """Tear down a profile's s6 gateway service inside the container. - No-op on host. Idempotent: absent services are silently skipped - by ``unregister_profile_gateway``. - - Same host short-circuit as :func:`_maybe_register_gateway_service` - — see that docstring. + No-op on host (same short-circuit as ``_maybe_register_gateway_service``); idempotent since + absent services are silently skipped. """ - try: - from hermes_cli.service_manager import detect_service_manager - if detect_service_manager() != "s6": - return # host path — silent - from hermes_cli.service_manager import get_service_manager - mgr = get_service_manager() - except RuntimeError: - return - except Exception: - return - if not mgr.supports_runtime_registration(): + mgr = _s6_runtime_manager() + if mgr is None: return try: mgr.unregister_profile_gateway(profile_name) @@ -1990,32 +1662,23 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: os.environ["HERMES_HOME"] = str(profile_dir) from hermes_cli.gateway import get_service_name, get_launchd_plist_path - if _platform.system() == "Linux": + def _run(*cmd: str) -> None: + subprocess.run(list(cmd), capture_output=True, check=False, timeout=10) + + system = _platform.system() + if system == "Linux": svc_name = get_service_name() svc_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service" if svc_file.exists(): - subprocess.run( - ["systemctl", "--user", "disable", svc_name], - capture_output=True, check=False, timeout=10, - ) - subprocess.run( - ["systemctl", "--user", "stop", svc_name], - capture_output=True, check=False, timeout=10, - ) + _run("systemctl", "--user", "disable", svc_name) + _run("systemctl", "--user", "stop", svc_name) svc_file.unlink(missing_ok=True) - subprocess.run( - ["systemctl", "--user", "daemon-reload"], - capture_output=True, check=False, timeout=10, - ) + _run("systemctl", "--user", "daemon-reload") print(f"✓ Service {svc_name} removed") - - elif _platform.system() == "Darwin": + elif system == "Darwin": plist_path = get_launchd_plist_path() if plist_path.exists(): - subprocess.run( - ["launchctl", "unload", str(plist_path)], - capture_output=True, check=False, timeout=10, - ) + _run("launchctl", "unload", str(plist_path)) plist_path.unlink(missing_ok=True) print("✓ Launchd service removed") except Exception as e: @@ -2023,14 +1686,12 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: finally: if old_home is not None: os.environ["HERMES_HOME"] = old_home - elif "HERMES_HOME" in os.environ: - del os.environ["HERMES_HOME"] + else: + os.environ.pop("HERMES_HOME", None) def _stop_gateway_process(profile_dir: Path) -> None: """Stop a running gateway process via its PID file.""" - import time as _time - pid_file = profile_dir / "gateway.pid" if not pid_file.exists(): return @@ -2043,7 +1704,11 @@ def _stop_gateway_process(profile_dir: Path) -> None: # names the gateway's TRUE owner. A contaminated/poisoned gateway.pid # inside this profile dir can point at another profile's live gateway # — killing it starts the mutual SIGTERM restart loop from the issue. - from gateway.status import recorded_gateway_home_conflicts + from gateway.status import ( + get_process_start_time, + recorded_gateway_home_conflicts, + terminate_pid, + ) if recorded_gateway_home_conflicts(data, expected_home=profile_dir): print( @@ -2057,27 +1722,14 @@ def _stop_gateway_process(profile_dir: Path) -> None: # _signal.SIGKILL raises AttributeError at import time on Windows, # and raw os.kill with SIGTERM doesn't cascade to child processes # the same way taskkill /T does. - from gateway.status import terminate_pid as _terminate_pid - from gateway.status import _pid_exists expected_start_time = data.get("start_time") if expected_start_time is None: - from gateway.status import get_process_start_time - expected_start_time = get_process_start_time(pid) - _terminate_pid(pid) # graceful first - # Wait up to 10s for graceful shutdown. On Windows, os.kill(pid, 0) - # is NOT a no-op — use the handle-based existence check. - for _ in range(20): - _time.sleep(0.5) - if not _pid_exists(pid): - print(f"✓ Gateway stopped (PID {pid})") - return - # Force kill - try: - _terminate_pid(pid, force=True, expected_start_time=expected_start_time) - except (ProcessLookupError, OSError): - pass - print(f"✓ Gateway force-stopped (PID {pid})") + terminate_pid(pid) # graceful first + if _wait_then_force_kill([pid], {pid: expected_start_time}): + print(f"✓ Gateway stopped (PID {pid})") + else: + print(f"✓ Gateway force-stopped (PID {pid})") except (ProcessLookupError, PermissionError): print("✓ Gateway already stopped") except Exception as e: @@ -2089,25 +1741,16 @@ def _stop_gateway_process(profile_dir: Path) -> None: # --------------------------------------------------------------------------- def get_active_profile() -> str: - """Read the sticky active profile name. - - Returns ``"default"`` if no active_profile file exists or it's empty. - """ + """Read the sticky active profile name.""" path = _get_active_profile_path() try: - name = path.read_text(encoding="utf-8").strip() - if not name: - return "default" - return name - except (FileNotFoundError, UnicodeDecodeError, OSError): + return path.read_text(encoding="utf-8").strip() or "default" + except (UnicodeDecodeError, OSError): return "default" def set_active_profile(name: str) -> None: - """Set the sticky active profile. - - Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear. - """ + """Set the sticky active profile.""" canon = normalize_profile_name(name) validate_profile_name(canon) if canon != "default" and not profile_exists(canon): @@ -2128,12 +1771,21 @@ def set_active_profile(name: str) -> None: tmp.replace(path) +def _retarget_active_profile(old: str, new: str, message: str) -> None: + """If the sticky active profile is *old*, point it at *new* and print *message*. Never raises.""" + try: + if get_active_profile() == old: + set_active_profile(new) + print(message) + except Exception: + pass + + def get_active_profile_name() -> str: """Infer the current profile name from HERMES_HOME. - Returns ``"default"`` if HERMES_HOME is not set or points to ``~/.hermes``. - Returns the profile name if HERMES_HOME points into ``~/.hermes/profiles/<name>``. - Returns ``"custom"`` if HERMES_HOME is set to an unrecognized path. + ``"default"`` when unset or ``~/.hermes``; the profile name when under + ``~/.hermes/profiles/<name>``; ``"custom"`` for any other path. """ from hermes_constants import get_hermes_home hermes_home = get_hermes_home() @@ -2162,13 +1814,10 @@ def get_active_profile_name() -> str: def _inside_git_checkout(path: Path) -> bool: """Return True when *path* lies inside a Git checkout. - Walks the path's OWN resolved ancestry for a ``.git`` marker (a directory - for normal clones, a file for worktrees/submodules). Anchoring on the - candidate path — not on ``Path.cwd()`` — keeps the safety proof valid when - ``HERMES_HOME`` points inside a checkout but the process runs from - somewhere else entirely (cron, a service manager, an absolute-path - invocation). On resolution failure we conservatively report True so the - caller falls through to a provably safe candidate. + Walks the path's OWN resolved ancestry for a ``.git`` marker (dir or worktree file), not + ``Path.cwd()``, so the check holds when HERMES_HOME sits in a checkout but the process runs + elsewhere (cron, service manager). Resolution failure reports True so callers fall through + to a provably safe candidate. """ try: resolved = path.resolve() @@ -2215,10 +1864,8 @@ def _profile_export_directory() -> Path: def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Path: """Return a managed destination for an export with no explicit output. - Keep automatic exports outside the current working directory and outside - every named profile. The CLI is commonly run from a source checkout; its - old ``<name>.tar.gz`` default therefore made a profile snapshot look like - a repository artifact and allowed it to be committed accidentally. + Kept outside the cwd and every named profile: the CLI is often run from a source checkout, + where a ``<name>.tar.gz`` default looked like a repo artifact and got committed by accident. """ canon = normalize_profile_name(name) validate_profile_name(canon) @@ -2244,30 +1891,22 @@ def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Pa def _default_export_ignore(root_dir: Path): """Return an *ignore* callable for :func:`shutil.copytree`. - Two-tier filtering: - * **Root-level allow-list** — only entries whose name appears in - ``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as - an unrelated ``x11-dev/`` directory in a Docker deployment where - HERMES_HOME equals the cwd) is excluded. Blacklisting was tried - first and proved unable to anticipate every non-Hermes file the - user may have lying alongside HERMES_HOME (#58394). - * **Universal exclusions at any depth** — ``__pycache__``, sockets, - temp files; plus npm lockfiles, which may appear at the root. + ``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as an unrelated ``x11-dev/`` + directory in a Docker deployment where HERMES_HOME equals the cwd) is excluded. - Surviving text files are later force-redacted by - :func:`_scrub_export_secrets` before the archive is written. + Surviving text files are later force-redacted by :func:`_scrub_export_secrets` before the + archive is written. """ def _ignore(directory: str, contents: list) -> set: - ignored: set = set() - for entry in contents: - # Universal exclusions (any depth) - if entry == "__pycache__" or entry.endswith((".sock", ".tmp")): - ignored.add(entry) - # npm lockfiles can appear at root - elif entry in {"package.json", "package-lock.json"}: - ignored.add(entry) + # Universal exclusions (any depth) plus npm lockfiles that can appear at root. + ignored: set = { + entry for entry in contents + if entry == "__pycache__" + or entry.endswith((".sock", ".tmp")) + or entry in {"package.json", "package-lock.json"} + } # Root-level allow-list: drop everything that isn't a known # Hermes profile artifact. if Path(directory) == root_dir: @@ -2279,6 +1918,9 @@ def _default_export_ignore(root_dir: Path): return _ignore +# Credential files dropped from named-profile exports. +_EXPORT_CREDENTIAL_FILES = frozenset({"auth.json", ".env"}) + # Text / config suffixes walked during export secret scrubbing. Binary DBs, # images, and other non-text artifacts are left alone (they may still leave # via named-profile export — scrubbing those is a separate concern). @@ -2298,41 +1940,31 @@ _EXPORT_REDACT_NAMES = frozenset({ def _should_redact_export_file(path: Path) -> bool: """True when *path* is a text-ish file we should secret-scrub on export.""" name = path.name - if name in _EXPORT_REDACT_NAMES: - return True - if name.lower().endswith(".env.example"): - return True - return path.suffix.lower() in _EXPORT_REDACT_SUFFIXES + return ( + name in _EXPORT_REDACT_NAMES + or name.lower().endswith(".env.example") + or path.suffix.lower() in _EXPORT_REDACT_SUFFIXES + ) def _scrub_export_secrets(staged: Path) -> None: """Force-redact secret-shaped strings in a staged export tree. - Same ``agent.redact.redact_sensitive_text(..., force=True)`` pass used by - ``hermes sessions export --redact``. Runs on the *staged copy only* so the - live profile is never rewritten. ``force=True`` ignores - ``security.redact_secrets`` / ``HERMES_REDACT_SECRETS`` — share archives - must not emit raw keys even when the user has disabled live redaction. + Same ``agent.redact.redact_sensitive_text(..., force=True)`` pass used by ``hermes sessions + export --redact``. Runs on the *staged copy only* so the live profile is never rewritten. - Symlinks to text files are materialized into regular files when their - content changes, so redaction never follows a link back into the source - profile (``copytree(..., symlinks=True)``). + Symlinks to text files are materialized into regular files when their content changes, so + redaction never follows a link back into the source profile (``copytree(..., symlinks=True)``). """ from agent.redact import redact_sensitive_text for path in staged.rglob("*"): try: is_link = path.is_symlink() - except OSError: - continue - if is_link: - # Skip broken links and symlinked directories. - try: - if not path.exists() or path.is_dir(): - continue - except OSError: + # Skip broken links, symlinked directories, and non-files. + if not path.is_file(): continue - elif not path.is_file(): + except OSError: continue if not _should_redact_export_file(path): @@ -2355,13 +1987,8 @@ def _scrub_export_secrets(staged: Path) -> None: def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path: """Export a profile to a tar.gz archive. - ``extra_files`` maps root-relative filenames (e.g. ``desktop.json``) to - text content staged into the archive alongside the profile's own files — - the desktop app uses it to bundle its appearance/interface overlay. - - Credential files (``auth.json``, ``.env``) are excluded, and secret-shaped - strings in staged text files are force-redacted before the archive is - written. Returns the output file path. + Credential files (``auth.json``, ``.env``) are excluded, and secret-shaped strings in staged + text files are force-redacted before the archive is written. Returns the output file path. """ import tempfile @@ -2382,45 +2009,23 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") - if canon == "default": - # The default profile IS ~/.hermes itself — its parent is ~/ and its - # directory name is ".hermes", not "default". We stage a clean copy - # under a temp dir so the archive contains ``default/...``. - with tempfile.TemporaryDirectory() as tmpdir: - staged = Path(tmpdir) / "default" - shutil.copytree( - profile_dir, - staged, - symlinks=True, - ignore=_default_export_ignore(profile_dir), - ) - _stage_extras(staged) - _scrub_export_secrets(staged) - result = make_targz(base, tmpdir, "default") - return Path(result) + # The default profile IS ~/.hermes itself (dir name ".hermes", not "default"), + # so both paths stage a filtered copy under a temp dir named after the canonical + # id: the root allow-list for default, credential exclusion for named profiles. + def _ignore_credentials(directory: str, contents: list) -> set: + return _EXPORT_CREDENTIAL_FILES & set(contents) - # Named profiles — stage a filtered copy to exclude credentials + ignore = _default_export_ignore(profile_dir) if canon == "default" else _ignore_credentials with tempfile.TemporaryDirectory() as tmpdir: staged = Path(tmpdir) / canon - _CREDENTIAL_FILES = {"auth.json", ".env"} - shutil.copytree( - profile_dir, - staged, - symlinks=True, - ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents), - ) + shutil.copytree(profile_dir, staged, symlinks=True, ignore=ignore) _stage_extras(staged) _scrub_export_secrets(staged) - result = make_targz(base, tmpdir, canon) - return Path(result) + return Path(make_targz(base, tmpdir, canon)) def import_profile(archive_path: str, name: Optional[str] = None) -> Path: - """Import a profile from a tar.gz archive. - - If *name* is not given, infers it from the archive's top-level directory. - Returns the imported profile directory. - """ + """Import a profile from a tar.gz archive.""" import tempfile archive = Path(archive_path) @@ -2482,6 +2087,21 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: # Rename # --------------------------------------------------------------------------- +def _atomic_write_json(path: Path, data: dict) -> bool: + """Write *data* to *path* via a sibling ``.tmp`` + rename. Returns False (tmp cleaned) on OSError.""" + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + tmp.replace(path) + return True + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + return False + + def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None: """Rename Honcho host blocks for a renamed profile without changing peers.""" old_host = f"hermes_{old_name}" @@ -2522,35 +2142,19 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> block = hosts[source_host] if isinstance(block, dict) and "aiPeer" not in block: - if source_host.startswith("hermes_"): - bare = source_host.split("_", 1)[1] - else: - bare = source_host.split(".", 1)[1] if "." in source_host else source_host - block["aiPeer"] = bare + # source_host is either ``hermes_<old>`` or legacy ``hermes.<old>``. + block["aiPeer"] = old_name hosts[new_host] = hosts.pop(source_host) - tmp = path.with_suffix(path.suffix + ".tmp") - try: - tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - tmp.replace(path) - except OSError: - try: - tmp.unlink(missing_ok=True) - except OSError: - pass - continue - - print(f"✓ Honcho host updated: {source_host} → {new_host}") + if _atomic_write_json(path, raw): + print(f"✓ Honcho host updated: {source_host} → {new_host}") def rename_profile(old_name: str, new_name: str) -> Path: """Rename a profile: directory, wrapper script, service, active_profile. - The default profile's home IS the installation root, so "renaming" it - sets a presentation-only ``display_name`` in profile.yaml instead — - the canonical id stays ``default`` and every resolution path is - untouched. - - Returns the (new) profile directory. + The default profile's home IS the installation root, so "renaming" it sets a presentation-only + ``display_name`` in profile.yaml instead — the canonical id stays ``default`` and every + resolution path is untouched. """ old_canon = normalize_profile_name(old_name) validate_profile_name(old_canon) @@ -2598,12 +2202,7 @@ def rename_profile(old_name: str, new_name: str) -> Path: print(f"⚠ Cannot create alias '{new_canon}' — {collision}") # 5. Update active_profile if it pointed to old name - try: - if get_active_profile() == old_canon: - set_active_profile(new_canon) - print(f"✓ Active profile updated: {new_canon}") - except Exception: - pass + _retarget_active_profile(old_canon, new_canon, f"✓ Active profile updated: {new_canon}") return new_dir @@ -2615,16 +2214,8 @@ def rename_profile(old_name: str, new_name: str) -> Path: def resolve_profile_env(profile_name: str) -> str: """Resolve a profile name to a HERMES_HOME path string. - Called early in the CLI entry point, before any hermes modules - are imported, to set the HERMES_HOME environment variable. - - When HERMES_HOME is already set, the configured spelling IS the - launch root (it may be a junction/symlink alias of the platform - default). Keep that spelling so profile re-home does not destroy - the launcher's lexical provenance -- the subprocess sanitizer needs - it to match Hermes-owned PYTHONPATH entries written in the same - spelling (#82581 junction follow-up). Physically the paths are - identical (junction-transparent); only the spelling is preserved. + Called early in the CLI entry point, before any hermes modules are imported, to set the + HERMES_HOME environment variable. """ canon = normalize_profile_name(profile_name) validate_profile_name(canon) diff --git a/hermes_cli/projects_cmd.py b/hermes_cli/projects_cmd.py index 4e2655b5cc..c357efc1e7 100644 --- a/hermes_cli/projects_cmd.py +++ b/hermes_cli/projects_cmd.py @@ -1,14 +1,4 @@ -"""``hermes project`` CLI — manage first-class, multi-folder Projects. - -A Project is a human-named workspace spanning one or more folders, with one -designated primary repo. Projects anchor desktop session grouping and (when -bound to a kanban board) give kanban tasks a deterministic worktree + branch -convention. State lives in the per-profile ``$HERMES_HOME/projects.db`` store -(see :mod:`hermes_cli.projects_db`). - -This is a footprint-ladder rung-2 capability: a CLI command + gateway RPC, -with zero model-tool schema cost. -""" +"""``hermes project`` CLI — manage first-class, multi-folder Projects.""" from __future__ import annotations @@ -37,67 +27,42 @@ def build_parser( p_create = sub.add_parser("create", help="Create a new project") p_create.add_argument("name", help="Human name, e.g. 'Hermes Agent'") - p_create.add_argument( - "folders", nargs="*", help="Folder paths to include (first = primary)" - ) + p_create.add_argument("folders", nargs="*", help="Folder paths to include (first = primary)") p_create.add_argument("--slug", default=None, help="Explicit slug override") - p_create.add_argument( - "--primary", default=None, metavar="PATH", help="Primary repo path" - ) + p_create.add_argument("--primary", default=None, metavar="PATH", help="Primary repo path") p_create.add_argument("--description", default=None) p_create.add_argument("--icon", default=None) p_create.add_argument("--color", default=None) - p_create.add_argument( - "--board", default=None, metavar="SLUG", help="Bind a kanban board" - ) - p_create.add_argument( - "--use", action="store_true", help="Set as the active project" - ) + p_create.add_argument("--board", default=None, metavar="SLUG", help="Bind a kanban board") + p_create.add_argument("--use", action="store_true", help="Set as the active project") p_list = sub.add_parser("list", aliases=["ls"], help="List projects") - p_list.add_argument( - "--all", action="store_true", dest="include_archived", - help="Include archived projects", - ) + p_list.add_argument("--all", action="store_true", dest="include_archived", help="Include archived projects") - p_show = sub.add_parser("show", help="Show a project's details") - p_show.add_argument("project", help="Project id or slug") + def project_sub(name: str, help: str) -> argparse.ArgumentParser: + sp = sub.add_parser(name, help=help) + sp.add_argument("project", help="Project id or slug") + return sp - p_add = sub.add_parser("add-folder", help="Add a folder to a project") - p_add.add_argument("project", help="Project id or slug") + project_sub("show", "Show a project's details") + + p_add = project_sub("add-folder", "Add a folder to a project") p_add.add_argument("path", help="Folder path") p_add.add_argument("--label", default=None) - p_add.add_argument( - "--primary", action="store_true", help="Mark as primary repo" + p_add.add_argument("--primary", action="store_true", help="Mark as primary repo") + + project_sub("remove-folder", "Remove a folder from a project").add_argument("path", help="Folder path") + project_sub("rename", "Rename a project").add_argument("name", help="New name") + project_sub("set-primary", "Set the primary folder").add_argument( + "path", help="Folder path (must already be in project)" ) - p_rm = sub.add_parser("remove-folder", help="Remove a folder from a project") - p_rm.add_argument("project", help="Project id or slug") - p_rm.add_argument("path", help="Folder path") - - p_rename = sub.add_parser("rename", help="Rename a project") - p_rename.add_argument("project", help="Project id or slug") - p_rename.add_argument("name", help="New name") - - p_primary = sub.add_parser("set-primary", help="Set the primary folder") - p_primary.add_argument("project", help="Project id or slug") - p_primary.add_argument("path", help="Folder path (must already be in project)") - p_use = sub.add_parser("use", help="Set the active project") - p_use.add_argument( - "project", nargs="?", default=None, - help="Project id or slug (omit to clear)", - ) + p_use.add_argument("project", nargs="?", default=None, help="Project id or slug (omit to clear)") - p_archive = sub.add_parser("archive", help="Archive a project") - p_archive.add_argument("project", help="Project id or slug") - - p_restore = sub.add_parser("restore", help="Restore an archived project") - p_restore.add_argument("project", help="Project id or slug") - - p_bind = sub.add_parser("bind-board", help="Bind a kanban board to a project") - p_bind.add_argument("project", help="Project id or slug") - p_bind.add_argument( + project_sub("archive", "Archive a project") + project_sub("restore", "Restore an archived project") + project_sub("bind-board", "Bind a kanban board to a project").add_argument( "board", nargs="?", default="", help="Board slug (omit to unbind)" ) @@ -114,27 +79,12 @@ def projects_command(args: argparse.Namespace) -> int: parser.print_help() else: print( - "usage: hermes project <action> [options]\n" - "Run 'hermes project --help' for the full list.", + "usage: hermes project <action> [options]\nRun 'hermes project --help' for the full list.", file=sys.stderr, ) return 0 - handlers = { - "create": _cmd_create, - "list": _cmd_list, - "ls": _cmd_list, - "show": _cmd_show, - "add-folder": _cmd_add_folder, - "remove-folder": _cmd_remove_folder, - "rename": _cmd_rename, - "set-primary": _cmd_set_primary, - "use": _cmd_use, - "archive": _cmd_archive, - "restore": _cmd_restore, - "bind-board": _cmd_bind_board, - } - handler = handlers.get(action) + handler = _HANDLERS.get(action) if handler is None: print(f"Unknown project action: {action}", file=sys.stderr) return 1 @@ -148,38 +98,38 @@ def _resolve(conn, ident: str): return proj -def _with_project(fn): - """Open the DB, resolve ``args.project``, and run ``fn(args, conn, proj)``. - - Collapses the connect / resolve / not-found(1) / bad-arg(2) boilerplate every - project-scoped subcommand repeated. - """ +def _db_command(fn): + """Open the DB and run ``fn(args, conn)``; a ``ValueError`` prints ``project: …`` and exits 2.""" @functools.wraps(fn) def wrapper(args: argparse.Namespace) -> int: - with pdb.connect_closing() as conn: - proj = _resolve(conn, args.project) - if proj is None: - return 1 - try: - return fn(args, conn, proj) - except ValueError as exc: - print(f"project: {exc}", file=sys.stderr) - return 2 + try: + with pdb.connect_closing() as conn: + return fn(args, conn) + except ValueError as exc: + print(f"project: {exc}", file=sys.stderr) + return 2 return wrapper +def _with_project(fn): + """Like ``_db_command`` but also resolves ``args.project`` into ``fn(args, conn, proj)``.""" + + @functools.wraps(fn) + def wrapper(args: argparse.Namespace, conn) -> int: + proj = _resolve(conn, args.project) + return 1 if proj is None else fn(args, conn, proj) + + return _db_command(wrapper) + + def _print_project(proj) -> None: - flags = " (archived)" if proj.archived else "" - print(f"{proj.slug} [{proj.id}]{flags}") + print(f"{proj.slug} [{proj.id}]{' (archived)' if proj.archived else ''}") print(f" name: {proj.name}") - if proj.description: - print(f" about: {proj.description}") - if proj.board_slug: - print(f" board: {proj.board_slug}") - if proj.primary_path: - print(f" primary: {proj.primary_path}") + for label, value in (("about", proj.description), ("board", proj.board_slug), ("primary", proj.primary_path)): + if value: + print(f" {label}:{' ' * (8 - len(label))}{value}") if proj.folders: print(" folders:") for f in proj.folders: @@ -188,26 +138,22 @@ def _print_project(proj) -> None: print(f" {mark} {f.path}{label}") -def _cmd_create(args: argparse.Namespace) -> int: - try: - with pdb.connect_closing() as conn: - pid = pdb.create_project( - conn, - name=args.name, - slug=args.slug, - folders=args.folders, - primary_path=args.primary, - description=args.description, - icon=args.icon, - color=args.color, - board_slug=args.board, - ) - if args.use: - pdb.set_active(conn, pid) - proj = pdb.get_project(conn, pid) - except ValueError as exc: - print(f"project: {exc}", file=sys.stderr) - return 2 +@_db_command +def _cmd_create(args, conn) -> int: + pid = pdb.create_project( + conn, + name=args.name, + slug=args.slug, + folders=args.folders, + primary_path=args.primary, + description=args.description, + icon=args.icon, + color=args.color, + board_slug=args.board, + ) + if args.use: + pdb.set_active(conn, pid) + proj = pdb.get_project(conn, pid) if proj is None: print("project: vanished after create", file=sys.stderr) return 2 @@ -216,20 +162,19 @@ def _cmd_create(args: argparse.Namespace) -> int: return 0 -def _cmd_list(args: argparse.Namespace) -> int: - with pdb.connect_closing() as conn: - active = pdb.get_active_id(conn) - projs = pdb.list_projects( - conn, include_archived=getattr(args, "include_archived", False) - ) +@_db_command +def _cmd_list(args, conn) -> int: + active = pdb.get_active_id(conn) + projs = pdb.list_projects( + conn, include_archived=getattr(args, "include_archived", False) + ) if not projs: print("No projects yet. Create one with `hermes project create <name>`.") return 0 for p in projs: marker = "*" if p.id == active else " " flags = " (archived)" if p.archived else "" - nfolders = len(p.folders) - print(f"{marker} {p.slug:<24} {p.name}{flags} [{nfolders} folder(s)]") + print(f"{marker} {p.slug:<24} {p.name}{flags} [{len(p.folders)} folder(s)]") return 0 @@ -275,16 +220,16 @@ def _cmd_set_primary(args, conn, proj) -> int: return 0 -def _cmd_use(args: argparse.Namespace) -> int: - with pdb.connect_closing() as conn: - if not args.project: - pdb.set_active(conn, None) - print("Cleared active project") - return 0 - proj = _resolve(conn, args.project) - if proj is None: - return 1 - pdb.set_active(conn, proj.id) +@_db_command +def _cmd_use(args, conn) -> int: + if not args.project: + pdb.set_active(conn, None) + print("Cleared active project") + return 0 + proj = _resolve(conn, args.project) + if proj is None: + return 1 + pdb.set_active(conn, proj.id) print(f"Active project: {proj.slug}") return 0 @@ -315,11 +260,7 @@ def _cmd_bind_board(args, conn, proj) -> int: def _sync_board_default_workdir(proj, board_slug: str) -> None: - """Best-effort: point the bound board's default_workdir at the primary repo. - - Keeps kanban task worktrees anchored to the project's repo. Failures here - are non-fatal — the binding itself already succeeded. - """ + """Best-effort: point the bound board's default_workdir at the primary repo.""" if not proj.primary_path: return try: @@ -333,3 +274,19 @@ def _sync_board_default_workdir(proj, board_slug: str) -> None: kb.write_board_metadata(slug, default_workdir=proj.primary_path) except Exception: pass + + +_HANDLERS = { + "create": _cmd_create, + "list": _cmd_list, + "ls": _cmd_list, + "show": _cmd_show, + "add-folder": _cmd_add_folder, + "remove-folder": _cmd_remove_folder, + "rename": _cmd_rename, + "set-primary": _cmd_set_primary, + "use": _cmd_use, + "archive": _cmd_archive, + "restore": _cmd_restore, + "bind-board": _cmd_bind_board, +} diff --git a/hermes_cli/projects_db.py b/hermes_cli/projects_db.py index 12eb206dda..5a70fbb453 100644 --- a/hermes_cli/projects_db.py +++ b/hermes_cli/projects_db.py @@ -1,22 +1,5 @@ """Per-profile first-class Project store. -A **Project** is a human-named, multi-folder workspace. Unlike the desktop's -old inferred "workspaces" (derived from each session's ``cwd`` + a git probe) -and unlike kanban's self-generated worktrees, a Project is an explicit, -persisted entity the user creates and names. It anchors: - -- **Desktop session grouping** — a session belongs to a project when its - ``cwd`` lives under one of the project's folders (longest-prefix match). -- **Kanban task worktrees** — a task linked to a project creates its worktree - under the project's primary repo with a deterministic branch name, instead - of the random ``wt/<task-id>`` fallback. - -Scope: **per-profile**, stored at ``$HERMES_HOME/projects.db`` (resolved via -``get_hermes_home()``), mirroring sessions / config / cron. This deliberately -differs from kanban, whose board DB is root-anchored and shared across -profiles. A Project may *bind* a kanban board (``board_slug``) so the two -systems agree on the repo + branch convention without merging their stores. - The schema is intentionally small and additive: column additions go through :func:`_add_column_if_missing` so opening an old DB is always safe. """ @@ -42,11 +25,7 @@ from hermes_constants import get_hermes_home def projects_db_path() -> Path: - """The per-profile projects DB path (``$HERMES_HOME/projects.db``). - - Profile-aware: ``get_hermes_home()`` already points at the active profile's - home. Tests pass an explicit ``db_path`` to :func:`connect`. - """ + """The per-profile projects DB path (``$HERMES_HOME/projects.db``).""" return get_hermes_home() / "projects.db" @@ -155,9 +134,9 @@ _INITIALIZED_PATHS: set[str] = set() def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: """Open (and initialize if needed) the per-profile projects DB. - WAL with DELETE fallback for network filesystems (shared helper from - ``hermes_state``). Schema init is idempotent (``CREATE TABLE IF NOT - EXISTS`` + additive migrations) and cached per-path per-process. + WAL with DELETE fallback for network filesystems (shared helper from ``hermes_state``). Schema + init is idempotent (``CREATE TABLE IF NOT EXISTS`` + additive migrations) and cached per-path + per-process. """ path = db_path if db_path is not None else projects_db_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -183,19 +162,17 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: def connect_closing(db_path: Optional[Path] = None): """Open a projects DB connection and guarantee it is closed on exit. - sqlite3's connection context manager only commits/rollbacks; it does NOT - close the file descriptor. Long-lived processes (gateway, dashboard) route - many project operations through ``connect()``; without closing, FDs to - ``projects.db`` accumulate. Mirrors ``kanban_db.connect_closing``. + sqlite3's connection context manager only commits/rollbacks; it does NOT close the file + descriptor. Long-lived processes (gateway, dashboard) route many project operations through + ``connect()``; without closing, FDs to ``projects.db`` accumulate. Mirrors + ``kanban_db.connect_closing``. """ conn = connect(db_path=db_path) try: yield conn finally: - try: + with contextlib.suppress(Exception): conn.close() - except Exception: - pass # TEXT columns added to `projects` after v1; re-applied idempotently on every @@ -262,6 +239,10 @@ class Project: } +# Nullable TEXT columns that may be absent from a legacy row. +_OPTIONAL_ROW_FIELDS = ("description", "icon", "color", "board_slug", "primary_path") + + def _project_from_row(row: sqlite3.Row) -> Project: keys = row.keys() return Project( @@ -269,34 +250,22 @@ def _project_from_row(row: sqlite3.Row) -> Project: slug=row["slug"], name=row["name"], created_at=row["created_at"], - description=row["description"] if "description" in keys else None, - icon=row["icon"] if "icon" in keys else None, - color=row["color"] if "color" in keys else None, - board_slug=row["board_slug"] if "board_slug" in keys else None, - primary_path=row["primary_path"] if "primary_path" in keys else None, archived=bool(row["archived"]) if "archived" in keys else False, + **{f: row[f] for f in _OPTIONAL_ROW_FIELDS if f in keys}, ) -def _load_folders(conn: sqlite3.Connection, project_id: str) -> List[ProjectFolder]: - rows = conn.execute( - "SELECT path, label, is_primary, added_at FROM project_folders " - "WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC", - (project_id,), - ).fetchall() - return [ - ProjectFolder( - path=r["path"], - label=r["label"], - is_primary=bool(r["is_primary"]), - added_at=r["added_at"], - ) - for r in rows +def _load_project(conn: sqlite3.Connection, row: sqlite3.Row) -> Project: + """Materialize a ``projects`` row together with its folders.""" + project = _project_from_row(row) + project.folders = [ + ProjectFolder(path=r["path"], label=r["label"], is_primary=bool(r["is_primary"]), added_at=r["added_at"]) + for r in conn.execute( + "SELECT path, label, is_primary, added_at FROM project_folders " + "WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC", + (project.id,), + ).fetchall() ] - - -def _attach_folders(conn: sqlite3.Connection, project: Project) -> Project: - project.folders = _load_folders(conn, project.id) return project @@ -307,15 +276,12 @@ def _attach_folders(conn: sqlite3.Connection, project: Project) -> Project: def _unique_slug(conn: sqlite3.Connection, candidate: str) -> str: """Return ``candidate`` or ``candidate-2``, ``-3`` ... if taken.""" - base = candidate n = 1 - slug = base - while conn.execute( - "SELECT 1 FROM projects WHERE slug = ?", (slug,) - ).fetchone() is not None: + slug = candidate + while conn.execute("SELECT 1 FROM projects WHERE slug = ?", (slug,)).fetchone() is not None: n += 1 suffix = f"-{n}" - slug = (base[: 64 - len(suffix)]).rstrip("-_") + suffix + slug = (candidate[: 64 - len(suffix)]).rstrip("-_") + suffix return slug @@ -329,8 +295,8 @@ def find_by_primary_path( ) -> Optional[Project]: """The first (oldest) project whose primary path matches ``path``, else None. - Comparison is separator/case normalized so equivalent Windows spellings of - the same folder do not slip past the dedup check. + Comparison is separator/case normalized so equivalent Windows spellings of the same folder do + not slip past the dedup check. """ key = _primary_path_key(path) if not key: @@ -360,15 +326,9 @@ def create_project( ) -> str: """Create a project and return its id. - ``folders`` are normalized to absolute paths. If ``primary_path`` is given - it is added to the folder set (if not already present) and marked primary; - otherwise the first folder becomes primary. - - Duplicate projects pointing at the same folder multiply the sidebar's - per-project repo subtrees (every duplicate renders its own copy of the same - lanes), so a create whose resolved primary path already belongs to a - non-archived project raises ``ValueError`` naming the existing project — - pass ``allow_duplicate_path=True`` to bypass deliberately. + ``folders`` are normalized to absolute paths. If ``primary_path`` is given it is added to the + folder set (if not already present) and marked primary; otherwise the first folder becomes + primary. """ name = str(name or "").strip() if not name: @@ -435,7 +395,7 @@ def list_projects( sql += " WHERE archived = 0" sql += " ORDER BY created_at ASC" rows = conn.execute(sql).fetchall() - return [_attach_folders(conn, _project_from_row(r)) for r in rows] + return [_load_project(conn, r) for r in rows] def get_project( @@ -451,7 +411,7 @@ def get_project( ).fetchone() if row is None: return None - return _attach_folders(conn, _project_from_row(row)) + return _load_project(conn, row) def update_project( @@ -466,33 +426,27 @@ def update_project( ) -> bool: """Patch top-level project fields. Only provided fields change. - ``icon``, ``color``, and ``board_slug`` accept an empty string to clear - (store NULL) — passing ``None`` leaves the field untouched, so callers that - want to clear must send ``""``. + ``icon``, ``color``, and ``board_slug`` accept an empty string to clear (store NULL) — passing + ``None`` leaves the field untouched, so callers that want to clear must send ``""``. """ - sets: List[str] = [] - params: List[object] = [] if name is not None: - n = str(name).strip() - if not n: + name = str(name).strip() + if not name: raise ValueError("project name must not be empty") - sets.append("name = ?") - params.append(n) - if description is not None: - sets.append("description = ?") - params.append(description) - if icon is not None: - sets.append("icon = ?") - params.append(icon or None) - if color is not None: - sets.append("color = ?") - params.append(color or None) if board_slug is not None: - sets.append("board_slug = ?") - params.append(normalize_slug(board_slug) if board_slug.strip() else None) + board_slug = normalize_slug(board_slug) if board_slug.strip() else "" + # (column, provided value, stored value) — "" clears icon/color/board_slug to NULL. + fields = ( + ("name", name, name), + ("description", description, description), + ("icon", icon, icon or None), + ("color", color, color or None), + ("board_slug", board_slug, board_slug or None), + ) + sets = [f"{col} = ?" for col, given, _ in fields if given is not None] if not sets: return False - params.append(project_id) + params = [stored for _, given, stored in fields if given is not None] + [project_id] with write_txn(conn): cur = conn.execute( f"UPDATE projects SET {', '.join(sets)} WHERE id = ?", params @@ -508,11 +462,7 @@ def add_folder( label: Optional[str] = None, is_primary: bool = False, ) -> str: - """Add a folder to a project. Returns the normalized path. - - When ``is_primary`` is set, the folder becomes the project's primary repo - (the previous primary is demoted, and ``projects.primary_path`` updates). - """ + """Add a folder to a project. Returns the normalized path.""" norm = _normalize_path(path) if not norm: raise ValueError("folder path must not be empty") @@ -608,20 +558,20 @@ def set_primary(conn: sqlite3.Connection, project_id: str, path: str) -> bool: return True -def archive_project(conn: sqlite3.Connection, project_id: str) -> bool: +def _set_archived(conn: sqlite3.Connection, project_id: str, archived: int) -> bool: with write_txn(conn): cur = conn.execute( - "UPDATE projects SET archived = 1 WHERE id = ?", (project_id,) + f"UPDATE projects SET archived = {int(archived)} WHERE id = ?", (project_id,) ) return cur.rowcount > 0 +def archive_project(conn: sqlite3.Connection, project_id: str) -> bool: + return _set_archived(conn, project_id, 1) + + def restore_project(conn: sqlite3.Connection, project_id: str) -> bool: - with write_txn(conn): - cur = conn.execute( - "UPDATE projects SET archived = 0 WHERE id = ?", (project_id,) - ) - return cur.rowcount > 0 + return _set_archived(conn, project_id, 0) def delete_project(conn: sqlite3.Connection, project_id: str) -> bool: @@ -640,31 +590,35 @@ _ACTIVE_META_KEY = "active_id" _DISCOVERY_POLICY_META_KEY = "repo_discovery_policy" +def _upsert_meta_locked(conn: sqlite3.Connection, key: str, value: str) -> None: + """Upsert a project_meta row (caller already holds a write txn).""" + conn.execute( + "INSERT INTO project_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + +def _get_meta(conn: sqlite3.Connection, key: str) -> Optional[str]: + row = conn.execute("SELECT value FROM project_meta WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None + + def set_active(conn: sqlite3.Connection, project_id: Optional[str]) -> None: """Set (or clear, when ``None``) the active project pointer.""" with write_txn(conn): if project_id is None: conn.execute("DELETE FROM project_meta WHERE key = ?", (_ACTIVE_META_KEY,)) else: - conn.execute( - "INSERT INTO project_meta (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (_ACTIVE_META_KEY, project_id), - ) + _upsert_meta_locked(conn, _ACTIVE_META_KEY, project_id) def get_active_id(conn: sqlite3.Connection) -> Optional[str]: - row = conn.execute( - "SELECT value FROM project_meta WHERE key = ?", (_ACTIVE_META_KEY,) - ).fetchone() - return row["value"] if row else None + return _get_meta(conn, _ACTIVE_META_KEY) def get_discovery_policy_key(conn: sqlite3.Connection) -> Optional[str]: - row = conn.execute( - "SELECT value FROM project_meta WHERE key = ?", (_DISCOVERY_POLICY_META_KEY,) - ).fetchone() - return row["value"] if row else None + return _get_meta(conn, _DISCOVERY_POLICY_META_KEY) def reconcile_discovered_repos_policy( @@ -675,8 +629,8 @@ def reconcile_discovered_repos_policy( ) -> bool: """Clear cached scan rows when their discovery policy changes. - Existing pre-policy rows are retained only for the backward-compatible - default policy. Returns whether rows were cleared. + Existing pre-policy rows are retained only for the backward-compatible default policy. Returns + whether rows were cleared. """ current = get_discovery_policy_key(conn) if current == policy_key: @@ -686,11 +640,7 @@ def reconcile_discovered_repos_policy( with write_txn(conn): if cleared: conn.execute("DELETE FROM discovered_repos") - conn.execute( - "INSERT INTO project_meta (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (_DISCOVERY_POLICY_META_KEY, policy_key), - ) + _upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key) return cleared @@ -700,11 +650,7 @@ def clear_discovered_repos( with write_txn(conn): conn.execute("DELETE FROM discovered_repos") if policy_key is not None: - conn.execute( - "INSERT INTO project_meta (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (_DISCOVERY_POLICY_META_KEY, policy_key), - ) + _upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key) # --------------------------------------------------------------------------- @@ -721,12 +667,11 @@ def record_discovered_repos( ) -> int: """Persist scanned git repo roots into the cache. - ``repos`` is an iterable of ``(root, label)``. Roots are normalized; the - label falls back to the basename. Returns the number of rows written. + ``repos`` is an iterable of ``(root, label)``. Roots are normalized; the label falls back to the + basename. Returns the number of rows written. - When ``replace`` is true, this is the authoritative result of a fresh disk - scan: delete stale rows first so old eval/worktree noise disappears instead - of living forever in the cache. + When ``replace`` is true, this is the authoritative result of a fresh disk scan: delete stale + rows first so old eval/worktree noise disappears instead of living forever in the cache. """ now = _now() rows = [] @@ -747,11 +692,7 @@ def record_discovered_repos( rows, ) if policy_key is not None: - conn.execute( - "INSERT INTO project_meta (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - (_DISCOVERY_POLICY_META_KEY, policy_key), - ) + _upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key) return len(rows) @@ -760,10 +701,7 @@ def list_discovered_repos(conn: sqlite3.Connection) -> List[dict]: rows = conn.execute( "SELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESC" ).fetchall() - return [ - {"root": r["root"], "label": r["label"], "last_seen": r["last_seen"]} - for r in rows - ] + return [dict(r) for r in rows] # --------------------------------------------------------------------------- @@ -776,9 +714,8 @@ def project_for_path( ) -> Optional[Project]: """Return the project owning ``path`` (longest-prefix folder match). - A folder owns ``path`` when ``path`` equals the folder or is nested under - it. The most specific (longest) folder wins, so nested projects resolve to - the innermost one. + A folder owns ``path`` when ``path`` equals the folder or is nested under it. The most specific + (longest) folder wins, so nested projects resolve to the innermost one. """ if not str(path or "").strip(): return None @@ -793,11 +730,14 @@ def project_for_path( best_len = -1 for row in conn.execute(sql).fetchall(): folder = row["folder"] - if target == folder or target.startswith(folder.rstrip("/\\") + os.sep) or \ - target.startswith(folder.rstrip("/\\") + "/"): - if len(folder) > best_len: - best_len = len(folder) - best_pid = row["pid"] + owns = ( + target == folder + or target.startswith(folder.rstrip("/\\") + os.sep) + or target.startswith(folder.rstrip("/\\") + "/") + ) + if owns and len(folder) > best_len: + best_len = len(folder) + best_pid = row["pid"] if best_pid is None: return None return get_project(conn, best_pid) @@ -810,8 +750,8 @@ _BRANCH_SAFE_RE = re.compile(r"[^a-z0-9._-]+") def branch_name_for(project: Project, task_id: str, *, title: str = "") -> str: """Deterministic branch name for a project-linked kanban task. - Shape: ``<project-slug>/<task-id>`` (optionally ``-<title-slug>``). Stable - and human-meaningful, replacing the random ``wt/<task-id>`` fallback. + Shape: ``<project-slug>/<task-id>`` (optionally ``-<title-slug>``). Stable and human-meaningful, + replacing the random ``wt/<task-id>`` fallback. """ slug = project.slug or _slugify(project.name) base = f"{slug}/{task_id}" diff --git a/hermes_cli/prompt_size.py b/hermes_cli/prompt_size.py index aac473db3d..ef0bf54255 100644 --- a/hermes_cli/prompt_size.py +++ b/hermes_cli/prompt_size.py @@ -1,14 +1,9 @@ """Prompt-size diagnostic: ``hermes prompt-size``. -Reports a byte/char breakdown of the system prompt the agent would build for -a fresh session — system prompt total, the ``<available_skills>`` index, -memory + user profile, and tool-schema JSON. Lets users see where their fixed -prompt budget goes (issue #34667) without parsing a saved session JSON by hand. - -The diagnostic builds a real inspection agent (so the numbers match what -actually ships on the wire) but never makes a network call: it passes dummy -credentials so ``AIAgent.__init__`` takes the direct-construction path, then -calls ``build_system_prompt_parts`` / inspects ``agent.tools`` offline. +The diagnostic builds a real inspection agent (so the numbers match what actually ships on the wire) +but never makes a network call: it passes dummy credentials so ``AIAgent.__init__`` takes the +direct-construction path, then calls ``build_system_prompt_parts`` / inspects ``agent.tools`` +offline. """ from __future__ import annotations @@ -50,9 +45,9 @@ def _tool_name(tool: Any) -> str: def _build_inspection_agent(platform: str) -> Any: """Construct an offline AIAgent for prompt inspection. - Dummy ``api_key`` + ``base_url`` force the direct-construction path in - ``run_agent.py`` (no provider auto-detection, no network). Toolsets and - platform come from the caller so the breakdown matches a real session. + Dummy ``api_key`` + ``base_url`` force the direct-construction path (no provider + auto-detection, no network). Toolsets and platform come from the caller so the breakdown + matches a real session. """ from run_agent import AIAgent from hermes_cli.config import load_config @@ -84,10 +79,9 @@ def _build_inspection_agent(platform: str) -> Any: def _skill_md_paths_by_name() -> Dict[str, Path]: """Map each installed skill's name to its ``SKILL.md`` path on disk. - Keyed by both the frontmatter ``name`` (what the index renders) and the - skill directory name, so either resolves. Local skills win over external - dirs (``get_all_skills_dirs`` yields local first), matching the index's own - precedence. Used to attribute the real on-disk read cost per skill. + Keyed by both the frontmatter ``name`` (what the index renders) and the skill directory name, so + either resolves. Local skills win over external dirs (``get_all_skills_dirs`` yields local + first), matching the index's own precedence. """ from agent.skill_utils import ( get_all_skills_dirs, @@ -100,48 +94,30 @@ def _skill_md_paths_by_name() -> Dict[str, Path]: if not skills_dir.exists(): continue for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"): - frontmatter_name = skill_file.parent.name + dir_name = skill_file.parent.name try: - frontmatter, _ = parse_frontmatter( - skill_file.read_text(encoding="utf-8") - ) - frontmatter_name = str(frontmatter.get("name") or frontmatter_name) + frontmatter, _ = parse_frontmatter(skill_file.read_text(encoding="utf-8")) + frontmatter_name = str(frontmatter.get("name") or dir_name) except Exception: - pass + frontmatter_name = dir_name # setdefault keeps the first (local) occurrence on name collisions. mapping.setdefault(frontmatter_name, skill_file) - mapping.setdefault(skill_file.parent.name, skill_file) + mapping.setdefault(dir_name, skill_file) return mapping def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: """Per-skill byte breakdown parsed from the rendered ``<available_skills>``. - Two honest, distinct numbers per skill: - - * ``index_line_bytes`` — the skill's attributed bytes in the always-on - index (the fixed per-call cost of *listing* the skill). For a compact - ``[names only]`` line, each name keeps its own bytes and receives an - even share of the category prefix and separators. The attributed bytes - therefore sum exactly to the shared rendered line. - * ``skill_md_bytes`` — the on-disk size of the skill's ``SKILL.md`` (the - real token cost paid only when the model loads it via ``skill_view``). - ``None`` when the name can't be mapped to a file (e.g. a plugin skill - whose source lives outside the scanned skill dirs). - - Sorted largest-first by ``skill_md_bytes`` (the read cost that dominates - pruning decisions), tie-broken by name. + * ``index_line_bytes`` — the skill's attributed bytes in the always-on index (the fixed per-call + cost of *listing* the skill). For a compact ``[names only]`` line, each name keeps its own bytes + and receives an even share of the category prefix and separators. """ name_to_path = _skill_md_paths_by_name() entries: List[Dict[str, Any]] = [] def append_entry( - name: str, - *, - attributed_bytes: int, - total_bytes: int, - shared_bytes: int, - skill_count: int, + name: str, *, attributed_bytes: int, total_bytes: int, shared_bytes: int, skill_count: int ) -> None: path = name_to_path.get(name) md_bytes: Optional[int] = None @@ -149,7 +125,7 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: try: md_bytes = path.stat().st_size except OSError: - md_bytes = None + pass entries.append({ "name": name, "index_line_bytes": attributed_bytes, @@ -162,46 +138,30 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: for line in skills_block.splitlines(): compact_match = _NAMES_ONLY_LINE_RE.match(line) + line_bytes = _bytes(line) if compact_match is not None: - names = [ - name.strip() - for name in compact_match.group("names").split(",") - if name.strip() - ] + names = [n.strip() for n in compact_match.group("names").split(",") if n.strip()] if not names: continue - total_bytes = _bytes(line) name_bytes = [_bytes(name) for name in names] - shared_total = total_bytes - sum(name_bytes) - shared_base, shared_remainder = divmod(shared_total, len(names)) + shared_base, shared_remainder = divmod(line_bytes - sum(name_bytes), len(names)) for index, name in enumerate(names): shared_bytes = shared_base + (1 if index < shared_remainder else 0) append_entry( - name, - attributed_bytes=name_bytes[index] + shared_bytes, - total_bytes=total_bytes, - shared_bytes=shared_bytes, - skill_count=len(names), + name, attributed_bytes=name_bytes[index] + shared_bytes, + total_bytes=line_bytes, shared_bytes=shared_bytes, skill_count=len(names), ) continue if not line.startswith(_SKILL_LINE_PREFIX): continue - rest = line[len(_SKILL_LINE_PREFIX):] # ``name: desc`` — the first ``": "`` separates name from description. # Namespaced names (``codex:rescue``) have no space after their colon, # so partitioning on ``": "`` keeps the full name intact. - name = rest.partition(": ")[0].strip() - if not name: - continue - line_bytes = _bytes(line) - append_entry( - name, - attributed_bytes=line_bytes, - total_bytes=line_bytes, - shared_bytes=0, - skill_count=1, - ) + name = line[len(_SKILL_LINE_PREFIX):].partition(": ")[0].strip() + if name: + append_entry(name, attributed_bytes=line_bytes, total_bytes=line_bytes, + shared_bytes=0, skill_count=1) entries.sort(key=lambda e: (-(e["skill_md_bytes"] or 0), e["name"])) return entries @@ -209,11 +169,9 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: def _compute_toolsets_breakdown(tools: List[Any]) -> List[Dict[str, Any]]: """Per-toolset schema-byte breakdown of the resolved tool list. - Each tool is attributed to its single canonical toolset from the registry, - so ``json_bytes`` sums are fully attributable: the grand total equals the - sum of the individual tool serializations (which is the array total from - ``tools['json_bytes']`` minus JSON framing of ``2 * count`` bytes). Sorted - largest-first by ``json_bytes``, tie-broken by toolset name. + Each tool is attributed to its single canonical toolset so ``json_bytes`` sums are fully + attributable (grand total = sum of per-tool serializations). Sorted largest-first, tie-broken + by toolset name. """ from tools.registry import registry @@ -222,25 +180,18 @@ def _compute_toolsets_breakdown(tools: List[Any]) -> List[Dict[str, Any]]: for tool in tools: name = _tool_name(tool) toolset = tool_to_toolset.get(name) or "(unknown)" - group = groups.setdefault( - toolset, {"toolset": toolset, "tool_count": 0, "json_bytes": 0} - ) + group = groups.setdefault(toolset, {"toolset": toolset, "tool_count": 0, "json_bytes": 0}) group["tool_count"] += 1 group["json_bytes"] += _bytes(json.dumps(tool, ensure_ascii=False)) - out = list(groups.values()) - out.sort(key=lambda g: (-g["json_bytes"], g["toolset"])) - return out + return sorted(groups.values(), key=lambda g: (-g["json_bytes"], g["toolset"])) def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]: """Return a dict of prompt-size measurements for a fresh session. - Keys: ``system_prompt`` (chars/bytes), ``skills_index``, ``memory``, - ``user_profile``, ``tools`` (count + json bytes), ``sections`` (a list of - (label, chars, bytes) for the three prompt tiers), ``skills_breakdown`` - (per-skill index-line + on-disk SKILL.md bytes, largest-first), and - ``toolsets_breakdown`` (per-toolset tool count + schema json bytes, - largest-first). The last two answer "what should I disable to cut tokens?". + Keys: ``system_prompt``, ``skills_index``, ``memory``, ``user_profile``, ``tools``, ``sections`` + (the three prompt tiers), ``skills_breakdown`` and ``toolsets_breakdown`` (largest-first); the + last two answer "what should I disable to cut tokens?". """ from agent.system_prompt import build_system_prompt, build_system_prompt_parts @@ -276,7 +227,9 @@ def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]: # Tool-schema JSON — the other half of the fixed per-call payload. tools = getattr(agent, "tools", None) or [] - tools_json = json.dumps(tools, ensure_ascii=False) + + def _size(text: str) -> Dict[str, int]: + return {"chars": len(text), "bytes": _bytes(text)} sections: List[Tuple[str, int, int]] = [ ("stable (identity/guidance/skills)", len(stable), _bytes(stable)), @@ -287,11 +240,11 @@ def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]: return { "platform": platform, "model": getattr(agent, "model", "") or "", - "system_prompt": {"chars": len(full), "bytes": _bytes(full)}, - "skills_index": {"chars": len(skills_index), "bytes": _bytes(skills_index)}, - "memory": {"chars": len(memory_block), "bytes": _bytes(memory_block)}, - "user_profile": {"chars": len(user_block), "bytes": _bytes(user_block)}, - "tools": {"count": len(tools), "json_bytes": _bytes(tools_json)}, + "system_prompt": _size(full), + "skills_index": _size(skills_index), + "memory": _size(memory_block), + "user_profile": _size(user_block), + "tools": {"count": len(tools), "json_bytes": _bytes(json.dumps(tools, ensure_ascii=False))}, "sections": sections, "skills_breakdown": _compute_skills_breakdown(skills_index), "toolsets_breakdown": _compute_toolsets_breakdown(tools), @@ -304,33 +257,29 @@ def _fmt_kb(n: int) -> str: def render_breakdown(data: Dict[str, Any]) -> str: """Render the breakdown as plain text suitable for a terminal.""" - lines: List[str] = [] sp = data["system_prompt"] - lines.append(f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})") - lines.append("") - lines.append(f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)") - lines.append("") - lines.append(" Major blocks:") - si = data["skills_index"] - mem = data["memory"] - up = data["user_profile"] - lines.append(f" skills index : {si['bytes']:>8,} B ({_fmt_kb(si['bytes'])})") - lines.append(f" memory : {mem['bytes']:>8,} B ({_fmt_kb(mem['bytes'])})") - lines.append(f" user profile : {up['bytes']:>8,} B ({_fmt_kb(up['bytes'])})") - lines.append("") - lines.append(" Prompt tiers:") + tools = data["tools"] + lines: List[str] = [ + f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})", + "", + f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)", + "", + " Major blocks:", + ] + for label, key in (("skills index", "skills_index"), ("memory", "memory"), + ("user profile", "user_profile")): + byts = data[key]["bytes"] + lines.append(f" {label:<19}: {byts:>8,} B ({_fmt_kb(byts)})") + lines += ["", " Prompt tiers:"] for label, chars, byts in data["sections"]: lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})") - lines.append("") - tools = data["tools"] - lines.append(f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)") + lines += ["", f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)"] # Per-toolset schema cost — which toolset's tools cost the most to ship. toolsets = data.get("toolsets_breakdown") or [] if toolsets: - lines.append("") - lines.append(" Toolsets by size (tool-schema JSON, largest first):") - lines.append(f" {'toolset':<22} {'tools':>5} {'schema':>10}") + lines += ["", " Toolsets by size (tool-schema JSON, largest first):", + f" {'toolset':<22} {'tools':>5} {'schema':>10}"] for ts in toolsets: lines.append( f" {ts['toolset']:<22} {ts['tool_count']:>5} " @@ -340,12 +289,10 @@ def render_breakdown(data: Dict[str, Any]) -> str: # Per-skill cost — index line (always shipped) vs SKILL.md (read on load). skills = data.get("skills_breakdown") or [] if skills: - lines.append("") - lines.append( - " Skills by size (SKILL.md on-disk = read cost; index cost = " - "attributed always-on bytes, largest first):" - ) - lines.append(f" {'skill':<28} {'SKILL.md':>10} {'index cost':>10}") + lines += ["", + " Skills by size (SKILL.md on-disk = read cost; index cost = " + "attributed always-on bytes, largest first):", + f" {'skill':<28} {'SKILL.md':>10} {'index cost':>10}"] shown = skills[:_SKILLS_TABLE_LIMIT] for sk in shown: md = sk["skill_md_bytes"] @@ -353,9 +300,7 @@ def render_breakdown(data: Dict[str, Any]) -> str: name = sk["name"] if len(name) > 28: name = name[:27] + "…" - lines.append( - f" {name:<28} {md_str} {sk['index_line_bytes']:>8,} B" - ) + lines.append(f" {name:<28} {md_str} {sk['index_line_bytes']:>8,} B") remaining = len(skills) - len(shown) if remaining > 0: lines.append(f" … and {remaining} more (use --json for the full list)") diff --git a/hermes_cli/prompt_stash.py b/hermes_cli/prompt_stash.py index b0397defff..cacbbe4d7d 100644 --- a/hermes_cli/prompt_stash.py +++ b/hermes_cli/prompt_stash.py @@ -1,24 +1,10 @@ """Ctrl+S prompt stash — pure state machine for the classic CLI composer. -Park a half-written prompt, send something else, then bring the draft back. -Mirrors Claude Code's ``ctrl + s to stash prompt`` affordance. +The state machine lives here (no prompt_toolkit imports) so it can be unit tested directly; +``cli.py`` owns only the keybinding and the rendering. -The state machine lives here (no prompt_toolkit imports) so it can be unit -tested directly; ``cli.py`` owns only the keybinding and the rendering. - -Gesture -------- -- Buffer has content → push it onto the stash, clear the composer. -- Buffer empty, 1 item → pop it straight back into the composer. -- Buffer empty, 2+ items → open the browse panel (↑↓ / Enter / D / Esc). - -Newest-first ordering: index 0 is always the most recently stashed draft, so -the common "undo my last Ctrl+S" case is a single keystroke. - -Nothing is written to disk. Drafts frequently contain credentials, prompts -under NDA, or pasted secrets, and a session-scoped stash keeps that material -in memory only. Callers that later want cross-restart persistence must route -through ``get_hermes_home()`` rather than hardcoding ``~/.hermes``. +Newest-first ordering: index 0 is always the most recently stashed draft, so the common "undo my +last Ctrl+S" case is a single keystroke. """ from __future__ import annotations @@ -35,11 +21,7 @@ MAX_STASH_ITEMS = 20 def build_preview(text: str, width: int = PREVIEW_WIDTH) -> str: - """Collapse a possibly multi-line draft into one preview line. - - Newlines and tabs become ``⏎``/space so a 40-line draft still renders as a - single panel row, and the result is ellipsized to ``width`` display chars. - """ + """Collapse a possibly multi-line draft into one preview line.""" if not text: return "" flat = text.replace("\r\n", "\n").replace("\r", "\n") @@ -70,11 +52,7 @@ class StashEntry: class PromptStash: - """Session-scoped stack of parked composer drafts. - - Pure state: no I/O, no prompt_toolkit, no global clock beyond - ``time.monotonic`` (injectable for tests via ``clock``). - """ + """Session-scoped stack of parked composer drafts.""" def __init__(self, *, max_items: int = MAX_STASH_ITEMS, clock=None): self._items: List[StashEntry] = [] @@ -88,10 +66,6 @@ class PromptStash: def __len__(self) -> int: return len(self._items) - def __bool__(self) -> bool: - # Explicit: an empty stash is falsey, but len() drives that anyway. - return bool(self._items) - @property def items(self) -> List[StashEntry]: """Newest-first list of entries (a copy — mutate via the API).""" @@ -104,13 +78,11 @@ class PromptStash: def indicator(self) -> str: """Status-bar indicator, or ``""`` when the stash is empty. - ``📌 2`` when idle, ``📌 2 ▲`` while the browse panel is open, so the - user can always tell a parked draft exists without opening anything. + ``📌 2`` when idle, ``📌 2 ▲`` while the browse panel is open, so the user can always tell a + parked draft exists without opening anything. """ n = len(self._items) - if not n: - return "" - return f"📌 {n} ▲" if self.panel_open else f"📌 {n}" + return "" if not n else f"📌 {n} ▲" if self.panel_open else f"📌 {n}" def placeholder_hint(self) -> str: """Composer placeholder text advertising the stashed draft.""" @@ -126,14 +98,11 @@ class PromptStash: def stash(self, text: str, images: Optional[Sequence[Any]] = None) -> bool: """Push a draft. Returns False (no-op) for a blank buffer. - A buffer that is empty or whitespace-only is not worth parking and - must stay a no-op, otherwise Ctrl+S on an empty composer would push a - junk entry instead of triggering the restore half of the gesture. - Text is stored verbatim — leading/trailing whitespace and newlines are - preserved so a restore round-trips byte-for-byte. + A buffer that is empty or whitespace-only is not worth parking and must stay a no-op, + otherwise Ctrl+S on an empty composer would push a junk entry instead of triggering the + restore half of the gesture. """ - has_images = bool(images) - if not (text or "").strip() and not has_images: + if not (text or "").strip() and not images: return False entry = StashEntry( @@ -146,13 +115,12 @@ class PromptStash: # Drop the oldest entries past the cap. del self._items[self._max_items:] # A push invalidates any open browse session. - self.panel_open = False - self.panel_cursor = 0 + self.close_panel() return True def pop(self, index: int = 0) -> Optional[Tuple[str, List[Any]]]: """Remove and return ``(text, images)`` at ``index``, or None.""" - if not self._items or not (0 <= index < len(self._items)): + if not 0 <= index < len(self._items): return None entry = self._items.pop(index) if not self._items: @@ -162,14 +130,11 @@ class PromptStash: def peek(self, index: int = 0) -> Optional[StashEntry]: """Return the entry at ``index`` without removing it.""" - if not self._items or not (0 <= index < len(self._items)): - return None - return self._items[index] + return self._items[index] if 0 <= index < len(self._items) else None def clear(self) -> None: self._items.clear() - self.panel_open = False - self.panel_cursor = 0 + self.close_panel() # ------------------------------------------------------------ panel state @@ -202,8 +167,7 @@ class PromptStash: idx = self._clamp_cursor(self.panel_cursor) self._items.pop(idx) if not self._items: - self.panel_open = False - self.panel_cursor = 0 + self.close_panel() else: self.panel_cursor = self._clamp_cursor(idx) return True @@ -234,9 +198,8 @@ def resolve_ctrl_s( ) -> Tuple[str, Optional[Tuple[str, List[Any]]]]: """Decide what one Ctrl+S press does. Returns ``(action, payload)``. - ``payload`` carries ``(text, images)`` for :data:`ACTION_RESTORED`, else - None. This is the whole decision table in one pure function so the - keybinding handler in ``cli.py`` stays a thin adapter. + ``payload`` is ``(text, images)`` for :data:`ACTION_RESTORED`, else None. The whole decision + table lives in this pure function so the ``cli.py`` keybinding handler stays a thin adapter. """ # Panel open → Ctrl+S is the "close it" escape hatch. if stash.panel_open: @@ -246,9 +209,7 @@ def resolve_ctrl_s( # Something to park → park it. Never silently clobbers an existing stash: # entries push onto a stack, so an earlier draft is still reachable. if (buffer_text or "").strip() or images: - if stash.stash(buffer_text, images): - return ACTION_STASHED, None - return ACTION_NOOP, None + return (ACTION_STASHED if stash.stash(buffer_text, images) else ACTION_NOOP), None # Empty buffer → restore half of the gesture. count = len(stash) diff --git a/hermes_cli/proxy/__init__.py b/hermes_cli/proxy/__init__.py index c8775990fa..b0aba01015 100644 --- a/hermes_cli/proxy/__init__.py +++ b/hermes_cli/proxy/__init__.py @@ -1,19 +1,4 @@ -"""Local OpenAI-compatible proxy that forwards to OAuth-authenticated upstreams. - -Lets external apps (OpenViking, Karakeep, Open WebUI, ...) ride the user's -already-logged-in provider subscription instead of needing a static API key -copy-pasted into each app's config. - -The proxy listens on ``127.0.0.1:<port>``, accepts any bearer (the client's -``Authorization`` header is discarded), and attaches the user's real -upstream credential to the forwarded request. The credential is refreshed -automatically when it approaches expiry. - -First-class adapter: - - ``nous`` — Nous Portal (https://inference-api.nousresearch.com/v1) - -Future adapters can plug in by implementing ``UpstreamAdapter``. -""" +"""Local OpenAI-compatible proxy that forwards to OAuth-authenticated upstreams.""" from hermes_cli.proxy.adapters.base import UpstreamAdapter diff --git a/hermes_cli/proxy/adapters/__init__.py b/hermes_cli/proxy/adapters/__init__.py index 7aa0c5c09a..32c35779a5 100644 --- a/hermes_cli/proxy/adapters/__init__.py +++ b/hermes_cli/proxy/adapters/__init__.py @@ -1,8 +1,8 @@ """Upstream adapter registry for the local proxy server. -Each adapter wraps a provider's OAuth state and exposes a uniform interface -the proxy server can use to forward requests with a freshly-minted bearer -token. See :class:`UpstreamAdapter` for the contract. +Each adapter wraps a provider's OAuth state and exposes a uniform interface the proxy server can use +to forward requests with a freshly-minted bearer token. See :class:`UpstreamAdapter` for the +contract. """ from typing import Dict, Type @@ -20,11 +20,7 @@ ADAPTERS: Dict[str, Type[UpstreamAdapter]] = { def get_adapter(name: str) -> UpstreamAdapter: - """Instantiate an adapter by provider name. - - Raises: - ValueError: if ``name`` is not a registered adapter. - """ + """Instantiate an adapter by provider name.""" key = (name or "").strip().lower() if key not in ADAPTERS: available = ", ".join(sorted(ADAPTERS)) or "(none)" diff --git a/hermes_cli/proxy/adapters/base.py b/hermes_cli/proxy/adapters/base.py index 65107b6f18..4bf2af31ba 100644 --- a/hermes_cli/proxy/adapters/base.py +++ b/hermes_cli/proxy/adapters/base.py @@ -1,13 +1,5 @@ """Abstract base for proxy upstream adapters. -An :class:`UpstreamAdapter` represents one OAuth-authenticated provider the -local proxy can forward requests to. The adapter is responsible for: - - - locating the user's auth state for that provider - - refreshing/minting credentials when needed - - reporting the resolved upstream base URL - - declaring which request paths it accepts - The proxy server is otherwise provider-agnostic. """ @@ -53,32 +45,25 @@ class UpstreamAdapter(ABC): def allowed_paths(self) -> FrozenSet[str]: """Set of relative request paths the upstream accepts. - Paths are relative to the proxy's ``/v1`` mount point. For example, - ``"/chat/completions"`` corresponds to a client request to - ``http://127.0.0.1:<port>/v1/chat/completions``. Requests to paths - not in this set get a 404 with a helpful error body. + Paths are relative to the proxy's ``/v1`` mount (``"/chat/completions"`` ⇒ + ``/v1/chat/completions``). Requests outside this set get a 404 with a helpful body. """ @abstractmethod def is_authenticated(self) -> bool: """Return True if the user has usable credentials for this upstream. - Should be cheap — no network calls. Used by ``proxy start`` for a - clear up-front error before binding a port. + Should be cheap — no network calls. Used by ``proxy start`` for a clear up-front error + before binding a port. """ @abstractmethod def get_credential(self) -> UpstreamCredential: """Return a fresh credential, refreshing or rotating if necessary. - Implementations should: - - refresh the access token if it's near expiry - - rotate the upstream bearer key if it's near expiry - - persist any refreshed state back to disk - - Raises: - RuntimeError: if the user isn't authenticated or the upstream - refresh fails. The proxy will return 401 to the client. + Implementations refresh a near-expiry access token, rotate a near-expiry upstream bearer key + and persist refreshed state to disk. Raises RuntimeError when unauthenticated or refresh + fails; the proxy then returns 401 to the client. """ def get_retry_credential( @@ -89,8 +74,8 @@ class UpstreamAdapter(ABC): ) -> Optional[UpstreamCredential]: """Return an alternate credential after an upstream auth failure. - The default is no retry. Providers can override this for one-shot - fallback paths after the upstream rejects the first request. + The default is no retry. Providers can override this for one-shot fallback paths after the + upstream rejects the first request. """ _ = failed_credential, status_code return None diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 18c0123a25..08346a2a11 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -1,9 +1,4 @@ -"""Nous Portal upstream adapter. - -Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the -shared runtime resolver, validates or refreshes the inference JWT, then exposes -the upstream base URL plus bearer for the proxy server to forward to. -""" +"""Nous Portal upstream adapter.""" from __future__ import annotations @@ -63,11 +58,9 @@ class NousPortalAdapter(UpstreamAdapter): return _ALLOWED_PATHS def is_authenticated(self) -> bool: - state = self._read_state() - if state is None: - return False # We need either a usable inference JWT OR (refresh_token + access_token) # to recover. The refresh helper validates and refreshes as needed. + state = self._read_state() or {} return bool( state.get("agent_key") or (state.get("refresh_token") and state.get("access_token")) @@ -86,9 +79,7 @@ class NousPortalAdapter(UpstreamAdapter): if status_code != 401: return None logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT") - return self._get_credential( - force_refresh=True, - ) + return self._get_credential(force_refresh=True) def _get_credential( self, @@ -103,16 +94,10 @@ class NousPortalAdapter(UpstreamAdapter): ) try: - refreshed = resolve_nous_runtime_credentials( - force_refresh=force_refresh, - ) - except AuthError as exc: - if _is_terminal_nous_refresh_error(exc): - _quarantine_nous_oauth_state( - state, - exc, - reason="proxy_refresh_failure", - ) + refreshed = resolve_nous_runtime_credentials(force_refresh=force_refresh) + except Exception as exc: + if isinstance(exc, AuthError) and _is_terminal_nous_refresh_error(exc): + _quarantine_nous_oauth_state(state, exc, reason="proxy_refresh_failure") self._save_state( state, quarantine_error=exc, @@ -121,10 +106,6 @@ class NousPortalAdapter(UpstreamAdapter): raise RuntimeError( f"Failed to refresh Nous Portal credentials: {exc}" ) from exc - except Exception as exc: - raise RuntimeError( - f"Failed to refresh Nous Portal credentials: {exc}" - ) from exc runtime_key = refreshed.get("api_key") if not runtime_key: @@ -166,11 +147,9 @@ class NousPortalAdapter(UpstreamAdapter): except Exception as exc: logger.warning("proxy: failed to load auth store: %s", exc) return None - providers = store.get("providers") or {} - state = providers.get("nous") - if not isinstance(state, dict): - return None - return dict(state) # copy so the refresh helper can mutate freely + state = (store.get("providers") or {}).get("nous") + # copy so the refresh helper can mutate freely + return dict(state) if isinstance(state, dict) else None def _save_state( self, diff --git a/hermes_cli/proxy/adapters/xai.py b/hermes_cli/proxy/adapters/xai.py index d85db8630a..2e533f668c 100644 --- a/hermes_cli/proxy/adapters/xai.py +++ b/hermes_cli/proxy/adapters/xai.py @@ -87,15 +87,13 @@ class XAIGrokAdapter(UpstreamAdapter): if pool is None: return None - if status_code == 429: - # Mark the rate-limited key with its 1-hour cooldown and rotate - # to the next available credential. Returns None when the pool - # has no other key to offer — the 429 will flow back to the client. + # 401: try refreshing the current key first. 429: never refresh — mark + # the rate-limited key with its 1-hour cooldown and rotate to the next + # available credential. Returns None when the pool has no other key to + # offer — the 429 will flow back to the client. + refreshed = pool.try_refresh_current() if status_code == 401 else None + if refreshed is None: refreshed = pool.mark_exhausted_and_rotate(status_code=status_code) - else: - refreshed = pool.try_refresh_current() - if refreshed is None: - refreshed = pool.mark_exhausted_and_rotate(status_code=status_code) if refreshed is None: return None @@ -116,29 +114,21 @@ class XAIGrokAdapter(UpstreamAdapter): return None def _credential_from_entry(self, entry: PooledCredential) -> UpstreamCredential: - bearer = ( - getattr(entry, "runtime_api_key", None) - or getattr(entry, "access_token", "") - or "" - ) - bearer = str(bearer).strip() + bearer = str(getattr(entry, "runtime_api_key", None) or entry.access_token or "").strip() if not bearer: raise RuntimeError( "xAI OAuth credential pool entry did not contain an access token. " "Re-authenticate with `hermes auth add xai-oauth --type oauth`." ) - base_url = ( - getattr(entry, "runtime_base_url", None) - or getattr(entry, "base_url", None) - or DEFAULT_XAI_OAUTH_BASE_URL - ) - base_url = str(base_url or DEFAULT_XAI_OAUTH_BASE_URL).strip().rstrip("/") + base_url = str( + getattr(entry, "runtime_base_url", None) or entry.base_url or DEFAULT_XAI_OAUTH_BASE_URL + ).strip().rstrip("/") return UpstreamCredential( bearer=bearer, base_url=base_url or DEFAULT_XAI_OAUTH_BASE_URL, - expires_at=getattr(entry, "expires_at", None), + expires_at=entry.expires_at, ) diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 5fc184509c..2fb457836e 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -26,10 +26,7 @@ def _print_aiohttp_missing() -> None: def cmd_proxy_start(args: Any) -> int: - """Run the proxy server in the foreground. - - Returns process exit code (0 on clean shutdown). - """ + """Run the proxy server in the foreground.""" if not AIOHTTP_AVAILABLE: _print_aiohttp_missing() return 1 diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index e0734ce6be..1b848718cf 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -1,20 +1,11 @@ """HTTP server that forwards OpenAI-compatible requests to a configured upstream. -Listens on ``http://<host>:<port>/v1/<path>`` and forwards each request to -``<upstream-base-url>/<path>`` with the client's ``Authorization`` header -replaced by a freshly-resolved bearer from the configured adapter. The -response body is streamed through unchanged (SSE deltas preserved). +One narrow SSE compatibility shim applies after a *clean* upstream EOF: when a ``text/event-stream`` +response carries a terminal ``finish_reason`` or ``lastOne: true`` but omits the OpenAI ``data: +[DONE]`` sentinel, the proxy appends a single ``[DONE]`` frame. -One narrow SSE compatibility shim applies after a *clean* upstream EOF: -when a ``text/event-stream`` response carries a terminal ``finish_reason`` -or ``lastOne: true`` but omits the OpenAI ``data: [DONE]`` sentinel, the -proxy appends a single ``[DONE]`` frame. It never rewrites earlier frames, -never duplicates an upstream ``[DONE]``, and never synthesizes ``[DONE]`` -after an error event or a mid-stream interrupt (see -:mod:`hermes_cli.proxy.sse_done`, issue #90848). - -Otherwise the server does not mediate, log, or rewrite request/response -bodies — it is a credential-attaching forwarder. +Otherwise the server does not mediate, log, or rewrite request/response bodies — it is a credential- +attaching forwarder. """ from __future__ import annotations @@ -69,41 +60,31 @@ DEFAULT_HOST = "127.0.0.1" MAX_REQUEST_BYTES = 10_000_000 +def _require_aiohttp() -> None: + if not AIOHTTP_AVAILABLE: + raise RuntimeError( + "aiohttp is required for `hermes proxy`. Run `hermes setup` to install it." + ) + + def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response": """Return an OpenAI-style error JSON response.""" body = {"error": {"message": message, "type": code, "code": code}} return web.json_response(body, status=status) -def _filter_request_headers(headers: "aiohttp.typedefs.LooseHeaders") -> dict: - """Strip hop-by-hop + auth headers from the inbound request.""" - out = {} - for key, value in headers.items(): - if key.lower() in _HOP_BY_HOP_HEADERS: - continue - out[key] = value - return out +# aiohttp recomputes Content-Encoding/Content-Length on stream — let it. +_RESPONSE_DROP_HEADERS = _HOP_BY_HOP_HEADERS | {"content-encoding", "content-length"} -def _filter_response_headers(headers) -> dict: - """Strip hop-by-hop headers from the upstream response.""" - out = {} - for key, value in headers.items(): - if key.lower() in _HOP_BY_HOP_HEADERS: - continue - # aiohttp recomputes Content-Encoding/Content-Length on stream — let it. - if key.lower() in {"content-encoding", "content-length"}: - continue - out[key] = value - return out +def _filter_headers(headers, drop: frozenset = _HOP_BY_HOP_HEADERS) -> dict: + """Strip hop-by-hop (+ auth) headers; ``drop`` widens the set for upstream responses.""" + return {key: value for key, value in headers.items() if key.lower() not in drop} def create_app(adapter: UpstreamAdapter) -> "web.Application": """Build the aiohttp application bound to a specific upstream adapter.""" - if not AIOHTTP_AVAILABLE: - raise RuntimeError( - "aiohttp is required for `hermes proxy`. Run `hermes setup` to install it." - ) + _require_aiohttp() app = web.Application(client_max_size=MAX_REQUEST_BYTES) # AppKey ensures forward-compat with future aiohttp versions that strip @@ -166,7 +147,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": if request.query_string: upstream_url = f"{upstream_url}?{request.query_string}" - fwd_headers = _filter_request_headers(request.headers) + fwd_headers = _filter_headers(request.headers) fwd_headers["Authorization"] = f"{active_cred.token_type} {active_cred.bearer}" logger.debug( @@ -199,23 +180,11 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": return _json_error(500, str(exc)), None except aiohttp.ClientError as exc: logger.warning("proxy: upstream connection failed: %s", exc) - return ( - _json_error( - 502, - f"upstream connection failed: {exc}", - code="upstream_unreachable", - ), - None, - ) + return _json_error( + 502, f"upstream connection failed: {exc}", code="upstream_unreachable" + ), None except asyncio.TimeoutError: - return ( - _json_error( - 504, - "upstream request timed out", - code="upstream_timeout", - ), - None, - ) + return _json_error(504, "upstream request timed out", code="upstream_timeout"), None session_or_response, upstream_resp = await _open_upstream(cred) if upstream_resp is None: @@ -251,7 +220,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": # Stream response back. Headers first, then chunked body. resp = web.StreamResponse( status=upstream_resp.status, - headers=_filter_response_headers(upstream_resp.headers), + headers=_filter_headers(upstream_resp.headers, _RESPONSE_DROP_HEADERS), ) await resp.prepare(request) @@ -297,14 +266,8 @@ async def run_server( port: int = DEFAULT_PORT, shutdown_event: Optional[asyncio.Event] = None, ) -> None: - """Run the proxy in the current event loop until shutdown_event is set. - - If shutdown_event is None, runs until cancelled (Ctrl+C or SIGTERM). - """ - if not AIOHTTP_AVAILABLE: - raise RuntimeError( - "aiohttp is required for `hermes proxy`. Run `hermes setup` to install it." - ) + """Run the proxy in the current event loop until shutdown_event is set.""" + _require_aiohttp() app = create_app(adapter) runner = web.AppRunner(app, access_log=None) diff --git a/hermes_cli/proxy/sse_done.py b/hermes_cli/proxy/sse_done.py index bf2f69e832..46f39e7acd 100644 --- a/hermes_cli/proxy/sse_done.py +++ b/hermes_cli/proxy/sse_done.py @@ -1,24 +1,13 @@ """SSE ``[DONE]`` sentinel normalization for OpenAI-compatible proxies. -Some upstreams (notably Nous Portal for certain free models) deliver a -complete chat-completions stream — content deltas, a non-null -``finish_reason``, and often a ``lastOne: true`` usage frame — then close -the connection without the conventional OpenAI terminal event:: +Strict OpenAI-compatible clients treat that shape as a truncated stream. This module watches the +forwarded SSE byte stream and reports whether the proxy should append a single ``data: [DONE]`` +frame after a *clean* upstream EOF. - data: [DONE] - -Strict OpenAI-compatible clients treat that shape as a truncated stream. -This module watches the forwarded SSE byte stream and reports whether the -proxy should append a single ``data: [DONE]`` frame after a *clean* -upstream EOF. - -Rules (issue #90848): -- Retain every original delta unchanged (this helper never rewrites bytes). -- Append ``[DONE]`` only after a complete terminal choice - (``finish_reason`` non-null) **or** an upstream ``lastOne: true`` marker. -- Never synthesize ``[DONE]`` after an error event, or when the stream was - interrupted before clean EOF. -- Never emit a second ``[DONE]`` when the upstream already sent one. +- Append ``[DONE]`` only after a complete terminal choice (``finish_reason`` non-null) **or** an +upstream ``lastOne: true`` marker. - Never synthesize ``[DONE]`` after an error event, or when the +stream was interrupted before clean EOF. - Never emit a second ``[DONE]`` when the upstream already +sent one. """ from __future__ import annotations diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py index aab1c6ad3b..c3cfa30910 100644 --- a/hermes_cli/proxy_cli.py +++ b/hermes_cli/proxy_cli.py @@ -1,24 +1,13 @@ -"""CLI handlers for ``hermes egress ...``. - -Subcommands: - install — download the pinned iron-proxy binary - setup — interactive wizard: install binary, generate CA, mint tokens, write config - start — launch the proxy as a managed subprocess - stop — terminate the managed proxy - status — show binary version + config presence + listen state + mappings - disable — flip ``proxy.enabled`` to False (does not stop a running proxy) - config — print the generated proxy.yaml path (for debugging / external review) - -The top-level command is ``hermes egress``. Note that the inbound OAuth -reverse-proxy command (``hermes proxy``) lives elsewhere in -``hermes_cli/main.py`` — different direction, different purpose. -""" +"""CLI handlers for ``hermes egress ...``.""" from __future__ import annotations import argparse import os -from typing import List +import shutil +import sys +from datetime import datetime +from typing import List, Optional from rich.console import Console from rich.panel import Panel @@ -34,11 +23,7 @@ from hermes_cli.config import load_config, save_config def register_cli(parent_parser: argparse.ArgumentParser) -> None: - """Attach the egress subcommand tree to a parent parser. - - Called from ``hermes_cli.main`` as part of building the top-level - ``hermes egress`` parser. - """ + """Attach the egress subcommand tree to a parent parser.""" # dest='egress_command' — keeps this subparser tree disjoint from the # inbound OAuth ``hermes proxy`` subparser (which uses dest='proxy_command'). @@ -46,87 +31,60 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: # but a future grep-and-refactor on ``proxy_command`` would otherwise # hit both handlers. sub = parent_parser.add_subparsers(dest="egress_command") - - install = sub.add_parser( - "install", - help=f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})", - ) - install.add_argument( - "--force", action="store_true", - help="Re-download even if a managed copy already exists", - ) - install.set_defaults(func=cmd_install) - - setup = sub.add_parser( - "setup", - help="Interactive wizard: install + CA + mint tokens + write config", - ) - setup.add_argument( - "--tunnel-port", type=int, default=None, - help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})", - ) - setup.add_argument( - "--from-bitwarden", action="store_true", - help="Treat secrets as managed by Bitwarden — discover provider keys " - "from secrets.bitwarden config instead of the current env. Fails " - "loudly if BW is unreachable rather than silently falling back.", - ) - setup.add_argument( - "--no-bitwarden", action="store_true", - help="Explicitly switch credential_source back to env on re-setup " - "(only meaningful when the previous setup used --from-bitwarden).", - ) - setup.add_argument( - "--rotate-tokens", action="store_true", - help="Mint fresh proxy tokens for every provider (default is to " - "preserve tokens for providers that already had one — avoids " - "401-ing already-running sandboxes on re-setup).", - ) - setup.add_argument( - "--restart", dest="restart", action="store_true", default=None, - help="If a daemon is already running, restart it automatically after " - "writing the new config/tokens (non-interactive default on a tty " - "is to ask).", - ) - setup.add_argument( - "--no-restart", dest="restart", action="store_false", - help="Do not restart a running daemon after setup; you'll need to run " - "`hermes egress restart` yourself for changes to take effect.", - ) - setup.set_defaults(func=cmd_setup) - - start = sub.add_parser("start", help="Start the managed iron-proxy") - start.set_defaults(func=cmd_start) - - stop = sub.add_parser("stop", help="Stop the managed iron-proxy") - stop.set_defaults(func=cmd_stop) - - restart = sub.add_parser( - "restart", - help="Restart the managed iron-proxy (stop if running, then start)", - ) - restart.set_defaults(func=cmd_restart) - - reload_p = sub.add_parser( - "reload", - help="Hot-reload the running daemon's ruleset from proxy.yaml " - "(management API — no restart, no dropped connections)", - ) - reload_p.set_defaults(func=cmd_reload) - - status = sub.add_parser("status", help="Show proxy state and mappings") - status.add_argument( - "--show-tokens", action="store_true", - help="Print the proxy tokens (default: redacted prefix only). " - "Beware: tokens may persist in your shell history.", - ) - status.set_defaults(func=cmd_status) - - disable = sub.add_parser("disable", help="Turn off the proxy integration") - disable.set_defaults(func=cmd_disable) - - cfg = sub.add_parser("config", help="Print the generated proxy.yaml path") - cfg.set_defaults(func=cmd_config) + # (name, help, handler, [(flag, add_argument kwargs), ...]) — declaration order is the + # ``--help`` order, so keep it stable. + commands = [ + ("install", f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})", cmd_install, [ + ("--force", dict(action="store_true", + help="Re-download even if a managed copy already exists")), + ]), + ("setup", "Interactive wizard: install + CA + mint tokens + write config", cmd_setup, [ + ("--tunnel-port", dict( + type=int, default=None, + help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})")), + ("--from-bitwarden", dict( + action="store_true", + help="Treat secrets as managed by Bitwarden — discover provider keys " + "from secrets.bitwarden config instead of the current env. Fails " + "loudly if BW is unreachable rather than silently falling back.")), + ("--no-bitwarden", dict( + action="store_true", + help="Explicitly switch credential_source back to env on re-setup " + "(only meaningful when the previous setup used --from-bitwarden).")), + ("--rotate-tokens", dict( + action="store_true", + help="Mint fresh proxy tokens for every provider (default is to " + "preserve tokens for providers that already had one — avoids " + "401-ing already-running sandboxes on re-setup).")), + ("--restart", dict( + dest="restart", action="store_true", default=None, + help="If a daemon is already running, restart it automatically after " + "writing the new config/tokens (non-interactive default on a tty " + "is to ask).")), + ("--no-restart", dict( + dest="restart", action="store_false", + help="Do not restart a running daemon after setup; you'll need to run " + "`hermes egress restart` yourself for changes to take effect.")), + ]), + ("start", "Start the managed iron-proxy", cmd_start, []), + ("stop", "Stop the managed iron-proxy", cmd_stop, []), + ("restart", "Restart the managed iron-proxy (stop if running, then start)", cmd_restart, []), + ("reload", "Hot-reload the running daemon's ruleset from proxy.yaml " + "(management API — no restart, no dropped connections)", cmd_reload, []), + ("status", "Show proxy state and mappings", cmd_status, [ + ("--show-tokens", dict( + action="store_true", + help="Print the proxy tokens (default: redacted prefix only). " + "Beware: tokens may persist in your shell history.")), + ]), + ("disable", "Turn off the proxy integration", cmd_disable, []), + ("config", "Print the generated proxy.yaml path", cmd_config, []), + ] + for name, help_text, func, arguments in commands: + parser = sub.add_parser(name, help=help_text) + for flag, kwargs in arguments: + parser.add_argument(flag, **kwargs) + parser.set_defaults(func=func) # --------------------------------------------------------------------------- @@ -189,56 +147,8 @@ def cmd_setup(args: argparse.Namespace) -> int: available_env_names: List[str] = [] if args.from_bitwarden: - cfg = load_config() - bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} - if not bw_cfg.get("enabled"): - console.print( - " [red]✗ --from-bitwarden requested but " - "secrets.bitwarden.enabled is false.[/red]" - ) - console.print( - " Run `hermes secrets bitwarden setup` first, or omit " - "--from-bitwarden." - ) - return 1 - try: - from agent.secret_sources import bitwarden as bw - access_token = os.environ.get( - bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), "" - ).strip() - if not access_token: - console.print( - f" [red]✗ --from-bitwarden requested but " - f"{bw_cfg.get('access_token_env', 'BWS_ACCESS_TOKEN')} " - "is not set in the environment.[/red]" - ) - return 1 - secrets, _ = bw.fetch_bitwarden_secrets( - access_token=access_token, - project_id=bw_cfg.get("project_id", ""), - cache_ttl_seconds=0, - use_cache=False, - ) - available_env_names = list(secrets.keys()) - if not available_env_names: - console.print( - " [red]✗ Bitwarden returned an empty secrets list.[/red]\n" - " Check the project_id in secrets.bitwarden and the " - "BWS access-token's project scope." - ) - return 1 - console.print( - f" Pulled {len(available_env_names)} env names from Bitwarden." - ) - except Exception as exc: # noqa: BLE001 — explicit user-facing error - console.print( - f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]" - ) - console.print( - " Either fix the Bitwarden config and retry, or rerun setup " - "without --from-bitwarden (the proxy will read secrets from " - "the host process env at start time)." - ) + available_env_names = _bitwarden_env_names(console) + if available_env_names is None: return 1 else: # Env-based discovery reads os.environ. Operators commonly keep their @@ -272,19 +182,13 @@ def cmd_setup(args: argparse.Namespace) -> int: # when stdin isn't a tty (CI / non-interactive use), in which case # the operator passed the flag deliberately. if rotate and existing: - import sys as _sys - from datetime import datetime as _dt - if _sys.stdin.isatty(): + if sys.stdin.isatty(): console.print( "[yellow]⚠[/yellow] --rotate-tokens will invalidate proxy " "tokens in every running Hermes sandbox. They will start " "401-ing against upstreams until restarted." ) - try: - ans = input("Type 'rotate' to confirm: ").strip().lower() - except EOFError: - ans = "" - if ans != "rotate": + if _prompt("Type 'rotate' to confirm: ") != "rotate": console.print("[yellow]Cancelled.[/yellow]") return 1 # Backup the existing mappings before we overwrite. The @@ -292,13 +196,12 @@ def cmd_setup(args: argparse.Namespace) -> int: # the operator manually recover tokens if they realise the # rotation was a mistake. try: - import shutil as _shutil state_dir = ip._proxy_state_dir() mappings_src = state_dir / "mappings.json" if mappings_src.exists(): - ts = _dt.now().strftime("%Y%m%dT%H%M%S") + ts = datetime.now().strftime("%Y%m%dT%H%M%S") backup = state_dir / f"mappings.json.rotated-{ts}" - _shutil.copy2(str(mappings_src), str(backup)) + shutil.copy2(str(mappings_src), str(backup)) console.print(f" [dim]backup: {backup}[/dim]") except OSError as exc: console.print( @@ -478,23 +381,16 @@ def cmd_setup(args: argparse.Namespace) -> int: # neither + !tty → restart when a daemon was running; otherwise no-op # (first-time setup never auto-starts — matches the # "configured, now run start" flow) - import sys as _sys restart_pref = getattr(args, "restart", None) - if restart_pref is True: - do_restart = True - elif restart_pref is False: - do_restart = False + if restart_pref is True or restart_pref is False: + do_restart = restart_pref elif was_running: - if _sys.stdin.isatty(): - try: - ans = input( - " Restart the running proxy now with the new config? [Y/n] " - ).strip().lower() - except EOFError: - ans = "" - do_restart = ans in ("", "y", "yes") - else: - do_restart = True + do_restart = ( + _prompt(" Restart the running proxy now with the new config? [Y/n] ") + in ("", "y", "yes") + if sys.stdin.isatty() + else True + ) else: do_restart = False @@ -675,10 +571,9 @@ def cmd_stop(args: argparse.Namespace) -> int: def cmd_restart(args: argparse.Namespace) -> int: """Stop the running daemon (if any) and start it with the current config. - The one-command way to apply config changes (new allowlist hosts, rotated - tokens, a Bitwarden key rotation) without making the operator remember the - stop/start dance. Delegates to ``cmd_start`` so all the credential-source - guards run exactly as they do for ``start``. + The one-command way to apply config changes (new allowlist hosts, rotated tokens, a + Bitwarden key rotation). Delegates to ``cmd_start`` so all credential-source guards run + exactly as for ``start``. """ console = Console() was_running = ip.stop_proxy() @@ -690,13 +585,10 @@ def cmd_restart(args: argparse.Namespace) -> int: def cmd_reload(args: argparse.Namespace) -> int: """Hot-reload the running daemon's ruleset via the management API. - Applies allowlist / token / mapping changes already written to - proxy.yaml WITHOUT restarting the daemon — no dropped connections, no - restart window. When the change involves new upstream SECRETS (a - Bitwarden rotation, a newly added provider key), use - ``hermes egress restart`` instead: the daemon reads real credentials - from its own environment at spawn time, and a reload does not - re-populate that env. + Applies allowlist/token/mapping changes already written to proxy.yaml WITHOUT restarting — + no dropped connections. For new upstream SECRETS (a Bitwarden rotation, a new provider key) + use ``hermes egress restart`` instead: the daemon reads credentials from its own environment + at spawn time and a reload does not re-populate that env. """ console = Console() try: @@ -722,24 +614,14 @@ def format_status_text(*, show_tokens: bool = False) -> str: proxy_cfg = cfg.get("proxy") or {} status = ip.get_status() - def yn(value: bool) -> str: - return "yes" if value else "no" - - lines = [ - "Egress proxy status", - "", - f"Enabled: {yn(bool(proxy_cfg.get('enabled')))}", - f"Binary: {status.binary_path or '(missing)'}", - f"Binary version: {status.binary_version or '(unknown)'}", - f"Config: {status.config_path or '(not generated)'}", - f"CA cert: {status.ca_cert_path or '(not generated)'}", - f"Tunnel port: {status.tunnel_port}", - f"Process: pid {status.pid}" if status.pid else "Process: (stopped)", - f"Listening: {yn(status.listening)}", - f"Credential src: {proxy_cfg.get('credential_source', 'env')}", - f"Docker enforce: {yn(bool(proxy_cfg.get('enforce_on_docker', True)))}", - "Scope: Docker backend only in this release", - ] + lines = ["Egress proxy status", ""] + lines.extend( + f"{label}: {value}" + for label, value in _status_rows( + proxy_cfg, status, yn=lambda v: "yes" if v else "no", dim=lambda t: t + ) + ) + lines.append("Scope: Docker backend only in this release") mappings = ip.load_mappings() if mappings: @@ -774,16 +656,8 @@ def cmd_status(args: argparse.Namespace) -> int: table = Table(show_header=False, box=None, padding=(0, 2)) table.add_column("", style="bold") table.add_column("") - table.add_row("Enabled", _yn(bool(proxy_cfg.get("enabled")))) - table.add_row("Binary", str(status.binary_path or "[dim](missing)[/dim]")) - table.add_row("Binary version", status.binary_version or "[dim](unknown)[/dim]") - table.add_row("Config", str(status.config_path or "[dim](not generated)[/dim]")) - table.add_row("CA cert", str(status.ca_cert_path or "[dim](not generated)[/dim]")) - table.add_row("Tunnel port", str(status.tunnel_port)) - table.add_row("Process", f"pid {status.pid}" if status.pid else "[dim](stopped)[/dim]") - table.add_row("Listening", _yn(status.listening)) - table.add_row("Credential src", str(proxy_cfg.get("credential_source", "env"))) - table.add_row("Docker enforce", _yn(bool(proxy_cfg.get("enforce_on_docker", True)))) + for label, value in _status_rows(proxy_cfg, status, yn=_yn, dim=lambda t: f"[dim]{t}[/dim]"): + table.add_row(label, value) console.print(table) mappings = ip.load_mappings() @@ -860,18 +734,72 @@ def cmd_config(args: argparse.Namespace) -> int: # --------------------------------------------------------------------------- +def _bitwarden_env_names(console: Console) -> Optional[List[str]]: + """Secret names from Bitwarden for ``setup --from-bitwarden``; prints the error and returns + ``None`` on any failure so the wizard aborts loudly instead of falling back to the host env. + """ + cfg = load_config() + bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} + if not bw_cfg.get("enabled"): + console.print( + " [red]✗ --from-bitwarden requested but " + "secrets.bitwarden.enabled is false.[/red]" + ) + console.print( + " Run `hermes secrets bitwarden setup` first, or omit " + "--from-bitwarden." + ) + return None + try: + from agent.secret_sources import bitwarden as bw + access_token = os.environ.get( + bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), "" + ).strip() + if not access_token: + console.print( + f" [red]✗ --from-bitwarden requested but " + f"{bw_cfg.get('access_token_env', 'BWS_ACCESS_TOKEN')} " + "is not set in the environment.[/red]" + ) + return None + secrets, _ = bw.fetch_bitwarden_secrets( + access_token=access_token, + project_id=bw_cfg.get("project_id", ""), + cache_ttl_seconds=0, + use_cache=False, + ) + names = list(secrets.keys()) + if not names: + console.print( + " [red]✗ Bitwarden returned an empty secrets list.[/red]\n" + " Check the project_id in secrets.bitwarden and the " + "BWS access-token's project scope." + ) + return None + console.print(f" Pulled {len(names)} env names from Bitwarden.") + return names + except Exception as exc: # noqa: BLE001 — explicit user-facing error + console.print( + f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]" + ) + console.print( + " Either fix the Bitwarden config and retry, or rerun setup " + "without --from-bitwarden (the proxy will read secrets from " + "the host process env at start time)." + ) + return None + + def _load_env_file_into_environ() -> int: """Backfill provider keys from ``~/.hermes/.env`` into ``os.environ``. - ``hermes egress setup`` discovers providers by reading ``os.environ``, but - many operators keep their keys ONLY in ``~/.hermes/.env`` (which the agent - loads at runtime but which is NOT exported into an interactive shell). - Without this, ``setup`` reports "no provider keys found" even though the - keys plainly exist — a confusing first-run papercut. + ``hermes egress setup`` discovers providers by reading ``os.environ``, but many operators keep + their keys ONLY in ``~/.hermes/.env`` (which the agent loads at runtime but which is NOT + exported into an interactive shell). - Only fills names that aren't already set in the process env (an exported - value always wins), and only for known bearer-provider names so we don't - slurp unrelated secrets into the process. Returns the count of names added. + Only fills names that aren't already set in the process env (an exported value always wins), and + only for known bearer-provider names so we don't slurp unrelated secrets into the process. + Returns the count of names added. """ try: from hermes_cli.config import load_env @@ -897,6 +825,33 @@ def _yn(value: bool) -> str: return "[green]yes[/green]" if value else "[dim]no[/dim]" +def _prompt(text: str) -> str: + """``input()`` lowered+stripped; EOF (closed stdin) reads as an empty answer.""" + try: + return input(text).strip().lower() + except EOFError: + return "" + + +def _status_rows(proxy_cfg: dict, status, *, yn, dim) -> list[tuple[str, str]]: + """``(label, value)`` pairs shared by the rich ``status`` table and the plain-text variant. + + ``yn`` renders booleans; ``dim`` wraps placeholder text for missing values. + """ + return [ + ("Enabled", yn(bool(proxy_cfg.get("enabled")))), + ("Binary", str(status.binary_path or dim("(missing)"))), + ("Binary version", status.binary_version or dim("(unknown)")), + ("Config", str(status.config_path or dim("(not generated)"))), + ("CA cert", str(status.ca_cert_path or dim("(not generated)"))), + ("Tunnel port", str(status.tunnel_port)), + ("Process", f"pid {status.pid}" if status.pid else dim("(stopped)")), + ("Listening", yn(status.listening)), + ("Credential src", str(proxy_cfg.get("credential_source", "env"))), + ("Docker enforce", yn(bool(proxy_cfg.get("enforce_on_docker", True)))), + ] + + def _redact_token(token: str) -> str: if len(token) < 16: return token diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py index c029324542..f320da4b34 100644 --- a/hermes_cli/psutil_android.py +++ b/hermes_cli/psutil_android.py @@ -68,19 +68,16 @@ def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path: """Safely extract the pinned psutil sdist and patch it for Android.""" _safe_extract_tar_gz(archive, destination) - src_roots = sorted( - ( - path for path in destination.iterdir() - if path.is_dir() and path.name.startswith("psutil-") - ), - key=lambda path: path.name, - ) + src_roots = [ + path for path in destination.iterdir() + if path.is_dir() and path.name.startswith("psutil-") + ] if not src_roots: raise PsutilAndroidInstallError( "psutil sdist did not contain a psutil-* directory" ) - src_root = src_roots[0] + src_root = min(src_roots, key=lambda path: path.name) common_py = src_root / "psutil" / "_common.py" if not common_py.is_file(): raise PsutilAndroidInstallError( diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index a9a3982639..b33de89b86 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -1,13 +1,4 @@ -"""Augmentations to prompt_toolkit's input-parsing tables. - -Imported once at CLI startup. Each helper installs a small mapping into -prompt_toolkit's `ANSI_SEQUENCES` so byte sequences emitted by modern -keyboard protocols (Kitty / xterm `modifyOtherKeys`) decode to existing -key tuples Hermes already binds. - -Kept in a standalone module — separate from `cli.py` — so the registrations -can be unit-tested without importing the whole CLI runtime. -""" +"""Augmentations to prompt_toolkit's input-parsing tables.""" from __future__ import annotations @@ -31,13 +22,10 @@ def _lock_twins(modifier: int) -> tuple[int, ...]: def _clear_vt100_prefix_cache() -> None: - """Drop prompt_toolkit's memoized "is this a prefix of a longer match?" - answers after mutating ``ANSI_SEQUENCES``. + """Drop prompt_toolkit's memoized prefix-match answers after mutating ``ANSI_SEQUENCES``. - The cache is module-global and populated lazily per distinct prefix, so - parsers created before an install (or primed by earlier tests) would - otherwise keep stale ``False`` answers and misparse newly registered - sequences. Call after any install that changed the table. + The cache is module-global and lazily filled per prefix, so parsers created before an install + would keep stale ``False`` answers and misparse newly registered sequences. """ try: from prompt_toolkit.input.vt100_parser import ( @@ -48,27 +36,37 @@ def _clear_vt100_prefix_cache() -> None: pass +def _pt_tables(): + """Return ``(ANSI_SEQUENCES, Keys)`` or ``None`` when prompt_toolkit is unavailable.""" + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + except Exception: + return None + return ANSI_SEQUENCES, Keys + + +def _register(table: dict, aliases: dict, *, overwrite: bool) -> int: + """Install ``aliases`` into ``table``; return the number of entries changed. + + ``overwrite=True`` replaces differing entries; ``overwrite=False`` behaves like ``setdefault`` + so existing/user registrations win. Clears the VT100 prefix cache when anything changed, since + new longer sequences can flip "is this a prefix of a longer match?" answers the parser cached. + """ + changed = 0 + for seq, key in aliases.items(): + if (table.get(seq) != key) if overwrite else (seq not in table): + table[seq] = key + changed += 1 + if changed: + _clear_vt100_prefix_cache() + return changed + + def install_keypress_data_normalization() -> int: - """Normalize KeyPress data for extended-key aliases that map to a - single plain character (Shift+Space → ``' '``, Shift+letter → the - uppercase letter, keypad digits → ``'0'``..``'9'``, keypad operators). - - Root cause of #88071: ``Vt100Parser._call_handler`` builds - ``KeyPress(key, match.group(0))`` — the *key* is correctly remapped by - ``ANSI_SEQUENCES``, but the *data* field still carries the full raw - escape text (e.g. ``"\\x1b[32;2u"``). prompt_toolkit's default - character-insert binding (``self-insert``, ``basic.py``) inserts - ``event.data``, so the raw CSI bytes land in the prompt buffer. For a - plain space both fields are ``' '`` so it is invisible; for any mapped - extended sequence the escape text is what gets inserted. - - This patches ``Vt100Parser._call_handler`` so that when a sequence maps - to a single plain character, the KeyPress data is that character rather - than the raw sequence — the bytes never reach the buffer. Idempotent; - repeated calls are no-ops. - - Returns 1 when the patch was applied, 0 when already applied or the - import failed. + """Normalize KeyPress data for extended-key aliases that map to a single plain character + (Shift+Space → ``' '``, Shift+letter → the uppercase letter, keypad digits → ``'0'``..``'9'``, + keypad operators). """ try: import prompt_toolkit.input.vt100_parser as _vt100_mod @@ -102,196 +100,84 @@ def install_keypress_data_normalization() -> int: return 1 -def install_shift_enter_alias() -> int: - """Map Shift+Enter byte sequences to the (Escape, ControlM) key tuple - that Alt+Enter produces, so the existing Alt+Enter newline handler - fires for terminals that emit a distinct Shift+Enter. +def _install_enter_alias(modifier: int) -> int: + """Map <modifier>+Enter (Kitty CSI-u ``ESC[13;<m>u`` plus lock-bit twins, xterm + ``ESC[27;<m>;13~`` / ``;13u``) to (Escape, ControlM) so the Alt+Enter newline handler fires. - Sequences mapped: - - "\\x1b[13;2u" — Kitty keyboard protocol / CSI-u, modifier=2 (Shift) - (plus its CapsLock/NumLock lock twins via ``_lock_variants``) - - "\\x1b[27;2;13~" — xterm modifyOtherKeys=2, modifier=2 (Shift) - - "\\x1b[27;2;13u" — alternate ordering some emitters use - - The CSI-u sequence is not in stock prompt_toolkit. The modifyOtherKeys - variant `\\x1b[27;2;13~` IS in stock prompt_toolkit but mapped to plain - `Keys.ControlM` — i.e. Shift+Enter behaves identically to Enter, which - is the very bug this helper exists to fix. We therefore overwrite - those two specific keys (and `\\x1b[27;2;13u`) unconditionally; other - `\\x1b[27;...;13~` sequences (Ctrl+Enter, Alt+Enter via modifyOtherKeys - variants 5/6/etc.) are left untouched. - - Default macOS Terminal and stock Windows Terminal still send the same - byte for Enter and Shift+Enter, so there is no fix for those terminals - at the application layer — the sequences above never reach Hermes. - - Returns the number of sequences whose mapping was changed. + Stock prompt_toolkit maps the tilde form to plain ControlM (i.e. Shift+Enter == Enter, the very + bug this fixes), so those keys are overwritten unconditionally; other modifier variants are + untouched. """ - try: - from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES - from prompt_toolkit.keys import Keys - except Exception: + tables = _pt_tables() + if tables is None: return 0 + seqs, keys = tables + alt_enter = (keys.Escape, keys.ControlM) + aliases = {f"\x1b[13;{m}u": alt_enter for m in _lock_variants(modifier)} + aliases[f"\x1b[27;{modifier};13~"] = alt_enter + aliases[f"\x1b[27;{modifier};13u"] = alt_enter + return _register(seqs, aliases, overwrite=True) - alt_enter = (Keys.Escape, Keys.ControlM) - changed = 0 - seqs = [f"\x1b[13;{m}u" for m in _lock_variants(2)] - seqs += ["\x1b[27;2;13~", "\x1b[27;2;13u"] - for seq in seqs: - if ANSI_SEQUENCES.get(seq) != alt_enter: - ANSI_SEQUENCES[seq] = alt_enter - changed += 1 - if changed: - _clear_vt100_prefix_cache() - return changed + +def install_shift_enter_alias() -> int: + """Map Shift+Enter sequences to (Escape, ControlM) so the Alt+Enter newline handler fires. + + macOS Terminal and stock Windows Terminal send the same byte for Enter and Shift+Enter, so + nothing can be done for them here. + """ + return _install_enter_alias(2) def install_ctrl_enter_alias() -> int: - """Map Ctrl+Enter byte sequences to the (Escape, ControlM) key tuple - that Alt+Enter produces, so the existing Alt+Enter newline handler - fires for terminals that emit a distinct Ctrl+Enter. + """Map Ctrl+Enter sequences to (Escape, ControlM) so the Alt+Enter newline handler fires. - Sequences mapped: - - "\\x1b[13;5u" — Kitty keyboard protocol / CSI-u, modifier=5 (Ctrl) - (plus its CapsLock/NumLock lock twins via ``_lock_variants``) - - "\\x1b[27;5;13~" — xterm modifyOtherKeys=2, modifier=5 (Ctrl) - - "\\x1b[27;5;13u" — alternate ordering some emitters use - - Stock prompt_toolkit maps only the tilde form ``\\x1b[27;5;13~`` (to - plain ``Keys.ControlM``, which this deliberately overwrites — same - bug-fix rationale as install_shift_enter_alias). Without this alias, - Kitty/mintty/xterm-with-modifyOtherKeys users over SSH never get a - Ctrl+Enter newline — the keystroke arrives as a raw CSI sequence that - falls through to the default character-insert handler. See #22379. - - Returns the number of sequences whose mapping was changed. + Without the alias, Kitty/mintty/xterm users over SSH get a raw CSI sequence inserted as text. """ - try: - from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES - from prompt_toolkit.keys import Keys - except Exception: - return 0 - - alt_enter = (Keys.Escape, Keys.ControlM) - changed = 0 - seqs = [f"\x1b[13;{m}u" for m in _lock_variants(5)] - seqs += ["\x1b[27;5;13~", "\x1b[27;5;13u"] - for seq in seqs: - if ANSI_SEQUENCES.get(seq) != alt_enter: - ANSI_SEQUENCES[seq] = alt_enter - changed += 1 - if changed: - _clear_vt100_prefix_cache() - return changed + return _install_enter_alias(5) def install_cmd_backspace_alias() -> int: - """Map Cmd+Backspace / Cmd+ForwardDelete to the readline kill bindings - prompt_toolkit already ships (``unix-line-discard`` / ``kill-line``). + """Map Cmd+Backspace / Cmd+ForwardDelete to prompt_toolkit's readline kill bindings. - Terminals that rewrite Cmd+Backspace to Ctrl+U (``\\x15``) already work. - Kitty keyboard protocol and xterm modifyOtherKeys terminals instead - report Cmd as the *super* modifier bit (8), producing sequences - prompt_toolkit does not map — the raw bytes then fall through to - literal insertion. - - Cmd+Backspace → ``Keys.ControlU`` (kill backward to start of line). - Codepoint 127 with modifier 9 (super) / 10 (super+shift), each with - its CapsLock/NumLock lock twins via ``_lock_variants``: - - ``\\x1b[127;9u`` / ``\\x1b[127;10u`` — Kitty CSI-u - - ``\\x1b[27;9;127~`` — xterm modifyOtherKeys - - Cmd+ForwardDelete → ``Keys.ControlK`` (kill to end of line). The - forward-delete key is a CSI *tilde* key, not a CSI-u codepoint, so the - modifier rides in the standard ``CSI 3 ; mod ~`` form: - - ``\\x1b[3;9~`` / ``\\x1b[3;10~`` - - Returns the number of sequences whose mapping was changed. + Terminals that rewrite Cmd+Backspace to Ctrl+U already work; Kitty/modifyOtherKeys report Cmd + as the super bit (8), yielding unmapped sequences that insert literally. Cmd+Backspace -> + ControlU (``ESC[127;9u``, ``;10u``, ``ESC[27;9;127~``); Cmd+ForwardDelete -> ControlK via + the CSI tilde form ``ESC[3;9~`` / ``;10~`` since forward-delete is not a CSI-u codepoint. """ - try: - from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES - from prompt_toolkit.keys import Keys - except Exception: + tables = _pt_tables() + if tables is None: return 0 - + seqs, keys = tables aliases: dict[str, object] = {} for base in (9, 10): # super / super+shift for mod in _lock_variants(base): - aliases[f"\x1b[127;{mod}u"] = Keys.ControlU - aliases[f"\x1b[3;{mod}~"] = Keys.ControlK - aliases["\x1b[27;9;127~"] = Keys.ControlU - changed = 0 - for seq, key in aliases.items(): - if ANSI_SEQUENCES.get(seq) != key: - ANSI_SEQUENCES[seq] = key - changed += 1 - if changed: - _clear_vt100_prefix_cache() - return changed + aliases[f"\x1b[127;{mod}u"] = keys.ControlU + aliases[f"\x1b[3;{mod}~"] = keys.ControlK + aliases["\x1b[27;9;127~"] = keys.ControlU + return _register(seqs, aliases, overwrite=True) def install_modify_other_keys_aliases() -> int: - """Map Ctrl+key and Alt+key sequences emitted under ``modifyOtherKeys`` level 2 - and Kitty CSI-u to the same ``Keys``.* values that the raw control bytes - already map to. + """Map modifyOtherKeys-2 / Kitty CSI-u Ctrl/Alt+key sequences to their raw-byte ``Keys``. - When the terminal is in ``modifyOtherKeys=2`` mode (pushed by - ``_enable_extended_enter_keys`` so Shift+Enter is distinguishable from - Enter), the terminal re-encodes *every* Ctrl+key combo as - ``ESC[27;5;<codepoint>~`` instead of the raw control byte (``\\x01`` etc.). - Kitty keyboard protocol emits ``ESC[<codepoint>;5u``. - - Stock prompt_toolkit 3.x only maps ``ESC[27;5;13~`` (Ctrl+Enter = Ctrl+M); - all other Ctrl+letter combos are unmapped and leak as literal text or get - swallowed — breaking Ctrl+A, Ctrl+C, Ctrl+D, Ctrl+E, Ctrl+K, Ctrl+R, - Ctrl+U, Ctrl+W, Ctrl+Z, etc. (#56684, #86866, #87390). - - This function populates ``ANSI_SEQUENCES`` for the full set: - - * **Ctrl+letter** (a–z): ``ESC[27;5;<codepoint>~`` and ``ESC[<codepoint>;5u`` - → ``Keys.ControlA`` .. ``Keys.ControlZ`` - * **Ctrl+digit** (0–9): same formats → ``Keys.Control0`` .. ``Keys.Control9`` - * **Ctrl+symbol** (``[`` ``\\`` ``]`` ``^`` ``_`` `` `` ``@``): - same formats → the same ``Keys`` value the raw control byte maps to. - * **Alt+letter** (a–z, A–Z): ``ESC[27;3;<codepoint>~`` and - ``ESC[<codepoint>;3u`` → ``(Keys.Escape, <letter>)`` — matching how - prompt_toolkit handles a bare ``ESC`` followed by a character. - * **Shift+letter** (a–z): → the uppercase character. - * **Multi-modifier letters** (Shift+Alt=4, Ctrl+Shift=6, Ctrl+Alt=7, - Ctrl+Alt+Shift=8): normalized onto the same targets — Ctrl-bearing - combos behave as the Ctrl key (Alt adds an ``Escape`` prefix), - matching how dte/kakoune normalize these protocols. - * **Lock-bit variants**: every CSI-u mapping above is also installed - with the CapsLock (64) and NumLock (128) bits ORed into the modifier - parameter — kitty/ghostty include them while a lock is on, and - without the variants every key combo dies with the lock enabled - (``ESC[99;133u`` instead of ``ESC[99;5u``, #89651). - * **Esc key**: ``ESC[27u`` / ``ESC[27;<mod>u`` (Kitty disambiguate mode - reports Esc this way, #56684) → ``Keys.Escape``. - * **Modified Enter/Tab/Backspace/Space**: Alt+Enter → the Alt+Enter - newline tuple; Shift+Tab → ``BackTab``; Ctrl+Tab → plain Tab; - Ctrl/Alt+Backspace → ``(Escape, ControlH)`` (backward-kill-word, - matching the Ink TUI and Desktop, #78285); Shift+Backspace → plain - backspace; Shift+Space → a plain space (#86866); Alt+Space → - ``(Escape, " ")``. - * **Kitty functional keys** (Private Use Area codepoints): keypad keys - → their non-keypad equivalents (KP_ENTER → Enter, KP_4 → '4', - KP_LEFT → Left, …); F13–F24 → ``Keys.F13``..``F24``; lock/media/ - modifier-event keys → ``Keys.Ignore`` so they are consumed instead of - leaking as literal text. kitty emits these CSI-u forms even in legacy - mode for keys that have no legacy encoding. - - Existing mappings (including those installed by - ``install_shift_enter_alias`` / ``install_ctrl_enter_alias``) are never - overwritten — ``setdefault`` semantics. - - Returns the number of sequences whose mapping was newly installed. + Once ``modifyOtherKeys=2`` is pushed (to distinguish Shift+Enter) the terminal re-encodes + EVERY Ctrl combo as ``ESC[27;5;<cp>~``; stock prompt_toolkit maps only Ctrl+Enter, so + Ctrl+A/C/D/E/K/R/U/W/Z leak as text. Installs Ctrl/Alt/Shift letters, digits, symbols, + multi-modifier combos, CapsLock/NumLock lock-bit variants, CSI-u Esc, modified + Enter/Tab/Backspace/Space, and Kitty functional keys. Uses ``setdefault`` so existing + mappings (incl. the Shift/Ctrl+Enter aliases) are never overwritten. """ - try: - from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES - from prompt_toolkit.keys import Keys - except Exception: + tables = _pt_tables() + if tables is None: return 0 + ANSI_SEQUENCES, Keys = tables + + # Everything below is collected into ``aliases`` (first writer wins, matching setdefault + # order) and installed once at the end. + aliases: dict[str, object] = {} + + def _put(seq: str, key_val: object) -> None: + aliases.setdefault(seq, key_val) # -- Ctrl+letter / Ctrl+digit / Ctrl+symbol → Keys.Control* ---- # codepoint -> Keys value. The raw control byte for Ctrl+<ch> is @@ -301,18 +187,6 @@ def install_modify_other_keys_aliases() -> int: ctrl_key_map: dict[int, object] = {} # a-z: Ctrl+A = \x01 = Keys.ControlA, ..., Ctrl+Z = \x1a = Keys.ControlZ - for ch in range(ord('a'), ord('z') + 1): - raw = chr(ch & 0x1F) # 0x01..0x1a - existing = ANSI_SEQUENCES.get(raw) - if existing is not None: - ctrl_key_map[ch] = existing - - # 0-9: Ctrl+digit codepoints don't have a useful raw-byte mapping - # (e.g. chr(ord('0') & 0x1F) = 0x10 = ControlP, not Control0), so map - # them directly to Keys.Control0..Keys.Control9. - for d in range(10): - ctrl_key_map[ord('0') + d] = getattr(Keys, f"Control{d}") - # Symbols that produce control chars: # Ctrl+@ (64) = \x00 = Keys.ControlAt # Ctrl+[ (91) = \x1b = Keys.Escape @@ -321,13 +195,17 @@ def install_modify_other_keys_aliases() -> int: # Ctrl+^ (94) = \x1e = Keys.ControlCircumflex # Ctrl+_ (95) = \x1f = Keys.ControlUnderscore # Ctrl+Space(32) = \x00 = Keys.ControlAt (prompt_toolkit maps \x00 → ControlAt) - for codepoint in (64, 91, 92, 93, 94, 95, 32): - raw = chr(codepoint & 0x1F) - existing = ANSI_SEQUENCES.get(raw) + letters = range(ord('a'), ord('z') + 1) + for codepoint in (*letters, 64, 91, 92, 93, 94, 95, 32): + existing = ANSI_SEQUENCES.get(chr(codepoint & 0x1F)) if existing is not None: ctrl_key_map[codepoint] = existing - changed = 0 + # 0-9: Ctrl+digit codepoints don't have a useful raw-byte mapping + # (e.g. chr(ord('0') & 0x1F) = 0x10 = ControlP, not Control0), so map + # them directly to Keys.Control0..Keys.Control9. + for d in range(10): + ctrl_key_map[ord('0') + d] = getattr(Keys, f"Control{d}") # Kitty CSI-u encodes CapsLock/NumLock state as extra modifier bits # (caps=64, num=128) ORed into the parameter: with NumLock on, Ctrl+C @@ -337,21 +215,14 @@ def install_modify_other_keys_aliases() -> int: # xterm modifyOtherKeys encoding never carries the lock bits, so only # the CSI-u form needs them. def _install_paired(modifier: int, mapping: dict) -> None: - """Install both modifyOtherKeys (ESC[27;N;CP~) and CSI-u (ESC[CP;Nu) - mappings for the given modifier and codepoint→key mapping. - - The tilde form is skipped for modifier 1 ("no modifier") — xterm - never emits modifier-1 tilde sequences. + """Install both modifyOtherKeys (ESC[27;N;CP~) and CSI-u (ESC[CP;Nu) mappings for the given + modifier and codepoint→key mapping. """ - nonlocal changed for codepoint, key_val in mapping.items(): - seqs = [] if modifier == 1 else [f"\x1b[27;{modifier};{codepoint}~"] + if modifier != 1: + _put(f"\x1b[27;{modifier};{codepoint}~", key_val) for mod in _lock_variants(modifier): - seqs.append(f"\x1b[{codepoint};{mod}u") - for seq in seqs: - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = key_val - changed += 1 + _put(f"\x1b[{codepoint};{mod}u", key_val) # Ctrl+letter / Ctrl+digit / Ctrl+symbol (modifier 5) _install_paired(5, ctrl_key_map) @@ -360,14 +231,7 @@ def install_modify_other_keys_aliases() -> int: # Under modifyOtherKeys, Alt+a = ESC[27;3;97~. Without mapping, this # leaks as literal text. prompt_toolkit handles bare Alt+letter as # (Escape, <letter>), so we map the extended sequences to the same tuple. - alt_map: dict[int, tuple] = {} - for ch in range(ord('a'), ord('z') + 1): - letter = chr(ch) - upper = chr(ch - 32) # uppercase variant - alt_map[ch] = (Keys.Escape, letter) - alt_map[ch - 32] = (Keys.Escape, upper) - _install_paired(3, alt_map) - + # # -- Shift+letter → uppercase letter ---- # Under modifyOtherKeys=2, some terminals re-encode Shift+a as # ESC[27;2;97~. Without mapping, this leaks as literal escape + @@ -380,13 +244,7 @@ def install_modify_other_keys_aliases() -> int: # modifyOtherKeys, they will leak, but that's better than wrong input. # Map both the lowercase and uppercase codepoints — some terminals send # the already-shifted codepoint (65 for 'A') with modifier=2. - shift_map: dict[int, str] = {} - for ch in range(ord('a'), ord('z') + 1): - upper_char = chr(ch - 32) # 'A'..'Z' - shift_map[ch] = upper_char - shift_map[ch - 32] = upper_char - _install_paired(2, shift_map) - + # # -- Multi-modifier letters: Shift+Alt (4), Ctrl+Shift (6), # Ctrl+Alt (7), Ctrl+Alt+Shift (8) ---- # The Kitty protocol always reports the UNSHIFTED codepoint; some @@ -395,17 +253,24 @@ def install_modify_other_keys_aliases() -> int: # prefix), Shift+Alt onto (Escape, UPPER) — the same normalization # dte/kakoune apply to these protocols. Without these, Ctrl+Shift+R # etc. leak as literal text under either protocol. + alt_map: dict[int, tuple] = {} + shift_map: dict[int, str] = {} shift_alt_map: dict[int, tuple] = {} ctrl_shift_map: dict[int, object] = {} ctrl_alt_map: dict[int, tuple] = {} - for ch in range(ord('a'), ord('z') + 1): + for ch in letters: upper_char = chr(ch - 32) + alt_map[ch] = (Keys.Escape, chr(ch)) + alt_map[ch - 32] = (Keys.Escape, upper_char) ctrl_key = ctrl_key_map.get(ch) for cp in (ch, ch - 32): + shift_map[cp] = upper_char shift_alt_map[cp] = (Keys.Escape, upper_char) if ctrl_key is not None: ctrl_shift_map[cp] = ctrl_key ctrl_alt_map[cp] = (Keys.Escape, ctrl_key) + _install_paired(3, alt_map) + _install_paired(2, shift_map) _install_paired(4, shift_alt_map) _install_paired(6, ctrl_shift_map) _install_paired(7, ctrl_alt_map) @@ -420,14 +285,10 @@ def install_modify_other_keys_aliases() -> int: # the lock-bit variants of the modifier-less form (1+64/128/192) are # how a lone Esc keypress arrives with a lock on. Lock bits (caps/num) # get the same variant treatment as _install_paired. - for seq in ["\x1b[27u"] + [ - f"\x1b[27;{mod}u" - for m in range(1, 17) - for mod in _lock_variants(m) - ]: - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = Keys.Escape - changed += 1 + _put("\x1b[27u", Keys.Escape) + for m in range(1, 17): + for mod in _lock_variants(m): + _put(f"\x1b[27;{mod}u", Keys.Escape) # -- Modified Enter / Tab / Backspace / Space ---- # Shift+Enter / Ctrl+Enter are installed by install_shift_enter_alias / @@ -483,21 +344,15 @@ def install_modify_other_keys_aliases() -> int: if key is None: continue for mod in _lock_twins(m): - seq = f"\x1b[1;{mod}{trailer}" - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = key - changed += 1 + _put(f"\x1b[1;{mod}{trailer}", key) # CSI-tilde navigation: Insert/Delete/PageUp/PageDown/Home/End - for num in (1, 2, 3, 4, 5, 6, 7, 8): + for num in range(1, 9): base_seq = f"\x1b[{num};{m}~" if m > 1 else f"\x1b[{num}~" key = ANSI_SEQUENCES.get(base_seq) if key is None: continue for mod in _lock_twins(m): - seq = f"\x1b[{num};{mod}~" - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = key - changed += 1 + _put(f"\x1b[{num};{mod}~", key) # -- Kitty functional keys (Private Use Area codepoints) ---- # kitty emits these CSI-u encodings even in LEGACY mode for keys that @@ -529,60 +384,24 @@ def install_modify_other_keys_aliases() -> int: ): functional_map.setdefault(code, Keys.Ignore) for code, key_val in functional_map.items(): - seq = f"\x1b[{code}u" - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = key_val - changed += 1 + _put(f"\x1b[{code}u", key_val) # Lock twins: with a lock on these arrive as ESC[<code>;129u etc. for mod in _lock_twins(1): - seq = f"\x1b[{code};{mod}u" - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = key_val - changed += 1 + _put(f"\x1b[{code};{mod}u", key_val) - # New longer sequences can flip "is this a prefix of a longer match?" - # answers the VT100 parser already cached — drop the cache so parsers - # created before this install (or in earlier tests) can't misparse. - if changed: - _clear_vt100_prefix_cache() - - return changed + return _register(ANSI_SEQUENCES, aliases, overwrite=False) def install_ignored_terminal_sequences() -> int: - """Map terminal-emitted noise sequences to ``Keys.Ignore`` so they - are consumed by the VT100 parser before they reach key bindings or - the input buffer. + """Map terminal noise sequences to ``Keys.Ignore`` so the VT100 parser consumes them. - Currently covers focus reports: - - ``\\x1b[I`` — terminal regained focus (focus in) - - ``\\x1b[O`` — terminal lost focus (focus out) - - Ghostty, iTerm2, and some xterm builds can emit these sequences when - the user switches tabs / windows or when a multiplexer toggles focus - tracking upstream. prompt_toolkit does not map these by default, so - its parser falls back to literal key presses (ESC, ``[``, ``I``/``O``) - and inserts ``[I``/``[O`` into the prompt buffer after the ESC byte - is handled. - - Registering them as ``Keys.Ignore`` is parser-level — strictly - cleaner than post-hoc regex stripping in the input sanitizer because - the bytes never reach the buffer. ``setdefault`` is used so any user - or downstream registration wins. - - Returns the number of sequences whose mapping was changed. + Covers focus reports ``ESC[I`` / ``ESC[O``, which Ghostty, iTerm2 and some xterms emit on + tab/window switches; unmapped, prompt_toolkit inserts ``[I``/``[O`` into the buffer. Parser- + level handling beats post-hoc regex stripping because the bytes never reach the buffer. + ``setdefault`` lets user/downstream registrations win. """ - try: - from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES - from prompt_toolkit.keys import Keys - except Exception: + tables = _pt_tables() + if tables is None: return 0 - - changed = 0 - for seq in ("\x1b[I", "\x1b[O"): - if seq not in ANSI_SEQUENCES: - ANSI_SEQUENCES[seq] = Keys.Ignore - changed += 1 - if changed: - _clear_vt100_prefix_cache() - return changed + seqs, keys = tables + return _register(seqs, {"\x1b[I": keys.Ignore, "\x1b[O": keys.Ignore}, overwrite=False) diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index cf4a4e60a7..ac35d74328 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -1,29 +1,13 @@ """PTY bridge for `hermes dashboard` chat tab. -Wraps a child process behind a pseudo-terminal so its ANSI output can be -streamed to a browser-side terminal emulator (xterm.js) and typed -keystrokes can be fed back in. The only caller today is the +Wraps a child process behind a pseudo-terminal so its ANSI output can be streamed to a browser-side +terminal emulator (xterm.js) and typed keystrokes can be fed back in. The only caller today is the ``/api/pty`` WebSocket endpoint in ``hermes_cli.web_server``. -Design constraints: - -* **POSIX-only.** This module depends on ``fcntl``, ``termios``, and - ``ptyprocess``, none of which exist on native Windows Python. Native - Windows ConPTY is a different API (Windows 10 build 17763+) and would - need a separate Windows implementation (``pywinpty``) — that's tracked - as a future enhancement. On native Windows, importing this module - raises :class:`ImportError` and the dashboard's ``/chat`` tab shows a - WSL-recommended banner instead of crashing. Every other feature in the - dashboard (sessions, jobs, metrics, config editor) works natively. -* **Zero Node dependency on the server side.** We use :mod:`ptyprocess`, - which is a pure-Python wrapper around the OS calls. The browser talks - to the same ``hermes --tui`` binary it would launch from the CLI, so - every TUI feature (slash popover, model picker, tool rows, markdown, - skin engine, clarify/sudo/approval prompts) ships automatically. -* **Byte-safe I/O.** Reads and writes go through the PTY master fd - directly — we avoid :class:`ptyprocess.PtyProcessUnicode` because - streaming ANSI is inherently byte-oriented and UTF-8 boundaries may land - mid-read. +* **POSIX-only.** This module depends on ``fcntl``, ``termios``, and ``ptyprocess``, none of which +exist on native Windows Python. Native Windows ConPTY is a different API (Windows 10 build 17763+) +and would need a separate Windows implementation (``pywinpty``) — that's tracked as a future +enhancement. """ from __future__ import annotations @@ -63,38 +47,29 @@ _MAX_ROWS = 1000 def _clamp_dimension(value: int, maximum: int) -> int: """Clamp a reported terminal dimension into ``[_MIN_DIMENSION, maximum]``. - Non-integer / non-finite values fall back to ``_MIN_DIMENSION`` so a bad - probe can never reach ``struct.pack`` and raise ``struct.error``. + Non-integer / non-finite values fall back to ``_MIN_DIMENSION`` so a bad probe can never reach + ``struct.pack`` and raise ``struct.error``. """ try: n = int(value) except (TypeError, ValueError, OverflowError): return _MIN_DIMENSION - if n < _MIN_DIMENSION: - return _MIN_DIMENSION - if n > maximum: - return maximum - return n + return max(_MIN_DIMENSION, min(n, maximum)) class PtyUnavailableError(RuntimeError): """Raised when a PTY cannot be created on this platform. - Today this means native Windows (no ConPTY bindings) or a dev - environment missing the ``ptyprocess`` dependency. The dashboard - surfaces the message to the user as a chat-tab banner. + Today this means native Windows (no ConPTY bindings) or a dev environment missing + ``ptyprocess``. The dashboard surfaces the message as a chat-tab banner. """ class PtyBridge: """Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. - Not thread-safe. A single bridge is owned by the WebSocket handler - that spawned it; the reader runs in an executor thread while writes - happen on the event-loop thread. Both sides are OK because the - kernel PTY is the actual synchronization point — we never call - :mod:`ptyprocess` methods concurrently, we only call ``os.read`` and - ``os.write`` on the master fd, which is safe. + Not thread-safe. A single bridge is owned by the WebSocket handler that spawned it; the reader + runs in an executor thread while writes happen on the event-loop thread. """ def __init__(self, proc: "ptyprocess.PtyProcess"): # type: ignore[name-defined] @@ -119,12 +94,7 @@ class PtyBridge: cols: int = 80, rows: int = 24, ) -> "PtyBridge": - """Spawn ``argv`` behind a new PTY and return a bridge. - - Raises :class:`PtyUnavailableError` if the platform can't host a - PTY. Raises :class:`FileNotFoundError` or :class:`OSError` for - ordinary exec failures (missing binary, bad cwd, etc.). - """ + """Spawn ``argv`` behind a new PTY and return a bridge.""" if not _PTY_AVAILABLE: if sys.platform.startswith("win"): raise PtyUnavailableError( @@ -134,8 +104,7 @@ class PtyBridge: if ptyprocess is None: raise PtyUnavailableError( "The `ptyprocess` package is missing. " - "Install with: pip install ptyprocess " - "(or pip install -e '.[pty]')." + "Install with: pip install ptyprocess (or pip install -e '.[pty]')." ) raise PtyUnavailableError("Pseudo-terminals are unavailable.") # PTY-hosted programs expect TERM to describe the terminal type. @@ -154,10 +123,7 @@ class PtyBridge: if not spawn_env.get("TERM"): spawn_env["TERM"] = "xterm-256color" proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr] - list(argv), - cwd=cwd, - env=spawn_env, - dimensions=(rows, cols), + list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols) ) return cls(proc) @@ -178,13 +144,8 @@ class PtyBridge: def read(self, timeout: float = 0.2) -> Optional[bytes]: """Read up to 64 KiB of raw bytes from the PTY master. - Returns: - * bytes — zero or more bytes of child output - * empty bytes (``b""``) — no data available within ``timeout`` - * None — child has exited and the master fd is at EOF - - Never blocks longer than ``timeout`` seconds. Safe to call after - :meth:`close`; returns ``None`` in that case. + Never blocks longer than ``timeout`` seconds. Safe to call after :meth:`close`; returns + ``None`` in that case. """ if self._closed: return None @@ -201,9 +162,7 @@ class PtyBridge: if exc.errno in {errno.EIO, errno.EBADF}: return None raise - if not data: - return None - return data + return data or None def write(self, data: bytes) -> None: """Write raw bytes to the PTY master (i.e. the child's stdin).""" @@ -225,21 +184,17 @@ class PtyBridge: def resize(self, cols: int, rows: int) -> None: """Forward a terminal resize to the child via ``TIOCSWINSZ``. - Dimensions are clamped to a sane range first. Some hosts report - garbage window sizes — the motivating case is WSL2, where xterm.js - in the dashboard ``/chat`` tab can pick up ``columns=131072, - rows=1`` from a broken winsize probe. ``struct winsize`` packs each - field as an unsigned short (max 65535), so an unclamped 131072 would - raise ``struct.error`` (not ``OSError``) and break the resize path, - leaving the TUI laid out for a one-row / absurdly-wide screen — - which is what shows up as blank / disappearing text. + Dimensions are clamped first: some hosts (WSL2 via xterm.js) report garbage like + ``columns=131072, rows=1``, and ``struct winsize`` packs unsigned shorts, so an unclamped + value raises ``struct.error`` (not ``OSError``), breaks resizing and leaves the TUI laid out + for a one-row screen — the blank/disappearing-text symptom. """ if self._closed: return - cols = _clamp_dimension(cols, _MAX_COLS) - rows = _clamp_dimension(rows, _MAX_ROWS) # struct winsize: rows, cols, xpixel, ypixel (all unsigned short) - winsize = struct.pack("HHHH", rows, cols, 0, 0) + winsize = struct.pack( + "HHHH", _clamp_dimension(rows, _MAX_ROWS), _clamp_dimension(cols, _MAX_COLS), 0, 0 + ) try: fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize) except OSError: @@ -250,8 +205,8 @@ class PtyBridge: def close(self) -> None: """Terminate the child (SIGTERM → 0.5s grace → SIGKILL) and close fds. - Idempotent. Reaping the child is important so we don't leak - zombies across the lifetime of the dashboard process. + Idempotent. Reaping the child is important so we don't leak zombies across the lifetime of + the dashboard process. """ if self._closed: return diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 2c569b8c63..8382a4a283 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -1,16 +1,14 @@ """Keep-alive PTY sessions for dashboard terminals. -A PTY process outlives the WebSocket that created it: a single drain task -always reads the PTY into a bounded RingBuffer and forwards to the attached -socket when present. Reconnecting with the same opaque token replays the -buffer and resumes live. See -docs/superpowers/specs/2026-06-20-pty-keepalive-reattach-design.md. +A PTY process outlives the WebSocket that created it: a single drain task always reads the PTY into +a bounded RingBuffer and forwards to the attached socket when present. Reconnecting with the same +opaque token replays the buffer and resumes live. """ from __future__ import annotations import asyncio import time -from typing import Optional +from typing import Callable, Dict, Optional, Tuple WS_CLOSE_PROCESS_EXITED = 4410 WS_CLOSE_SUPERSEDED = 4409 @@ -40,6 +38,15 @@ class RingBuffer: return self._truncated +async def _close_ws(ws, code: int) -> None: + if ws is None: + return + try: + await ws.close(code=code) + except Exception: + pass + + class PtySession: def __init__(self, key: str, bridge, *, buffer_cap: int, read_timeout: float) -> None: self.key = key @@ -61,12 +68,7 @@ class PtySession: chunk = await loop.run_in_executor(None, self.bridge.read, self._read_timeout) if chunk is None: # EOF — the agent process exited self.alive = False - ws = self._ws - if ws is not None: - try: - await ws.close(code=WS_CLOSE_PROCESS_EXITED) - except Exception: - pass + await _close_ws(self._ws, WS_CLOSE_PROCESS_EXITED) return if not chunk: # idle tick await asyncio.sleep(0) @@ -82,17 +84,12 @@ class PtySession: async def attach(self, ws, *, force_redraw: bool = False) -> None: """Attach a browser terminal and replay buffered PTY output. - The TUI uses an alternate screen and differential rendering, so a - bounded ANSI tail is not guaranteed to be a self-contained frame. - Reattaching a fresh xterm therefore asks the live TUI to emit one - complete redraw after the replay. + The TUI uses an alternate screen and differential rendering, so a bounded ANSI tail is not + guaranteed to be a self-contained frame. Reattaching a fresh xterm therefore asks the live + TUI to emit one complete redraw after the replay. """ - old = self._ws - if old is not None and old is not ws: - try: - await old.close(code=WS_CLOSE_SUPERSEDED) - except Exception: - pass + if self._ws is not ws: + await _close_ws(self._ws, WS_CLOSE_SUPERSEDED) self._ws = ws self.attached = True self.last_detached_at = None @@ -129,9 +126,6 @@ class PtySession: pass -from typing import Callable, Dict, Tuple - - class RegistryFull(Exception): pass diff --git a/hermes_cli/resource_limits.py b/hermes_cli/resource_limits.py index a9daf54be5..6e4ceca44e 100644 --- a/hermes_cli/resource_limits.py +++ b/hermes_cli/resource_limits.py @@ -1,9 +1,4 @@ -"""Best-effort process resource-limit adjustments for long-running services. - -The public helper in this module is shared by the gateway and the dashboard/ -serve entrypoints. It deliberately has no user-facing environment-variable -control: the target comes from the profile's canonical ``config.yaml`` loader. -""" +"""Best-effort process resource-limit adjustments for long-running services.""" from __future__ import annotations @@ -24,14 +19,15 @@ DEFAULT_NOFILE_SOFT_LIMIT = int(DEFAULT_CONFIG["runtime"]["nofile_soft_limit"]) _MISSING = object() -def _configured_nofile_soft_limit( - config: Mapping[str, Any] | None, +def configured_nofile_soft_limit( + config: Mapping[str, Any] | None = None, ) -> int | None: """Resolve ``runtime.nofile_soft_limit`` from a loaded config. - A missing key uses the default. Explicit ``0``, ``false``, and ``null`` - disable the adjustment. Other non-integer or negative values are invalid - and are ignored (the caller fails open without changing the process limit). + A missing key uses the default. Explicit ``0``, ``false``, and ``null`` disable the + adjustment; other non-integer or negative values are ignored (caller fails open). + Used by service-definition generators (e.g. the launchd plist) so persisted service limits + and the in-process floor share one knob. ``None`` when disabled or unresolvable. """ if config is None: try: @@ -56,45 +52,28 @@ def _configured_nofile_soft_limit( raw_value = runtime.get("nofile_soft_limit", _MISSING) if raw_value is _MISSING: return DEFAULT_NOFILE_SOFT_LIMIT - if raw_value is None or raw_value is False: - return None - if raw_value is True or not isinstance(raw_value, int): - return None - if raw_value <= 0: + if isinstance(raw_value, bool) or not isinstance(raw_value, int) or raw_value <= 0: return None return raw_value -def configured_nofile_soft_limit( - config: Mapping[str, Any] | None = None, -) -> int | None: - """Public accessor for the resolved ``runtime.nofile_soft_limit`` target. - - Used by service-definition generators (e.g. the launchd plist) so the - persisted service limits and the in-process floor share one config knob. - Returns ``None`` when the adjustment is disabled or unresolvable. - """ - return _configured_nofile_soft_limit(config) - - def apply_nofile_soft_limit( config: Mapping[str, Any] | None = None, ) -> bool: """Raise this process's ``RLIMIT_NOFILE`` soft limit when possible. The target defaults to :data:`DEFAULT_NOFILE_SOFT_LIMIT` and can be set with - ``runtime.nofile_soft_limit``. The target is clamped to a finite hard limit, - never lowers an existing higher soft limit, and returns ``False`` for an - explicit opt-out or when the platform/sandbox refuses the operation. + ``runtime.nofile_soft_limit``. The target is clamped to a finite hard limit, never lowers an + existing higher soft limit, and returns ``False`` for an explicit opt-out or when the + platform/sandbox refuses the operation. - This is intentionally best-effort. Unsupported platforms, malformed - settings, and denied ``setrlimit`` calls must never prevent a server from - starting. + This is intentionally best-effort. Unsupported platforms, malformed settings, and denied + ``setrlimit`` calls must never prevent a server from starting. """ if _resource is None: return False - target = _configured_nofile_soft_limit(config) + target = configured_nofile_soft_limit(config) if target is None: return False @@ -104,15 +83,10 @@ def apply_nofile_soft_limit( # On platforms where RLIM_INFINITY is represented as -1, ordinary # integer ordering would make an unlimited soft limit look lower than # every positive target. Never replace infinity with a finite limit. - if current_soft == getattr(_resource, "RLIM_INFINITY", object()): + infinity = getattr(_resource, "RLIM_INFINITY", object()) + if current_soft == infinity or current_soft >= target: return False - if current_soft >= target: - return False - - if current_hard == getattr(_resource, "RLIM_INFINITY", object()): - new_soft = target - else: - new_soft = min(target, current_hard) + new_soft = target if current_hard == infinity else min(target, current_hard) if new_soft <= current_soft: return False diff --git a/hermes_cli/route_identity.py b/hermes_cli/route_identity.py index 03ae6ea7c9..9e7d45194b 100644 --- a/hermes_cli/route_identity.py +++ b/hermes_cli/route_identity.py @@ -57,8 +57,8 @@ def should_clear_context_pin( ) -> bool: """True when a configured ``model.context_length`` pin no longer matches its runtime route. - Fail-closed: any error during route comparison returns ``True`` (drop the pin) - so a stale window never silently inflates the compression threshold. + Fail-closed: any error during route comparison returns ``True`` (drop the pin) so a stale window + never silently inflates the compression threshold. """ configured_model = str(configured_model or "").strip() if configured_model and configured_model != str(active_model or "").strip(): @@ -86,10 +86,9 @@ async def should_clear_context_pin_async( ) -> bool: """Async wrapper for ``should_clear_context_pin``. - Offloads the route comparison to a worker thread so async gateway - handlers never run it on the event loop — the resolution chain is - cache-only (``allow_network=False``) but can still do cold-start disk - I/O. Shares all logic with the sync version — no code duplication. + Offloads the route comparison to a worker thread so async gateway handlers never run it on the + event loop — the resolution chain is cache-only (``allow_network=False``) but can still do cold- + start disk I/O. Shares all logic with the sync version — no code duplication. """ import asyncio diff --git a/hermes_cli/security_advisories.py b/hermes_cli/security_advisories.py index ef058a5a42..171de7b83d 100644 --- a/hermes_cli/security_advisories.py +++ b/hermes_cli/security_advisories.py @@ -1,34 +1,8 @@ -""" -Security advisory checker for Hermes Agent. +"""Security advisory checker for Hermes Agent. -Detects known-compromised Python packages installed in the active venv -(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that -poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to -the user. - -Design goals: - -- **Cheap.** A single ``importlib.metadata.version()`` call per advisory - package. Safe to run on every CLI startup. -- **Loud when it matters, silent otherwise.** If no compromised package is - installed, the user sees nothing. -- **Acknowledgeable.** Once the user has read and acted on an advisory they - can dismiss it via ``hermes doctor --ack <id>``; the ack is persisted to - ``config.security.acked_advisories`` and survives restart. -- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``; - adding a new compromised version is a one-line edit. No code changes - needed when the next worm hits. - -The check is invoked from three places: - -1. ``hermes doctor`` (and ``hermes doctor --ack <id>``) -2. CLI startup banner (one short line, then full guidance via - ``hermes doctor``) -3. Gateway startup (logged to gateway.log; first interactive message gets - a one-line operator banner) - -This module is intentionally dependency-free beyond the stdlib so it can -run in environments where the rest of Hermes failed to import. +- **Cheap.** A single ``importlib.metadata.version()`` call per advisory package. Safe to run on +every CLI startup. - **Loud when it matters, silent otherwise.** If no compromised package is +installed, the user sees nothing. """ from __future__ import annotations @@ -67,19 +41,9 @@ logger = logging.getLogger(__name__) class Advisory: """One security advisory entry. - Attributes: - id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``). - Lowercase-hyphen, never reused. - title: one-line headline shown in banners. - summary: 1-3 sentence description of what was compromised and how. - url: reference URL (Socket advisory, GitHub advisory, PyPI page). - compromised: tuple of ``(package_name, frozenset_of_versions)`` - pairs. Empty frozenset means "any version of this package is - considered suspect" — use sparingly. - remediation: ordered list of steps the user should take. First step - should be the uninstall command; subsequent steps the credential - audit / rotation guidance. - published: ISO date string for sort order. + ``id`` is lowercase-hyphen, stable and never reused (it is what acks key on). An empty version + frozenset in ``compromised`` means any version is suspect — use sparingly. ``remediation`` is + ordered: uninstall command first, then credential audit/rotation guidance. """ id: str @@ -146,13 +110,10 @@ class AdvisoryHit: def _installed_version(pkg_name: str) -> Optional[str]: """Return the installed version of ``pkg_name``, or None if not installed. - Uses ``importlib.metadata`` so we don't depend on pip being importable - inside the active venv (uv-created venvs may lack pip). + Uses ``importlib.metadata`` so we don't depend on pip being importable inside the active venv + (uv-created venvs may lack pip). """ - try: - from importlib.metadata import PackageNotFoundError, version - except ImportError: # py<3.8 — Hermes requires 3.10+ but defensive. - return None + from importlib.metadata import PackageNotFoundError, version try: return version(pkg_name) except PackageNotFoundError: @@ -169,23 +130,15 @@ def detect_compromised( ) -> list[AdvisoryHit]: """Scan installed packages and return all advisory hits. - A "hit" means an advisory's listed package is installed AND the version - is in the compromised set (or the compromised set is empty, meaning - *any* version is suspect). + A hit means the package is installed AND its version is in the compromised set (or the set is + empty, meaning any version is suspect). """ - hits: list[AdvisoryHit] = [] - for advisory in advisories: - for pkg_name, bad_versions in advisory.compromised: - installed = _installed_version(pkg_name) - if installed is None: - continue - if not bad_versions or installed in bad_versions: - hits.append(AdvisoryHit( - advisory=advisory, - package=pkg_name, - installed_version=installed, - )) - return hits + return [ + AdvisoryHit(advisory, pkg_name, installed) + for advisory in advisories + for pkg_name, bad_versions in advisory.compromised + if (installed := _installed_version(pkg_name)) is not None and (not bad_versions or installed in bad_versions) + ] # ============================================================================= @@ -202,9 +155,8 @@ def detect_compromised( def get_acked_ids() -> set[str]: """Return the set of advisory IDs the user has dismissed. - Returns an empty set if config can't be loaded (don't block startup - just because config is broken — the advisory will keep firing until - config is repaired, which is fine). + Returns an empty set if config can't be loaded (don't block startup just because config is + broken — the advisory will keep firing until config is repaired, which is fine). """ try: from hermes_cli.config import load_config @@ -212,18 +164,12 @@ def get_acked_ids() -> set[str]: except Exception: logger.debug("Could not load config for advisory acks", exc_info=True) return set() - sec = cfg.get("security") or {} - raw = sec.get("acked_advisories") or [] - if not isinstance(raw, list): - return set() - return {str(x).strip() for x in raw if str(x).strip()} + raw = (cfg.get("security") or {}).get("acked_advisories") or [] + return {str(x).strip() for x in raw if str(x).strip()} if isinstance(raw, list) else set() def ack_advisory(advisory_id: str) -> bool: - """Persist an ack for ``advisory_id``. Returns True on success. - - Idempotent — acking an already-acked ID is a no-op. - """ + """Persist an ack for ``advisory_id``. Returns True on success.""" advisory_id = advisory_id.strip() if not advisory_id: return False @@ -236,11 +182,9 @@ def ack_advisory(advisory_id: str) -> bool: cfg = load_config() sec = cfg.setdefault("security", {}) existing = sec.get("acked_advisories") or [] - if not isinstance(existing, list): - existing = [] + existing = existing if isinstance(existing, list) else [] if advisory_id not in existing: - existing.append(advisory_id) - sec["acked_advisories"] = existing + sec["acked_advisories"] = existing + [advisory_id] save_config(cfg) return True except Exception: @@ -250,9 +194,7 @@ def ack_advisory(advisory_id: str) -> bool: def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]: """Return only hits whose advisories the user has not dismissed.""" - if not hits: - return [] - acked = get_acked_ids() + acked = get_acked_ids() if hits else set() return [h for h in hits if h.advisory.id not in acked] @@ -262,18 +204,14 @@ def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]: def _term_supports_color() -> bool: - if os.environ.get("NO_COLOR"): - return False - if not sys.stdout.isatty(): - return False - return True + return not os.environ.get("NO_COLOR") and sys.stdout.isatty() def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: """Return 1-3 short lines suitable for a startup banner. - Caller is responsible for color/styling. Always names the worst hit - explicitly so the user knows what's wrong without running doctor. + Caller is responsible for color/styling. Always names the worst hit explicitly so the user knows + what's wrong without running doctor. """ if not hits: return [] @@ -292,7 +230,7 @@ def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: def full_remediation_text(hit: AdvisoryHit) -> list[str]: """Return a multi-line block describing the advisory + remediation.""" a = hit.advisory - lines = [ + return [ f"=== {a.title} ===", f"ID: {a.id} Severity: {a.severity} Published: {a.published}", f"Detected: {hit.package}=={hit.installed_version}", @@ -301,10 +239,8 @@ def full_remediation_text(hit: AdvisoryHit) -> list[str]: a.summary, "", "Remediation:", + *(f" {i}. {step}" for i, step in enumerate(a.remediation, 1)), ] - for i, step in enumerate(a.remediation, 1): - lines.append(f" {i}. {step}") - return lines # ============================================================================= @@ -341,15 +277,10 @@ def _read_banner_cache() -> dict[str, float]: out: dict[str, float] = {} try: for line in p.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue parts = line.split(None, 1) - if len(parts) != 2: - continue - advisory_id, ts = parts try: - out[advisory_id] = float(ts) + if len(parts) == 2: + out[parts[0]] = float(parts[1]) except ValueError: continue except Exception: @@ -362,8 +293,7 @@ def _write_banner_cache(seen: dict[str, float]) -> None: if p is None: return try: - lines = [f"{aid} {ts}" for aid, ts in seen.items()] - p.write_text("\n".join(lines) + "\n", encoding="utf-8") + p.write_text("\n".join(f"{aid} {ts}" for aid, ts in seen.items()) + "\n", encoding="utf-8") except Exception: logger.debug("Could not write advisory banner cache", exc_info=True) @@ -375,8 +305,8 @@ def hits_due_for_banner( ) -> list[AdvisoryHit]: """Return only hits whose banner is due (not acked, not recently shown). - Side effect: stamps the banner cache for any hit that's about to be - shown. Callers should subsequently render the result. + Side effect: stamps the banner cache for any hit that's about to be shown. Callers should + subsequently render the result. """ import time @@ -387,12 +317,9 @@ def hits_due_for_banner( cache = _read_banner_cache() cutoff = now - (repeat_hours * 3600) - due: list[AdvisoryHit] = [] - for hit in fresh: - last = cache.get(hit.advisory.id, 0.0) - if last < cutoff: - due.append(hit) - cache[hit.advisory.id] = now + due = [hit for hit in fresh if cache.get(hit.advisory.id, 0.0) < cutoff] + for hit in due: + cache[hit.advisory.id] = now if due: _write_banner_cache(cache) return due @@ -403,39 +330,17 @@ def hits_due_for_banner( # ============================================================================= -def render_doctor_section(hits: list[AdvisoryHit]) -> tuple[bool, list[str]]: - """Render the security-advisory section for ``hermes doctor``. - - Returns ``(has_problems, lines)``. Caller is responsible for printing - with whatever color scheme it uses. - """ - fresh = filter_unacked(hits) - if not fresh: - return False, ["No active security advisories. ✓"] - - lines: list[str] = [] - for i, hit in enumerate(fresh): - if i: - lines.append("") - lines.extend(full_remediation_text(hit)) - return True, lines - - def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]: """Return a printable startup banner, or None if nothing is due. - Updates the banner cache as a side effect (so the next call within - 24h returns None for the same hit). + Updates the banner cache as a side effect (so the next call within 24h returns None for the same + hit). """ due = hits_due_for_banner(hits) if not due: return None - lines = short_banner_lines(due) - if _term_supports_color(): - red = "\x1b[1;31m" - reset = "\x1b[0m" - return red + "\n".join(lines) + reset - return "\n".join(lines) + text = "\n".join(short_banner_lines(due)) + return f"\x1b[1;31m{text}\x1b[0m" if _term_supports_color() else text def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]: @@ -445,9 +350,11 @@ def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]: return None if len(fresh) == 1: h = fresh[0] - return (f"Security advisory [{h.advisory.id}] active: " - f"{h.package}=={h.installed_version} matches {h.advisory.title}. " - f"See {h.advisory.url}") - return (f"{len(fresh)} security advisories active " - f"(IDs: {', '.join(h.advisory.id for h in fresh)}). " - f"Run `hermes doctor` on the gateway host for details.") + return ( + f"Security advisory [{h.advisory.id}] active: {h.package}=={h.installed_version} " + f"matches {h.advisory.title}. See {h.advisory.url}" + ) + return ( + f"{len(fresh)} security advisories active (IDs: {', '.join(h.advisory.id for h in fresh)}). " + "Run `hermes doctor` on the gateway host for details." + ) diff --git a/hermes_cli/security_audit.py b/hermes_cli/security_audit.py index 78d8a8b7e0..ebc5ac769f 100644 --- a/hermes_cli/security_audit.py +++ b/hermes_cli/security_audit.py @@ -1,20 +1,8 @@ """On-demand supply-chain audit for Hermes Agent installs. -Scans three surfaces a Hermes user actually controls and we can map to -upstream advisories without auth or extra binaries: - -1. The Hermes venv (every PyPI dist via ``importlib.metadata``). -2. Python deps declared by user-installed plugins under ``~/.hermes/plugins`` - (``requirements.txt`` + ``pyproject.toml`` best-effort pin extraction). -3. MCP servers wired in ``config.yaml`` whose ``command/args`` look like - ``npx -y <pkg>@<ver>`` or ``uvx <pkg>==<ver>``. - -Vulnerabilities are looked up against OSV.dev (``api.osv.dev/v1/querybatch`` -+ ``/v1/vulns/{id}``). Single-shot, on-demand, never daily — see the design -notes in ``references/security-disclosure-triage.md``. - -Out of scope on purpose: global pip/npm, editor/browser extensions, -daily background scans, auto-blocking installs. +Vulnerabilities are looked up against OSV.dev (``api.osv.dev/v1/querybatch`` + ``/v1/vulns/{id}``). +Single-shot, on-demand, never daily — see the design notes in ``references/security-disclosure- +triage.md``. """ from __future__ import annotations @@ -93,13 +81,10 @@ def _discover_venv() -> list[Component]: except Exception: continue version = (dist.version or "").strip() - if not name or not version: - continue key = (name.lower(), version) - if key in seen: - continue - seen.add(key) - out.append(Component(name=name, version=version, ecosystem="PyPI", source="venv")) + if name and version and key not in seen: + seen.add(key) + out.append(Component(name=name, version=version, ecosystem="PyPI", source="venv")) return out @@ -116,29 +101,23 @@ _REQ_LINE = re.compile( ) +def _match_pins(specs: Iterable[str]) -> list[tuple[str, str]]: + """``name==version`` pairs for every spec that is an exact pin; all others are skipped.""" + return [(m.group("name"), m.group("version")) for spec in specs if (m := _REQ_LINE.match(spec))] + + def _parse_requirements(text: str) -> list[tuple[str, str]]: """Extract ``name==version`` pins. Everything else (>=, ~=, no pin) is skipped. - A loose pin can't be mapped to a single OSV query, and getting it wrong - is worse than missing a finding for an audit tool — false positives - train users to ignore output. + A loose pin can't be mapped to a single OSV query, and getting it wrong is worse than missing a + finding for an audit tool — false positives train users to ignore output. """ - pins: list[tuple[str, str]] = [] - for raw in text.splitlines(): - line = raw.strip() - if not line or line.startswith("#") or line.startswith("-"): - continue - m = _REQ_LINE.match(line) - if m: - pins.append((m.group("name"), m.group("version"))) - return pins + lines = (raw.strip() for raw in text.splitlines()) + return _match_pins(line for line in lines if line and not line.startswith(("#", "-"))) def _parse_pyproject_pins(text: str) -> list[tuple[str, str]]: - """Pull ``name==version`` pins from a ``pyproject.toml`` ``dependencies`` list. - - Uses stdlib ``tomllib`` (3.11+). Same exact-pin policy as requirements. - """ + """Pull ``name==version`` pins from a ``pyproject.toml`` ``dependencies`` list.""" try: import tomllib except ImportError: # pragma: no cover - 3.10 only @@ -147,29 +126,24 @@ def _parse_pyproject_pins(text: str) -> list[tuple[str, str]]: data = tomllib.loads(text) except Exception: return [] - deps: list[str] = [] project = data.get("project") or {} - if isinstance(project.get("dependencies"), list): - deps.extend(str(x) for x in project["dependencies"]) optional = project.get("optional-dependencies") or {} - if isinstance(optional, dict): - for group in optional.values(): - if isinstance(group, list): - deps.extend(str(x) for x in group) - pins: list[tuple[str, str]] = [] - for dep in deps: - m = _REQ_LINE.match(dep) - if m: - pins.append((m.group("name"), m.group("version"))) - return pins + groups = [project.get("dependencies")] + (list(optional.values()) if isinstance(optional, dict) else []) + return _match_pins(str(x) for group in groups if isinstance(group, list) for x in group) + + +_PLUGIN_PIN_FILES = ( + ("requirements.txt", _parse_requirements), + ("requirements-dev.txt", _parse_requirements), + ("pyproject.toml", _parse_pyproject_pins), +) def _discover_plugins(hermes_home: Path) -> list[Component]: """Python deps declared by plugins under ``~/.hermes/plugins``. - Plugins typically don't install into the venv (they're directory-based - with relative imports), so their stated requirements are useful audit - surface even when the venv scan misses them. + Plugins typically don't install into the venv (they're directory-based with relative imports), + so their stated requirements are useful audit surface even when the venv scan misses them. """ plugins_dir = hermes_home / "plugins" if not plugins_dir.is_dir(): @@ -179,24 +153,13 @@ def _discover_plugins(hermes_home: Path) -> list[Component]: for plugin_dir in sorted(plugins_dir.iterdir()): if not plugin_dir.is_dir() or plugin_dir.name.startswith("."): continue - source = f"plugin:{plugin_dir.name}" - for req_file in ("requirements.txt", "requirements-dev.txt"): - path = plugin_dir / req_file - if path.is_file(): - try: - pins = _parse_requirements(path.read_text(encoding="utf-8", errors="replace")) - except OSError: - continue - for name, version in pins: - out.append(Component(name=name, version=version, ecosystem="PyPI", source=source)) - pyproject = plugin_dir / "pyproject.toml" - if pyproject.is_file(): + for filename, parse in _PLUGIN_PIN_FILES: + path = plugin_dir / filename try: - pins = _parse_pyproject_pins(pyproject.read_text(encoding="utf-8", errors="replace")) + pins = parse(path.read_text(encoding="utf-8", errors="replace")) if path.is_file() else [] except OSError: continue - for name, version in pins: - out.append(Component(name=name, version=version, ecosystem="PyPI", source=source)) + out.extend(Component(name=n, version=v, ecosystem="PyPI", source=f"plugin:{plugin_dir.name}") for n, v in pins) return out @@ -211,48 +174,25 @@ _NPX_PKG = re.compile(r"^(@[A-Za-z0-9._-]+/[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)@([A- # uvx pkg==1.2.3 # uvx --with pkg==1.2.3 entrypoint _UVX_PKG = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9._+!-]+)$") +# launcher basename -> (package-ref regex, OSV ecosystem) +_MCP_LAUNCHERS = {"npx": (_NPX_PKG, "npm"), "uvx": (_UVX_PKG, "PyPI")} def _extract_mcp_component(server_name: str, command: str, args: list[str]) -> Optional[Component]: """Best-effort: parse `command/args` into a (name, version, ecosystem). - Returns None when the entry doesn't pin a version we can audit (local - paths, Docker images, unversioned npx, etc.). Audit output stays silent - rather than guess. + Returns None when the entry doesn't pin an auditable version (local paths, Docker images, + unversioned npx, ...); the audit stays silent rather than guess. """ cmd = (command or "").strip().lower() - if not args: + launcher = next((k for k in _MCP_LAUNCHERS if cmd.endswith(k)), None) # any prefix path + # Skip flag tokens; the first non-flag token must be a pinned ref or we stay silent. + ref = next((token for token in args if not token.startswith("-")), None) + if launcher is None or ref is None: return None - # npx (any prefix path) - if cmd.endswith("npx") or cmd == "npx": - # Skip flag tokens until we see the first thing that looks like a pkg ref - for token in args: - if token.startswith("-"): - continue - m = _NPX_PKG.match(token) - if m: - return Component( - name=m.group(1), - version=m.group(2), - ecosystem="npm", - source=f"mcp:{server_name}", - ) - return None # First non-flag token isn't a pinned ref - # uvx (any prefix path) - if cmd.endswith("uvx") or cmd == "uvx": - for token in args: - if token.startswith("-"): - continue - m = _UVX_PKG.match(token) - if m: - return Component( - name=m.group(1), - version=m.group(2), - ecosystem="PyPI", - source=f"mcp:{server_name}", - ) - return None - return None + pattern, ecosystem = _MCP_LAUNCHERS[launcher] + m = pattern.match(ref) + return m and Component(name=m.group(1), version=m.group(2), ecosystem=ecosystem, source=f"mcp:{server_name}") def _discover_mcp() -> list[Component]: @@ -262,19 +202,15 @@ def _discover_mcp() -> list[Component]: except Exception: return [] - out: list[Component] = [] servers = _get_mcp_servers() if not isinstance(servers, dict): return [] + out: list[Component] = [] for name, cfg in servers.items(): - if not isinstance(cfg, dict): + if not isinstance(cfg, dict) or not isinstance(cfg.get("args") or [], list): continue - command = cfg.get("command", "") or "" - args = cfg.get("args") or [] - if not isinstance(args, list): - continue - comp = _extract_mcp_component(name, command, [str(a) for a in args]) - if comp is not None: + comp = _extract_mcp_component(name, cfg.get("command", "") or "", [str(a) for a in cfg.get("args") or []]) + if comp: out.append(comp) return out @@ -282,48 +218,35 @@ def _discover_mcp() -> list[Component]: # ─── OSV client ─────────────────────────────────────────────────────────────── -def _http_post_json(url: str, payload: dict) -> dict: - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request( - url, data=data, headers={"Content-Type": "application/json"}, method="POST" - ) - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: - return json.loads(resp.read().decode("utf-8")) +_HTTP_ERRORS = (urllib.error.URLError, TimeoutError, ConnectionError) -def _http_get_json(url: str) -> dict: - req = urllib.request.Request(url, method="GET") +def _http_json(url: str, payload: Optional[dict] = None) -> dict: + """GET ``url`` (or POST ``payload`` as JSON when given) and decode the JSON body.""" + if payload is None: + req = urllib.request.Request(url, method="GET") + else: + req = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST" + ) with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: return json.loads(resp.read().decode("utf-8")) def _osv_query_batch(components: list[Component]) -> dict[Component, list[str]]: - """Return {component -> [osv_id, ...]} for components with any vulns. - - Components without findings are omitted from the result dict. - """ - if not components: - return {} + """Return {component -> [osv_id, ...]} for components with any vulns.""" findings: dict[Component, list[str]] = {} for chunk_start in range(0, len(components), OSV_BATCH_MAX): chunk = components[chunk_start:chunk_start + OSV_BATCH_MAX] payload = { - "queries": [ - { - "package": {"name": c.name, "ecosystem": c.ecosystem}, - "version": c.version, - } - for c in chunk - ] + "queries": [{"package": {"name": c.name, "ecosystem": c.ecosystem}, "version": c.version} for c in chunk] } try: - resp = _http_post_json(OSV_BATCH_URL, payload) - except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + resp = _http_json(OSV_BATCH_URL, payload) + except _HTTP_ERRORS as exc: raise RuntimeError(f"OSV batch query failed: {exc}") from exc - results = resp.get("results") or [] - for comp, result in zip(chunk, results): - vulns = (result or {}).get("vulns") or [] - ids = [v.get("id") for v in vulns if v.get("id")] + for comp, result in zip(chunk, resp.get("results") or []): + ids = [v.get("id") for v in (result or {}).get("vulns") or [] if v.get("id")] if ids: findings[comp] = ids return findings @@ -333,54 +256,26 @@ def _osv_severity_from_record(record: dict) -> str: """Extract CVSS-derived severity tier from an OSV vuln record.""" # OSV puts CVSS in `severity` (top-level or per-affected) and a # human-readable bucket in `database_specific.severity` for GHSAs. - db_specific = record.get("database_specific") or {} - raw = db_specific.get("severity") - if isinstance(raw, str) and raw.strip(): - upper = raw.strip().upper() - if upper in SEVERITY_ORDER: - return upper - # Fall back to CVSS score → tier - score: Optional[float] = None - for sev_entry in record.get("severity") or []: - s = sev_entry.get("score") - if isinstance(s, str): - # CVSS vector strings look like "CVSS:3.1/AV:N/..." — we can't - # parse without a lib. Look for an explicit numeric in - # affected[].ecosystem_specific later if present. - continue - affected = record.get("affected") or [] - for entry in affected: - eco_spec = entry.get("ecosystem_specific") or {} - sev = eco_spec.get("severity") + # Top-level `severity` holds CVSS vector strings we can't tier without a lib; fall back to + # the per-affected ecosystem_specific bucket after the GHSA database_specific one. + candidates = [(record.get("database_specific") or {}).get("severity")] + [ + (entry.get("ecosystem_specific") or {}).get("severity") for entry in record.get("affected") or [] + ] + for sev in candidates: if isinstance(sev, str) and sev.strip().upper() in SEVERITY_ORDER: return sev.strip().upper() - if score is not None: - if score >= 9.0: - return "CRITICAL" - if score >= 7.0: - return "HIGH" - if score >= 4.0: - return "MODERATE" - if score > 0: - return "LOW" return "UNKNOWN" def _osv_fixed_versions(record: dict) -> list[str]: - fixes: list[str] = [] - for entry in record.get("affected") or []: - for rng in entry.get("ranges") or []: - for event in rng.get("events") or []: - if "fixed" in event: - fixes.append(str(event["fixed"])) - # Dedupe, preserve order - seen: set[str] = set() - out: list[str] = [] - for f in fixes: - if f not in seen: - seen.add(f) - out.append(f) - return out + fixes = [ + str(event["fixed"]) + for entry in record.get("affected") or [] + for rng in entry.get("ranges") or [] + for event in rng.get("events") or [] + if "fixed" in event + ] + return list(dict.fromkeys(fixes)) # dedupe, preserve order def _osv_fetch_details(vuln_ids: Iterable[str]) -> dict[str, Vulnerability]: @@ -388,12 +283,11 @@ def _osv_fetch_details(vuln_ids: Iterable[str]) -> dict[str, Vulnerability]: unique = sorted({vid for vid in vuln_ids if vid}) if not unique: return {} - out: dict[str, Vulnerability] = {} def _fetch_one(vid: str) -> Vulnerability: try: - rec = _http_get_json(OSV_VULN_URL.format(vid=vid)) - except (urllib.error.URLError, TimeoutError, ConnectionError): + rec = _http_json(OSV_VULN_URL.format(vid=vid)) + except _HTTP_ERRORS: return Vulnerability(osv_id=vid) return Vulnerability( osv_id=vid, @@ -403,9 +297,7 @@ def _osv_fetch_details(vuln_ids: Iterable[str]) -> dict[str, Vulnerability]: ) with concurrent.futures.ThreadPoolExecutor(max_workers=DETAIL_PARALLELISM) as pool: - for vuln in pool.map(_fetch_one, unique): - out[vuln.osv_id] = vuln - return out + return {vuln.osv_id: vuln for vuln in pool.map(_fetch_one, unique)} # ─── Orchestration ──────────────────────────────────────────────────────────── @@ -440,36 +332,22 @@ def run_audit( ) -> list[Finding]: """Query OSV for the given (or freshly discovered) components. - ``components`` lets callers that already ran discovery (e.g. for a - component count) reuse it instead of scanning the venv/plugins/MCP - config a second time. + ``components`` lets callers that already ran discovery (e.g. for a count) reuse it instead + of scanning the venv/plugins/MCP config a second time. """ if components is None: components = _discover_components( - skip_venv=skip_venv, - skip_plugins=skip_plugins, - skip_mcp=skip_mcp, - hermes_home=hermes_home, + skip_venv=skip_venv, skip_plugins=skip_plugins, skip_mcp=skip_mcp, hermes_home=hermes_home ) - - if not components: - return [] - - raw = _osv_query_batch(components) + raw = _osv_query_batch(components) if components else {} if not raw: return [] - - all_ids: list[str] = [] - for ids in raw.values(): - all_ids.extend(ids) - details = _osv_fetch_details(all_ids) - - findings: list[Finding] = [] - for comp, ids in raw.items(): - for vid in ids: - vuln = details.get(vid) or Vulnerability(osv_id=vid) - findings.append(Finding(component=comp, vuln=vuln)) - + details = _osv_fetch_details(vid for ids in raw.values() for vid in ids) + findings = [ + Finding(component=comp, vuln=details.get(vid) or Vulnerability(osv_id=vid)) + for comp, ids in raw.items() + for vid in ids + ] findings.sort( key=lambda f: ( -SEVERITY_ORDER.get(f.vuln.severity, 0), @@ -488,25 +366,15 @@ def _render_human(findings: list[Finding], total_components: int) -> str: if not findings: return f"No known vulnerabilities found across {total_components} component(s)." - lines: list[str] = [] - lines.append( - f"Found {len(findings)} known vulnerability finding(s) " - f"across {total_components} component(s):" - ) - lines.append("") + lines = [f"Found {len(findings)} known vulnerability finding(s) across {total_components} component(s):", ""] last_source = None for f in findings: if f.component.source != last_source: lines.append(f"[{f.component.source}]") last_source = f.component.source - sev = f.vuln.severity.ljust(8) - head = f" {sev} {f.component.name}=={f.component.version} {f.vuln.osv_id}" - lines.append(head) - if f.vuln.summary: - summary = f.vuln.summary - if len(summary) > 100: - summary = summary[:97] + "..." - lines.append(f" {summary}") + lines.append(f" {f.vuln.severity.ljust(8)} {f.component.name}=={f.component.version} {f.vuln.osv_id}") + if summary := f.vuln.summary: + lines.append(f" {summary if len(summary) <= 100 else summary[:97] + '...'}") if f.vuln.fixed_versions: lines.append(f" fixed in: {', '.join(f.vuln.fixed_versions[:3])}") return "\n".join(lines) @@ -557,33 +425,20 @@ def cmd_security_audit(args: argparse.Namespace) -> int: ) total = len(components) if total == 0: - msg = "No components discovered (everything skipped, or empty environment)." - if output_json: - print(json.dumps({"total_components_scanned": 0, "finding_count": 0, "findings": []})) - else: - print(msg) + print( + json.dumps({"total_components_scanned": 0, "finding_count": 0, "findings": []}) + if output_json + else "No components discovered (everything skipped, or empty environment)." + ) return 0 try: - findings = run_audit( - skip_venv=skip_venv, - skip_plugins=skip_plugins, - skip_mcp=skip_mcp, - hermes_home=home, - components=components, - ) + findings = run_audit(hermes_home=home, components=components) except RuntimeError as exc: print(f"audit failed: {exc}", file=sys.stderr) return 2 - if output_json: - print(_render_json(findings, total)) - else: - print(_render_human(findings, total)) - + print((_render_json if output_json else _render_human)(findings, total)) # Exit code: 1 iff any finding meets or exceeds the --fail-on threshold. threshold = SEVERITY_ORDER[fail_on] - for f in findings: - if SEVERITY_ORDER.get(f.vuln.severity, 0) >= threshold: - return 1 - return 0 + return int(any(SEVERITY_ORDER.get(f.vuln.severity, 0) >= threshold for f in findings)) diff --git a/hermes_cli/security_audit_startup.py b/hermes_cli/security_audit_startup.py index 91e677be31..059e6f8936 100644 --- a/hermes_cli/security_audit_startup.py +++ b/hermes_cli/security_audit_startup.py @@ -1,24 +1,11 @@ """Startup security posture audit (warn-on-load, never blocks). -Surfaces dangerous host / deployment posture at process start so operators -get an at-a-glance "you're exposed" signal. Motivated by the June 2026 -MCP-config persistence campaign, where compromised boxes ran as root with an -exposed dashboard / API server and no firewall — and nothing ever told the -operator. These checks are advisory: they emit ``logger.warning`` records -and return human-readable strings; they never raise or block startup. +Surfaces dangerous host / deployment posture at process start so operators get an at-a-glance +"you're exposed" signal. These checks are advisory: they emit ``logger.warning`` records and return +human-readable strings; they never raise or block startup. -Checks (each is independent and fail-safe — any internal error is swallowed -and simply yields no finding): - -1. Running as root (POSIX uid 0). -2. SSH daemon present with password authentication enabled. -3. Running inside a container with no persistent volume mount over the - HERMES_HOME data dir (state is ephemeral — lost on container restart). -4. A network-accessible gateway listener (dashboard / API server) with no - authentication configured. - -Cross-platform: the root and SSH checks are POSIX-only and no-op on Windows. -Everything is best-effort and read-only. +Checks (each is independent and fail-safe — any internal error is swallowed and simply yields no +finding): """ from __future__ import annotations @@ -38,10 +25,8 @@ _AUDIT_RAN = False def _is_root() -> bool: """True when the process runs as POSIX uid 0. Always False on Windows.""" getuid = getattr(os, "geteuid", None) or getattr(os, "getuid", None) - if getuid is None: - return False try: - return getuid() == 0 + return getuid is not None and getuid() == 0 except Exception: return False @@ -57,54 +42,44 @@ def _running_as_root() -> Optional[str]: ) -_SSHD_CONFIG_PATHS = ( - "/etc/ssh/sshd_config", -) -_SSHD_CONFIG_DIR = "/etc/ssh/sshd_config.d" - - def _iter_sshd_config_lines() -> list[str]: """Yield non-comment lines from sshd_config + its drop-in directory.""" lines: list[str] = [] - paths: list[Path] = [Path(p) for p in _SSHD_CONFIG_PATHS] + paths: list[Path] = [Path("/etc/ssh/sshd_config")] try: - d = Path(_SSHD_CONFIG_DIR) + d = Path("/etc/ssh/sshd_config.d") if d.is_dir(): paths.extend(sorted(d.glob("*.conf"))) except Exception: pass for p in paths: try: - for raw in p.read_text(encoding="utf-8", errors="replace").splitlines(): - stripped = raw.strip() - if stripped and not stripped.startswith("#"): - lines.append(stripped) + raw_lines = p.read_text(encoding="utf-8", errors="replace").splitlines() except Exception: continue + lines.extend(s for s in map(str.strip, raw_lines) if s and not s.startswith("#")) return lines def _ssh_password_auth_enabled() -> Optional[str]: """Warn when an SSH daemon has password authentication enabled. - Password auth on a public SSH daemon is the classic brute-force surface - and pairs badly with a root-capable agent box. POSIX-only; returns None - when there's no sshd config to read (e.g. Windows, or SSH not installed). + Password auth on a public SSH daemon is the classic brute-force surface and pairs badly with a + root-capable agent box. POSIX-only; returns None when there's no sshd config to read (e.g. + Windows, or SSH not installed). """ lines = _iter_sshd_config_lines() if not lines: return None # Last directive wins in sshd_config. Default (no directive) is "yes". - verdict = "yes" - saw_directive = False - for line in lines: - m = re.match(r"(?i)^PasswordAuthentication\s+(\w+)", line) - if m: - verdict = m.group(1).lower() - saw_directive = True - if verdict == "no": + directives = [ + m.group(1).lower() + for m in (re.match(r"(?i)^PasswordAuthentication\s+(\w+)", line) for line in lines) + if m + ] + if directives and directives[-1] == "no": return None - qualifier = "" if saw_directive else " (default — no explicit directive)" + qualifier = "" if directives else " (default — no explicit directive)" return ( f"SSH password authentication is ENABLED{qualifier}. Password auth is " "brute-forceable and dangerous on an internet-facing box. Set " @@ -120,20 +95,17 @@ def _in_container() -> bool: return False # desktop child, not a server container try: cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="replace") - if any(tok in cgroup for tok in ("docker", "containerd", "kubepods", "libpod")): - return True except Exception: - pass - return False + return False + return any(tok in cgroup for tok in ("docker", "containerd", "kubepods", "libpod")) def _path_is_mounted(path: Path) -> bool: """True if *path* sits on (or under) a real mount point per /proc/mounts. - Container overlay/root filesystems are ephemeral; a bind/volume mount over - the data dir shows up as a distinct mount entry. We treat the path as - persisted when a mountpoint at or above it is NOT the container root - overlay. + Container overlay/root filesystems are ephemeral; a bind/volume mount over the data dir + shows up as a distinct mount entry. The path counts as persisted when a mountpoint at or + above it is NOT the container root overlay. """ try: target = path.resolve() @@ -143,24 +115,16 @@ def _path_is_mounted(path: Path) -> bool: mounts = Path("/proc/mounts").read_text(encoding="utf-8", errors="replace").splitlines() except Exception: return True # can't tell — fail safe (no warning) - best = None - best_fstype = "" - for line in mounts: - parts = line.split() - if len(parts) < 3: - continue - mountpoint, fstype = parts[1], parts[2] - try: - mp = Path(mountpoint) - except Exception: - continue - if mp == target or mp in target.parents: - # Longest matching mountpoint wins (most specific). - if best is None or len(str(mp)) > len(str(best)): - best = mp - best_fstype = fstype - if best is None: + # (mountpoint, fstype) entries at or above target; the longest (most specific) wins, + # first one on ties. + covering = [ + (Path(parts[1]), parts[2]) + for parts in (line.split() for line in mounts) + if len(parts) >= 3 and (Path(parts[1]) == target or Path(parts[1]) in target.parents) + ] + if not covering: return True + best_fstype = max(covering, key=lambda entry: len(str(entry[0])))[1] # overlay / tmpfs over the data dir = ephemeral container storage. return best_fstype not in ("overlay", "tmpfs", "aufs") @@ -168,19 +132,14 @@ def _path_is_mounted(path: Path) -> bool: def _container_no_volume_mount(hermes_home: Optional[Path]) -> Optional[str]: if not _in_container(): return None - if hermes_home is not None: - home = hermes_home - else: + if hermes_home is None: from hermes_constants import get_hermes_home - home = get_hermes_home() - try: - if _path_is_mounted(home): - return None - except Exception: + hermes_home = get_hermes_home() + if _path_is_mounted(hermes_home): # any error propagates to run_security_audit (= no finding) return None return ( - f"Running in a container but the data dir ({home}) is NOT on a " + f"Running in a container but the data dir ({hermes_home}) is NOT on a " "persistent volume mount — sessions, memory, skills, and API keys are " "ephemeral and lost on container restart. Mount a host volume over the " "HERMES_HOME data directory." @@ -190,37 +149,29 @@ def _container_no_volume_mount(hermes_home: Optional[Path]) -> Optional[str]: def _network_listener_without_auth(config: Optional[dict]) -> list[str]: """Warn about network-accessible gateway listeners with no auth. - Covers the API server (no API_SERVER_KEY) and the dashboard (non-loopback - bind with no auth provider). Read-only against config + env; overlaps the - hard fail-closed guards but surfaces the posture proactively at startup. + Covers the API server (no API_SERVER_KEY) and the dashboard (non-loopback bind with no auth + provider). Read-only against config + env; overlaps the hard fail-closed guards but surfaces the + posture proactively at startup. """ - findings: list[str] = [] - try: - from gateway.platforms.base import is_network_accessible - except Exception: - return findings + # Any error (incl. an unimportable gateway package) propagates to run_security_audit, + # which treats it as "no finding". + from gateway.platforms.base import is_network_accessible - cfg = config or {} - - # API server. - try: - plats = (cfg.get("platforms") or {}) - api = plats.get("api_server") if isinstance(plats, dict) else None - if isinstance(api, dict) and api.get("enabled"): - extra = api.get("extra") or {} - host = extra.get("host") or os.environ.get("API_SERVER_HOST", "127.0.0.1") - key = extra.get("key") or os.environ.get("API_SERVER_KEY", "") - if is_network_accessible(str(host)) and not str(key).strip(): - findings.append( - f"OpenAI-compatible API server is network-accessible ({host}) " - "with NO API_SERVER_KEY. It dispatches terminal-capable agent " - "work — an unauthenticated network endpoint is remote code " - "execution. Set a strong API_SERVER_KEY." - ) - except Exception: - pass - - return findings + plats = (config or {}).get("platforms") or {} + api = plats.get("api_server") if isinstance(plats, dict) else None + if not (isinstance(api, dict) and api.get("enabled")): + return [] + extra = api.get("extra") or {} + host = extra.get("host") or os.environ.get("API_SERVER_HOST", "127.0.0.1") + key = extra.get("key") or os.environ.get("API_SERVER_KEY", "") + if not is_network_accessible(str(host)) or str(key).strip(): + return [] + return [ + f"OpenAI-compatible API server is network-accessible ({host}) " + "with NO API_SERVER_KEY. It dispatches terminal-capable agent " + "work — an unauthenticated network endpoint is remote code " + "execution. Set a strong API_SERVER_KEY." + ] def run_security_audit( @@ -228,31 +179,24 @@ def run_security_audit( ) -> list[str]: """Run all checks and return a list of human-readable warning strings. - Pure: no logging, no side effects. Each check is independently - fail-safe. Used directly by tests; the logging wrapper is - :func:`log_startup_security_warnings`. + Pure: no logging, no side effects. Each check is independently fail-safe. Used directly by + tests; the logging wrapper is :func:`log_startup_security_warnings`. """ findings: list[str] = [] for check in ( _running_as_root, _ssh_password_auth_enabled, + lambda: _container_no_volume_mount(hermes_home), + lambda: _network_listener_without_auth(config), ): try: r = check() - if r: - findings.append(r) except Exception: continue - try: - r = _container_no_volume_mount(hermes_home) - if r: + if isinstance(r, list): + findings.extend(r) + elif r: findings.append(r) - except Exception: - pass - try: - findings.extend(_network_listener_without_auth(config)) - except Exception: - pass return findings @@ -264,21 +208,17 @@ def log_startup_security_warnings( ) -> list[str]: """Run the audit once per process and emit each finding via logger.warning. - Returns the findings (also for tests). Never raises. Idempotent unless - ``force=True`` (used by tests). + Returns the findings (also for tests). Never raises. Idempotent unless ``force=True`` (used by + tests). """ global _AUDIT_RAN if _AUDIT_RAN and not force: return [] _AUDIT_RAN = True - try: - findings = run_security_audit(hermes_home=hermes_home, config=config) - except Exception: - return [] + findings = run_security_audit(hermes_home=hermes_home, config=config) if findings: logger.warning( - "Security posture audit found %d issue(s) — review your deployment:", - len(findings), + "Security posture audit found %d issue(s) — review your deployment:", len(findings) ) for i, f in enumerate(findings, 1): logger.warning(" [security %d/%d] %s", i, len(findings), f) diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py index 6d00e54c6c..1d2a7e3fad 100644 --- a/hermes_cli/send_cmd.py +++ b/hermes_cli/send_cmd.py @@ -1,27 +1,5 @@ -"""CLI subcommand: ``hermes send`` — pipe text from shell scripts to any -configured messaging platform (Telegram, Discord, Slack, Signal, SMS, etc.). - -This is a thin wrapper around ``tools.send_message_tool.send_message_tool`` -that exposes its functionality as a standalone CLI entry point so ops -scripts, cron jobs, CI hooks, and monitoring daemons can reuse the gateway's -already-configured credentials without having to reimplement each platform's -REST API client. - -Design notes: - -* No LLM, no agent loop — the subcommand just resolves arguments, reads the - message body, calls the shared tool function, and prints/returns the - result. It is intentionally fast, cheap, and side-effect-only. -* For platforms that send via bot token (Telegram, Discord, Slack, Signal, - SMS, WhatsApp-CloudAPI, …) no running gateway is required. The tool - talks directly to each platform's REST endpoint. For platforms that rely - on a persistent adapter connection (plugin platforms, Matrix in some - modes, …) a live gateway is needed; the underlying tool surfaces that - error to the caller. -* Exit codes follow the classic Unix convention: - 0 — delivery (or list) succeeded - 1 — delivery failed at the platform level - 2 — usage / argument / config error (argparse already uses 2) +"""CLI subcommand: ``hermes send`` — pipe text from shell scripts to any configured messaging platform +(Telegram, Discord, Slack, Signal, SMS, etc.). """ from __future__ import annotations @@ -38,18 +16,23 @@ _FAILURE_EXIT = 1 _SUCCESS_EXIT = 0 +def _fail(msg: str, exit_code: int | None = None) -> int: + """Print ``msg`` to stderr; exit with ``exit_code`` when given, else return ``_FAILURE_EXIT``.""" + print(msg, file=sys.stderr) + if exit_code is not None: + sys.exit(exit_code) + return _FAILURE_EXIT + + def _read_message_body( positional: Optional[str], file_path: Optional[str], ) -> Optional[str]: - """Resolve the message body from (in order): + """Resolve the message body from the positional arg, ``--file``, or piped stdin. - 1. An explicit positional message argument. - 2. ``--file PATH`` or ``--file -`` (where ``-`` means stdin). - 3. Piped stdin when it is not attached to a TTY. - - Returns ``None`` when nothing is available — callers must treat that as - a usage error. + Order: explicit positional argument, then ``--file PATH`` / ``--file -`` (stdin), then piped + stdin when not attached to a TTY. Returns ``None`` when nothing is available — callers must + treat that as a usage error. """ if positional: return positional @@ -60,7 +43,7 @@ def _read_message_body( try: return Path(file_path).read_text(encoding="utf-8") except UnicodeDecodeError: - print( + _fail( f"hermes send: {file_path} is not a text file. --file reads the " "message *body* (logs, reports, markdown).\n" "To send an image/document/audio file as a native attachment, " @@ -69,29 +52,15 @@ def _read_message_body( f' hermes send --to telegram "optional caption MEDIA:{file_path}"\n' "Add [[as_document]] to deliver an image as an uncompressed file:\n" f' hermes send --to telegram "[[as_document]] MEDIA:{file_path}"', - file=sys.stderr, + _USAGE_EXIT, ) - sys.exit(_USAGE_EXIT) except OSError as exc: - print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) - sys.exit(_USAGE_EXIT) + _fail(f"hermes send: cannot read {file_path}: {exc}", _USAGE_EXIT) # Piped input: only consume stdin when it is not a TTY. Reading from a # TTY would block the user in a half-broken "type your message" state, # which is a poor default for an ops CLI. - if not sys.stdin.isatty(): - data = sys.stdin.read() - if data: - return data - - return None - - -def _resolve_target(arg_to: Optional[str]) -> Optional[str]: - """Return a cleaned ``--to`` value, or ``None`` when nothing is set.""" - if arg_to and arg_to.strip(): - return arg_to.strip() - return None + return (sys.stdin.read() or None) if not sys.stdin.isatty() else None def _emit_result( @@ -102,8 +71,8 @@ def _emit_result( ) -> int: """Print the tool result in the requested format and return the exit code. - The underlying ``send_message_tool`` always returns a JSON string. We - parse it, decide success/failure, and format accordingly. + The underlying ``send_message_tool`` always returns a JSON string. We parse it, decide + success/failure, and format accordingly. """ try: payload = json.loads(result_json) if result_json else {} @@ -114,53 +83,36 @@ def _emit_result( if json_mode: print(json.dumps(payload, indent=2)) - elif quiet: - pass - else: + elif not quiet: if payload.get("error"): print(f"hermes send: {payload['error']}", file=sys.stderr) elif payload.get("success"): - note = payload.get("note") - if note: - print(note) - else: - print("sent") + print(payload.get("note") or "sent") else: # Unknown shape — dump it so nothing is silently dropped. print(json.dumps(payload, indent=2)) - if payload.get("error"): - return _FAILURE_EXIT - if payload.get("skipped"): + # Unknown / unexpected shapes are failures so scripts notice. + if not payload.get("error") and (payload.get("skipped") or payload.get("success")): return _SUCCESS_EXIT - if payload.get("success"): - return _SUCCESS_EXIT - # Unknown / unexpected — treat as failure so scripts notice. return _FAILURE_EXIT def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: """Print the channel directory (all configured targets across platforms). - Uses ``load_directory()`` for structured JSON output and - ``format_directory_for_display()`` for the human-readable rendering that - the send_message tool itself shows to the model — keeps the two surfaces - identical. + Uses ``load_directory()`` for JSON and ``format_directory_for_display()`` for the human + rendering the send_message tool shows the model, keeping the two surfaces identical. """ try: - from gateway.channel_directory import ( - format_directory_for_display, - load_directory, - ) + from gateway.channel_directory import format_directory_for_display, load_directory except Exception as exc: - print(f"hermes send: failed to load channel directory: {exc}", file=sys.stderr) - return _FAILURE_EXIT + return _fail(f"hermes send: failed to load channel directory: {exc}") try: raw = load_directory() except Exception as exc: - print(f"hermes send: failed to read channel directory: {exc}", file=sys.stderr) - return _FAILURE_EXIT + return _fail(f"hermes send: failed to read channel directory: {exc}") platforms = dict(raw.get("platforms") or {}) @@ -176,9 +128,8 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: gw_config = load_gateway_config() for plat in gw_config.get_connected_platforms(): plat_name = getattr(plat, "value", str(plat)) - if plat_name in ("local", "api_server", "webhook"): - continue - platforms.setdefault(plat_name, []) + if plat_name not in ("local", "api_server", "webhook"): + platforms.setdefault(plat_name, []) except Exception: # Directory contents alone are still useful; don't fail --list over # a config parse problem. @@ -188,12 +139,10 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: key = platform_filter.strip().lower() filtered = {k: v for k, v in platforms.items() if k.lower() == key} if not filtered: - print( + return _fail( f"hermes send: no targets found for platform '{platform_filter}'. " - f"Configured: {', '.join(sorted(platforms)) or '(none)'}", - file=sys.stderr, + f"Configured: {', '.join(sorted(platforms)) or '(none)'}" ) - return _FAILURE_EXIT platforms = filtered if json_mode: @@ -230,23 +179,9 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: def _load_hermes_env() -> None: - """Populate ``os.environ`` from ``~/.hermes/.env`` AND bridge top-level - ``config.yaml`` keys into the environment so the underlying gateway - config loader sees platform credentials and home channel IDs. - - ``send_message_tool`` reads tokens and home-channel IDs via - ``os.getenv(...)`` on each call. The gateway process does two things at - startup that ``hermes send`` must replicate when invoked standalone: - - 1. ``load_dotenv(~/.hermes/.env)`` — brings bot tokens into the env. - 2. Bridge top-level simple values from ``~/.hermes/config.yaml`` into - ``os.environ`` (without overriding existing env vars). This is where - ``TELEGRAM_HOME_CHANNEL`` and friends live when the user saved them - via ``hermes config set``. - - See ``gateway/run.py`` for the canonical version of this bridge — we - intentionally reimplement the minimum needed here so ``hermes send`` - doesn't pull in the full gateway module just to resolve a home channel. + """Populate ``os.environ`` from ``~/.hermes/.env`` AND bridge top-level ``config.yaml`` keys into + the environment so the underlying gateway config loader sees platform credentials and home + channel IDs. """ # Step 1: dotenv try: @@ -318,11 +253,8 @@ def _load_hermes_env() -> None: return for key, val in raw.items(): - if not isinstance(val, (str, int, float, bool)): - continue - if key in os.environ: - continue - os.environ[key] = str(val) + if isinstance(val, (str, int, float, bool)) and key not in os.environ: + os.environ[key] = str(val) def cmd_send(args: argparse.Namespace) -> None: @@ -341,29 +273,24 @@ def cmd_send(args: argparse.Namespace) -> None: exit_code = _list_targets(platform_filter, json_mode=getattr(args, "json", False)) sys.exit(exit_code) - target = _resolve_target(getattr(args, "to", None)) + target = (getattr(args, "to", None) or "").strip() if not target: - print( + _fail( "hermes send: --to PLATFORM[:channel[:thread]] is required\n" "Examples:\n" " hermes send --to telegram \"hello\"\n" " hermes send --to discord:#ops --file report.md\n" " hermes send --list # list available targets", - file=sys.stderr, + _USAGE_EXIT, ) - sys.exit(_USAGE_EXIT) - message = _read_message_body( - getattr(args, "message", None), - getattr(args, "file", None), - ) + message = _read_message_body(getattr(args, "message", None), getattr(args, "file", None)) if message is None or not message.strip(): - print( + _fail( "hermes send: no message provided. Pass text as a positional " "argument, use --file PATH, or pipe data via stdin.", - file=sys.stderr, + _USAGE_EXIT, ) - sys.exit(_USAGE_EXIT) # Optional: prepend a subject line. Useful for alerting scripts that # want a consistent header without inlining it into every call. @@ -380,28 +307,46 @@ def cmd_send(args: argparse.Namespace) -> None: # Signal/SMS/WhatsApp; live-adapter path for plugin platforms). # # It expects the standard tool-call dict and returns a JSON string. - tool_args = { - "action": "send", - "target": target, - "message": message, - } + result = send_message_tool({"action": "send", "target": target, "message": message}) + sys.exit(_emit_result(result, json_mode=getattr(args, "json", False), quiet=getattr(args, "quiet", False))) - result = send_message_tool(tool_args) - exit_code = _emit_result( - result, - json_mode=getattr(args, "json", False), - quiet=getattr(args, "quiet", False), - ) - sys.exit(exit_code) + +# (flags, add_argument kwargs) in --help order. +_SEND_ARGUMENTS = ( + (("-t", "--to"), dict( + metavar="TARGET", + default=None, + help=( + "Delivery target. Format: 'platform' (home channel), " + "'platform:chat_id', 'platform:chat_id:thread_id', or " + "'platform:#channel-name'. Examples: telegram, " + "telegram:-1001234567890:17585, discord:#ops, slack:C0123ABCD, " + "signal:+15551234567." + ), + )), + (("message",), dict(nargs="?", default=None, help="Message text. If omitted, read from --file or stdin.")), + # Legacy / convenience positional removed — use --to for clarity. + (("-f", "--file"), dict( + metavar="PATH", + default=None, + help=( + "Read message body from PATH (text only). Use '-' to force stdin. " + "To send an image/document as an attachment, use MEDIA:<path> in " + "the message text instead." + ), + )), + (("-s", "--subject"), dict(metavar="LINE", default=None, help="Prepend a subject/header line before the message body.")), + (("-l", "--list"), dict( + dest="list_targets", action="store_true", default=False, + help="List available targets. Optional positional filter: `hermes send --list telegram`.", + )), + (("-q", "--quiet"), dict(action="store_true", default=False, help="Suppress stdout on success (exit code only).")), + (("--json",), dict(action="store_true", default=False, help="Emit raw JSON result instead of human-readable output.")), +) def register_send_subparser(subparsers) -> argparse.ArgumentParser: - """Create the ``send`` subparser and return it. - - Kept as a standalone function so the top-level parser builder can wire - it in next to the other messaging subcommands without cluttering - ``_parser.py`` or ``main.py``. - """ + """Create the ``send`` subparser and return it.""" parser = subparsers.add_parser( "send", help="Send a message to a configured platform (scripts, cron jobs, CI).", @@ -427,73 +372,8 @@ def register_send_subparser(subparsers) -> argparse.ArgumentParser: formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument( - "-t", - "--to", - metavar="TARGET", - default=None, - help=( - "Delivery target. Format: 'platform' (home channel), " - "'platform:chat_id', 'platform:chat_id:thread_id', or " - "'platform:#channel-name'. Examples: telegram, " - "telegram:-1001234567890:17585, discord:#ops, slack:C0123ABCD, " - "signal:+15551234567." - ), - ) - - parser.add_argument( - "message", - nargs="?", - default=None, - help="Message text. If omitted, read from --file or stdin.", - ) - - # Legacy / convenience positional removed — use --to for clarity. - - parser.add_argument( - "-f", - "--file", - metavar="PATH", - default=None, - help=( - "Read message body from PATH (text only). Use '-' to force stdin. " - "To send an image/document as an attachment, use MEDIA:<path> in " - "the message text instead." - ), - ) - - parser.add_argument( - "-s", - "--subject", - metavar="LINE", - default=None, - help="Prepend a subject/header line before the message body.", - ) - - parser.add_argument( - "-l", - "--list", - dest="list_targets", - action="store_true", - default=False, - help="List available targets. Optional positional filter: `hermes send --list telegram`.", - ) - - parser.add_argument( - "-q", - "--quiet", - action="store_true", - default=False, - help="Suppress stdout on success (exit code only).", - ) - - parser.add_argument( - "--json", - action="store_true", - default=False, - help="Emit raw JSON result instead of human-readable output.", - ) - + for flags, kwargs in _SEND_ARGUMENTS: + parser.add_argument(*flags, **kwargs) parser.set_defaults(func=cmd_send) return parser diff --git a/hermes_cli/session_export.py b/hermes_cli/session_export.py index f8695b8d62..51548ce6f0 100644 --- a/hermes_cli/session_export.py +++ b/hermes_cli/session_export.py @@ -1,9 +1,8 @@ """Shared renderers for session export commands. -The CLI, dashboard, and slash-command surfaces all deal with the same -session-shaped data: a session dict with a ``messages`` list. Keep filtering -and human-readable rendering here so each surface only has to load sessions -and write bytes. +The CLI, dashboard, and slash-command surfaces all deal with the same session-shaped data: a session +dict with a ``messages`` list. Keep filtering and human-readable rendering here so each surface only +has to load sessions and write bytes. """ from __future__ import annotations @@ -20,10 +19,8 @@ ExportOnly = Literal["user-prompts"] def normalize_export_format(fmt: str) -> ExportFormat: """Return the canonical export format name.""" - value = (fmt or "jsonl").strip().lower() - if value == "md": - value = "markdown" - if value not in {"jsonl", "markdown"}: + value = {"jsonl": "jsonl", "markdown": "markdown", "md": "markdown"}.get((fmt or "jsonl").strip().lower()) + if value is None: raise ValueError(f"Unsupported session export format: {fmt}") return value # type: ignore[return-value] @@ -32,8 +29,7 @@ def normalize_export_only(only: Optional[str]) -> Optional[ExportOnly]: """Return the canonical export filter name.""" if only is None: return None - value = only.strip().lower() - if value in {"user", "prompts", "user-prompts", "user_prompts"}: + if only.strip().lower() in {"user", "prompts", "user-prompts", "user_prompts"}: return "user-prompts" raise ValueError(f"Unsupported session export filter: {only}") @@ -46,18 +42,22 @@ def render_sessions_export( ) -> str: """Render exported sessions in a stable, reusable format. - ``fmt=jsonl`` with no filter intentionally preserves the legacy shape: - one full session object per line. ``only=user-prompts`` switches the unit - of export to one prompt record per line so the output is easy to pipe into - review, memory-ingestion, or prompt-library tooling. + ``fmt=jsonl`` with no filter intentionally preserves the legacy shape: one full session object + per line. ``only=user-prompts`` switches the unit of export to one prompt record per line so the + output is easy to pipe into review, memory-ingestion, or prompt-library tooling. """ session_list = list(sessions) export_format = normalize_export_format(fmt) export_only = normalize_export_only(only) + prompts_only = export_only == "user-prompts" if export_format == "jsonl": - return _render_jsonl(session_list, only=export_only) - return _render_markdown(session_list, only=export_only) + rows = iter_user_prompt_records(session_list) if prompts_only else session_list + lines = [json.dumps(row, ensure_ascii=False) for row in rows] + return ("\n".join(lines) + "\n") if lines else "" + if prompts_only: + return _render_user_prompts_markdown(session_list) + return _render_full_markdown(session_list) def export_record_count( @@ -65,8 +65,7 @@ def export_record_count( ) -> Tuple[int, str]: """Return ``(count, noun)`` for status messages after an export.""" session_list = list(sessions) - export_only = normalize_export_only(only) - if export_only == "user-prompts": + if normalize_export_only(only) == "user-prompts": return sum(1 for _ in iter_user_prompt_records(session_list)), "prompt" return len(session_list), "session" @@ -98,45 +97,31 @@ def iter_user_prompt_records( yield record -def _render_jsonl( - sessions: List[Dict[str, Any]], *, only: Optional[ExportOnly] -) -> str: - if only == "user-prompts": - rows = iter_user_prompt_records(sessions) - else: - rows = iter(sessions) - lines = [json.dumps(row, ensure_ascii=False) for row in rows] - return ("\n".join(lines) + "\n") if lines else "" - - -def _render_markdown( - sessions: List[Dict[str, Any]], *, only: Optional[ExportOnly] -) -> str: - if only == "user-prompts": - return _render_user_prompts_markdown(sessions) - return _render_full_markdown(sessions) - - def _render_user_prompts_markdown(sessions: List[Dict[str, Any]]) -> str: + lines = _render_sessions_markdown( + sessions, "User prompts export", + lambda session: f"User prompts for session {_heading_text(_session_id(session))}", + lambda session: f"Session {_heading_text(_session_id(session))}", + _append_prompt_records, + ) + if not sessions: + lines += ["_No user prompts found._", ""] + return _finish_markdown(lines) + + +def _render_sessions_markdown(sessions, multi_title, single_heading, multi_heading, append_body) -> List[str]: + """One session → its own H1 with body at H2; several → a shared H1, each session H2/H3.""" lines: List[str] = [] if len(sessions) == 1: session = sessions[0] - lines.append(f"# User prompts for session {_heading_text(_session_id(session))}") - lines.extend(_session_metadata_lines(session)) - lines.append("") - _append_prompt_records(lines, session, heading_level=2) + lines += [f"# {single_heading(session)}", *_session_metadata_lines(session), ""] + append_body(lines, session, heading_level=2) else: - lines.append("# User prompts export") - lines.append("") + lines += [f"# {multi_title}", ""] for session in sessions: - lines.append(f"## Session {_heading_text(_session_id(session))}") - lines.extend(_session_metadata_lines(session)) - lines.append("") - _append_prompt_records(lines, session, heading_level=3) - if not sessions: - lines.append("_No user prompts found._") - lines.append("") - return _finish_markdown(lines) + lines += [f"## {multi_heading(session)}", *_session_metadata_lines(session), ""] + append_body(lines, session, heading_level=3) + return lines def _append_prompt_records( @@ -144,38 +129,24 @@ def _append_prompt_records( ) -> None: prompts = list(iter_user_prompt_records([session])) if not prompts: - lines.append("_No user prompts found._") - lines.append("") + lines += ["_No user prompts found._", ""] return marker = "#" * heading_level for prompt in prompts: timestamp = prompt.get("created_at") or "timestamp unavailable" lines.append(f"{marker} {prompt['index']}. {timestamp}") - message_id = prompt.get("message_id") - if message_id is not None: - lines.append(f"Message ID: `{message_id}`") - lines.append("") - lines.append(str(prompt.get("text") or "")) - lines.append("") + if (message_id := prompt.get("message_id")) is not None: + lines += [f"Message ID: `{message_id}`", ""] + lines += [str(prompt.get("text") or ""), ""] def _render_full_markdown(sessions: List[Dict[str, Any]]) -> str: - lines: List[str] = [] - if len(sessions) == 1: - session = sessions[0] - lines.append(f"# Session: {_heading_text(_session_title_or_id(session))}") - lines.extend(_session_metadata_lines(session)) - lines.append("") - _append_session_messages(lines, session, heading_level=2) - else: - lines.append("# Hermes sessions export") - lines.append("") - for session in sessions: - lines.append(f"## Session: {_heading_text(_session_title_or_id(session))}") - lines.extend(_session_metadata_lines(session)) - lines.append("") - _append_session_messages(lines, session, heading_level=3) - return _finish_markdown(lines) + return _finish_markdown(_render_sessions_markdown( + sessions, "Hermes sessions export", + lambda session: f"Session: {_heading_text(_session_title_or_id(session))}", + lambda session: f"Session: {_heading_text(_session_title_or_id(session))}", + _append_session_messages, + )) def _append_session_messages( @@ -186,34 +157,24 @@ def _append_session_messages( message for message in _messages(session) if message.get("role") != "system" ] if not visible_messages: - lines.append("_No messages found._") - lines.append("") + lines += ["_No messages found._", ""] return for message in visible_messages: role = str(message.get("role") or "unknown") timestamp = _format_timestamp(message.get("timestamp")) suffix = f" - {timestamp}" if timestamp else "" + text = _message_text(message.get("content")) if role == "tool": tool_name = str(message.get("tool_name") or message.get("name") or "tool") - lines.append(f"{marker} Tool: {_heading_text(tool_name)}{suffix}") - lines.append("") - lines.append(f"<details><summary>{html_escape(tool_name)}</summary>") - lines.append("") - lines.append(_fenced_text(_message_text(message.get("content")))) - lines.append("") - lines.append("</details>") - lines.append("") + lines += [ + f"{marker} Tool: {_heading_text(tool_name)}{suffix}", "", + f"<details><summary>{html_escape(tool_name)}</summary>", "", + _fenced_text(text), "", "</details>", "", + ] continue - - label = { - "user": "User", - "assistant": "Assistant", - }.get(role, role.title()) - lines.append(f"{marker} {label}{suffix}") - lines.append("") - lines.append(_message_text(message.get("content"))) - lines.append("") + label = {"user": "User", "assistant": "Assistant"}.get(role, role.title()) + lines += [f"{marker} {label}{suffix}", "", text, ""] def _messages(session: Dict[str, Any]) -> List[Dict[str, Any]]: @@ -224,18 +185,9 @@ def _messages(session: Dict[str, Any]) -> List[Dict[str, Any]]: def _message_text(content: Any) -> str: if content is None: return "" - if isinstance(content, str): - return content if isinstance(content, list): - parts = [_content_part_text(part) for part in content] - return "\n".join(part for part in parts if part) - if isinstance(content, dict): - for key in ("text", "content"): - value = content.get(key) - if isinstance(value, str): - return value - return json.dumps(content, ensure_ascii=False, sort_keys=True) - return str(content) + return "\n".join(part for part in map(_content_part_text, content) if part) + return _content_part_text(content) def _content_part_text(part: Any) -> str: @@ -254,35 +206,24 @@ def _format_timestamp(value: Any) -> Optional[str]: if value is None: return None if isinstance(value, (int, float)): - return ( - datetime.fromtimestamp(float(value), tz=timezone.utc) - .isoformat(timespec="seconds") - .replace("+00:00", "Z") - ) - if isinstance(value, datetime): - dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc).isoformat(timespec="seconds").replace( - "+00:00", "Z" - ) - return str(value) + dt = datetime.fromtimestamp(float(value), tz=timezone.utc) + elif isinstance(value, datetime): + dt = (value if value.tzinfo else value.replace(tzinfo=timezone.utc)).astimezone(timezone.utc) + else: + return str(value) + return dt.isoformat(timespec="seconds").replace("+00:00", "Z") def _session_metadata_lines(session: Dict[str, Any]) -> List[str]: lines: List[str] = [f"- Session ID: `{_session_id(session)}`"] - source = session.get("source") - if source: - lines.append(f"- Source: `{source}`") - model = session.get("model") - if model: - lines.append(f"- Model: `{model}`") - title = session.get("title") - if title: + for key, label in (("source", "Source"), ("model", "Model")): + if session.get(key): + lines.append(f"- {label}: `{session[key]}`") + if title := session.get("title"): lines.append(f"- Title: {_inline_text(str(title))}") - started = _format_timestamp(session.get("started_at")) - if started: + if started := _format_timestamp(session.get("started_at")): lines.append(f"- Started: {started}") - message_count = session.get("message_count") - if message_count is not None: + if (message_count := session.get("message_count")) is not None: lines.append(f"- Messages: {message_count}") return lines @@ -292,8 +233,7 @@ def _session_id(session: Dict[str, Any]) -> str: def _session_title_or_id(session: Dict[str, Any]) -> str: - title = str(session.get("title") or "").strip() - return title or _session_id(session) + return str(session.get("title") or "").strip() or _session_id(session) def _heading_text(value: str) -> str: @@ -343,27 +283,19 @@ Examples: /save html session.html redact""" - def normalize_save_format(fmt: Optional[str]) -> str: """Map a user-typed /save format token to a canonical format.""" token = (fmt or "json").strip().lower() - if token in ("json", "snapshot"): - return "json" - if token in ("md", "markdown"): - return "md" - if token == "html": - return "html" - raise ValueError( - f"Unknown format {token!r} — expected one of: json, md, html" - ) + canonical = {"json": "json", "snapshot": "json", "md": "md", "markdown": "md", "html": "html"} + if token not in canonical: + raise ValueError( + f"Unknown format {token!r} — expected one of: json, md, html" + ) + return canonical[token] def render_session_for_save(session: Dict[str, Any], fmt: str) -> str: - """Render one exported session dict for /save. - - ``json`` -> pretty-printed JSON; ``md`` -> the shared full-markdown - renderer; ``html`` -> the standalone single-file HTML export. - """ + """Render one exported session dict for /save.""" if fmt == "json": return json.dumps(session, indent=2, ensure_ascii=False, default=str) if fmt == "md": diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 51052b6e06..6e05856267 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -1,9 +1,6 @@ -""" -HTML Export generator for Hermes sessions. -Generates a standalone, beautiful HTML file with all messages embedded. -Supports single and multi-session exports with a professional sidebar. -No remote dependencies. -Enhanced with UI-UX-PRO-MAX design intelligence. +"""HTML Export generator for Hermes sessions. Generates a standalone, beautiful HTML file with all +messages embedded. Supports single and multi-session exports with a professional sidebar. No remote +dependencies. Enhanced with UI-UX-PRO-MAX design intelligence. """ import datetime @@ -657,6 +654,40 @@ def _format_timestamp(ts: float) -> str: if not ts: return "N/A" return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") +_ROLE_ICONS = {"user": ICON_USER, "assistant": ICON_BOT, "system": ICON_SHIELD} +_CHEVRON_HTML = ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ') + + +def _content_text(content: Any) -> str: + """Flatten multimodal/complex content parts into display text.""" + if not isinstance(content, list): + return content + parts = [] + for part in content: + if not isinstance(part, dict): + parts.append(str(part)) + elif part.get("type") == "text": + parts.append(part.get("text", "")) + elif part.get("type") == "image_url": + parts.append("[Image Attachment]") + return "\n".join(parts) + + +def _collapsible(kind: str, icon: str, title: str, inner: str, indent: str, outer_class: str = "") -> str: + """A chevron-headed collapsible block (tool-call / reasoning / system-prompt).""" + return f''' +{indent}<div class="{outer_class or kind}"> +{indent} <div class="{kind}-header"> +{indent} {_CHEVRON_HTML} +{indent} {icon} {title} +{indent} </div> +{indent} <div class="{kind}-content"> +{indent} {inner} +{indent} </div> +{indent}</div> +{indent}''' + + def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: html_list = [] for i, msg in enumerate(messages): @@ -666,30 +697,9 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: if role == "session_meta": continue - content = msg.get("content") or "" + content = _content_text(msg.get("content") or "") timestamp = _format_timestamp(msg.get("timestamp", 0)) - - # Icon selection - role_icon = ICON_TERMINAL - if role == "user": - role_icon = ICON_USER - elif role == "assistant": - role_icon = ICON_BOT - elif role == "system": - role_icon = ICON_SHIELD - - # Handle multimodal or complex content - if isinstance(content, list): - content_parts = [] - for part in content: - if isinstance(part, dict): - if part.get("type") == "text": - content_parts.append(part.get("text", "")) - elif part.get("type") == "image_url": - content_parts.append("[Image Attachment]") - else: - content_parts.append(str(part)) - content = "\n".join(content_parts) + role_icon = _ROLE_ICONS.get(role, ICON_TERMINAL) # Build message HTML. The role feeds two sinks and for tool/MCP messages # is externally influenced, so treat each sink on its own terms: @@ -704,54 +714,33 @@ def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: # Delay animation for initial items delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" - chevron_html = ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ') - html = f'<div class="{msg_class}"{delay_style}>' html += f' <div class="message-header">' - html += f' <div class="role-badge">{chevron_html} {role_icon} {safe_role}</div>' + html += f' <div class="role-badge">{_CHEVRON_HTML} {role_icon} {safe_role}</div>' html += f' <div class="timestamp">{timestamp}</div>' html += ' </div>' html += ' <div class="message-body">' - # Tool Calls - tool_calls = msg.get("tool_calls") - if tool_calls: - for tc in tool_calls: - fn_name = tc.get("function", {}).get("name", "unknown") - args = tc.get("function", {}).get("arguments", "{}") - html += f''' - <div class="tool-call"> - <div class="tool-call-header"> - {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} - {ICON_WRENCH} Tool Call: {_escape_html(fn_name)} - </div> - <div class="tool-call-content"> - <pre><code>{_escape_html(args)}</code></pre> - </div> - </div> - ''' + for tc in msg.get("tool_calls") or (): + fn_name = tc.get("function", {}).get("name", "unknown") + args = tc.get("function", {}).get("arguments", "{}") + html += _collapsible( + "tool-call", ICON_WRENCH, f"Tool Call: {_escape_html(fn_name)}", + f"<pre><code>{_escape_html(args)}</code></pre>", " " * 16, + ) - # Content if content: if role == "tool": html += f' <div class="content"><pre><code>{_escape_html(content)}</code></pre></div>' else: html += f' <div class="content">{_escape_html(content)}</div>' - # Reasoning reasoning = msg.get("reasoning") or msg.get("reasoning_content") if reasoning: - html += f''' - <div class="reasoning"> - <div class="reasoning-header"> - {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} - {ICON_SPARKLES} Reasoning - </div> - <div class="reasoning-content"> - <div class="content">{_escape_html(reasoning)}</div> - </div> - </div> - ''' + html += _collapsible( + "reasoning", ICON_SPARKLES, "Reasoning", + f'<div class="content">{_escape_html(reasoning)}</div>', " " * 12, + ) html += ' </div>' html += '</div>' @@ -812,32 +801,20 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: title = s.get("title") or "Hermes Session" model = s.get("model") or "Unknown" started_at = _format_timestamp(s.get("started_at", 0)) - messages = s.get("messages", []) - - messages_html = _generate_messages_html(messages) - - view_class = "session-view" - if not is_multi: view_class += " active" - - session_view_id = f"view-{escaped_sid}" + messages_html = _generate_messages_html(s.get("messages", [])) + view_class = "session-view" if is_multi else "session-view active" system_prompt = s.get("system_prompt") system_html = "" if system_prompt: - system_html = f''' - <div class="system-prompt-section active"> - <div class="system-prompt-header"> - {ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ')} - {ICON_SHIELD} System Prompt (Persona) - </div> - <div class="system-prompt-content"> - <div class="content">{_escape_html(system_prompt)}</div> - </div> - </div> - ''' + system_html = _collapsible( + "system-prompt", ICON_SHIELD, "System Prompt (Persona)", + f'<div class="content">{_escape_html(system_prompt)}</div>', " " * 12, + outer_class="system-prompt-section active", + ) session_html = f''' - <div class="{view_class}" id="{session_view_id}"> + <div class="{view_class}" id="view-{escaped_sid}"> <header class="fade-in"> <h1>{_escape_html(title)}</h1> <div class="meta"> @@ -854,7 +831,6 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: ''' sessions_html_list.append(session_html) - script_nonce = secrets.token_urlsafe(16) return HTML_TEMPLATE.format( page_title="Hermes Session Export" if is_multi else _escape_html(sessions[0].get("title") or "Hermes Session"), sidebar_html=sidebar_html, @@ -862,7 +838,7 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: main_margin="var(--sidebar-width)" if is_multi else "0", layout_class="layout-multi" if is_multi else "layout-single", generated_at=generated_at, - script_nonce=script_nonce, + script_nonce=secrets.token_urlsafe(16), ) def generate_html_export(session_data: Dict[str, Any]) -> str: diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py index e2ab0c8a6d..a06ccabd13 100644 --- a/hermes_cli/session_export_md.py +++ b/hermes_cli/session_export_md.py @@ -1,8 +1,8 @@ """Markdown/QMD export helpers for Hermes sessions. -This module is intentionally filesystem-only: it formats already-exported -SessionDB dictionaries and writes them to user-selected export directories. It -must not mutate state.db or call delete/prune/archive APIs. +This module is intentionally filesystem-only: it formats already-exported SessionDB dictionaries and +writes them to user-selected export directories. It must not mutate state.db or call +delete/prune/archive APIs. """ from __future__ import annotations @@ -219,12 +219,9 @@ def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, def redact_session_data(session: dict[str, Any]) -> dict[str, Any]: """Return a deep copy of a session export dict with secrets redacted. - Runs every message's content and tool-call arguments through the - force-mode redaction pass (``agent.redact.redact_sensitive_text``), so - API keys, tokens, and credentials that appeared in tool output never - land in plaintext export files. Force mode ignores the user's global - ``security.redact_secrets`` preference — an explicit ``--redact`` export - must never emit raw secrets. + Runs every message's content and tool-call arguments through the force-mode redaction pass + (``agent.redact.redact_sensitive_text``), so API keys, tokens, and credentials that appeared in + tool output never land in plaintext export files. """ from agent.redact import redact_sensitive_text @@ -247,10 +244,7 @@ def redact_session_data(session: dict[str, Any]) -> dict[str, Any]: def write_session_markdown( session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False ) -> Path: - """Write a Markdown/QMD export file and return its path. - - Raises FileExistsError when the destination exists and force=False. - """ + """Write a Markdown/QMD export file and return its path.""" out_dir = Path(output_dir).expanduser() out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / safe_session_filename(session, fmt=fmt) diff --git a/hermes_cli/session_filters.py b/hermes_cli/session_filters.py index 6633f9e3c0..9d252fdbd3 100644 --- a/hermes_cli/session_filters.py +++ b/hermes_cli/session_filters.py @@ -1,18 +1,4 @@ -"""Shared time/filter parsing for `hermes sessions prune` / `archive`. - -Turns user-friendly CLI values into the epoch bounds and filter kwargs -consumed by ``SessionDB.prune_sessions`` / ``archive_sessions`` / -``list_prune_candidates``. - -Two value shapes are accepted anywhere a point in time is expected: - -* Durations (relative to now): ``5h``, ``30m``, ``2d``, ``1w`` — and, for - backward compatibility with the original ``--older-than N`` flag, a bare - integer which means **days**. -* Absolute timestamps: ``2026-07-05``, ``2026-07-05 14:30``, - ``2026-07-05T14:30:00`` (any ISO-8601 form ``datetime.fromisoformat`` - understands; naive values are interpreted in local time). -""" +"""Shared time/filter parsing for `hermes sessions prune` / `archive`.""" from __future__ import annotations @@ -51,9 +37,8 @@ def parse_duration_seconds(value: str) -> Optional[float]: def parse_point_in_time(value: str, flag: str) -> float: """Parse a CLI time value into an epoch timestamp. - Durations are interpreted as "that long ago" (``5h`` → now − 5 hours). - Absolute ISO timestamps are returned as-is (naive = local time). - Raises ``ValueError`` with a user-facing message on unparseable input. + Durations mean "that long ago" (``5h`` = now minus 5 hours); ISO timestamps are taken as-is + (naive = local time). Raises ``ValueError`` with a user-facing message on bad input. """ s = str(value).strip() dur = parse_duration_seconds(s) @@ -82,153 +67,102 @@ def format_epoch(ts: Optional[float]) -> str: def build_prune_filters(args: Any) -> Dict[str, Any]: """Translate argparse Namespace flags into SessionDB filter kwargs. - Understands: ``--older-than``, ``--newer-than``, ``--before``, - ``--after``, ``--source``, ``--title``, ``--end-reason``, ``--cwd``, - ``--min-messages``, ``--max-messages``, ``--archived``/``--no-archived``. + Understands: ``--older-than``, ``--newer-than``, ``--before``, ``--after``, ``--source``, + ``--title``, ``--end-reason``, ``--cwd``, ``--min-messages``, ``--max-messages``, + ``--archived``/``--no-archived``. - ``--older-than`` / ``--newer-than`` bound last activity, while - ``--before`` / ``--after`` explicitly bound session start time. Last - activity is the latest message timestamp, falling back to ``started_at`` - for empty sessions. - - Raises ``ValueError`` on unparseable values or an empty/inverted window. + ``--older-than`` / ``--newer-than`` bound last activity, while ``--before`` / ``--after`` + explicitly bound session start time. Last activity is the latest message timestamp, falling back + to ``started_at`` for empty sessions. """ - last_active_before: Optional[float] = None - last_active_after: Optional[float] = None - started_before: Optional[float] = None - started_after: Optional[float] = None + bounds: Dict[str, Optional[float]] = {} + for key, attr, flag in _TIME_BOUNDS: + raw = getattr(args, attr, None) + bounds[key] = None if raw is None else parse_point_in_time(raw, flag) - def _tighter(current: Optional[float], new: float, upper: bool) -> float: - if current is None: - return new - return min(current, new) if upper else max(current, new) + for lo, hi, label, lo_flag, hi_flag in _WINDOWS: + if bounds[hi] is not None and bounds[lo] is not None and bounds[lo] >= bounds[hi]: + raise ValueError( + f"Empty {label} window: the {lo_flag} bound " + f"({format_epoch(bounds[lo])}) is not earlier than the " + f"{hi_flag} bound ({format_epoch(bounds[hi])})." + ) - older_than = getattr(args, "older_than", None) - if older_than is not None: - last_active_before = _tighter( - last_active_before, - parse_point_in_time(older_than, "--older-than"), - True, - ) - newer_than = getattr(args, "newer_than", None) - if newer_than is not None: - last_active_after = _tighter( - last_active_after, - parse_point_in_time(newer_than, "--newer-than"), - False, - ) - before = getattr(args, "before", None) - if before is not None: - started_before = _tighter( - started_before, parse_point_in_time(before, "--before"), True - ) - after = getattr(args, "after", None) - if after is not None: - started_after = _tighter( - started_after, parse_point_in_time(after, "--after"), False - ) - - if ( - started_before is not None - and started_after is not None - and started_after >= started_before - ): - raise ValueError( - "Empty start-time window: the --after bound " - f"({format_epoch(started_after)}) is not earlier than the " - f"--before bound ({format_epoch(started_before)})." - ) - if ( - last_active_before is not None - and last_active_after is not None - and last_active_after >= last_active_before - ): - raise ValueError( - "Empty activity window: the --newer-than bound " - f"({format_epoch(last_active_after)}) is not earlier than the " - f"--older-than bound ({format_epoch(last_active_before)})." - ) - - filters: Dict[str, Any] = { - # older_than_days=None: the epoch bounds above are the whole story. - # Without this, prune_sessions' default 90-day cutoff would silently - # cap an --after/--newer-than-only window. - "older_than_days": None, - "last_active_before": last_active_before, - "last_active_after": last_active_after, - "started_before": started_before, - "started_after": started_after, - "source": getattr(args, "source", None), - "title_like": getattr(args, "title", None), - "end_reason": getattr(args, "end_reason", None), - "cwd_prefix": getattr(args, "cwd", None), - "min_messages": getattr(args, "min_messages", None), - "max_messages": getattr(args, "max_messages", None), - "model_like": getattr(args, "model", None), - "provider": getattr(args, "provider", None), - "user_id": getattr(args, "user", None), - "chat_id": getattr(args, "chat_id", None), - "chat_type": getattr(args, "chat_type", None), - "branch_like": getattr(args, "branch", None), - "min_tokens": getattr(args, "min_tokens", None), - "max_tokens": getattr(args, "max_tokens", None), - "min_cost": getattr(args, "min_cost", None), - "max_cost": getattr(args, "max_cost", None), - "min_tool_calls": getattr(args, "min_tool_calls", None), - "max_tool_calls": getattr(args, "max_tool_calls", None), - } + # older_than_days=None: the epoch bounds above are the whole story. + # Without this, prune_sessions' default 90-day cutoff would silently + # cap an --after/--newer-than-only window. + filters: Dict[str, Any] = {"older_than_days": None, **bounds} + for key, attr in _ARG_FILTERS: + filters[key] = getattr(args, attr, None) return filters +# (filter key, argparse attr, CLI flag) for the four epoch bounds. +_TIME_BOUNDS = ( + ("last_active_before", "older_than", "--older-than"), + ("last_active_after", "newer_than", "--newer-than"), + ("started_before", "before", "--before"), + ("started_after", "after", "--after"), +) +# (lower key, upper key, window label, lower flag, upper flag); checked in this order. +_WINDOWS = ( + ("started_after", "started_before", "start-time", "--after", "--before"), + ("last_active_after", "last_active_before", "activity", "--newer-than", "--older-than"), +) +_ARG_FILTERS = ( + ("source", "source"), + ("title_like", "title"), + ("end_reason", "end_reason"), + ("cwd_prefix", "cwd"), + ("min_messages", "min_messages"), + ("max_messages", "max_messages"), + ("model_like", "model"), + ("provider", "provider"), + ("user_id", "user"), + ("chat_id", "chat_id"), + ("chat_type", "chat_type"), + ("branch_like", "branch"), + ("min_tokens", "min_tokens"), + ("max_tokens", "max_tokens"), + ("min_cost", "min_cost"), + ("max_cost", "max_cost"), + ("min_tool_calls", "min_tool_calls"), + ("max_tool_calls", "max_tool_calls"), +) + +# (filter key, description template, include when: "set" == `is not None`, "truthy" == bool(v)). +_DESCRIBE = ( + ("last_active_before", "last active before {e}", "set"), + ("last_active_after", "last active after {e}", "set"), + ("started_before", "started before {e}", "set"), + ("started_after", "started after {e}", "set"), + ("source", "source '{v}'", "truthy"), + ("title_like", "title contains '{v}'", "truthy"), + ("end_reason", "end reason '{v}'", "truthy"), + ("cwd_prefix", "cwd under '{v}'", "truthy"), + ("min_messages", ">= {v} messages", "set"), + ("max_messages", "<= {v} messages", "set"), + ("model_like", "model contains '{v}'", "truthy"), + ("provider", "provider '{v}'", "truthy"), + ("user_id", "user '{v}'", "truthy"), + ("chat_id", "chat '{v}'", "truthy"), + ("chat_type", "chat type '{v}'", "truthy"), + ("branch_like", "git branch contains '{v}'", "truthy"), + ("min_tokens", ">= {v} tokens", "set"), + ("max_tokens", "<= {v} tokens", "set"), + ("min_cost", ">= ${v}", "set"), + ("max_cost", "<= ${v}", "set"), + ("min_tool_calls", ">= {v} tool calls", "set"), + ("max_tool_calls", "<= {v} tool calls", "set"), +) + + def describe_filters(filters: Dict[str, Any]) -> str: """Human-readable summary of active filters for confirmation prompts.""" parts = [] - if filters.get("last_active_before") is not None: - parts.append( - f"last active before {format_epoch(filters['last_active_before'])}" - ) - if filters.get("last_active_after") is not None: - parts.append( - f"last active after {format_epoch(filters['last_active_after'])}" - ) - if filters.get("started_before") is not None: - parts.append(f"started before {format_epoch(filters['started_before'])}") - if filters.get("started_after") is not None: - parts.append(f"started after {format_epoch(filters['started_after'])}") - if filters.get("source"): - parts.append(f"source '{filters['source']}'") - if filters.get("title_like"): - parts.append(f"title contains '{filters['title_like']}'") - if filters.get("end_reason"): - parts.append(f"end reason '{filters['end_reason']}'") - if filters.get("cwd_prefix"): - parts.append(f"cwd under '{filters['cwd_prefix']}'") - if filters.get("min_messages") is not None: - parts.append(f">= {filters['min_messages']} messages") - if filters.get("max_messages") is not None: - parts.append(f"<= {filters['max_messages']} messages") - if filters.get("model_like"): - parts.append(f"model contains '{filters['model_like']}'") - if filters.get("provider"): - parts.append(f"provider '{filters['provider']}'") - if filters.get("user_id"): - parts.append(f"user '{filters['user_id']}'") - if filters.get("chat_id"): - parts.append(f"chat '{filters['chat_id']}'") - if filters.get("chat_type"): - parts.append(f"chat type '{filters['chat_type']}'") - if filters.get("branch_like"): - parts.append(f"git branch contains '{filters['branch_like']}'") - if filters.get("min_tokens") is not None: - parts.append(f">= {filters['min_tokens']} tokens") - if filters.get("max_tokens") is not None: - parts.append(f"<= {filters['max_tokens']} tokens") - if filters.get("min_cost") is not None: - parts.append(f">= ${filters['min_cost']}") - if filters.get("max_cost") is not None: - parts.append(f"<= ${filters['max_cost']}") - if filters.get("min_tool_calls") is not None: - parts.append(f">= {filters['min_tool_calls']} tool calls") - if filters.get("max_tool_calls") is not None: - parts.append(f"<= {filters['max_tool_calls']} tool calls") + for key, template, mode in _DESCRIBE: + value = filters.get(key) + if (value is not None) if mode == "set" else bool(value): + shown = format_epoch(value) if "{e}" in template else value + parts.append(template.replace("{e}", "{v}").format(v=shown)) return ", ".join(parts) if parts else "no filters (all ended sessions)" diff --git a/hermes_cli/session_listing.py b/hermes_cli/session_listing.py index 7c2c0e4e0d..9cae4f4d53 100644 --- a/hermes_cli/session_listing.py +++ b/hermes_cli/session_listing.py @@ -8,15 +8,11 @@ from typing import Any def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str, str | None]: """Parse `/sessions`-style args into listing flags, a resume target, and a search query. - Returns ``(include_all_sources, include_unnamed, target, search_query)``. - ``list``/``ls`` and ``browse`` are display aliases; ``all``/``--all`` widens - source scope; ``full``/``--full`` keeps unnamed sessions in the listing. - ``search``/``find`` makes the remaining words a search query — - ``search_query`` is ``None`` when search wasn't requested and ``""`` when it - was requested without a query. Flags are only honored before the first - positional word, so titles containing e.g. "all" aren't misparsed. Anything - else is treated as a target so `/sessions <id-or-title>` can delegate to - `/resume`. + Returns ``(include_all_sources, include_unnamed, target, search_query)``. ``all`` widens + source scope, ``full`` keeps unnamed sessions, ``search``/``find`` makes the rest a query + (``None`` = not requested, ``""`` = requested with no terms). Flags are honored only before + the first positional word so titles containing "all" aren't misparsed; anything else is a + target so `/sessions <id-or-title>` can delegate to `/resume`. """ import shlex @@ -57,15 +53,11 @@ def query_session_listing( ) -> list[dict[str, Any]]: """Return session rows for interactive listing surfaces. - This is the shared selection policy behind CLI/gateway session browsing: - source-scoped by default, optionally global, hide unnamed sessions unless - the caller asks for a full listing, and hide the current session unless the - caller asks to show it with an ``is_current_session`` marker. - ``session_key`` further restricts gateway callers to one exact conversation - lane before the database applies its result limit. - With ``search_query``, rows are filtered by title/id match (SQL-level, see - ``SessionDB.list_sessions_rich``) and ordered by most-recent activity; - unnamed sessions stay visible since an id match may be the only handle. + Shared CLI/gateway policy: source-scoped unless global is requested, unnamed hidden unless a + full listing is asked for, current session hidden unless requested (then marked + ``is_current_session``); ``session_key`` restricts gateway callers to one lane before the DB + limit applies. With ``search_query`` rows are filtered by title/id in SQL, ordered by recent + activity, and unnamed sessions stay visible since an id match may be the only handle. """ query_source = None if include_all_sources else source fetch_limit = max(limit * 4, limit) @@ -103,9 +95,8 @@ def format_gateway_session_listing( ) -> str: """Render a compact Markdown-ish session list for gateway messengers. - ``notice`` appends an explanatory line above the footer — used e.g. when - a requested scope widening (``all``) was declined so the caller isn't - left guessing why sessions are missing. + ``notice`` appends an explanatory line above the footer — used e.g. when a requested scope + widening (``all``) was declined so the caller isn't left guessing why sessions are missing. """ if not rows: parts = [ diff --git a/hermes_cli/session_lost_and_found.py b/hermes_cli/session_lost_and_found.py index b1fadcafe7..5503c9529e 100644 --- a/hermes_cli/session_lost_and_found.py +++ b/hermes_cli/session_lost_and_found.py @@ -1,24 +1,7 @@ """Last-resort page-level salvage for an unreadable session database schema. -``hermes sessions recover --allow-partial`` normally copies rows through SQL, -which requires the ``sessions`` and ``messages`` table *schemas* to be -readable. When the schema page itself is damaged, SQL-level salvage is -impossible — but the row payloads frequently survive on their b-tree pages. - -The SQLite command-line shell ships a page-level ``.recover`` command that -walks raw pages and rebuilds rows it cannot attribute to a schema into -``lost_and_found`` tables of the shape:: - - lost_and_found(rootpgno, pgno, nfield, id, c0, c1, ..., cN) - -This module shells out to that CLI (it is a shell feature, NOT available via -the Python ``sqlite3`` module) and then heuristically maps ``lost_and_found`` -rows back into a fresh current-schema Hermes session database. - -Everything produced through this lane is explicitly **best effort**: column -mapping is heuristic (field counts plus sentinel values), fabricated parent -sessions are stubbed for orphaned child rows rather than deleting salvaged -data, and derived FTS indexes are rebuilt from scratch. +The SQLite command-line shell ships a page-level ``.recover`` command that walks raw pages and +rebuilds rows it cannot attribute to a schema into ``lost_and_found`` tables of the shape:: """ from __future__ import annotations @@ -75,11 +58,9 @@ class LostAndFoundError(RuntimeError): def find_sqlite3_cli() -> Optional[str]: """Return a ``.recover``-capable sqlite3 CLI path, or None. - PATH presence is not enough: distro builds (e.g. Ubuntu's) can ship a - sqlite3 shell compiled without the ``sqlite_dbpage`` virtual table that - ``.recover`` requires — those fail every recovery with - ``no such table: sqlite_dbpage``. Probe capability on a scratch DB once - instead of discovering it mid-recovery. + PATH presence is not enough: distro builds can ship a shell without the ``sqlite_dbpage`` + virtual table ``.recover`` needs, failing every recovery. Probe on a scratch DB once instead + of discovering it mid-recovery. """ binary = shutil.which("sqlite3") @@ -122,11 +103,7 @@ def run_cli_lost_and_found_recover( *, timeout: float = 3600.0, ) -> dict[str, Any]: - """Run ``sqlite3 <source> .recover`` streamed into a fresh scratch DB. - - ``--ignore-freelist`` avoids resurrecting deleted rows; older shells - without that option fall back to a plain ``.recover``. - """ + """Run ``sqlite3 <source> .recover`` streamed into a fresh scratch DB.""" attempts: list[dict[str, Any]] = [] for command in (".recover --ignore-freelist", ".recover"): @@ -185,17 +162,13 @@ def _lost_and_found_db_usable(lf_path: Path) -> bool: try: conn = sqlite3.connect(str(lf_path)) try: - tables = [ - str(row[0]) - for row in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ) - ] + return conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1" + ).fetchone() is not None finally: conn.close() except sqlite3.DatabaseError: return False - return bool(tables) def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: @@ -205,11 +178,9 @@ def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: def _notnull_defaults(conn: sqlite3.Connection, table: str) -> dict[int, Any]: """Map column index -> substitute value for NOT NULL columns. - Page-level salvage can hand back records with NULL in positions that the - live schema declares NOT NULL (torn cells, historical rows). Dropping the - whole row over one damaged optional counter would defeat the lane, so - NULLs in such positions are replaced by the schema default (or '' / 0 - when no default is declared). + Page-level salvage can return NULLs where the live schema says NOT NULL (torn cells, old rows). + Dropping a whole row over one damaged optional counter would defeat the lane, so such NULLs + get the schema default (or '' / 0 when none is declared). """ substitutes: dict[int, Any] = {} @@ -221,20 +192,22 @@ def _notnull_defaults(conn: sqlite3.Connection, table: str) -> dict[int, Any]: declared = str(row[2] or "").upper() substitutes[index] = 0 if ("INT" in declared or "REAL" in declared) else "" continue - text = str(default) - if text.startswith("'") and text.endswith("'"): - substitutes[index] = text[1:-1] - else: - try: - substitutes[index] = int(text) - except ValueError: - try: - substitutes[index] = float(text) - except ValueError: - substitutes[index] = text + substitutes[index] = _parse_sql_default(str(default)) return substitutes +def _parse_sql_default(text: str) -> Any: + """Coerce a ``PRAGMA table_info`` default literal: quoted string, int, float, or raw.""" + if text.startswith("'") and text.endswith("'"): + return text[1:-1] + for cast in (int, float): + try: + return cast(text) + except ValueError: + continue + return text + + def _is_session_id(value: Any) -> bool: return isinstance(value, str) and bool(SESSION_ID_PATTERN.match(value)) @@ -249,10 +222,7 @@ def classify_lost_and_found_row( nfield: int, cells: tuple[Any, ...], ) -> Optional[str]: - """Classify one lost_and_found record by field count + sentinel values. - - Returns 'sessions', 'messages', 'session_model_usage', or None. - """ + """Classify one lost_and_found record by field count + sentinel values.""" if len(cells) >= 3 and cells[0] is None: # Rowid-alias tables store their INTEGER PRIMARY KEY as NULL in the @@ -270,20 +240,19 @@ def classify_lost_and_found_row( if not _is_session_id(cells[0] if cells else None): return None + second = cells[1] if len(cells) > 1 else None if nfield == SESSION_MODEL_USAGE_NFIELD: # 18 fields, session id first, model string second. - if len(cells) > 1 and isinstance(cells[1], str) and cells[1]: - return "session_model_usage" - return None + return "session_model_usage" if isinstance(second, str) and second else None - if nfield in SESSIONS_LAYOUT_NFIELDS or nfield == SESSIONS_LEGACY_MINIMAL_NFIELD: - if len(cells) > 1 and _looks_like_source(cells[1]): - return "sessions" - return None - - # Unknown historical sessions layout: session-id first cell plus a - # recognizable source string is still strong enough for a prefix map. - if nfield >= 30 and len(cells) > 1 and _looks_like_source(cells[1]): + # Known sessions layouts, or an unknown historical one (>= 30 fields): a + # session-id first cell plus a recognizable source string is still strong + # enough for a prefix map. + if ( + nfield in SESSIONS_LAYOUT_NFIELDS + or nfield == SESSIONS_LEGACY_MINIMAL_NFIELD + or nfield >= 30 + ) and _looks_like_source(second): return "sessions" return None @@ -367,11 +336,7 @@ def map_lost_and_found_rows( lf_conn: sqlite3.Connection, dest: sqlite3.Connection, ) -> dict[str, Any]: - """Best-effort mapping of a .recover output DB into a fresh SessionDB. - - Handles both rows .recover attributed to real tables and unattributed - ``lost_and_found`` rows classified by field count + sentinel columns. - """ + """Best-effort mapping of a .recover output DB into a fresh SessionDB.""" report: dict[str, Any] = { "direct_table_rows": {}, @@ -386,23 +351,20 @@ def map_lost_and_found_rows( try: report["direct_table_rows"] = _copy_direct_tables(lf_conn, dest) - sessions_columns = _table_columns(dest, "sessions") - messages_columns = _table_columns(dest, "messages") - usage_columns = _table_columns(dest, "session_model_usage") - sessions_defaults = _notnull_defaults(dest, "sessions") - messages_defaults = _notnull_defaults(dest, "messages") - usage_defaults = _notnull_defaults(dest, "session_model_usage") - # Never fabricate identity fields: a row whose session id / role / - # source cell is genuinely NULL was already rejected by - # classify_lost_and_found_row, so these substitutions only fill - # NOT NULL bookkeeping counters and flag columns. - for defaults, protected in ( - (sessions_defaults, (0, 1)), - (messages_defaults, (1, 2)), - (usage_defaults, (0, 1)), + # Per-kind destination columns + NOT NULL substitutes. Never fabricate + # identity fields: a row whose session id / role / source cell is + # genuinely NULL was already rejected by classify_lost_and_found_row, + # so the substitutions only fill NOT NULL bookkeeping counters/flags. + targets: dict[str, tuple[list[str], dict[int, Any]]] = {} + for kind_name, protected in ( + ("sessions", (0, 1)), + ("messages", (1, 2)), + ("session_model_usage", (0, 1)), ): + defaults = _notnull_defaults(dest, kind_name) for index in protected: defaults.pop(index, None) + targets[kind_name] = (_table_columns(dest, kind_name), defaults) lf_tables = [ str(row[0]) @@ -429,19 +391,15 @@ def map_lost_and_found_rows( if kind is None: report["unmapped_rows"] += 1 continue + columns, defaults = targets[kind] try: if kind == "messages": - values = [lf_rowid, *cells[1 : min(nfield, len(messages_columns))]] - inserted = _insert_prefix_row( - dest, "messages", messages_columns, values, - messages_defaults, - ) + # Rowid-alias PK is NULL in the record; use the lost_and_found rowid. + values = [lf_rowid, *cells[1 : min(nfield, len(columns))]] + inserted = _insert_prefix_row(dest, kind, columns, values, defaults) elif kind == "session_model_usage": - values = list(cells[: len(usage_columns)]) - inserted = _insert_prefix_row( - dest, "session_model_usage", usage_columns, values, - usage_defaults, - ) + values = list(cells[: len(columns)]) + inserted = _insert_prefix_row(dest, kind, columns, values, defaults) elif nfield == SESSIONS_LEGACY_MINIMAL_NFIELD: # A pre-modern layout whose column order is unknown: # salvage identity + timing rather than guessing 14 @@ -465,18 +423,13 @@ def map_lost_and_found_rows( if inserted: report["legacy_minimal_sessions"] += 1 else: - values = list(cells[: min(nfield, len(sessions_columns))]) - inserted = _insert_prefix_row( - dest, "sessions", sessions_columns, values, - sessions_defaults, - ) + values = list(cells[: min(nfield, len(columns))]) + inserted = _insert_prefix_row(dest, kind, columns, values, defaults) except sqlite3.DatabaseError: report["unmapped_rows"] += 1 continue if inserted: - report["mapped"][ - "sessions" if kind == "sessions" else kind - ] += 1 + report["mapped"][kind] += 1 else: report["insert_conflicts"] += 1 dest.execute("COMMIT") @@ -489,9 +442,9 @@ def map_lost_and_found_rows( def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]: """Fabricate placeholder parents for salvaged child rows. - Salvaged children (messages, model-usage rows) are NEVER deleted for - foreign-key cleanup — a fabricated parent is cheaper than losing the only - surviving copy of the user's data. Stubs are clearly marked. + Salvaged children (messages, model-usage rows) are NEVER deleted for foreign-key cleanup — a + fabricated parent is cheaper than losing the only surviving copy of the user's data. Stubs are + clearly marked. """ result: dict[str, Any] = { diff --git a/hermes_cli/session_recap.py b/hermes_cli/session_recap.py index 407254d257..281202edb3 100644 --- a/hermes_cli/session_recap.py +++ b/hermes_cli/session_recap.py @@ -1,24 +1,11 @@ """Session recap — summarize what's happened in the current session. -Inspired by Claude Code's `/recap` command (v2.1.114, April 2026), which -shows a one-line summary of what happened while a terminal was unfocused -so users juggling multiple sessions can re-orient quickly. - -Source: https://code.claude.com/docs/en/whats-new/2026-w17 - -Differences from Claude Code: - - Pure local computation from the in-memory conversation history. No - LLM call, no auxiliary model, no prompt-cache invalidation. A - recap should be instant and free. - - Works unchanged on CLI and every gateway platform (Telegram, - Discord, Slack, …) because both call into the same ``build_recap`` - helper. Claude Code only shows this on the CLI. - - Tailored to hermes-agent's tool vocabulary (``terminal``, ``patch``, - ``write_file``, ``delegate_task``, ``browser_*``, ``web_*``) — the - recap surfaces which classes of work were most active. +Differences from Claude Code: - Pure local computation from the in-memory conversation history. No +LLM call, no auxiliary model, no prompt-cache invalidation. A recap should be instant and free. """ from __future__ import annotations +import json import os from collections import Counter from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple @@ -51,9 +38,8 @@ _FILE_EDIT_TOOLS: Mapping[str, str] = { def _coerce_text(value: Any) -> str: """Flatten assistant/user ``content`` into a plain string. - Content can be a string or a list of content blocks (for multimodal - or reasoning models). We concatenate every text-like block and - ignore the rest. + Content may be a string or a list of blocks (multimodal/reasoning models); text-like blocks + are concatenated and the rest ignored. """ if value is None: return "" @@ -64,8 +50,7 @@ def _coerce_text(value: Any) -> str: for block in value: if isinstance(block, str): parts.append(block) - continue - if isinstance(block, Mapping): + elif isinstance(block, Mapping): text = block.get("text") if isinstance(text, str) and text: parts.append(text) @@ -76,28 +61,22 @@ def _coerce_text(value: Any) -> str: def _tool_call_name_and_args(tool_call: Any) -> Tuple[str, Mapping[str, Any]]: """Extract ``(name, arguments_dict)`` from a tool_call entry. - ``arguments`` may be a JSON string or a dict depending on provider. - Return an empty dict if it cannot be parsed. + ``arguments`` may be a JSON string or a dict depending on provider. Return an empty dict if it + cannot be parsed. """ if not isinstance(tool_call, Mapping): return "", {} fn = tool_call.get("function") or {} if not isinstance(fn, Mapping): return "", {} - name = str(fn.get("name") or "") or "" + name = str(fn.get("name") or "") raw_args = fn.get("arguments") - if isinstance(raw_args, Mapping): - return name, raw_args if isinstance(raw_args, str) and raw_args: try: - import json - - parsed = json.loads(raw_args) - if isinstance(parsed, Mapping): - return name, parsed + raw_args = json.loads(raw_args) except Exception: return name, {} - return name, {} + return name, raw_args if isinstance(raw_args, Mapping) else {} def _iter_assistant_tool_calls( @@ -121,53 +100,25 @@ def _count_visible_turns( messages: Sequence[Mapping[str, Any]], ) -> Tuple[int, int, int]: """Return ``(user_turn_count, assistant_turn_count, tool_message_count)``.""" - users = assistants = tools = 0 - for msg in messages: - if not isinstance(msg, Mapping): - continue - role = msg.get("role") - if role == "user": - users += 1 - elif role == "assistant": - assistants += 1 - elif role == "tool": - tools += 1 - return users, assistants, tools + roles = Counter(msg.get("role") for msg in messages if isinstance(msg, Mapping)) + return roles["user"], roles["assistant"], roles["tool"] -def _latest_user_prompt( - messages: Sequence[Mapping[str, Any]], -) -> Optional[str]: +def _latest_text(messages: Sequence[Mapping[str, Any]], role: str) -> Optional[str]: + """Most recent non-empty ``content`` text for *role*, or None.""" for msg in reversed(messages): - if isinstance(msg, Mapping) and msg.get("role") == "user": + if isinstance(msg, Mapping) and msg.get("role") == role: text = _coerce_text(msg.get("content")).strip() if text: return text return None -def _latest_assistant_text( - messages: Sequence[Mapping[str, Any]], -) -> Optional[str]: - for msg in reversed(messages): - if not isinstance(msg, Mapping): - continue - if msg.get("role") != "assistant": - continue - text = _coerce_text(msg.get("content")).strip() - if text: - return text - return None - - def _recent_window( messages: Sequence[Mapping[str, Any]], window: int = _RECENT_TURN_WINDOW ) -> List[Mapping[str, Any]]: - """Return the tail slice of ``messages`` covering at most ``window`` - user+assistant turns (tool messages ride along inside the window). - - Iterating from the end, we count user and assistant messages and - keep everything from the first message that falls within the window. + """Return the tail slice of ``messages`` covering at most ``window`` user+assistant turns (tool + messages ride along inside the window). """ count = 0 cut = 0 @@ -207,14 +158,13 @@ def _summarise_tool_activity( ) -> Tuple[List[Tuple[str, int]], List[str]]: """Return ``(tool_counts_sorted, recently_edited_files)``. - ``tool_counts_sorted`` is descending by count, keeping the full list - so callers can truncate for display. ``recently_edited_files`` lists - distinct paths (most recent first) from file-editing tools. + Counts are descending and kept in full so callers truncate for display; files are distinct + paths, most recent first, from file-editing tools. """ counter: Counter[str] = Counter() files_seen: List[str] = [] files_set: set[str] = set() - # Walk in reverse so "most recent first" drops out of order-preserved iteration. + # Walk in reverse so files_seen comes out newest→oldest (Counter ignores order). for name, args in reversed(list(tool_calls)): counter[name] += 1 arg_key = _FILE_EDIT_TOOLS.get(name) @@ -223,13 +173,17 @@ def _summarise_tool_activity( if isinstance(path, str) and path and path not in files_set: files_set.add(path) files_seen.append(_shortened_path(path)) - # Restore "reverse of reverse" for correct counts; Counter ignores order - # so only files_seen needed the reversal. Fix ordering: currently - # files_seen is newest→oldest which is what we want for display. tool_counts = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])) return tool_counts, files_seen +def _join_capped(items: List[str], limit: int) -> str: + """``a, b, c (+N more)`` — comma-join the first *limit* items and count the rest.""" + text = ", ".join(items[:limit]) + extra = len(items) - limit + return f"{text} (+{extra} more)" if extra > 0 else text + + def _truncate(text: str, limit: int) -> str: # Stored history is untrusted for display — remove escape sequences and # control chars so a recap line can't clear the screen / retitle the @@ -248,19 +202,10 @@ def build_recap( session_id: Optional[str] = None, platform: Optional[str] = None, ) -> str: - """Build a multi-line recap of recent activity. + """Build a multi-line recap of recent activity from chat-completion-style ``messages``. - Inputs: - messages: the full conversation history as a list of - chat-completion-style dicts (``role``, ``content``, - ``tool_calls``, …). - session_title: optional human title (from SessionDB). - session_id: optional session id. - platform: optional hint (``"cli"``, ``"telegram"``, …). Does not - change behavior today but is accepted for forward compat. - - The output is plain text designed to render well in both a terminal - (with 80-col wrapping) and a gateway message bubble. + ``platform`` is accepted for forward compat but does not change behavior. Output is plain + text that renders well both in an 80-col terminal and in a gateway message bubble. """ _ = platform # reserved for future use lines: List[str] = [] @@ -291,24 +236,16 @@ def build_recap( tool_calls = list(_iter_assistant_tool_calls(window)) tool_counts, files = _summarise_tool_activity(tool_calls) if tool_counts: - top = ", ".join(f"{name}×{count}" for name, count in tool_counts[:5]) - extra = len(tool_counts) - 5 - if extra > 0: - top += f" (+{extra} more)" + top = _join_capped([f"{name}×{count}" for name, count in tool_counts], 5) lines.append(f" Tools used: {top}") if files: - shown = files[:_MAX_FILES_LISTED] - extra = len(files) - len(shown) - entry = ", ".join(shown) - if extra > 0: - entry += f" (+{extra} more)" - lines.append(f" Files touched: {entry}") + lines.append(f" Files touched: {_join_capped(files, _MAX_FILES_LISTED)}") - latest_user = _latest_user_prompt(window) + latest_user = _latest_text(window, "user") if latest_user: lines.append(f" Last ask: {_truncate(latest_user, _PROMPT_PREVIEW_CHARS)}") - latest_reply = _latest_assistant_text(window) + latest_reply = _latest_text(window, "assistant") if latest_reply: lines.append(f" Last reply: {_truncate(latest_reply, _ASSISTANT_PREVIEW_CHARS)}") diff --git a/hermes_cli/session_recovery.py b/hermes_cli/session_recovery.py index 9a376550ad..16e4a257dc 100644 --- a/hermes_cli/session_recovery.py +++ b/hermes_cli/session_recovery.py @@ -1,13 +1,10 @@ """Offline, non-destructive recovery for a damaged Hermes session database. -The recovery path deliberately avoids in-place repair: - -* the supplied source database is never opened by SQLite; -* the source file and any WAL/SHM/rollback-journal sidecars are copied into a - disposable working directory first; -* canonical rows are copied into a newly initialized current-schema database; -* derived FTS tables and migration bookkeeping are rebuilt, not copied; and -* the recovered database is never installed over the active database. +* the supplied source database is never opened by SQLite; * the source file and any +WAL/SHM/rollback-journal sidecars are copied into a disposable working directory first; * canonical +rows are copied into a newly initialized current-schema database; * derived FTS tables and migration +bookkeeping are rebuilt, not copied; and * the recovered database is never installed over the active +database. """ from __future__ import annotations @@ -17,8 +14,9 @@ import os import shutil import sqlite3 import tempfile +from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, Callable, Iterator, Optional from hermes_state import ( FTS_STORAGE_VERSION, @@ -46,7 +44,6 @@ _TOPIC_TABLES = ( ) - def _init_delivery_ledger_schema(conn: sqlite3.Connection) -> None: from gateway.delivery_ledger import _initialize_schema @@ -128,10 +125,7 @@ def _validate_paths( output: Optional[Path] = None if output_path is not None: output = _resolved_output_path(output_path) - protected = { - _sidecar_path(source, suffix).resolve(strict=False) - for suffix in _SIDECAR_SUFFIXES - } + protected = {_sidecar_path(source, suffix).resolve(strict=False) for suffix in _SIDECAR_SUFFIXES} if output.resolve(strict=False) in protected: raise SessionRecoverySafetyError( "The recovery output must not be the source database or one of " @@ -163,10 +157,7 @@ def _source_fingerprint(source: Path) -> dict[str, dict[str, int]]: if not path.exists(): continue stat = path.stat() - fingerprint[suffix or "main"] = { - "size": stat.st_size, - "mtime_ns": stat.st_mtime_ns, - } + fingerprint[suffix or "main"] = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns} return fingerprint @@ -205,10 +196,7 @@ def _disk_space_preflight( # a legacy database, but using the complete source bundle as the estimate # avoids betting the user's disk on that expectation. output_allowance = bundle_bytes if output_parent is not None else 0 - headroom = max( - _MINIMUM_SPACE_HEADROOM, - int((bundle_bytes + output_allowance) * 0.05), - ) + headroom = max(_MINIMUM_SPACE_HEADROOM, int((bundle_bytes + output_allowance) * 0.05)) work_free = int(shutil.disk_usage(work_root).free) report: dict[str, Any] = { @@ -268,17 +256,12 @@ def _disk_space_preflight( def _copy_source_bundle(source: Path, snapshot_dir: Path) -> tuple[Path, list[str]]: """Copy the source DB bundle aside so SQLite never opens the original. - The whole copy runs inside ``offline_file_access``, which holds the - connection-lifecycle lock for its duration. Checking for a live connection - and *then* copying would be a check/use race: a connection could open in - that window, and the copy's ``close()`` would cancel its POSIX advisory - locks -- the failure class ``hermes_cli.sqlite_safe_read`` exists to - prevent (see #71724). Holding the lock means no connection can appear - mid-copy, across the main file and every sidecar. + The whole copy runs inside ``offline_file_access``, which holds the connection-lifecycle lock + for its duration. - Recovery normally runs as its own short-lived CLI process against an - offline/quarantined file, so the refusal should never fire; the guard - keeps this path consistent with ``hermes_state._backup_db_file``. + Recovery normally runs as its own short-lived CLI process against an offline/quarantined file, + so the refusal should never fire; the guard keeps this path consistent with + ``hermes_state._backup_db_file``. """ from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access @@ -302,6 +285,91 @@ def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: return [str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")')] +def _count_rows(conn: sqlite3.Connection, table: str) -> int: + return int(conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]) + + +def _connect(path: Path) -> sqlite3.Connection: + """Autocommit connection with a short busy timeout (source snapshot or fresh output).""" + return sqlite3.connect(str(path), isolation_level=None, timeout=1.0) + + +@contextmanager +def _immediate_transaction(conn: sqlite3.Connection) -> Iterator[None]: + """``BEGIN IMMEDIATE`` ... ``COMMIT``, rolling back on any exception.""" + conn.execute("BEGIN IMMEDIATE") + try: + yield + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise + + +def _compatible_columns( + source: sqlite3.Connection, + destination: sqlite3.Connection, + table: str, + result: dict[str, Any], +) -> Optional[list[str]]: + """Columns shared by source and destination; sets a terminal status and returns None otherwise.""" + source_columns = _table_columns(source, table) + columns = [column for column in _table_columns(destination, table) if column in source_columns] + result["columns"] = columns + if not source_columns: + result["status"] = "missing" + return None + if not columns: + result["status"] = "failed" + result["error"] = "source and destination have no compatible columns" + return None + return columns + + +def _quoted_columns(columns: list[str]) -> tuple[str, str]: + """``("a", "b")`` column list and matching ``?, ?`` placeholders.""" + return ", ".join(f'"{column}"' for column in columns), ", ".join("?" for _ in columns) + + +def _copy_rows( + source: sqlite3.Connection, + destination: sqlite3.Connection, + select_sql: str, + params: tuple[Any, ...], + insert_sql: str, + *, + table: str, + chunk_size: int, + progress_cb: Optional[ProgressCallback], + expected_rows: Optional[int], + result: dict[str, Any], +) -> dict[str, Any]: + """Chunked straight copy; fills ``status``/``error`` on ``result``.""" + try: + cursor = source.execute(select_sql, params) + while True: + rows = cursor.fetchmany(chunk_size) + if not rows: + break + with _immediate_transaction(destination): + destination.executemany(insert_sql, rows) + result["copied_rows"] += len(rows) + if progress_cb is not None: + progress_cb( + {"table": table, "copied_rows": result["copied_rows"], "source_rows": expected_rows} + ) + except sqlite3.DatabaseError as exc: + result["status"] = "partial" if result["copied_rows"] else "failed" + result["error"] = str(exc) + return result + + complete = expected_rows is None or result["copied_rows"] == expected_rows + result["status"] = "complete" if complete else "partial" + if result["status"] == "partial": + result["error"] = f"copied {result['copied_rows']} of {expected_rows} readable rows" + return result + + def _table_inventory( conn: sqlite3.Connection, table: str, @@ -313,20 +381,22 @@ def _table_inventory( return result result["available"] = True result["columns"] = columns - result["rows"] = int( - conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] - ) + result["rows"] = _count_rows(conn, table) except sqlite3.DatabaseError as exc: result["error"] = str(exc) return result +def _journal_mode(conn: sqlite3.Connection) -> Optional[str]: + row = conn.execute("PRAGMA journal_mode").fetchone() + return str(row[0]).lower() if row else None + + def _inspect_connection(conn: sqlite3.Connection) -> dict[str, Any]: conn.execute("PRAGMA writable_schema=ON") report: dict[str, Any] = {"tables": {}, "errors": [], "warnings": []} try: - row = conn.execute("PRAGMA journal_mode").fetchone() - report["journal_mode"] = str(row[0]).lower() if row else None + report["journal_mode"] = _journal_mode(conn) except sqlite3.DatabaseError as exc: report["journal_mode"] = None # Journal metadata is useful context but not canonical session data. @@ -339,9 +409,7 @@ def _inspect_connection(conn: sqlite3.Connection) -> dict[str, Any]: for required in ("sessions", "messages"): table_report = report["tables"][required] if not table_report.get("available") or table_report.get("rows") is None: - report["errors"].append( - f"required table {required} is not completely readable" - ) + report["errors"].append(f"required table {required} is not completely readable") report["recoverable"] = not report["errors"] return report @@ -373,11 +441,7 @@ def _snapshot_and_inspect( "database." ) - conn = sqlite3.connect( - str(snapshot_source), - isolation_level=None, - timeout=1.0, - ) + conn = _connect(snapshot_source) try: inspection = _inspect_connection(conn) finally: @@ -406,8 +470,7 @@ def inspect_session_database( "source": str(source), "disk_space": disk_space, **inspection, - "source_unchanged": _source_fingerprint(source) - == inspection["source_fingerprint"], + "source_unchanged": _source_fingerprint(source) == inspection["source_fingerprint"], } finally: temp_dir.cleanup() @@ -419,19 +482,30 @@ def _ensure_auxiliary_destination_schema( ) -> None: """Create a lazy auxiliary table on the recovered destination. - Recovery initializes the destination through base ``SessionDB``, which - does not create gateway-owned tables. Copying into a missing dest table - would report ``missing`` / ``no compatible columns`` and drop the rows. + Recovery initializes the destination via base ``SessionDB``, which does not create gateway-owned + tables; copying into a missing table would report ``missing``/``no compatible columns`` and drop + the rows. """ initialize = _AUXILIARY_TABLE_SCHEMAS.get(table) if initialize is None: - raise SessionRecoverySafetyError( - f"no destination schema initializer registered for table {table!r}" - ) + raise SessionRecoverySafetyError(f"no destination schema initializer registered for table {table!r}") initialize(destination) +def _fresh_destination(output: Path, *, topic_tables: bool = False) -> sqlite3.Connection: + """Initialize a current-schema database at ``output`` and open it with foreign keys off.""" + destination_db = SessionDB(db_path=output) + try: + if topic_tables: + destination_db.apply_telegram_topic_migration() + finally: + destination_db.close() + conn = _connect(output) + conn.execute("PRAGMA foreign_keys=OFF") + return conn + + def _copy_table( source: sqlite3.Connection, destination: sqlite3.Connection, @@ -441,63 +515,28 @@ def _copy_table( progress_cb: Optional[ProgressCallback], source_rows: Optional[int], ) -> dict[str, Any]: - source_columns = _table_columns(source, table) - destination_columns = _table_columns(destination, table) - columns = [column for column in destination_columns if column in source_columns] result: dict[str, Any] = { "source_rows": source_rows, "copied_rows": 0, - "columns": columns, } - if not source_columns: - result["status"] = "missing" - return result - if not columns: - result["status"] = "failed" - result["error"] = "source and destination have no compatible columns" + columns = _compatible_columns(source, destination, table, result) + if columns is None: return result - quoted = ", ".join(f'"{column}"' for column in columns) - placeholders = ", ".join("?" for _ in columns) - select_sql = f'SELECT {quoted} FROM "{table}"' + quoted, placeholders = _quoted_columns(columns) insert_prefix = "INSERT OR REPLACE" if table == "state_meta" else "INSERT" - insert_sql = f'{insert_prefix} INTO "{table}" ({quoted}) VALUES ({placeholders})' - - try: - cursor = source.execute(select_sql) - while True: - rows = cursor.fetchmany(chunk_size) - if not rows: - break - destination.execute("BEGIN IMMEDIATE") - try: - destination.executemany(insert_sql, rows) - destination.execute("COMMIT") - except BaseException: - destination.execute("ROLLBACK") - raise - result["copied_rows"] += len(rows) - if progress_cb is not None: - progress_cb({ - "table": table, - "copied_rows": result["copied_rows"], - "source_rows": source_rows, - }) - except sqlite3.DatabaseError as exc: - result["status"] = "partial" if result["copied_rows"] else "failed" - result["error"] = str(exc) - return result - - result["status"] = ( - "complete" - if source_rows is None or result["copied_rows"] == source_rows - else "partial" + return _copy_rows( + source, + destination, + f'SELECT {quoted} FROM "{table}"', + (), + f'{insert_prefix} INTO "{table}" ({quoted}) VALUES ({placeholders})', + table=table, + chunk_size=chunk_size, + progress_cb=progress_cb, + expected_rows=source_rows, + result=result, ) - if result["status"] == "partial": - result["error"] = ( - f"copied {result['copied_rows']} of {source_rows} readable rows" - ) - return result def _append_skipped_range( @@ -508,11 +547,7 @@ def _append_skipped_range( ) -> None: """Record skipped rowid ranges without producing one entry per row.""" - if ( - ranges - and ranges[-1]["high"] + 1 == low - and ranges[-1]["error"] == error - ): + if ranges and ranges[-1]["high"] + 1 == low and ranges[-1]["error"] == error: ranges[-1]["high"] = high return ranges.append({"low": low, "high": high, "error": error}) @@ -529,19 +564,14 @@ def _salvage_rowid_bounds( directions = (("low", "ASC"), ("high", "DESC")) for edge, direction in directions: try: - row = source.execute( - f'SELECT rowid FROM "{table}" ORDER BY rowid {direction} LIMIT 1' - ).fetchone() + row = source.execute(f'SELECT rowid FROM "{table}" ORDER BY rowid {direction} LIMIT 1').fetchone() if row is not None: rows[edge] = int(row[0]) except sqlite3.DatabaseError as exc: result["errors"].append(f"{edge} rowid: {exc}") - if rows["low"] is None and rows["high"] is None and not result["errors"]: - result["empty"] = True - return result if rows["low"] is None and rows["high"] is None: - result["unavailable"] = True + result["empty" if not result["errors"] else "unavailable"] = True return result # A damaged edge can prevent one of the ordered probes from completing. @@ -555,8 +585,19 @@ def _salvage_rowid_bounds( rows["high"] = _MAX_SQLITE_ROWID result["fallback_edges"].append("high") - result["low"] = rows["low"] - result["high"] = rows["high"] + result.update(rows) + # Issue #80205: a damaged ordered edge probe used to substitute the whole + # SQLite rowid domain, and bisecting that synthetic tail exhausted the + # range-query budget while readable tail rows were still waiting to be + # copied. Gallop outward from the surviving edge for a finite bound first. + if result["fallback_edges"]: + result["edge_probes"] = [] + for edge, anchor_edge in (("high", "low"), ("low", "high")): + if edge in result["fallback_edges"]: + probe = _probe_populated_edge(source, table, edge=edge, anchor=int(result[anchor_edge])) + result["edge_probes"].append(probe) + if probe["capped"]: + result[edge] = int(probe["bound"]) return result @@ -569,17 +610,10 @@ def _probe_populated_edge( ) -> dict[str, Any]: """Find a finite bound for a damaged rowid edge (issue #80205). - When an ordered edge probe fails, :func:`_salvage_rowid_bounds` used to - substitute the whole SQLite rowid domain. Range bisection then burned the - entire ``_MAX_SALVAGE_RANGE_QUERIES`` budget subdividing an enormous - synthetic tail that could not contain real rows — and once the budget was - gone, rows that were still readable were silently recorded as skipped. - - This gallops outward from the readable ``anchor`` edge with exponentially - growing offsets. A probe that cleanly reports "no rows beyond X" caps the - domain at X; a probe that errors (its b-tree path crosses the damage) or - finds a row keeps growing. At most ~64 probes per edge, so the cap costs - a bounded, tiny slice of the salvage budget instead of all of it. + Substituting the whole rowid domain for a failed edge probe made range bisection burn the entire + salvage budget on an empty synthetic tail, after which readable rows were silently skipped. + Instead gallop outward from the readable ``anchor`` with exponential offsets: a clean "no rows + beyond X" caps the domain, an error or hit keeps growing. At most ~64 probes per edge. """ ascending = edge == "high" @@ -637,25 +671,18 @@ def _copy_table_salvage( ) -> dict[str, Any]: """Best-effort rowid-range copy that continues past damaged source pages.""" - source_columns = _table_columns(source, table) - destination_columns = _table_columns(destination, table) - columns = [column for column in destination_columns if column in source_columns] result: dict[str, Any] = { "mode": "rowid_range_salvage", "source_rows": source_rows, "copied_rows": 0, "excluded_rows": 0, - "columns": columns, + "columns": [], "range_queries": 0, "exact_lookup_recovered": 0, "skipped_rowid_ranges": [], } - if not source_columns: - result["status"] = "missing" - return result - if not columns: - result["status"] = "failed" - result["error"] = "source and destination have no compatible columns" + columns = _compatible_columns(source, destination, table, result) + if columns is None: return result bounds = _salvage_rowid_bounds(source, table) @@ -671,30 +698,7 @@ def _copy_table_salvage( result["error"] += f": {details}" return result - # Issue #80205: a damaged ordered edge probe used to substitute the whole - # SQLite rowid domain, and bisecting that synthetic tail exhausted the - # range-query budget while readable tail rows were still waiting to be - # copied. Gallop outward from the surviving edge for a finite bound first. - fallback_edges = bounds.get("fallback_edges") or [] - if fallback_edges: - bounds["edge_probes"] = [] - if "high" in fallback_edges and bounds.get("low") is not None: - probe = _probe_populated_edge( - source, table, edge="high", anchor=int(bounds["low"]) - ) - bounds["edge_probes"].append(probe) - if probe["capped"]: - bounds["high"] = int(probe["bound"]) - if "low" in fallback_edges and bounds.get("high") is not None: - probe = _probe_populated_edge( - source, table, edge="low", anchor=int(bounds["high"]) - ) - bounds["edge_probes"].append(probe) - if probe["capped"]: - bounds["low"] = int(probe["bound"]) - - quoted = ", ".join(f'"{column}"' for column in columns) - placeholders = ", ".join("?" for _ in columns) + quoted, placeholders = _quoted_columns(columns) select_sql = ( f'SELECT rowid, {quoted} FROM "{table}" ' "WHERE rowid BETWEEN ? AND ? ORDER BY rowid" @@ -709,14 +713,9 @@ def _copy_table_salvage( def recover_exact_rowid(rowid: int) -> bool: """Issue #80205: salvage one row by exact-key lookup. - A singleton range scan (``rowid BETWEEN x AND x ORDER BY rowid``) - must advance the cursor past ``x`` to prove the range is exhausted; - when the *next* cell or page is damaged that advance raises AFTER the - row was produced, and the driver discards the already-fetched row. An - equality lookup on the rowid stops at the hit, so the boundary row - directly before a damaged page — readable in the field case and by - SQLite's page-level ``.recover`` — is recovered instead of being - recorded as skipped. + A singleton range scan (``rowid BETWEEN x AND x ORDER BY rowid``) must advance the cursor + past ``x`` to prove the range is exhausted; when the *next* cell or page is damaged that + advance raises AFTER the row was produced, and the driver discards the already-fetched row. """ result["range_queries"] += 1 try: @@ -729,13 +728,8 @@ def _copy_table_salvage( if row_filter is not None and not row_filter(value, column_names): result["excluded_rows"] += 1 return True - destination.execute("BEGIN IMMEDIATE") - try: + with _immediate_transaction(destination): destination.execute(insert_sql, value) - destination.execute("COMMIT") - except BaseException: - destination.execute("ROLLBACK") - raise result["copied_rows"] += 1 result["exact_lookup_recovered"] += 1 return True @@ -747,10 +741,7 @@ def _copy_table_salvage( if result["range_queries"] >= _MAX_SALVAGE_RANGE_QUERIES: stopped_at_query_limit = True _append_skipped_range( - result["skipped_rowid_ranges"], - low, - high, - "salvage range query limit reached", + result["skipped_rowid_ranges"], low, high, "salvage range query limit reached" ) return @@ -774,13 +765,8 @@ def _copy_table_salvage( excluded_count = 0 if included: - destination.execute("BEGIN IMMEDIATE") - try: + with _immediate_transaction(destination): destination.executemany(insert_sql, included) - destination.execute("COMMIT") - except BaseException: - destination.execute("ROLLBACK") - raise result["copied_rows"] += len(included) result["excluded_rows"] += excluded_count @@ -793,21 +779,12 @@ def _copy_table_salvage( "skipped_ranges": len(result["skipped_rowid_ranges"]), }) except sqlite3.DatabaseError as exc: - retry_low = ( - last_committed_rowid + 1 - if last_committed_rowid is not None - else low - ) + retry_low = last_committed_rowid + 1 if last_committed_rowid is not None else low if retry_low > high: return if retry_low == high: if not recover_exact_rowid(retry_low): - _append_skipped_range( - result["skipped_rowid_ranges"], - retry_low, - high, - str(exc), - ) + _append_skipped_range(result["skipped_rowid_ranges"], retry_low, high, str(exc)) return midpoint = retry_low + (high - retry_low) // 2 copy_range(retry_low, midpoint) @@ -815,16 +792,12 @@ def _copy_table_salvage( copy_range(int(bounds["low"]), int(bounds["high"])) skipped_ranges = result["skipped_rowid_ranges"] - result["skipped_rowid_span"] = sum( - item["high"] - item["low"] + 1 for item in skipped_ranges - ) + result["skipped_rowid_span"] = sum(item["high"] - item["low"] + 1 for item in skipped_ranges) result["query_limit_reached"] = stopped_at_query_limit if skipped_ranges: result["status"] = "partial" if result["copied_rows"] else "failed" - result["error"] = ( - f"{len(skipped_ranges)} rowid range(s) skipped" - ) + result["error"] = f"{len(skipped_ranges)} rowid range(s) skipped" elif ( source_rows is not None and result["copied_rows"] + result["excluded_rows"] != source_rows @@ -839,6 +812,53 @@ def _copy_table_salvage( return result +def _state_meta_result(source_rows: Optional[int], **extra: Any) -> dict[str, Any]: + return { + "source_meta_rows": source_rows, + "copied_rows": 0, + "columns": ["key", "value"], + "excluded_keys": sorted(_GENERATED_META_KEYS), + **extra, + } + + +def _state_meta_precheck( + source: sqlite3.Connection, + destination: sqlite3.Connection, + source_rows: Optional[int], + *, + salvage: bool, +) -> Optional[dict[str, Any]]: + """Terminal ``state_meta`` result when the key/value schema is unusable, else ``None``. + + Status matters here. In salvage mode an unusable-but-PRESENT table reports ``failed``, not + ``missing``: verification only escalates ``failed``/``partial`` into a warning + + ``loss_detected``, so reporting ``missing`` would silently drop real metadata and still claim + ``complete=True``. + """ + extra = {"mode": "rowid_range_salvage"} if salvage else {} + source_columns = _table_columns(source, "state_meta") + if not {"key", "value"}.issubset(source_columns): + if salvage and source_columns: + # Present but unusable: this IS data loss and must be reported. + return _state_meta_result( + source_rows, + **extra, + status="failed", + error=( + "source state_meta exists but is missing the key/value " + f"columns (found: {', '.join(source_columns) or 'none'})" + ), + ) + # Genuinely absent from the source — nothing was lost. + return _state_meta_result(source_rows, **extra, status="missing") + if not {"key", "value"}.issubset(_table_columns(destination, "state_meta")): + return _state_meta_result( + source_rows, **extra, status="failed", error="destination state_meta schema is incomplete" + ) + return None + + def _copy_state_meta( source: sqlite3.Connection, destination: sqlite3.Connection, @@ -847,76 +867,36 @@ def _copy_state_meta( progress_cb: Optional[ProgressCallback], source_rows: Optional[int], ) -> dict[str, Any]: - source_columns = _table_columns(source, "state_meta") - destination_columns = _table_columns(destination, "state_meta") - result: dict[str, Any] = { - "source_meta_rows": source_rows, - "copied_rows": 0, - "columns": ["key", "value"], - "excluded_keys": sorted(_GENERATED_META_KEYS), - } - if not {"key", "value"}.issubset(source_columns): - result["status"] = "missing" - return result - if not {"key", "value"}.issubset(destination_columns): - result["status"] = "failed" - result["error"] = "destination state_meta schema is incomplete" - return result + problem = _state_meta_precheck(source, destination, source_rows, salvage=False) + if problem is not None: + return problem placeholders = ", ".join("?" for _ in _GENERATED_META_KEYS) + params = tuple(_GENERATED_META_KEYS) filtered_source_rows: Optional[int] = None try: filtered_source_rows = int( source.execute( f"SELECT COUNT(*) FROM state_meta WHERE key NOT IN ({placeholders})", - tuple(_GENERATED_META_KEYS), + params, ).fetchone()[0] ) except sqlite3.DatabaseError: # The copy loop below will return the concrete read error. pass - try: - cursor = source.execute( - f"SELECT key, value FROM state_meta WHERE key NOT IN ({placeholders})", - tuple(_GENERATED_META_KEYS), - ) - while True: - rows = cursor.fetchmany(chunk_size) - if not rows: - break - destination.execute("BEGIN IMMEDIATE") - try: - destination.executemany( - "INSERT OR REPLACE INTO state_meta(key, value) VALUES (?, ?)", - rows, - ) - destination.execute("COMMIT") - except BaseException: - destination.execute("ROLLBACK") - raise - result["copied_rows"] += len(rows) - if progress_cb is not None: - progress_cb({ - "table": "state_meta", - "copied_rows": result["copied_rows"], - "source_rows": filtered_source_rows, - }) - except sqlite3.DatabaseError as exc: - result["status"] = "partial" if result["copied_rows"] else "failed" - result["error"] = str(exc) - return result - - result["status"] = ( - "complete" - if filtered_source_rows is None or result["copied_rows"] == filtered_source_rows - else "partial" + return _copy_rows( + source, + destination, + f"SELECT key, value FROM state_meta WHERE key NOT IN ({placeholders})", + params, + "INSERT OR REPLACE INTO state_meta(key, value) VALUES (?, ?)", + table="state_meta", + chunk_size=chunk_size, + progress_cb=progress_cb, + expected_rows=filtered_source_rows, + result=_state_meta_result(source_rows), ) - if result["status"] == "partial": - result["error"] = ( - f"copied {result['copied_rows']} of {filtered_source_rows} readable rows" - ) - return result def _copy_state_meta_salvage( @@ -927,64 +907,12 @@ def _copy_state_meta_salvage( progress_cb: Optional[ProgressCallback], source_rows: Optional[int], ) -> dict[str, Any]: - """Salvage readable user metadata while regenerating derived FTS state. + """Salvage readable user metadata while regenerating derived FTS state.""" + problem = _state_meta_precheck(source, destination, source_rows, salvage=True) + if problem is not None: + return problem - Requires both ``key`` and ``value``, matching the non-partial - :func:`_copy_state_meta`. A damaged ``state_meta`` can retain one column - and lose the other; without this check a missing ``key`` raised - ``ValueError`` from ``columns.index("key")`` and aborted the entire - partial recovery, and a missing ``value`` would have copied key-only rows - while reporting the table complete. - - Status matters here. An unusable-but-PRESENT table reports ``failed``, not - ``missing``: verification only escalates ``failed``/``partial`` into a - warning + ``loss_detected``, so reporting ``missing`` would silently drop - real metadata and still claim ``complete=True``. ``missing`` is reserved - for a table that genuinely is not there. Either way ``state_meta`` is - optional, so ``--allow-partial`` records the loss and carries on - recovering sessions and messages. - """ - source_columns = _table_columns(source, "state_meta") - destination_columns = _table_columns(destination, "state_meta") - if not source_columns: - # Genuinely absent from the source — nothing was lost. - return { - "mode": "rowid_range_salvage", - "source_meta_rows": source_rows, - "copied_rows": 0, - "columns": ["key", "value"], - "excluded_keys": sorted(_GENERATED_META_KEYS), - "status": "missing", - } - if not {"key", "value"}.issubset(source_columns): - # Present but unusable: this IS data loss and must be reported. - return { - "mode": "rowid_range_salvage", - "source_meta_rows": source_rows, - "copied_rows": 0, - "columns": ["key", "value"], - "excluded_keys": sorted(_GENERATED_META_KEYS), - "status": "failed", - "error": ( - "source state_meta exists but is missing the key/value " - f"columns (found: {', '.join(source_columns) or 'none'})" - ), - } - if not {"key", "value"}.issubset(destination_columns): - return { - "mode": "rowid_range_salvage", - "source_meta_rows": source_rows, - "copied_rows": 0, - "columns": ["key", "value"], - "excluded_keys": sorted(_GENERATED_META_KEYS), - "status": "failed", - "error": "destination state_meta schema is incomplete", - } - - def keep_user_meta( - row: tuple[Any, ...], - columns: tuple[str, ...], - ) -> bool: + def keep_user_meta(row: tuple[Any, ...], columns: tuple[str, ...]) -> bool: return str(row[columns.index("key")]) not in _GENERATED_META_KEYS result = _copy_table_salvage( @@ -1007,25 +935,17 @@ def _reconstruct_missing_sessions( ) -> dict[str, Any]: """Recreate placeholder session rows for salvaged orphaned messages. - When the ``sessions`` b-tree is damaged worse than ``messages``, salvage - can recover the conversation text while recovering few or none of the - session rows that own it. Deleting those messages as "orphans" throws away - the only readable copy of the user's data — the exact opposite of what - ``--allow-partial`` is for. A real report (July 2026) copied 20,817 of - 20,824 messages and then removed every one of them, producing an output - with 0 sessions and 0 messages. + When the ``sessions`` b-tree is damaged worse than ``messages``, salvage can recover the + conversation text while recovering few or none of the session rows that own it. Deleting those + messages as "orphans" throws away the only readable copy of the user's data — the exact opposite + of what ``--allow-partial`` is for. - Instead, synthesize a minimal session row per orphaned ``session_id`` - (only ``id``/``source``/``started_at`` are NOT NULL) so the messages stay - reachable and foreign keys hold. ``started_at`` is taken from the earliest - surviving message so ordering stays sane. Rows are marked with - ``source='recovered'`` and a ``title`` that says so, because a fabricated - session must never be mistaken for an original. + Instead, synthesize a minimal session row per orphaned ``session_id`` (only + ``id``/``source``/``started_at`` are NOT NULL) so the messages stay reachable and foreign keys + hold. ``started_at`` is taken from the earliest surviving message so ordering stays sane. """ result: dict[str, Any] = {"sessions_reconstructed": 0, "messages_retained": 0} - if not _table_columns(destination, "sessions"): - return result - if not _table_columns(destination, "messages"): + if not (_table_columns(destination, "sessions") and _table_columns(destination, "messages")): return result orphaned = destination.execute( @@ -1042,50 +962,50 @@ def _reconstruct_missing_sessions( for session_id, first_timestamp, message_count in orphaned: started_at = float(first_timestamp) if first_timestamp is not None else 0.0 while True: - title = ( - f"[recovered {title_sequence}] " - "session metadata was unreadable" - ) + title = f"[recovered {title_sequence}] session metadata was unreadable" title_sequence += 1 - if ( - destination.execute( - "SELECT 1 FROM sessions WHERE title = ? LIMIT 1", - (title,), - ).fetchone() - is None - ): + taken = destination.execute("SELECT 1 FROM sessions WHERE title = ? LIMIT 1", (title,)).fetchone() + if taken is None: break cursor = destination.execute( "INSERT INTO sessions " "(id, source, started_at, title, message_count) " "VALUES (?, 'recovered', ?, ?, ?)", - ( - session_id, - started_at, - title, - int(message_count), - ), + (session_id, started_at, title, int(message_count)), ) if cursor.rowcount != 1: - raise sqlite3.IntegrityError( - f"failed to reconstruct missing session {session_id!r}" - ) + raise sqlite3.IntegrityError(f"failed to reconstruct missing session {session_id!r}") result["sessions_reconstructed"] += 1 result["messages_retained"] += int(message_count) return result +def _reconcile(destination: sqlite3.Connection, table: str, where: str, mutation: str) -> int: + """Count rows of ``table`` matching ``where``; run ``mutation WHERE where`` only when there are any.""" + count = int(destination.execute(f'SELECT COUNT(*) FROM "{table}" WHERE {where}').fetchone()[0]) + if count: + destination.execute(f"{mutation} WHERE {where}") + return count + + +_DEPENDENT_TABLES = ( + "messages", + "session_model_usage", + "compression_locks", + "telegram_dm_topic_bindings", +) +_RELINK_COUNTERS = ("session_prompt_refs_cleared", "sessions_parent_cleared") + + def _cleanup_partial_orphans( destination: sqlite3.Connection, ) -> dict[str, Any]: """Reconcile references to sessions that could not be salvaged. - Messages are never discarded for lack of a session row: their owning - session is reconstructed as a placeholder first (see - :func:`_reconstruct_missing_sessions`). Only rows that remain orphaned - after that — and rows in tables carrying no recoverable user content — - are removed. + Messages are never discarded for lack of a session row: their owning session is reconstructed as + a placeholder first (see :func:`_reconstruct_missing_sessions`). Only rows that remain orphaned + after that — and rows in tables carrying no recoverable user content — are removed. """ result: dict[str, Any] = { @@ -1094,116 +1014,47 @@ def _cleanup_partial_orphans( "sessions_parent_cleared": 0, "sessions_reconstructed": 0, "messages_retained": 0, - "messages_removed": 0, - "session_model_usage_removed": 0, - "compression_locks_removed": 0, - "telegram_dm_topic_bindings_removed": 0, + **{f"{table}_removed": 0 for table in _DEPENDENT_TABLES}, } - destination.execute("BEGIN IMMEDIATE") - try: + with _immediate_transaction(destination): # Rebuild owners BEFORE any orphan deletion so salvaged conversation # text is never dropped for want of a session row. - rebuilt = _reconstruct_missing_sessions(destination) - result["sessions_reconstructed"] = rebuilt["sessions_reconstructed"] - result["messages_retained"] = rebuilt["messages_retained"] + result.update(_reconstruct_missing_sessions(destination)) - parent_count = int( - destination.execute( - "SELECT COUNT(*) FROM sessions AS child " - "WHERE child.parent_session_id IS NOT NULL " - "AND NOT EXISTS (" - "SELECT 1 FROM sessions AS parent " - "WHERE parent.id = child.parent_session_id)" - ).fetchone()[0] + result["sessions_parent_cleared"] = _reconcile( + destination, + "sessions", + "parent_session_id IS NOT NULL AND NOT EXISTS (" + "SELECT 1 FROM sessions AS parent WHERE parent.id = sessions.parent_session_id)", + "UPDATE sessions SET parent_session_id = NULL", ) - if parent_count: - destination.execute( - "UPDATE sessions SET parent_session_id = NULL " - "WHERE parent_session_id IS NOT NULL " - "AND NOT EXISTS (" - "SELECT 1 FROM sessions AS parent " - "WHERE parent.id = sessions.parent_session_id)" - ) - result["sessions_parent_cleared"] = parent_count - - prompt_ref_count = int( - destination.execute( - "SELECT COUNT(*) FROM sessions " - "WHERE system_prompt_hash IS NOT NULL " - "AND NOT EXISTS (" - "SELECT 1 FROM system_prompts " - "WHERE system_prompts.hash = sessions.system_prompt_hash)" - ).fetchone()[0] + result["session_prompt_refs_cleared"] = _reconcile( + destination, + "sessions", + "system_prompt_hash IS NOT NULL AND NOT EXISTS (" + "SELECT 1 FROM system_prompts WHERE system_prompts.hash = sessions.system_prompt_hash)", + "UPDATE sessions SET system_prompt_hash = NULL", ) - if prompt_ref_count: - destination.execute( - "UPDATE sessions SET system_prompt_hash = NULL " - "WHERE system_prompt_hash IS NOT NULL " - "AND NOT EXISTS (" - "SELECT 1 FROM system_prompts " - "WHERE system_prompts.hash = sessions.system_prompt_hash)" - ) - result["session_prompt_refs_cleared"] = prompt_ref_count - - unreferenced_prompt_count = int( - destination.execute( - "SELECT COUNT(*) FROM system_prompts " - "WHERE NOT EXISTS (" - "SELECT 1 FROM sessions " - "WHERE sessions.system_prompt_hash = system_prompts.hash)" - ).fetchone()[0] + result["system_prompts_removed"] = _reconcile( + destination, + "system_prompts", + "NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash)", + "DELETE FROM system_prompts", ) - if unreferenced_prompt_count: - destination.execute( - "DELETE FROM system_prompts " - "WHERE NOT EXISTS (" - "SELECT 1 FROM sessions " - "WHERE sessions.system_prompt_hash = system_prompts.hash)" - ) - result["system_prompts_removed"] = unreferenced_prompt_count - - dependent_tables = ( - ("messages", "messages_removed"), - ("session_model_usage", "session_model_usage_removed"), - ("compression_locks", "compression_locks_removed"), - ( - "telegram_dm_topic_bindings", - "telegram_dm_topic_bindings_removed", - ), - ) - for table, report_key in dependent_tables: + for table in _DEPENDENT_TABLES: if not _table_columns(destination, table): continue - orphan_count = int( - destination.execute( - f'SELECT COUNT(*) FROM "{table}" AS dependent ' - "WHERE NOT EXISTS (" - "SELECT 1 FROM sessions " - "WHERE sessions.id = dependent.session_id)" - ).fetchone()[0] + result[f"{table}_removed"] = _reconcile( + destination, + table, + f'NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.id = "{table}".session_id)', + f'DELETE FROM "{table}"', ) - if orphan_count: - destination.execute( - f'DELETE FROM "{table}" ' - "WHERE NOT EXISTS (" - "SELECT 1 FROM sessions " - f'WHERE sessions.id = "{table}".session_id)' - ) - result[report_key] = orphan_count - destination.execute("COMMIT") - except BaseException: - destination.execute("ROLLBACK") - raise # Only destructive/relinking actions belong in this total. The # reconstruction counters describe data RETAINED, so summing them here # would report saving the user's messages as if it were losing them. - result["total_removed_or_relinked"] = ( - int(result["session_prompt_refs_cleared"]) - + int(result["sessions_parent_cleared"]) - + int(result["messages_removed"]) - + int(result["session_model_usage_removed"]) - + int(result["compression_locks_removed"]) - + int(result["telegram_dm_topic_bindings_removed"]) + result["total_removed_or_relinked"] = sum( + int(result[key]) for key in (*_RELINK_COUNTERS, *(f"{table}_removed" for table in _DEPENDENT_TABLES)) ) return result @@ -1222,6 +1073,14 @@ def _verify_recovered_database( "loss_detected": False, } + def flag(message: str, *, soft: bool) -> None: + """Record data loss as a warning (``soft``) or as a verification error.""" + if soft: + verification["warnings"].append(message) + verification["loss_detected"] = True + else: + verification["errors"].append(message) + open_error = _db_opens_cleanly(output) verification["opens_cleanly"] = open_error is None if open_error is not None: @@ -1229,30 +1088,19 @@ def _verify_recovered_database( conn = sqlite3.connect(str(output), isolation_level=None) try: - integrity_rows = [ - str(row[0]) for row in conn.execute("PRAGMA integrity_check").fetchall() - ] + integrity_rows = [str(row[0]) for row in conn.execute("PRAGMA integrity_check").fetchall()] verification["integrity_check"] = integrity_rows if integrity_rows != ["ok"]: - verification["errors"].append( - "PRAGMA integrity_check did not return exactly 'ok'" - ) + verification["errors"].append("PRAGMA integrity_check did not return exactly 'ok'") - foreign_key_rows = [ - list(row) for row in conn.execute("PRAGMA foreign_key_check").fetchall() - ] + foreign_key_rows = [list(row) for row in conn.execute("PRAGMA foreign_key_check").fetchall()] verification["foreign_key_check"] = foreign_key_rows if foreign_key_rows: verification["errors"].append("foreign key violations remain") - journal_row = conn.execute("PRAGMA journal_mode").fetchone() - verification["journal_mode"] = ( - str(journal_row[0]).lower() if journal_row else None - ) + verification["journal_mode"] = _journal_mode(conn) - schema_row = conn.execute( - "SELECT version FROM schema_version LIMIT 1" - ).fetchone() + schema_row = conn.execute("SELECT version FROM schema_version LIMIT 1").fetchone() verification["schema_version"] = int(schema_row[0]) if schema_row else None if verification["schema_version"] != SCHEMA_VERSION: verification["errors"].append( @@ -1262,53 +1110,30 @@ def _verify_recovered_database( meta = { str(row[0]): row[1] - for row in conn.execute( - "SELECT key, value FROM state_meta WHERE key LIKE 'fts_%'" - ).fetchall() + for row in conn.execute("SELECT key, value FROM state_meta WHERE key LIKE 'fts_%'").fetchall() } verification["fts_meta"] = meta if meta.get("fts_storage_version") != str(FTS_STORAGE_VERSION): - verification["errors"].append( - "fresh FTS storage version was not established" - ) + verification["errors"].append("fresh FTS storage version was not established") pending_keys = sorted( key - for key in ( - "fts_optimize_available", - "fts_rebuild_high_water", - "fts_rebuild_progress", - "fts_cjk_stale", - "fts_cjk_rebuild_high_water", - "fts_cjk_rebuild_progress", - ) - if key in meta + for key in _GENERATED_META_KEYS + if key.startswith("fts_") and key != "fts_storage_version" and key in meta ) verification["pending_fts_keys"] = pending_keys if pending_keys: - verification["errors"].append( - "derived FTS transition markers remain in the recovered database" - ) + verification["errors"].append("derived FTS transition markers remain in the recovered database") counts: dict[str, int] = {} for table in _INVENTORY_TABLES: - columns = _table_columns(conn, table) - if columns: - counts[table] = int( - conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] - ) + if _table_columns(conn, table): + counts[table] = _count_rows(conn, table) verification["table_counts"] = counts for table in ("sessions", "messages", *_AUXILIARY_TABLES): expected = expected_counts.get(table) if expected is not None and counts.get(table) != expected: - message = ( - f"{table} count is {counts.get(table)}, expected {expected}" - ) - if allow_partial: - verification["warnings"].append(message) - verification["loss_detected"] = True - else: - verification["errors"].append(message) + flag(f"{table} count is {counts.get(table)}, expected {expected}", soft=allow_partial) cleanup = orphan_cleanup or {} rebuilt_sessions = int(cleanup.get("sessions_reconstructed") or 0) @@ -1328,44 +1153,30 @@ def _verify_recovered_database( status = table_report.get("status") if status not in {"failed", "partial"}: continue - message = f"{table} copy status is {status}" - if allow_partial and ( - status == "partial" - or table not in {"sessions", "messages"} - or ( - table == "sessions" - and status == "failed" - and sessions_fully_reconstructed - ) - ): - verification["warnings"].append(message) - verification["loss_detected"] = True - else: - verification["errors"].append(message) + flag( + f"{table} copy status is {status}", + soft=allow_partial + and ( + status == "partial" + or table not in {"sessions", "messages"} + or (table == "sessions" and sessions_fully_reconstructed) + ), + ) if orphan_cleanup: - orphan_count = int( - orphan_cleanup.get("total_removed_or_relinked") or 0 - ) + orphan_count = int(orphan_cleanup.get("total_removed_or_relinked") or 0) if orphan_count: - verification["warnings"].append( - f"{orphan_count} orphaned reference(s) were removed or relinked" - ) - verification["loss_detected"] = True - rebuilt_sessions = int( - orphan_cleanup.get("sessions_reconstructed") or 0 - ) + flag(f"{orphan_count} orphaned reference(s) were removed or relinked", soft=True) if rebuilt_sessions: - retained = int(orphan_cleanup.get("messages_retained") or 0) # Not a clean recovery: the conversation text survived but its # session metadata did not, so these rows are placeholders. - verification["warnings"].append( + flag( f"{rebuilt_sessions} session(s) could not be salvaged and " f"were reconstructed as placeholders to retain " - f"{retained} message(s); their metadata (title, model, " - "timestamps, cost) is lost" + f"{retained_messages} message(s); their metadata (title, model, " + "timestamps, cost) is lost", + soft=True, ) - verification["loss_detected"] = True fts_checks: dict[str, str] = {} for table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"): @@ -1389,9 +1200,7 @@ def _verify_recovered_database( conn.close() verification["healthy"] = not verification["errors"] - verification["complete"] = bool( - verification["healthy"] and not verification["loss_detected"] - ) + verification["complete"] = bool(verification["healthy"] and not verification["loss_detected"]) return verification @@ -1412,8 +1221,7 @@ def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any fts_keys = tuple(key for key in _GENERATED_META_KEYS if key.startswith("fts_")) placeholders = ", ".join("?" for _ in fts_keys) - destination.execute("BEGIN IMMEDIATE") - try: + with _immediate_transaction(destination): destination.execute( f"DELETE FROM state_meta WHERE key IN ({placeholders})", fts_keys, @@ -1423,10 +1231,6 @@ def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any "ON CONFLICT(key) DO UPDATE SET value = excluded.value", ("fts_storage_version", str(FTS_STORAGE_VERSION)), ) - destination.execute("COMMIT") - except BaseException: - destination.execute("ROLLBACK") - raise result["finalized"] = True return result @@ -1443,10 +1247,9 @@ def _recover_via_lost_and_found( ) -> dict[str, Any]: """Best-effort page-level salvage when table schemas are unreadable. - Shells out to the sqlite3 CLI's ``.recover`` (a shell-only feature, not - part of Python's ``sqlite3`` module) to rebuild rows into a scratch - lost_and_found database, then heuristically maps them into a fresh - current-schema database. The result is explicitly labeled best-effort. + Shells out to the sqlite3 CLI's ``.recover`` (a shell-only feature, not part of Python's + ``sqlite3`` module) to rebuild rows into a scratch lost_and_found database, then heuristically + maps them into a fresh current-schema database. The result is explicitly labeled best-effort. """ from hermes_cli.session_lost_and_found import ( @@ -1469,9 +1272,7 @@ def _recover_via_lost_and_found( lf_path = snapshot_dir / "lost_and_found.db" try: - cli_report = run_cli_lost_and_found_recover( - snapshot_source, lf_path, sqlite3_bin - ) + cli_report = run_cli_lost_and_found_recover(snapshot_source, lf_path, sqlite3_bin) except (LostAndFoundError, OSError) as exc: raise SessionRecoverySourceError( "Partial recovery could not read the table schemas for: " @@ -1479,15 +1280,9 @@ def _recover_via_lost_and_found( + f", and page-level .recover salvage failed: {exc}" ) from exc - destination_db = SessionDB(db_path=output) - destination_db.close() - lf_conn = sqlite3.connect(str(lf_path), isolation_level=None) - destination_conn = sqlite3.connect( - str(output), isolation_level=None, timeout=1.0 - ) + destination_conn = _fresh_destination(output) try: - destination_conn.execute("PRAGMA foreign_keys=OFF") mapping = map_lost_and_found_rows(lf_conn, destination_conn) stubbing = stub_missing_parent_sessions(destination_conn) fts = rebuild_fts_indexes(destination_conn) @@ -1510,17 +1305,18 @@ def _recover_via_lost_and_found( for table in ("sessions", "messages", "session_model_usage") } + orphan_cleanup = { + "sessions_reconstructed": stubbing["sessions_stubbed"], + "messages_retained": stubbing["messages_retained"], + "messages_removed": 0, + "total_removed_or_relinked": 0, + } verification = _verify_recovered_database( output, expected_counts={"sessions": None, "messages": None}, copy_report=copy_report, allow_partial=True, - orphan_cleanup={ - "sessions_reconstructed": stubbing["sessions_stubbed"], - "messages_retained": stubbing["messages_retained"], - "messages_removed": 0, - "total_removed_or_relinked": 0, - }, + orphan_cleanup=orphan_cleanup, ) verification["loss_detected"] = True verification["warnings"].append( @@ -1530,20 +1326,48 @@ def _recover_via_lost_and_found( ) verification["complete"] = False - source_unchanged = ( - _source_fingerprint(source) == inspection["source_fingerprint"] + return _recovery_report( + source, + output, + inspection, + disk_space, + verification, + on_source_change="healthy", + allow_partial=True, + mode="lost_and_found_salvage", + best_effort=True, + unreadable_schemas=missing_required, + sqlite3_cli=cli_report, + lost_and_found=mapping, + session_stubs=stubbing, + fts_rebuild=fts, + copy=copy_report, + orphan_cleanup=orphan_cleanup, + derived_metadata=derived_metadata, ) - if not source_unchanged: - verification["errors"].append( - "the source database bundle changed during recovery" - ) - verification["healthy"] = False + +def _recovery_report( + source: Path, + output: Path, + inspection: dict[str, Any], + disk_space: dict[str, Any], + verification: dict[str, Any], + *, + on_source_change: str, + **fields: Any, +) -> dict[str, Any]: + """The ``recover`` report: shared header, mode-specific ``fields``, then the verdict. + + A source bundle that changed during recovery is a verification error and additionally clears + ``verification[on_source_change]``. + """ + source_unchanged = _source_fingerprint(source) == inspection["source_fingerprint"] + if not source_unchanged: + verification["errors"].append("the source database bundle changed during recovery") + verification[on_source_change] = False return { "operation": "recover", - "allow_partial": True, - "mode": "lost_and_found_salvage", - "best_effort": True, "source": str(source), "output": str(output), "source_bundle": inspection["source_bundle"], @@ -1556,22 +1380,10 @@ def _recover_via_lost_and_found( "errors": inspection["errors"], "warnings": inspection["warnings"], }, - "unreadable_schemas": missing_required, - "sqlite3_cli": cli_report, - "lost_and_found": mapping, - "session_stubs": stubbing, - "fts_rebuild": fts, - "copy": copy_report, - "orphan_cleanup": { - "sessions_reconstructed": stubbing["sessions_stubbed"], - "messages_retained": stubbing["messages_retained"], - "messages_removed": 0, - "total_removed_or_relinked": 0, - }, - "derived_metadata": derived_metadata, + **fields, "verification": verification, - "complete": False, - "partial": True, + "complete": bool(verification.get("complete") and source_unchanged), + "partial": bool(verification.get("loss_detected")), "verified": bool(verification.get("healthy") and source_unchanged), "installed": False, } @@ -1588,8 +1400,8 @@ def recover_session_database( ) -> dict[str, Any]: """Recover canonical rows into a separate current-schema database. - The source path and its sidecars are copied before SQLite opens anything. - ``output_path`` must not exist and is never swapped into place. + The source path and its sidecars are copied before SQLite opens anything. ``output_path`` must + not exist and is never swapped into place. """ if chunk_size <= 0: @@ -1632,167 +1444,65 @@ def recover_session_database( missing_required=missing_required, ) - source_conn = sqlite3.connect( - str(snapshot_source), - isolation_level=None, - timeout=1.0, - ) + source_conn = _connect(snapshot_source) source_conn.execute("PRAGMA writable_schema=ON") - destination_db: Optional[SessionDB] = None destination_conn: Optional[sqlite3.Connection] = None try: - has_topic_tables = any( - inspection["tables"][table].get("available") for table in _TOPIC_TABLES + destination_conn = _fresh_destination( + output, + topic_tables=any(inspection["tables"][table].get("available") for table in _TOPIC_TABLES), ) - destination_db = SessionDB(db_path=output) - if has_topic_tables: - destination_db.apply_telegram_topic_migration() - destination_db.close() - destination_db = None - - destination_conn = sqlite3.connect( - str(output), - isolation_level=None, - timeout=1.0, + copy_meta, copy_rows = ( + (_copy_state_meta_salvage, _copy_table_salvage) if allow_partial else (_copy_state_meta, _copy_table) ) - destination_conn.execute("PRAGMA foreign_keys=OFF") - copy_report: dict[str, dict[str, Any]] = {} - for table in _CANONICAL_TABLES: - table_inspection = inspection["tables"][table] - copy_function = ( - _copy_table_salvage if allow_partial else _copy_table - ) - copy_report[table] = copy_function( - source_conn, - destination_conn, - table, - chunk_size=chunk_size, - progress_cb=progress_cb, - source_rows=table_inspection.get("rows"), - ) - - state_meta_inspection = inspection["tables"]["state_meta"] - if state_meta_inspection.get("available"): - state_meta_copy_function = ( - _copy_state_meta_salvage - if allow_partial - else _copy_state_meta - ) - copy_report["state_meta"] = state_meta_copy_function( - source_conn, - destination_conn, - chunk_size=chunk_size, - progress_cb=progress_cb, - source_rows=state_meta_inspection.get("rows"), - ) - else: - copy_report["state_meta"] = {"status": "missing", "copied_rows": 0} - - for table in _TOPIC_TABLES: - table_inspection = inspection["tables"][table] - if not table_inspection.get("available"): - copy_report[table] = { - "status": "missing", - "copied_rows": 0, - } + for table in (*_CANONICAL_TABLES, "state_meta", *_TOPIC_TABLES, *_AUXILIARY_TABLES): + if table not in _CANONICAL_TABLES and not inspection["tables"][table].get("available"): + copy_report[table] = {"status": "missing", "copied_rows": 0} continue - copy_function = ( - _copy_table_salvage if allow_partial else _copy_table - ) + if table in _AUXILIARY_TABLES: + _ensure_auxiliary_destination_schema(destination_conn, table) + copy_function, args = (copy_meta, ()) if table == "state_meta" else (copy_rows, (table,)) copy_report[table] = copy_function( source_conn, destination_conn, - table, + *args, chunk_size=chunk_size, progress_cb=progress_cb, - source_rows=table_inspection.get("rows"), + source_rows=inspection["tables"][table].get("rows"), ) - - for table in _AUXILIARY_TABLES: - table_inspection = inspection["tables"][table] - if not table_inspection.get("available"): - copy_report[table] = { - "status": "missing", - "copied_rows": 0, - } - continue - _ensure_auxiliary_destination_schema(destination_conn, table) - copy_function = ( - _copy_table_salvage if allow_partial else _copy_table - ) - copy_report[table] = copy_function( - source_conn, - destination_conn, - table, - chunk_size=chunk_size, - progress_cb=progress_cb, - source_rows=table_inspection.get("rows"), - ) - orphan_cleanup = ( - _cleanup_partial_orphans(destination_conn) - if allow_partial - else None - ) + orphan_cleanup = _cleanup_partial_orphans(destination_conn) if allow_partial else None derived_metadata = _finalize_derived_metadata(destination_conn) finally: source_conn.close() if destination_conn is not None: destination_conn.close() - if destination_db is not None: - destination_db.close() verification = _verify_recovered_database( output, expected_counts={ - **{ - table: inspection["tables"][table].get("rows") - for table in _CANONICAL_TABLES - }, - **{ - table: inspection["tables"][table].get("rows") - for table in _AUXILIARY_TABLES - if inspection["tables"].get(table, {}).get("available") - }, + table: inspection["tables"][table].get("rows") + for table in (*_CANONICAL_TABLES, *_AUXILIARY_TABLES) + if table in _CANONICAL_TABLES + or inspection["tables"].get(table, {}).get("available") }, copy_report=copy_report, allow_partial=allow_partial, orphan_cleanup=orphan_cleanup, ) - source_unchanged = ( - _source_fingerprint(source) == inspection["source_fingerprint"] + return _recovery_report( + source, + output, + inspection, + disk_space, + verification, + on_source_change="complete", + allow_partial=allow_partial, + copy=copy_report, + orphan_cleanup=orphan_cleanup, + derived_metadata=derived_metadata, ) - if not source_unchanged: - verification["errors"].append( - "the source database bundle changed during recovery" - ) - verification["complete"] = False - - return { - "operation": "recover", - "allow_partial": allow_partial, - "source": str(source), - "output": str(output), - "source_bundle": inspection["source_bundle"], - "source_fingerprint": inspection["source_fingerprint"], - "source_unchanged": source_unchanged, - "disk_space": disk_space, - "inspection": { - "journal_mode": inspection.get("journal_mode"), - "tables": inspection["tables"], - "errors": inspection["errors"], - "warnings": inspection["warnings"], - }, - "copy": copy_report, - "orphan_cleanup": orphan_cleanup, - "derived_metadata": derived_metadata, - "verification": verification, - "complete": bool(verification.get("complete") and source_unchanged), - "partial": bool(verification.get("loss_detected")), - "verified": bool(verification.get("healthy") and source_unchanged), - "installed": False, - } finally: temp_dir.cleanup() diff --git a/hermes_cli/setup_whatsapp_cloud.py b/hermes_cli/setup_whatsapp_cloud.py index c61fa618bc..af26fc6f3b 100644 --- a/hermes_cli/setup_whatsapp_cloud.py +++ b/hermes_cli/setup_whatsapp_cloud.py @@ -1,35 +1,13 @@ -""" -Interactive setup wizard for the WhatsApp Cloud API adapter. +"""Interactive setup wizard for the WhatsApp Cloud API adapter. -Entry point: ``hermes whatsapp-cloud`` (dispatched from -``cmd_whatsapp_cloud`` in ``hermes_cli/main.py``). +Walks the user through the 6 credentials Meta requires + recipient allowlist, auto-generates the +verify token, and prints exact follow-up instructions for the parts that can't happen inside the +wizard process (starting cloudflared, starting the gateway, configuring Meta's webhook dashboard, +adding their phone to the recipient list). -Walks the user through the 6 credentials Meta requires + recipient -allowlist, auto-generates the verify token, and prints exact follow-up -instructions for the parts that can't happen inside the wizard process -(starting cloudflared, starting the gateway, configuring Meta's -webhook dashboard, adding their phone to the recipient list). - -Heavy emphasis on field-shape validation to catch the most common -configuration mistakes: - -- Putting the actual phone number in ``WHATSAPP_CLOUD_PHONE_NUMBER_ID`` - (the field expects Meta's 15-17 digit internal ID, not a phone number). - This is the #1 trap — caught us during Phase 3 live testing. -- Pasting tokens with trailing whitespace. -- Pasting an OpenAI / Slack / GitHub key by mistake. -- Confusing App ID with WABA ID with Phone Number ID. - -Each prompt has contextual help showing exactly where to find the value -in Meta's App Dashboard, with a one-line description and the field's -expected shape ("starts with EAA", "15-17 digits", "32 hex chars", etc.). - -The wizard intentionally does NOT smoke-test the webhook itself — the -Hermes gateway and the cloudflared tunnel both run in separate -processes the user starts AFTER this wizard exits, so any in-wizard -probe would fail by design. Instead the final SETUP COMPLETE block -prints the exact curl command the user can run from a third terminal -to verify the loop end-to-end once everything's running. +The wizard intentionally does NOT smoke-test the webhook itself — the Hermes gateway and the +cloudflared tunnel both run in separate processes the user starts AFTER this wizard exits, so any +in-wizard probe would fail by design. """ from __future__ import annotations @@ -53,10 +31,9 @@ from typing import Optional def _validate_phone_number_id(value: str) -> tuple[bool, Optional[str]]: """Phone Number ID is a 15-17 digit numeric ID assigned by Meta. - It's NOT a phone number. The #1 setup mistake is pasting the actual - phone number (e.g. ``15556422442``) into this field — that's only - 10-11 digits and gets rejected by Graph as "Object with ID does - not exist." + It's NOT a phone number. The #1 setup mistake is pasting the actual phone number (e.g. + ``15556422442``) into this field — that's only 10-11 digits and gets rejected by Graph as + "Object with ID does not exist." """ if not value: return False, "Phone Number ID is required" @@ -81,28 +58,33 @@ def _validate_phone_number_id(value: str) -> tuple[bool, Optional[str]]: return True, None -def _validate_waba_id(value: str) -> tuple[bool, Optional[str]]: - """WABA ID is numeric, similar length range as Phone Number ID.""" - if not value: - return False, "WABA ID is required" - s = value.strip() - if not s.isdigit(): - return False, "WABA ID must be numeric" - if len(s) < 10 or len(s) > 25: - return False, "WABA ID looks wrong (expected 10-25 digits)" - return True, None +def _numeric_id_validator(label: str, lo: int, hi: int, expected: str): + """Validator for a numeric Meta ID whose digit count must fall in [lo, hi].""" + def validate(value: str) -> tuple[bool, Optional[str]]: + if not value: + return False, f"{label} is required" + s = value.strip() + if not s.isdigit(): + return False, f"{label} must be numeric" + if len(s) < lo or len(s) > hi: + return False, f"{label} looks wrong (expected {expected})" + return True, None + return validate -def _validate_app_id(value: str) -> tuple[bool, Optional[str]]: - """Meta App ID is numeric, typically 15-16 digits.""" - if not value: - return False, "App ID is required" - s = value.strip() - if not s.isdigit(): - return False, "App ID must be numeric" - if len(s) < 13 or len(s) > 20: - return False, "App ID looks wrong (expected 15-16 digits)" - return True, None +# WABA ID: similar length range as Phone Number ID. App ID: typically 15-16 digits. +_validate_waba_id = _numeric_id_validator("WABA ID", 10, 25, "10-25 digits") +_validate_app_id = _numeric_id_validator("App ID", 13, 20, "15-16 digits") + +# Common paste mistakes for the access-token field: (prefixes, what it actually is). +_FOREIGN_TOKEN_PREFIXES = ( + (("sk-",), "That's an OpenAI key (starts with 'sk-'), not a Meta " + "WhatsApp access token. Meta tokens start with 'EAA'."), + (("xoxb-", "xoxp-"), "That's a Slack token, not a Meta WhatsApp access token. " + "Meta tokens start with 'EAA'."), + (("ghp_", "gho_"), "That's a GitHub token, not a Meta WhatsApp access " + "token. Meta tokens start with 'EAA'."), +) def _validate_app_secret(value: str) -> tuple[bool, Optional[str]]: @@ -124,29 +106,17 @@ def _validate_app_secret(value: str) -> tuple[bool, Optional[str]]: def _validate_access_token(value: str) -> tuple[bool, Optional[str]]: """Meta access tokens start with ``EAA`` and are 100-300+ characters. - Both temp tokens (24h) and System User permanent tokens share this - prefix. We don't try to distinguish them. + Both temp tokens (24h) and System User permanent tokens share this prefix. We don't try to + distinguish them. """ if not value: return False, "Access token is required" s = value.strip() if not s.startswith("EAA"): # Diagnose common paste mistakes - if s.startswith("sk-"): - return False, ( - "That's an OpenAI key (starts with 'sk-'), not a Meta " - "WhatsApp access token. Meta tokens start with 'EAA'." - ) - if s.startswith("xoxb-") or s.startswith("xoxp-"): - return False, ( - "That's a Slack token, not a Meta WhatsApp access token. " - "Meta tokens start with 'EAA'." - ) - if s.startswith("ghp_") or s.startswith("gho_"): - return False, ( - "That's a GitHub token, not a Meta WhatsApp access " - "token. Meta tokens start with 'EAA'." - ) + for prefixes, reason in _FOREIGN_TOKEN_PREFIXES: + if s.startswith(prefixes): + return False, reason return False, ( "Meta WhatsApp access tokens start with 'EAA'. Check that " "you're copying from the right place (API Setup → 'Generate " @@ -166,13 +136,9 @@ def _validate_access_token(value: str) -> tuple[bool, Optional[str]]: def _prompt(message: str, default: Optional[str] = None, secret: bool = False) -> str: """Read one line of input. Returns "" on EOF / Ctrl+C / empty input. - The ``default`` parameter is shown to the user but NOT auto-applied - on empty input — callers handle the "user kept existing" case - explicitly so they can distinguish between a real value and a - display preview (e.g. ``"abc12345..."`` for masked secrets). - - ``secret=True`` reads via ``getpass`` so credentials are not echoed - to the terminal (or left in scrollback). + ``default`` is shown but NOT auto-applied on empty input: callers handle "kept existing" + explicitly so a real value is distinguishable from a display preview (masked secrets). + ``secret=True`` reads via ``getpass`` so credentials are not echoed or left in scrollback. """ try: suffix = f" [{default}]" if default else "" @@ -198,9 +164,8 @@ def _prompt_validated( ) -> Optional[str]: """Repeat the prompt until the user enters a valid value or aborts. - Returns the validated value, or None if the user gave up (empty - response after an error, or Ctrl+C). ``current`` is shown as a - default for re-runs of the wizard with existing config. + Returns the validated value, or None if the user gave up (empty response after an error, or + Ctrl+C). ``current`` is shown as a default for re-runs of the wizard with existing config. """ if help_text: for line in help_text.strip().splitlines(): @@ -230,11 +195,27 @@ def _prompt_validated( # --------------------------------------------------------------------------- +def _header(title: str) -> None: + print("─" * 50) + print(title) + print("─" * 50) + + +def _save_optional(key: str, value: Optional[str], current: Optional[str]) -> None: + from hermes_cli.config import save_env_value + + if value: + save_env_value(key, value) + print(f" ✓ Saved: {value}") + elif current: + print(f" ✓ Keeping existing: {current}") + + def run_whatsapp_cloud_setup() -> int: """Interactive wizard for the WhatsApp Cloud API adapter. - Returns 0 on full success, 1 on user abort, 2 on partial completion - (some fields written but the user bailed before finishing). + Returns 0 on full success, 1 on user abort, 2 on partial completion (some fields written but the + user bailed before finishing). """ from hermes_cli.config import get_env_value, save_env_value @@ -261,15 +242,13 @@ def run_whatsapp_cloud_setup() -> int: print(" start; switch to a System User permanent token later)") print() try: - proceed = input("Press Enter to continue, or Ctrl+C to abort... ").strip() + input("Press Enter to continue, or Ctrl+C to abort... ") except (EOFError, KeyboardInterrupt): print("\nSetup cancelled.") return 1 print() - print("─" * 50) - print("STEP 1 — Phone Number ID") - print("─" * 50) + _header("STEP 1 — Phone Number ID") current_phone_id = get_env_value("WHATSAPP_CLOUD_PHONE_NUMBER_ID") or None phone_id = _prompt_validated( "Phone Number ID", @@ -296,9 +275,7 @@ def run_whatsapp_cloud_setup() -> int: print(f" ✓ Saved: {phone_id}") print() - print("─" * 50) - print("STEP 2 — Access Token") - print("─" * 50) + _header("STEP 2 — Access Token") current_token = get_env_value("WHATSAPP_CLOUD_ACCESS_TOKEN") or None current_display = (current_token[:15] + "...") if current_token else None token = _prompt_validated( @@ -337,9 +314,7 @@ def run_whatsapp_cloud_setup() -> int: print(" ✓ Saved (token hidden)") print() - print("─" * 50) - print("STEP 3 — App Secret (required for webhook signature verification)") - print("─" * 50) + _header("STEP 3 — App Secret (required for webhook signature verification)") current_secret = get_env_value("WHATSAPP_CLOUD_APP_SECRET") or None current_secret_display = (current_secret[:8] + "...") if current_secret else None app_secret = _prompt_validated( @@ -368,9 +343,7 @@ def run_whatsapp_cloud_setup() -> int: print(" ✓ Saved (secret hidden)") print() - print("─" * 50) - print("STEP 4 — App ID & WABA ID (optional, for analytics)") - print("─" * 50) + _header("STEP 4 — App ID & WABA ID (optional, for analytics)") current_app_id = get_env_value("WHATSAPP_CLOUD_APP_ID") or None app_id = _prompt_validated( "App ID (optional, press Enter to skip)", @@ -382,11 +355,7 @@ def run_whatsapp_cloud_setup() -> int: "Not required for messaging — useful only for analytics later." ), ) - if app_id: - save_env_value("WHATSAPP_CLOUD_APP_ID", app_id) - print(f" ✓ Saved: {app_id}") - elif current_app_id: - print(f" ✓ Keeping existing: {current_app_id}") + _save_optional("WHATSAPP_CLOUD_APP_ID", app_id, current_app_id) current_waba_id = get_env_value("WHATSAPP_CLOUD_WABA_ID") or None waba_id = _prompt_validated( @@ -400,16 +369,10 @@ def run_whatsapp_cloud_setup() -> int: "Not required for messaging — useful for analytics." ), ) - if waba_id: - save_env_value("WHATSAPP_CLOUD_WABA_ID", waba_id) - print(f" ✓ Saved: {waba_id}") - elif current_waba_id: - print(f" ✓ Keeping existing: {current_waba_id}") + _save_optional("WHATSAPP_CLOUD_WABA_ID", waba_id, current_waba_id) print() - print("─" * 50) - print("STEP 5 — Verify Token (auto-generated)") - print("─" * 50) + _header("STEP 5 — Verify Token (auto-generated)") current_verify = get_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN") or None if current_verify: print(f" An existing verify token is already set ({current_verify[:8]}...).") @@ -433,17 +396,14 @@ def run_whatsapp_cloud_setup() -> int: print(" configuration dialog (next step).") print() - print("─" * 50) - print("STEP 6 — Recipient Allowlist") - print("─" * 50) + _header("STEP 6 — Recipient Allowlist") print() print(" Who is allowed to message the bot? (Comma-separated phone") print(" numbers with country code, no '+' / spaces / dashes. Use '*'") print(" to allow anyone — only safe if you've also configured Meta's") print(" recipient whitelist for app-development mode.)") print() - current_allow = get_env_value("WHATSAPP_CLOUD_ALLOWED_USERS") or None - allow_default = current_allow if current_allow else None + allow_default = get_env_value("WHATSAPP_CLOUD_ALLOWED_USERS") or None try: allowed = line_input( f" → Allowed users{' [' + allow_default + ']' if allow_default else ''}: " @@ -462,9 +422,7 @@ def run_whatsapp_cloud_setup() -> int: print(" Re-run this wizard or set WHATSAPP_CLOUD_ALLOWED_USERS manually.") print() - print("─" * 50) - print("SETUP COMPLETE — Next steps") - print("─" * 50) + _header("SETUP COMPLETE — Next steps") print() print(" Hermes needs a public HTTPS URL to receive WhatsApp messages.") print(" The recommended path is Cloudflare Tunnel (free, no port") @@ -509,9 +467,7 @@ def run_whatsapp_cloud_setup() -> int: print() print(" 7. DM the bot's test number from your phone.") print() - print("─" * 50) - print("Optional: polish your bot's WhatsApp profile") - print("─" * 50) + _header("Optional: polish your bot's WhatsApp profile") print() print(" WhatsApp shows a display name and profile picture for your bot") print(" in every chat header and contact list. These are set in Meta's") @@ -519,13 +475,10 @@ def run_whatsapp_cloud_setup() -> int: print(" it once you're up and running:") print() effective_waba = waba_id or current_waba_id - if effective_waba: - print(" • Display name + profile picture:") - print(" https://business.facebook.com/wa/manage/phone-numbers/" - f"?waba_id={effective_waba}") - else: - print(" • Display name + profile picture:") - print(" https://business.facebook.com/wa/manage/phone-numbers/") + print(" • Display name + profile picture:") + print(" https://business.facebook.com/wa/manage/phone-numbers/" + + (f"?waba_id={effective_waba}" if effective_waba else "")) + if not effective_waba: print(" (select your WhatsApp Business Account on that page)") print(" Display-name changes go through a ~24-48h Meta review.") print() diff --git a/hermes_cli/sizefmt.py b/hermes_cli/sizefmt.py index f0680f4229..0448da095e 100644 --- a/hermes_cli/sizefmt.py +++ b/hermes_cli/sizefmt.py @@ -1,16 +1,10 @@ """Small shared size-formatting helpers for CLI/agent output. -Sibling of ``hermes_cli.timefmt`` (same extraction rationale: a tiny -purpose-named module lightweight consumers can import without dragging in -the CLI surface). Replaces six near-identical private byte formatters. +Sibling of ``hermes_cli.timefmt`` (same extraction rationale: a tiny purpose-named module +lightweight consumers can import without dragging in the CLI surface). Replaces six near-identical +private byte formatters. Two in-repo formatters intentionally do NOT delegate here: - -* ``hermes_cli/session_recovery.py`` uses binary suffixes (KiB/MiB/GiB) - throughout its recovery report — a deliberate, self-consistent style. -* ``gateway/platforms/qqbot/chunked_upload.py`` renders bytes with one - decimal ("100.0 B", pinned by tests) inside a self-contained upload - protocol module. """ from __future__ import annotations @@ -19,9 +13,9 @@ from __future__ import annotations def format_bytes(n) -> str: """1234567 -> '1.2 MB' (B/KB/MB/GB/TB; integer bytes, one decimal above). - Accepts anything ``float()`` accepts; returns ``"?"`` for None or - unparseable input so display call sites never raise (contract inherited - from doctor's original copy — its stats dict tolerates None fields). + Accepts anything ``float()`` accepts; returns ``"?"`` for None or unparseable input so display + call sites never raise (contract inherited from doctor's original copy — its stats dict + tolerates None fields). """ try: size = float(n) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index b548e7bd90..b32ad59532 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -1,19 +1,12 @@ #!/usr/bin/env python3 -""" -Skills Hub CLI — Unified interface for the Hermes Skills Hub. - -Powers both: - - `hermes skills <subcommand>` (CLI argparse entry point) - - `/skills <subcommand>` (slash command in the interactive chat) - -All logic lives in shared do_* functions. The CLI entry point and slash command -handler are thin wrappers that parse args and delegate. -""" +"""Skills Hub CLI — Unified interface for the Hermes Skills Hub.""" import json import logging import re import shutil +import sys +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional @@ -31,9 +24,8 @@ _console = Console() def _display_source(r) -> str: """Human-facing source label for a result row. - GitHub-tap skills are stored under source="github"; surface their per-tap - provider label (NVIDIA / OpenAI / ...) when present so the table reflects - the real origin instead of the generic "github". + GitHub-tap skills are stored under source="github"; surface their per-tap provider label + (NVIDIA / OpenAI / ...) when present so the table reflects the real origin. """ if r.source == "github": provider = (getattr(r, "extra", None) or {}).get("provider") @@ -47,11 +39,9 @@ def _display_source(r) -> str: # --------------------------------------------------------------------------- def _resolve_short_name(name: str, sources, console: Console) -> str: - """ - Resolve a short skill name (e.g. 'pptx') to a full identifier by searching - all sources. If exactly one match is found, returns its identifier. If multiple - matches exist, shows them and asks the user to use the full identifier. - Returns empty string if nothing found or ambiguous. + """Resolve a short skill name (e.g. 'pptx') to a full identifier by searching all sources. If + exactly one match is found, returns its identifier. If multiple matches exist, shows them and + asks the user to use the full identifier. Returns empty string if nothing found or ambiguous. """ from tools.skills_hub import unified_search @@ -105,9 +95,9 @@ def _resolve_short_name(name: str, sources, console: Console) -> str: def _print_tier1_advisory(skill_dir, console) -> None: """Print the advisory SkillEvaluator Tier 1 report, if available. - Never raises and never blocks the install: scanner missing, disabled - via ``skills.tier1_advisory: false``, or erroring all degrade to - silence. Secrets-class findings render red, the rest yellow. + Never raises and never blocks the install: scanner missing, disabled via + ``skills.tier1_advisory: false``, or erroring all degrade to silence. Secrets-class findings + render red, the rest yellow. """ try: from tools.skillevaluator_scan import ( @@ -168,10 +158,9 @@ def _format_extra_metadata_lines(extra: Dict[str, Any]) -> list[str]: def _resolve_source_meta_and_bundle(identifier: str, sources): """Resolve metadata and bundle from a single source adapter. - Meta and bundle must come from the same adapter. Keeping catalog - metadata from skills.sh while taking a ClawHub zip of a same-named - skill is how ``hermes skills inspect owner/repo/skills/foo`` showed - the requested identifier and the wrong SKILL.md. + Meta and bundle must come from the same adapter. Keeping catalog metadata from skills.sh while + taking a ClawHub zip of a same-named skill is how ``hermes skills inspect + owner/repo/skills/foo`` showed the requested identifier and the wrong SKILL.md. """ first_meta = None first_meta_source = None @@ -226,11 +215,8 @@ def _is_valid_installed_skill_name(name: str) -> bool: def _existing_categories() -> List[str]: - """Return sorted subdirectory names under ``~/.hermes/skills/`` that look - like category buckets (contain at least one ``SKILL.md`` somewhere below). - - Used to suggest reusable categories when interactively installing from a - URL. Hidden dirs (``.hub``, ``.trash``) are skipped. + """Return sorted subdirectory names under ``~/.hermes/skills/`` that look like category buckets + (contain at least one ``SKILL.md`` somewhere below). """ from tools.skills_hub import SKILLS_DIR, _category_skill_dirs try: @@ -246,6 +232,49 @@ def _existing_categories() -> List[str]: return [] + +_TRUST_STYLE = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"} + + +def _trust_cell(trust_level: str, source: str, official_label: str = "official") -> str: + """Rich-styled trust cell; official-source rows show `official_label` instead of the level.""" + label = official_label if source == "official" else trust_level + return f"[{_TRUST_STYLE.get(trust_level, 'dim')}]{label}[/]" + + +def _truncate(text: str, width: int) -> str: + return text[:width] + ("..." if len(text) > width else "") + + +def _sources(): + """Source router over all registries (authenticated GitHub when available).""" + from tools.skills_hub import GitHubAuth, create_source_router + return create_source_router(GitHubAuth()) + + +def _confirm() -> bool: + """Prompt `Confirm [y/N]:`; EOF/Ctrl-C counts as no.""" + try: + answer = input("Confirm [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + return answer in {"y", "yes"} + + +def _clear_skills_cache() -> None: + """Invalidate the skills prompt cache so the change appears immediately.""" + try: + from agent.prompt_builder import clear_skills_system_prompt_cache + clear_skills_system_prompt_cache(clear_snapshot=True) + except Exception: + pass + + +def _print_next_session_notice(c: Console) -> None: + c.print("[dim]Change will take effect in your next session.[/]") + c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n") + + def _prompt_for_skill_name(c: Console, url: str, default: str = "") -> Optional[str]: """Prompt interactively for a skill name. Returns None on cancel/EOF.""" c.print() @@ -304,18 +333,15 @@ def do_search(query: str, source: str = "all", limit: int = 10, console: Optional[Console] = None, as_json: bool = False) -> None: """Search registries and display results as a Rich table. - When ``as_json=True`` writes a JSON array of result records to stdout - (one object per skill: ``name``, ``identifier``, ``source``, - ``trust_level``, ``description``) and skips the table render. This is - the scripting / copy-paste handle: the full identifier is always - intact, even for browse-sh slugs that the table would otherwise wrap. + ``as_json=True`` writes a JSON array (``name``, ``identifier``, ``source``, ``trust_level``, + ``description``) instead of the table: the scripting handle where the full identifier stays + intact, even for slugs the table would wrap. """ - from tools.skills_hub import GitHubAuth, create_source_router, unified_search + from tools.skills_hub import unified_search c = console or _console - auth = GitHubAuth() - sources = create_source_router(auth) + sources = _sources() if as_json: # Avoid Rich status spinner contaminating stdout — JSON consumers # expect a clean parseable stream. @@ -353,13 +379,11 @@ def do_search(query: str, source: str = "all", limit: int = 10, table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False) for r in results: - trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim") - trust_label = "official" if r.source == "official" else r.trust_level table.add_row( r.name, - r.description[:60] + ("..." if len(r.description) > 60 else ""), + _truncate(r.description, 60), _display_source(r), - f"[{trust_style}]{trust_label}[/]", + _trust_cell(r.trust_level, r.source), r.identifier, ) @@ -375,17 +399,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all", Official skills are always shown first, regardless of source filter. """ - from tools.skills_hub import ( - GitHubAuth, create_source_router, parallel_search_sources, - ) + from tools.skills_hub import parallel_search_sources # Clamp page_size to safe range page_size = max(1, min(page_size, 100)) c = console or _console - auth = GitHubAuth() - sources = create_source_router(auth) + sources = _sources() # Collect results from all (or filtered) sources in parallel. # Per-source limits are generous — parallelism + 30s timeout cap prevents hangs. @@ -497,20 +518,12 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all", table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False) for i, r in enumerate(page_items, start=start + 1): - trust_style = {"builtin": "bright_cyan", "trusted": "green", - "community": "yellow"}.get(r.trust_level, "dim") - trust_label = "★ official" if r.source == "official" else r.trust_level - - desc = r.description[:44] - if len(r.description) > 44: - desc += "..." - table.add_row( str(i), r.name, - desc, + _truncate(r.description, 44), _display_source(r), - f"[{trust_style}]{trust_label}[/]", + _trust_cell(r.trust_level, r.source, official_label="★ official"), r.identifier, ) @@ -540,6 +553,114 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all", "'hermes skills search <query>' to search deeper[/]\n") +def _install_blocked(c: Console, bundle, message: str, verdict: str, detail: str, + q_path: Optional[Path] = None, lead: str = "") -> None: + """Print the blocked-install line, drop the quarantine copy, append the audit row.""" + c.print(f"{lead}[bold red]Installation blocked:[/] {message}") + if q_path is not None: + shutil.rmtree(q_path, ignore_errors=True) + from tools.skills_hub import append_audit_log + append_audit_log("BLOCKED", bundle.name, bundle.source, bundle.trust_level, verdict, detail) + + +def _resolve_url_bundle_name(c: Console, bundle, meta, identifier: str, + name_override: str, skip_confirm: bool) -> bool: + """Give a URL-sourced bundle a name when SKILL.md has none. + + URL-sourced skills may arrive with an empty name when SKILL.md has no ``name:`` in + frontmatter AND the URL path doesn't yield a valid identifier. Resolve by (1) --name + override, (2) interactive prompt on a TTY, (3) refuse with an actionable error on + non-interactive surfaces. Returns False when the install must abort. + """ + bundle_meta = getattr(bundle, "metadata", {}) or {} + if bundle.source == "url" and (not bundle.name or bundle_meta.get("awaiting_name")): + if name_override and _is_valid_installed_skill_name(name_override): + bundle.name = name_override.strip() + bundle_meta["awaiting_name"] = False + elif name_override: + c.print( + f"[bold red]Invalid --name:[/] {name_override!r}. " + "Must be a lowercase identifier (letters, digits, hyphens, " + "underscores; starts with a letter).\n" + ) + return False + elif skip_confirm: + # Non-interactive surface (slash command / TUI / gateway). Can't + # prompt — emit an actionable error. + url = bundle_meta.get("url") or identifier + c.print( + f"[bold red]Cannot install from URL:[/] {url}\n" + "[yellow]The SKILL.md has no `name:` in its frontmatter, " + "and the URL path doesn't produce a valid identifier.[/]\n\n" + "Retry with an explicit name:\n" + f" [bold]/skills install {url} --name <your-name>[/]\n" + f" [bold]hermes skills install {url} --name <your-name>[/]\n\n" + "[dim]Or ask the SKILL.md's author to add a `name:` field to " + "its YAML frontmatter.[/]\n" + ) + return False + else: + # Interactive TTY — prompt. + url = bundle_meta.get("url") or identifier + chosen = _prompt_for_skill_name(c, url) + if not chosen: + c.print("[dim]Installation cancelled.[/]\n") + return False + bundle.name = chosen + bundle_meta["awaiting_name"] = False + # Keep SkillMeta in sync so downstream "already installed" checks, + # audit logs, and display all see the final name. + if meta is not None: + meta.name = bundle.name + meta.path = bundle.name + return True + + +def _announce_blueprint(c: Console, skill_name: str) -> None: + """Register an installed skill's ``metadata.hermes.blueprint`` as a Suggested Cron Job. + + A blueprint block marks a skill as a runnable automation. It is registered as a + suggestion rather than auto-scheduled — installing never silently creates a recurring + job; the user accepts it via /suggestions, the single surface every automation + proposal flows through. Best-effort: never raises. + """ + try: + from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion + + try: + spec = blueprint_spec_for_installed(skill_name) + except BlueprintError as _rec_err: + c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n") + spec = None + if spec is not None: + registered = register_blueprint_suggestion(spec) + if registered is not None: + c.print( + f"[bold cyan]Blueprint:[/] '{skill_name}' is an automation " + f"(schedule [bold]{spec.schedule}[/])." + ) + c.print( + "[dim]Added to your suggestions — run[/] [bold]/suggestions[/] " + "[dim]to schedule or dismiss it.[/]\n" + ) + else: + # Dropped: already offered/dismissed (latched) or the pending + # list is at its cap. Say so instead of silently doing nothing — + # the user can still schedule it by hand. + c.print( + f"[bold cyan]Blueprint:[/] '{skill_name}' is an automation " + f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to " + "your suggestions (already offered/dismissed, or the pending " + "list is full — run [bold]/suggestions[/] to review)." + ) + c.print( + "[dim]You can still schedule it any time by asking the agent " + "or via[/] [bold]hermes cron add[/][dim].[/]\n" + ) + except Exception: # pragma: no cover - blueprint detection is best-effort + pass + + def do_install(identifier: str, category: str = "", force: bool = False, console: Optional[Console] = None, skip_confirm: bool = False, invalidate_cache: bool = True, @@ -547,23 +668,13 @@ def do_install(identifier: str, category: str = "", force: bool = False, source_id: Optional[str] = None) -> None: """Fetch, quarantine, scan, confirm, and install a skill. - ``name_override`` lets non-interactive callers (slash commands, gateway, - scripts) supply a skill name when the upstream SKILL.md lacks a valid - ``name:`` frontmatter field. On interactive TTY surfaces, a missing name - triggers a prompt instead; ``skip_confirm=True`` means "non-interactive" - (so pair it with ``name_override`` when installing from a URL that has - no frontmatter). - - ``source_id`` pins resolution to a single source adapter (e.g. ``clawhub``). - Callers that already know a skill's provenance -- notably ``do_update``, - which reads it from the lockfile -- should pass it so a bare, slash-less - identifier cannot be fuzzy-resolved to a same-named skill in a different - registry. Skill names are not namespaced across registries, so an - unconstrained resolve can silently change a skill's provenance. + ``source_id`` pins resolution to a single source adapter (e.g. ``clawhub``). Callers that + already know a skill's provenance -- notably ``do_update``, which reads it from the lockfile -- + should pass it so a bare, slash-less identifier cannot be fuzzy-resolved to a same-named skill + in a different registry. """ from tools.skills_hub import ( - GitHubAuth, create_source_router, ensure_hub_dirs, - quarantine_bundle, install_from_quarantine, HubLockFile, + ensure_hub_dirs, quarantine_bundle, install_from_quarantine, HubLockFile, _source_matches, ) from tools.skills_guard import scan_skill_cached, should_allow_install, format_scan_report @@ -572,8 +683,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, ensure_hub_dirs() # Resolve which source adapter handles this identifier - auth = GitHubAuth() - sources = create_source_router(auth) + sources = _sources() if source_id: pinned = [src for src in sources if _source_matches(src, source_id)] @@ -617,51 +727,8 @@ def do_install(identifier: str, category: str = "", force: bool = False, c.print() return - # URL-sourced skills may arrive with an empty name when SKILL.md has no - # ``name:`` in frontmatter AND the URL path doesn't yield a valid - # identifier. Resolve by (1) --name override, (2) interactive prompt on - # a TTY, (3) refuse with an actionable error on non-interactive surfaces. - bundle_meta = getattr(bundle, "metadata", {}) or {} - if bundle.source == "url" and (not bundle.name or bundle_meta.get("awaiting_name")): - if name_override and _is_valid_installed_skill_name(name_override): - bundle.name = name_override.strip() - bundle_meta["awaiting_name"] = False - elif name_override: - c.print( - f"[bold red]Invalid --name:[/] {name_override!r}. " - "Must be a lowercase identifier (letters, digits, hyphens, " - "underscores; starts with a letter).\n" - ) - return - elif skip_confirm: - # Non-interactive surface (slash command / TUI / gateway). Can't - # prompt — emit an actionable error. - url = bundle_meta.get("url") or identifier - c.print( - f"[bold red]Cannot install from URL:[/] {url}\n" - "[yellow]The SKILL.md has no `name:` in its frontmatter, " - "and the URL path doesn't produce a valid identifier.[/]\n\n" - "Retry with an explicit name:\n" - f" [bold]/skills install {url} --name <your-name>[/]\n" - f" [bold]hermes skills install {url} --name <your-name>[/]\n\n" - "[dim]Or ask the SKILL.md's author to add a `name:` field to " - "its YAML frontmatter.[/]\n" - ) - return - else: - # Interactive TTY — prompt. - url = bundle_meta.get("url") or identifier - chosen = _prompt_for_skill_name(c, url) - if not chosen: - c.print("[dim]Installation cancelled.[/]\n") - return - bundle.name = chosen - bundle_meta["awaiting_name"] = False - # Keep SkillMeta in sync so downstream "already installed" checks, - # audit logs, and display all see the final name. - if meta is not None: - meta.name = bundle.name - meta.path = bundle.name + if not _resolve_url_bundle_name(c, bundle, meta, identifier, name_override, skip_confirm): + return # URL-sourced skills: offer to pick a category interactively when the # caller didn't specify one (TTY only — non-interactive installs fall @@ -693,10 +760,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, try: q_path = quarantine_bundle(bundle) except ValueError as exc: - c.print(f"[bold red]Installation blocked:[/] {exc}\n") - from tools.skills_hub import append_audit_log - append_audit_log("BLOCKED", bundle.name, bundle.source, - bundle.trust_level, "invalid_path", str(exc)) + _install_blocked(c, bundle, f"{exc}\n", "invalid_path", str(exc)) return c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]") @@ -732,13 +796,8 @@ def do_install(identifier: str, category: str = "", force: bool = False, # Check install policy allowed, reason = should_allow_install(result, force=force) if not allowed: - c.print(f"\n[bold red]Installation blocked:[/] {reason}") - # Clean up quarantine - shutil.rmtree(q_path, ignore_errors=True) - from tools.skills_hub import append_audit_log - append_audit_log("BLOCKED", bundle.name, bundle.source, - bundle.trust_level, result.verdict, - f"{len(result.findings)}_findings") + _install_blocked(c, bundle, reason, result.verdict, f"{len(result.findings)}_findings", + q_path=q_path, lead="\n") return # Advisory SkillEvaluator Tier 1 scan (optional second opinion). @@ -776,11 +835,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, border_style="yellow", )) c.print(f"[bold]Install '{bundle.name}'?[/]") - try: - answer = input("Confirm [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer not in {"y", "yes"}: + if not _confirm(): c.print("[dim]Installation cancelled.[/]\n") shutil.rmtree(q_path, ignore_errors=True) return @@ -789,64 +844,17 @@ def do_install(identifier: str, category: str = "", force: bool = False, try: install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result) except ValueError as exc: - c.print(f"[bold red]Installation blocked:[/] {exc}\n") - shutil.rmtree(q_path, ignore_errors=True) - from tools.skills_hub import append_audit_log - append_audit_log("BLOCKED", bundle.name, bundle.source, - bundle.trust_level, "invalid_path", str(exc)) + _install_blocked(c, bundle, f"{exc}\n", "invalid_path", str(exc), q_path=q_path) return from tools.skills_hub import SKILLS_DIR c.print(f"[bold green]Installed:[/] {install_dir.resolve().relative_to(Path(SKILLS_DIR).resolve()).as_posix()}") c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n") - # Blueprint detection: if the installed skill declares a - # metadata.hermes.blueprint block, it is a runnable automation. Register it as - # a Suggested Cron Job rather than auto-scheduling — installing never - # silently creates a recurring job; the user accepts it via /suggestions. - # This is the single surface every automation proposal flows through. - try: - from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion - - try: - spec = blueprint_spec_for_installed(bundle.name) - except BlueprintError as _rec_err: - c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n") - spec = None - if spec is not None: - registered = register_blueprint_suggestion(spec) - if registered is not None: - c.print( - f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation " - f"(schedule [bold]{spec.schedule}[/])." - ) - c.print( - "[dim]Added to your suggestions — run[/] [bold]/suggestions[/] " - "[dim]to schedule or dismiss it.[/]\n" - ) - else: - # Dropped: already offered/dismissed (latched) or the pending - # list is at its cap. Say so instead of silently doing nothing — - # the user can still schedule it by hand. - c.print( - f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation " - f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to " - "your suggestions (already offered/dismissed, or the pending " - "list is full — run [bold]/suggestions[/] to review)." - ) - c.print( - "[dim]You can still schedule it any time by asking the agent " - "or via[/] [bold]hermes cron add[/][dim].[/]\n" - ) - except Exception: # pragma: no cover - blueprint detection is best-effort - pass + _announce_blueprint(c, bundle.name) if invalidate_cache: # Invalidate the skills prompt cache so the new skill appears immediately - try: - from agent.prompt_builder import clear_skills_system_prompt_cache - clear_skills_system_prompt_cache(clear_snapshot=True) - except Exception: - pass + _clear_skills_cache() else: c.print("[dim]Skill will be available in your next session.[/]") c.print("[dim]Use /reset to start a new session now, or --now to activate immediately (invalidates prompt cache).[/]\n") @@ -854,11 +862,8 @@ def do_install(identifier: str, category: str = "", force: bool = False, def do_inspect(identifier: str, console: Optional[Console] = None) -> None: """Preview a skill's SKILL.md content without installing.""" - from tools.skills_hub import GitHubAuth, create_source_router - c = console or _console - auth = GitHubAuth() - sources = create_source_router(auth) + sources = _sources() if "/" not in identifier: identifier = _resolve_short_name(identifier, sources, c) @@ -903,13 +908,8 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict: - """Paginated hub browse for programmatic callers (e.g. TUI gateway). - - Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``. - """ - from tools.skills_hub import ( - GitHubAuth, create_source_router, parallel_search_sources, - ) + """Paginated hub browse for programmatic callers (e.g. TUI gateway).""" + from tools.skills_hub import parallel_search_sources page_size = max(1, min(page_size, 100)) _TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1} @@ -919,8 +919,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di _PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50, "lobehub": 50, "browse-sh": 500} - auth = GitHubAuth() - sources = create_source_router(auth) + sources = _sources() # Delegate to the shared parallel walker so this inherits the index-aware # source-skip logic — querying hermes-index AND the external APIs at once # would double-count every skill. @@ -953,15 +952,13 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di def inspect_skill(identifier: str) -> Optional[dict]: """Skill metadata (+ SKILL.md preview) for programmatic callers.""" - from tools.skills_hub import GitHubAuth, create_source_router class _Q: def print(self, *a, **k): pass c = _Q() - auth = GitHubAuth() - sources = create_source_router(auth) + sources = _sources() ident = identifier if "/" not in ident: ident = _resolve_short_name(ident, sources, c) @@ -994,14 +991,9 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None: """List installed skills, distinguishing hub, builtin, and local skills. - Args: - source_filter: ``all`` | ``hub`` | ``builtin`` | ``local``. - enabled_only: If True, hide disabled skills from the output. - - Enabled/disabled state is resolved against the currently active profile's - config — ``hermes -p <profile> skills list`` reads that profile's - ``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process - start. No explicit profile flag needed here. + Enabled/disabled state is resolved against the currently active profile's config — ``hermes -p + <profile> skills list`` reads that profile's ``skills.disabled`` list because ``-p`` swaps + ``HERMES_HOME`` at process start. No explicit profile flag needed here. """ from tools.skills_hub import HubLockFile, ensure_hub_dirs from tools.skills_sync import _read_manifest @@ -1074,9 +1066,7 @@ def do_list(source_filter: str = "all", disabled_count += 1 status_cell = "[dim red]disabled[/]" - trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim") - trust_label = "official" if source_display == "official" else trust - table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]", status_cell) + table.add_row(name, category, source_display, _trust_cell(trust, source_display), status_cell) c.print(table) summary = f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local" @@ -1115,14 +1105,9 @@ def do_update(name: Optional[str] = None, console: Optional[Console] = None, force: bool = False) -> None: """Update hub-installed skills with upstream changes. - Skills whose on-disk content no longer matches the hash recorded at - install time have been edited locally; updating them would silently - destroy the user's work (``do_install(force=True)`` rmtree-replaces the - directory). Those are skipped by default and only overwritten when - ``force=True``. Mirrors the user-modified protection bundled skills - already get from ``hermes update`` (ported from - paperclipai/paperclip#10978's explicit-merge-mode rule: destructive - replacement must be an explicit caller choice, never a rerun default). + Skills whose on-disk content no longer matches the install-time hash were edited locally; + updating would rmtree-replace the user's work, so they are skipped unless ``force=True`` + (destructive replacement must be an explicit caller choice, never a rerun default). """ from tools.skills_hub import SKILLS_DIR, HubLockFile, check_for_skill_updates from tools.skills_guard import content_hash @@ -1184,9 +1169,8 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None, deep: bool = False) -> None: """Re-run security scan on installed hub skills. - When ``deep=True``, also runs an opt-in AST-level diagnostic on Python - files (review aid only — not a security gate; skills_guard.py verdicts - are unchanged). + When ``deep=True``, also runs an opt-in AST-level diagnostic on Python files (review aid only — + not a security gate; skills_guard.py verdicts are unchanged). """ from tools.skills_hub import HubLockFile, SKILLS_DIR from tools.skills_guard import scan_skill, format_scan_report @@ -1237,11 +1221,7 @@ def do_uninstall(name: str, console: Optional[Console] = None, # skip_confirm bypasses the prompt (needed in TUI mode where input() hangs) if not skip_confirm: c.print(f"\n[bold]Uninstall '{name}'?[/]") - try: - answer = input("Confirm [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer not in {"y", "yes"}: + if not _confirm(): c.print("[dim]Cancelled.[/]\n") return @@ -1249,14 +1229,9 @@ def do_uninstall(name: str, console: Optional[Console] = None, if success: c.print(f"[bold green]{msg}[/]\n") if invalidate_cache: - try: - from agent.prompt_builder import clear_skills_system_prompt_cache - clear_skills_system_prompt_cache(clear_snapshot=True) - except Exception: - pass + _clear_skills_cache() else: - c.print("[dim]Change will take effect in your next session.[/]") - c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n") + _print_next_session_notice(c) else: c.print(f"[bold red]Error:[/] {msg}\n") @@ -1273,11 +1248,7 @@ def do_reset(name: str, restore: bool = False, if not skip_confirm and restore: c.print(f"\n[bold]Restore '{name}' from bundled source?[/]") c.print("[dim]This will DELETE your current copy and re-copy the bundled version.[/]") - try: - answer = input("Confirm [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer not in {"y", "yes"}: + if not _confirm(): c.print("[dim]Cancelled.[/]\n") return @@ -1296,14 +1267,9 @@ def do_reset(name: str, restore: bool = False, c.print() if invalidate_cache: - try: - from agent.prompt_builder import clear_skills_system_prompt_cache - clear_skills_system_prompt_cache(clear_snapshot=True) - except Exception: - pass + _clear_skills_cache() else: - c.print("[dim]Change will take effect in your next session.[/]") - c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n") + _print_next_session_notice(c) def do_list_modified(console: Optional[Console] = None, @@ -1315,8 +1281,6 @@ def do_list_modified(console: Optional[Console] = None, modified = list_user_modified_bundled_skills() if as_json: - import json - c.print(json.dumps([m["name"] for m in modified])) return @@ -1379,10 +1343,9 @@ def do_opt_out(remove: bool = False, invalidate_cache: bool = True) -> None: """Opt the active profile out of bundled-skill seeding. - Always writes the .no-bundled-skills marker (stop future seeding). With - ``remove``, also deletes already-present bundled skills that are pristine - (manifest-tracked AND unmodified); user-edited and non-bundled skills are - never touched. + Always writes the .no-bundled-skills marker (stop future seeding). With ``remove``, also deletes + already-present bundled skills that are pristine (manifest-tracked AND unmodified); user-edited + and non-bundled skills are never touched. """ from tools.skills_sync import ( set_bundled_skills_opt_out, @@ -1421,11 +1384,7 @@ def do_opt_out(remove: bool = False, if not skip_confirm: c.print("[dim]This deletes the on-disk copies. User-edited and " "hub/local skills are NOT touched.[/]") - try: - answer = input("Confirm [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer not in {"y", "yes"}: + if not _confirm(): c.print("[dim]Marker kept; no skills deleted.[/]\n") return @@ -1436,21 +1395,13 @@ def do_opt_out(remove: bool = False, c.print() if invalidate_cache: - try: - from agent.prompt_builder import clear_skills_system_prompt_cache - clear_skills_system_prompt_cache(clear_snapshot=True) - except Exception: - pass + _clear_skills_cache() def do_opt_in(sync: bool = False, console: Optional[Console] = None, invalidate_cache: bool = True) -> None: - """Remove the opt-out marker so bundled-skill seeding resumes. - - With ``sync``, immediately re-seed bundled skills instead of waiting for - the next ``hermes update``. - """ + """Remove the opt-out marker so bundled-skill seeding resumes.""" from tools.skills_sync import set_bundled_skills_opt_out, sync_skills c = console or _console @@ -1466,11 +1417,7 @@ def do_opt_in(sync: bool = False, copied = len(synced.get("copied", [])) c.print(f"[dim]Re-seeded {copied} bundled skill(s).[/]") if invalidate_cache: - try: - from agent.prompt_builder import clear_skills_system_prompt_cache - clear_skills_system_prompt_cache(clear_snapshot=True) - except Exception: - pass + _clear_skills_cache() c.print() @@ -1485,11 +1432,7 @@ def do_repair_official(name: str, restore: bool = False, if restore and not skip_confirm: c.print(f"\n[bold]Restore official optional skill '{name}' from repo source?[/]") c.print("[dim]Existing matching active copies will be moved to a restore backup before copying the official source.[/]") - try: - answer = input("Confirm [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer not in {"y", "yes"}: + if not _confirm(): c.print("[dim]Cancelled.[/]\n") return @@ -1509,11 +1452,7 @@ def do_repair_official(name: str, restore: bool = False, c.print() if invalidate_cache: - try: - from agent.prompt_builder import clear_skills_system_prompt_cache - clear_skills_system_prompt_cache(clear_snapshot=True) - except Exception: - pass + _clear_skills_cache() def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> None: @@ -1581,7 +1520,6 @@ def do_publish(skill_path: str, target: str = "github", repo: str = "", skill_md = skill_md.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors) fm = {} if skill_md.startswith("---"): - import re match = re.search(r'\n---\s*\n', skill_md[3:]) if match: try: @@ -1739,9 +1677,7 @@ def do_snapshot_export(output_path: str, console: Optional[Console] = None) -> N snapshot = { "hermes_version": "0.1.0", - "exported_at": __import__("datetime").datetime.now( - __import__("datetime").timezone.utc - ).isoformat(), + "exported_at": datetime.now(timezone.utc).isoformat(), "skills": [ { "name": entry["name"], @@ -1757,7 +1693,6 @@ def do_snapshot_export(output_path: str, console: Optional[Console] = None) -> N payload = json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n" if output_path == "-": - import sys sys.stdout.write(payload) else: out = Path(output_path) @@ -1817,104 +1752,240 @@ def do_snapshot_import(input_path: str, force: bool = False, # CLI argparse entry point # --------------------------------------------------------------------------- +def _snapshot_cli(args) -> None: + snap_action = getattr(args, "snapshot_action", None) + if snap_action == "export": + do_snapshot_export(args.output) + elif snap_action == "import": + do_snapshot_import(args.input, force=getattr(args, "force", False)) + else: + _console.print("Usage: hermes skills snapshot [export|import]\n") + + +def _tap_cli(args) -> None: + tap_action = getattr(args, "tap_action", None) + repo = getattr(args, "repo", "") or getattr(args, "name", "") + if not tap_action: + _console.print("Usage: hermes skills tap [list|add|remove]\n") + return + do_tap(tap_action, repo=repo) + + +# `hermes skills <action>` -> handler(args). Lambdas late-bind the do_* names so +# tests that patch("hermes_cli.skills_hub.do_install") still intercept. +_CLI_ACTIONS = { + "browse": lambda a: do_browse(page=a.page, page_size=a.size, source=a.source), + "search": lambda a: do_search(a.query, source=a.source, limit=a.limit, + as_json=getattr(a, "json", False)), + "install": lambda a: do_install(a.identifier, category=a.category, force=a.force, + skip_confirm=getattr(a, "yes", False), + name_override=getattr(a, "name", "") or ""), + "inspect": lambda a: do_inspect(a.identifier), + "list": lambda a: do_list(source_filter=a.source, + enabled_only=getattr(a, "enabled_only", False)), + "check": lambda a: do_check(name=getattr(a, "name", None)), + "update": lambda a: do_update(name=getattr(a, "name", None), + force=getattr(a, "force", False)), + "audit": lambda a: do_audit(name=getattr(a, "name", None), + deep=getattr(a, "deep", False)), + "uninstall": lambda a: do_uninstall(a.name, skip_confirm=getattr(a, "yes", False)), + "reset": lambda a: do_reset(a.name, restore=getattr(a, "restore", False), + skip_confirm=getattr(a, "yes", False)), + "list-modified": lambda a: do_list_modified(as_json=getattr(a, "json", False)), + "diff": lambda a: do_diff(a.name), + "opt-out": lambda a: do_opt_out(remove=getattr(a, "remove", False), + skip_confirm=getattr(a, "yes", False)), + "opt-in": lambda a: do_opt_in(sync=getattr(a, "sync", False)), + "repair-official": lambda a: do_repair_official(a.name, restore=getattr(a, "restore", False), + skip_confirm=getattr(a, "yes", False)), + "publish": lambda a: do_publish(a.skill_path, target=getattr(a, "to", "github"), + repo=getattr(a, "repo", "")), + "snapshot": lambda a: _snapshot_cli(a), + "tap": lambda a: _tap_cli(a), +} + + def skills_command(args) -> None: """Router for `hermes skills <subcommand>` — called from hermes_cli/main.py.""" - action = getattr(args, "skills_action", None) - - if action == "browse": - do_browse(page=args.page, page_size=args.size, source=args.source) - elif action == "search": - do_search(args.query, source=args.source, limit=args.limit, - as_json=getattr(args, "json", False)) - elif action == "install": - do_install(args.identifier, category=args.category, force=args.force, - skip_confirm=getattr(args, "yes", False), - name_override=getattr(args, "name", "") or "") - elif action == "inspect": - do_inspect(args.identifier) - elif action == "list": - do_list( - source_filter=args.source, - enabled_only=getattr(args, "enabled_only", False), - ) - elif action == "check": - do_check(name=getattr(args, "name", None)) - elif action == "update": - do_update(name=getattr(args, "name", None), - force=getattr(args, "force", False)) - elif action == "audit": - do_audit(name=getattr(args, "name", None), - deep=getattr(args, "deep", False)) - elif action == "uninstall": - do_uninstall(args.name, skip_confirm=getattr(args, "yes", False)) - elif action == "reset": - do_reset(args.name, restore=getattr(args, "restore", False), - skip_confirm=getattr(args, "yes", False)) - elif action == "list-modified": - do_list_modified(as_json=getattr(args, "json", False)) - elif action == "diff": - do_diff(args.name) - elif action == "opt-out": - do_opt_out(remove=getattr(args, "remove", False), - skip_confirm=getattr(args, "yes", False)) - elif action == "opt-in": - do_opt_in(sync=getattr(args, "sync", False)) - elif action == "repair-official": - do_repair_official(args.name, restore=getattr(args, "restore", False), - skip_confirm=getattr(args, "yes", False)) - elif action == "publish": - do_publish( - args.skill_path, - target=getattr(args, "to", "github"), - repo=getattr(args, "repo", ""), - ) - elif action == "snapshot": - snap_action = getattr(args, "snapshot_action", None) - if snap_action == "export": - do_snapshot_export(args.output) - elif snap_action == "import": - do_snapshot_import(args.input, force=getattr(args, "force", False)) - else: - _console.print("Usage: hermes skills snapshot [export|import]\n") - elif action == "tap": - tap_action = getattr(args, "tap_action", None) - repo = getattr(args, "repo", "") or getattr(args, "name", "") - if not tap_action: - _console.print("Usage: hermes skills tap [list|add|remove]\n") - return - do_tap(tap_action, repo=repo) - else: + handler = _CLI_ACTIONS.get(getattr(args, "skills_action", None)) + if handler is None: _console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n") _console.print("Run 'hermes skills <command> --help' for details.\n") + return + handler(args) # --------------------------------------------------------------------------- # Slash command entry point (/skills in chat) # --------------------------------------------------------------------------- -def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: - """ - Parse and dispatch `/skills <subcommand> [args]` from the chat interface. +def _opt_value(args: List[str], flag: str, default: str, last: bool = False) -> str: + """Value following `flag` in args (default if absent/trailing). - Examples: - /skills search kubernetes - /skills install openai/skills/skill-creator - /skills install openai/skills/skill-creator --force - /skills install https://example.com/path/SKILL.md - /skills inspect openai/skills/skill-creator - /skills list - /skills list --source hub - /skills check - /skills update - /skills audit - /skills audit my-skill - /skills audit --deep - /skills audit my-skill --deep - /skills uninstall my-skill - /skills tap list - /skills tap add owner/repo - /skills tap remove owner/repo + `last=True` lets a repeated flag's final occurrence win (the historical + behaviour of the install/publish/browse parsers); otherwise the first wins. """ + found = default + for i, a in enumerate(args): + if a == flag and i + 1 < len(args): + found = args[i + 1] + if not last: + break + return found + + +def _opt_int(args: List[str], flag: str, default: int) -> int: + """Like _opt_value(last=True) but int-parsed; a non-integer keeps the default.""" + try: + return int(_opt_value(args, flag, str(default), last=True)) + except ValueError: + return default + + +def _slash_browse(args, c): + do_browse(page=_opt_int(args, "--page", 1), page_size=_opt_int(args, "--size", 20), + source=_opt_value(args, "--source", "all", last=True), console=c) + + +def _slash_search(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|github|official|nvidia|openai|anthropic|huggingface] [--limit N] [--json]\n") + return + source = "all" + limit = 25 + as_json = False + query_parts = [] + i = 0 + while i < len(args): + if args[i] == "--source" and i + 1 < len(args): + source = args[i + 1] + i += 2 + elif args[i] == "--limit" and i + 1 < len(args): + try: + limit = int(args[i + 1]) + except ValueError: + pass + i += 2 + elif args[i] == "--json": + as_json = True + i += 1 + else: + query_parts.append(args[i]) + i += 1 + do_search(" ".join(query_parts), source=source, limit=limit, + console=c, as_json=as_json) + + +def _slash_install(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills install <identifier-or-url> [--name <name>] [--category <cat>] [--force] [--now]\n") + return + # Slash commands run inside prompt_toolkit where input() hangs, so + # confirmation is always skipped — typing the command is implicit consent. + # --now invalidates prompt cache immediately (costs more money); + # default defers to next session to preserve cache. + do_install(args[0], category=_opt_value(args, "--category", "", last=True), + force="--force" in args, skip_confirm=True, invalidate_cache="--now" in args, + name_override=_opt_value(args, "--name", "", last=True), console=c) + + +def _slash_inspect(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills inspect <identifier>\n") + return + do_inspect(args[0], console=c) + + +def _slash_list(args, c): + do_list(source_filter=_opt_value(args, "--source", "all"), + enabled_only="--enabled-only" in args or "--enabled" in args, console=c) + + +def _slash_update(args, c): + pos = [a for a in args if not a.startswith("--")] + do_update(name=pos[0] if pos else None, console=c, force="--force" in args) + + +def _slash_audit(args, c): + name = args[0] if args and not args[0].startswith("--") else None + do_audit(name=name, console=c, deep="--deep" in args) + + +def _slash_uninstall(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills uninstall <name> [--now]\n") + return + # Slash commands run inside prompt_toolkit where input() hangs. + do_uninstall(args[0], console=c, skip_confirm=True, invalidate_cache="--now" in args) + + +def _slash_reset(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills reset <name> [--restore] [--now]\n") + c.print("[dim]Clears the bundled-skills manifest entry so future updates stop marking it as user-modified.[/]") + c.print("[dim]Pass --restore to also replace the current copy with the bundled version.[/]\n") + return + # Slash commands can't prompt — --restore in slash mode is implicit consent. + do_reset(args[0], restore="--restore" in args, console=c, skip_confirm=True, + invalidate_cache="--now" in args) + + +def _slash_diff(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills diff <name>\n") + return + do_diff(args[0], console=c) + + +def _slash_publish(args, c): + if not args: + c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n") + return + do_publish(args[0], target=_opt_value(args, "--to", "github", last=True), + repo=_opt_value(args, "--repo", "", last=True), console=c) + + +def _slash_snapshot(args, c): + if args and args[0] == "export" and len(args) > 1: + do_snapshot_export(args[1], console=c) + elif args and args[0] == "import" and len(args) > 1: + do_snapshot_import(args[1], force="--force" in args, console=c) + else: + c.print("[bold red]Usage:[/] /skills snapshot export <file> | /skills snapshot import <file>\n") + + +def _slash_tap(args, c): + if not args: + do_tap("list", console=c) + return + do_tap(args[0], repo=args[1] if len(args) > 1 else "", console=c) + + +_SLASH_ACTIONS = { + "browse": _slash_browse, + "search": _slash_search, + "install": _slash_install, + "inspect": _slash_inspect, + "list": _slash_list, + "check": lambda args, c: do_check(name=args[0] if args else None, console=c), + "update": _slash_update, + "audit": _slash_audit, + "uninstall": _slash_uninstall, + "reset": _slash_reset, + "list-modified": lambda args, c: do_list_modified(console=c, as_json="--json" in args), + "modified": lambda args, c: do_list_modified(console=c, as_json="--json" in args), + "diff": _slash_diff, + "publish": _slash_publish, + "snapshot": _slash_snapshot, + "tap": _slash_tap, + "help": lambda args, c: _print_skills_help(c), + "--help": lambda args, c: _print_skills_help(c), + "-h": lambda args, c: _print_skills_help(c), +} + + +def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: + """Parse and dispatch `/skills <subcommand> [args]` from the chat interface.""" c = console or _console parts = cmd.strip().split() @@ -1927,187 +1998,12 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: return action = parts[0].lower() - args = parts[1:] - - if action == "browse": - page = 1 - page_size = 20 - source = "all" - i = 0 - while i < len(args): - if args[i] == "--page" and i + 1 < len(args): - try: - page = int(args[i + 1]) - except ValueError: - pass - i += 2 - elif args[i] == "--size" and i + 1 < len(args): - try: - page_size = int(args[i + 1]) - except ValueError: - pass - i += 2 - elif args[i] == "--source" and i + 1 < len(args): - source = args[i + 1] - i += 2 - else: - i += 1 - do_browse(page=page, page_size=page_size, source=source, console=c) - - elif action == "search": - if not args: - c.print("[bold red]Usage:[/] /skills search <query> [--source skills-sh|github|official|nvidia|openai|anthropic|huggingface] [--limit N] [--json]\n") - return - source = "all" - limit = 25 - as_json = False - query_parts = [] - i = 0 - while i < len(args): - if args[i] == "--source" and i + 1 < len(args): - source = args[i + 1] - i += 2 - elif args[i] == "--limit" and i + 1 < len(args): - try: - limit = int(args[i + 1]) - except ValueError: - pass - i += 2 - elif args[i] == "--json": - as_json = True - i += 1 - else: - query_parts.append(args[i]) - i += 1 - do_search(" ".join(query_parts), source=source, limit=limit, - console=c, as_json=as_json) - - elif action == "install": - if not args: - c.print("[bold red]Usage:[/] /skills install <identifier-or-url> [--name <name>] [--category <cat>] [--force] [--now]\n") - return - identifier = args[0] - category = "" - name_override = "" - # Slash commands run inside prompt_toolkit where input() hangs. - # Always skip confirmation — the user typing the command is implicit consent. - skip_confirm = True - force = "--force" in args - # --now invalidates prompt cache immediately (costs more money). - # Default: defer to next session to preserve cache. - invalidate_cache = "--now" in args - for i, a in enumerate(args): - if a == "--category" and i + 1 < len(args): - category = args[i + 1] - elif a == "--name" and i + 1 < len(args): - name_override = args[i + 1] - do_install(identifier, category=category, force=force, - skip_confirm=skip_confirm, invalidate_cache=invalidate_cache, - name_override=name_override, console=c) - - elif action == "inspect": - if not args: - c.print("[bold red]Usage:[/] /skills inspect <identifier>\n") - return - do_inspect(args[0], console=c) - - elif action == "list": - source_filter = "all" - enabled_only = "--enabled-only" in args or "--enabled" in args - if "--source" in args: - idx = args.index("--source") - if idx + 1 < len(args): - source_filter = args[idx + 1] - do_list(source_filter=source_filter, enabled_only=enabled_only, console=c) - - elif action == "check": - name = args[0] if args else None - do_check(name=name, console=c) - - elif action == "update": - force = "--force" in args - pos = [a for a in args if not a.startswith("--")] - name = pos[0] if pos else None - do_update(name=name, console=c, force=force) - - elif action == "audit": - name = args[0] if args and not args[0].startswith("--") else None - deep = "--deep" in args - do_audit(name=name, console=c, deep=deep) - - elif action == "uninstall": - if not args: - c.print("[bold red]Usage:[/] /skills uninstall <name> [--now]\n") - return - # Slash commands run inside prompt_toolkit where input() hangs. - skip_confirm = True - invalidate_cache = "--now" in args - do_uninstall(args[0], console=c, skip_confirm=skip_confirm, - invalidate_cache=invalidate_cache) - - elif action == "reset": - if not args: - c.print("[bold red]Usage:[/] /skills reset <name> [--restore] [--now]\n") - c.print("[dim]Clears the bundled-skills manifest entry so future updates stop marking it as user-modified.[/]") - c.print("[dim]Pass --restore to also replace the current copy with the bundled version.[/]\n") - return - name = args[0] - restore = "--restore" in args - invalidate_cache = "--now" in args - # Slash commands can't prompt — --restore in slash mode is implicit consent. - do_reset(name, restore=restore, console=c, skip_confirm=True, - invalidate_cache=invalidate_cache) - - elif action in {"list-modified", "modified"}: - do_list_modified(console=c, as_json="--json" in args) - - elif action == "diff": - if not args: - c.print("[bold red]Usage:[/] /skills diff <name>\n") - return - do_diff(args[0], console=c) - - elif action == "publish": - if not args: - c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n") - return - skill_path = args[0] - target = "github" - repo = "" - for i, a in enumerate(args): - if a == "--to" and i + 1 < len(args): - target = args[i + 1] - if a == "--repo" and i + 1 < len(args): - repo = args[i + 1] - do_publish(skill_path, target=target, repo=repo, console=c) - - elif action == "snapshot": - if not args: - c.print("[bold red]Usage:[/] /skills snapshot export <file> | /skills snapshot import <file>\n") - return - snap_action = args[0] - if snap_action == "export" and len(args) > 1: - do_snapshot_export(args[1], console=c) - elif snap_action == "import" and len(args) > 1: - force = "--force" in args - do_snapshot_import(args[1], force=force, console=c) - else: - c.print("[bold red]Usage:[/] /skills snapshot export <file> | /skills snapshot import <file>\n") - - elif action == "tap": - if not args: - do_tap("list", console=c) - return - tap_action = args[0] - repo = args[1] if len(args) > 1 else "" - do_tap(tap_action, repo=repo, console=c) - - elif action in {"help", "--help", "-h"}: - _print_skills_help(c) - - else: + handler = _SLASH_ACTIONS.get(action) + if handler is None: c.print(f"[bold red]Unknown action:[/] {action}") _print_skills_help(c) + return + handler(parts[1:], c) def _print_skills_help(console: Console) -> None: diff --git a/hermes_cli/skin_cmd.py b/hermes_cli/skin_cmd.py index bde243bc48..f7606072a5 100644 --- a/hermes_cli/skin_cmd.py +++ b/hermes_cli/skin_cmd.py @@ -1,11 +1,8 @@ """``hermes skin`` — list, switch, and tweak skins from the CLI. -``set`` is the load-bearing verb: it changes ONE color of the ACTIVE skin **in -place**, so tweaking (say) the tool marker never disturbs the rest of the look — -background included. Editing the file bumps its mtime; the gateway's skin watcher -repaints every live surface within ~a second. A built-in skin (no file) is forked -into an editable copy that carries its full palette, so the current look is -preserved and only the one key changes. +``set`` is the load-bearing verb: it changes ONE color of the ACTIVE skin **in place**, so tweaking +(say) the tool marker never disturbs the rest of the look — background included. Editing the file +bumps its mtime; the gateway's skin watcher repaints every live surface within ~a second. """ from __future__ import annotations diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index a2a093b7dc..92f6c6be03 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -1,145 +1,4 @@ -"""Hermes skin/theme engine — the theme SDK for every surface. - -A data-driven skin system that lets users (and Hermes itself) customize the -visual appearance across the CLI, the TUI, and the desktop GUI from a single -file. Skins are defined as YAML files in ~/.hermes/skins/ or as built-in presets. -No code changes are needed to add a new skin. - -This module is the source of truth: it resolves the active skin, and the gateway -pushes the resolved palette to the TUI and desktop (see tui_gateway's -``resolve_skin`` / ``skin.changed``). A skin dropped in ~/.hermes/skins/ therefore -themes all three surfaces at once — the theme analogue of the plugin SDK. - -SKIN YAML SCHEMA -================ - -All fields are optional. Missing values inherit from the ``default`` skin. - -.. code-block:: yaml - - # Required: skin identity - name: mytheme # Unique skin name (lowercase, hyphens ok) - description: Short description # Shown in /skin listing - - # Colors: hex values for Rich markup (banner, UI, response box) - colors: - background: "#0e0e12" # App/base surface — the seed the TUI - # status bar and the desktop GUI derive - # their whole palette from (see below). - banner_border: "#CD7F32" # Panel border color - banner_title: "#FFD700" # Panel title text color - banner_accent: "#FFBF00" # Section headers (Available Tools, etc.) - banner_dim: "#B8860B" # Dim/muted text (separators, labels) - banner_text: "#FFF8DC" # Body text (tool names, skill names) - ui_accent: "#FFBF00" # General UI accent - ui_label: "#DAA520" # UI labels (warm gold; teal clashed w/ default banner gold) - ui_ok: "#4caf50" # Success indicators - ui_error: "#ef5350" # Error indicators - ui_warn: "#ffa726" # Warning indicators - ui_tool: "#FFBF00" # Tool-call markers (● / spinner); falls back to ui_accent - ui_thinking: "#CC9B1F" # Reasoning/thinking text; falls back to banner_dim - diff_added: "#dcffdc" # Diff added-line background (TUI) - diff_removed: "#ffdcdc" # Diff removed-line background - diff_added_word: "#248a3d" # Diff added word-level foreground - diff_removed_word: "#cf222e" # Diff removed word-level foreground - syntax_string: "#FFBF00" # Code strings; falls back to ui_accent - syntax_number: "#FFF8DC" # Code numbers; falls back to ui_text - syntax_keyword: "#CD7F32" # Code keywords; falls back to ui_border - syntax_comment: "#CC9B1F" # Code comments; falls back to banner_dim - prompt: "#FFF8DC" # Prompt text color - input_rule: "#CD7F32" # Input area horizontal rule - response_border: "#FFD700" # Response box border (ANSI) - status_bar_bg: "#1a1a2e" # Status bar background - status_bar_text: "#C0C0C0" # Status bar default text - status_bar_strong: "#FFD700" # Status bar highlighted text - status_bar_dim: "#8B8682" # Status bar separators/muted text - status_bar_good: "#8FBC8F" # Healthy context usage - status_bar_warn: "#FFD700" # Warning context usage - status_bar_bad: "#FF8C00" # High context usage - status_bar_critical: "#FF6B6B" # Critical context usage - session_label: "#DAA520" # Session label color - session_border: "#8B8682" # Session ID dim color - status_bar_bg: "#1a1a2e" # TUI status/usage bar background - voice_status_bg: "#1a1a2e" # TUI voice status background - selection_bg: "#333355" # TUI mouse-selection highlight background - completion_menu_bg: "#1a1a2e" # Completion menu background - completion_menu_current_bg: "#333355" # Active completion row background - completion_menu_meta_bg: "#1a1a2e" # Completion meta column background - completion_menu_meta_current_bg: "#333355" # Active completion meta background - - # Optional paired palette for the opposite terminal polarity (mirrors the - # desktop app's colors/darkColors pairing). If `colors` above is authored - # for dark terminals, `light_colors` supplies the hand-tuned light-terminal - # variant (same keys); light-authored skins supply `dark_colors` instead. - # Without a paired block, the TUI adapts `colors` automatically - # (contrast-clamped foregrounds, polarity-corrected fills). - light_colors: - banner_title: "#8B6914" - # ... same keys as `colors` ... - - # Spinner: customize the animated spinner during API calls - spinner: - waiting_faces: # Faces shown while waiting for API - - "(⚔)" - - "(⛨)" - thinking_faces: # Faces shown during reasoning - - "(⌁)" - - "(<>)" - thinking_verbs: # Verbs for spinner messages - - "forging" - - "plotting" - wings: # Optional left/right spinner decorations - - ["⟪⚔", "⚔⟫"] # Each entry is [left, right] pair - - ["⟪▲", "▲⟫"] - - # Branding: text strings used throughout the CLI - branding: - agent_name: "Hermes Agent" # Banner title, status display - welcome: "Welcome message" # Shown at CLI startup - goodbye: "Goodbye! ⚕" # Shown on exit - response_label: " ⚕ Hermes " # Response box header label - prompt_symbol: "❯" # Input prompt symbol (bare token; renderers add trailing space) - help_header: "(^_^)? Commands" # /help header text - - # Tool prefix: character for tool output lines (default: ┊) - tool_prefix: "┊" - - # Tool emojis: override the default emoji for any tool (used in spinners & progress) - tool_emojis: - terminal: "⚔" # Override terminal tool emoji - web_search: "🔮" # Override web_search tool emoji - # Any tool not listed here uses its registry default - -USAGE -===== - -.. code-block:: python - - from hermes_cli.skin_engine import get_active_skin, list_skins, set_active_skin - - skin = get_active_skin() - print(skin.colors["banner_title"]) # "#FFD700" - print(skin.get_branding("agent_name")) # "Hermes Agent" - - set_active_skin("ares") # Switch to built-in ares skin - set_active_skin("mytheme") # Switch to user skin from ~/.hermes/skins/ - -BUILT-IN SKINS -============== - -- ``default`` — Classic Hermes gold/kawaii (the current look) -- ``ares`` — Crimson/bronze war-god theme with custom spinner wings -- ``mono`` — Clean grayscale monochrome -- ``slate`` — Cool blue developer-focused theme -- ``daylight`` — Light background theme with dark text and blue accents -- ``warm-lightmode`` — Warm brown/gold text for light terminal backgrounds - -USER SKINS -========== - -Drop a YAML file in ``~/.hermes/skins/<name>.yaml`` following the schema above. -Activate with ``/skin <name>`` in the CLI or ``display.skin: <name>`` in config.yaml. -""" +"""Hermes skin/theme engine — the theme SDK for every surface.""" import logging from dataclasses import dataclass, field @@ -182,12 +41,11 @@ class SkinConfig: def get_spinner_wings(self) -> List[Tuple[str, str]]: """Get spinner wing pairs, or empty list if none.""" - raw = self.spinner.get("wings", []) - result = [] - for pair in raw: - if isinstance(pair, (list, tuple)) and len(pair) == 2: - result.append((str(pair[0]), str(pair[1]))) - return result + return [ + (str(pair[0]), str(pair[1])) + for pair in self.spinner.get("wings", []) + if isinstance(pair, (list, tuple)) and len(pair) == 2 + ] def get_branding(self, key: str, fallback: str = "") -> str: """Get a branding value with fallback.""" @@ -198,6 +56,16 @@ class SkinConfig: # Built-in skin definitions # ============================================================================= +# Branding shared by every Hermes-named built-in (mono/daylight override help_header). +_HERMES_BRANDING: Dict[str, str] = { + "agent_name": "Hermes Agent", + "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", + "goodbye": "Goodbye! ⚕", + "response_label": " ⚕ Hermes ", + "prompt_symbol": "❯", + "help_header": "(^_^)? Available Commands", +} + _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "default": { "name": "default", @@ -270,17 +138,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "status_bar_bg": "#F5F5F5", "voice_status_bg": "#F5F5F5", }, - "spinner": { - # Empty = use hardcoded defaults in display.py - }, - "branding": { - "agent_name": "Hermes Agent", - "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", - "goodbye": "Goodbye! ⚕", - "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯", - "help_header": "(^_^)? Available Commands", - }, + "spinner": {}, # empty = hardcoded defaults in display.py + "branding": _HERMES_BRANDING, "tool_prefix": "┊", }, "ares": { @@ -323,12 +182,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "forging", "marching", "sizing the field", "holding the line", "hammering plans", "tempering steel", "plotting impact", "raising the shield", ], - "wings": [ - ["⟪⚔", "⚔⟫"], - ["⟪▲", "▲⟫"], - ["⟪╸", "╺⟫"], - ["⟪⛨", "⛨⟫"], - ], + "wings": [["⟪⚔", "⚔⟫"], ["⟪▲", "▲⟫"], ["⟪╸", "╺⟫"], ["⟪⛨", "⛨⟫"]], }, "branding": { "agent_name": "Ares Agent", @@ -394,14 +248,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "voice_status_bg": "#1F1F1F", }, "spinner": {}, - "branding": { - "agent_name": "Hermes Agent", - "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", - "goodbye": "Goodbye! ⚕", - "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯", - "help_header": "[?] Available Commands", - }, + "branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"}, "tool_prefix": "┊", }, "slate": { @@ -438,14 +285,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "voice_status_bg": "#151C2F", }, "spinner": {}, - "branding": { - "agent_name": "Hermes Agent", - "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", - "goodbye": "Goodbye! ⚕", - "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯", - "help_header": "(^_^)? Available Commands", - }, + "branding": _HERMES_BRANDING, "tool_prefix": "┊", }, "daylight": { @@ -484,14 +324,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "voice_status_bg": "#E5EDF8", }, "spinner": {}, - "branding": { - "agent_name": "Hermes Agent", - "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", - "goodbye": "Goodbye! ⚕", - "response_label": " ⚕ Hermes ", - "prompt_symbol": "❯", - "help_header": "[?] Available Commands", - }, + "branding": {**_HERMES_BRANDING, "help_header": "[?] Available Commands"}, "tool_prefix": "│", }, "warm-lightmode": { @@ -530,15 +363,8 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "voice_status_bg": "#F5F0E8", }, "spinner": {}, - "branding": { - "agent_name": "Hermes Agent", - "welcome": "Welcome to Hermes Agent! Type your message or /help for commands.", - "goodbye": "Goodbye! \u2695", - "response_label": " \u2695 Hermes ", - "prompt_symbol": "\u276f", - "help_header": "(^_^)? Available Commands", - }, - "tool_prefix": "\u250a", + "branding": _HERMES_BRANDING, + "tool_prefix": "┊", }, "poseidon": { "name": "poseidon", @@ -581,12 +407,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "steering the trident", "tracking undertow", "plotting sea lanes", "calling the swell", "measuring pressure", ], - "wings": [ - ["⟪≈", "≈⟫"], - ["⟪Ψ", "Ψ⟫"], - ["⟪∿", "∿⟫"], - ["⟪◌", "◌⟫"], - ], + "wings": [["⟪≈", "≈⟫"], ["⟪Ψ", "Ψ⟫"], ["⟪∿", "∿⟫"], ["⟪◌", "◌⟫"]], }, "branding": { "agent_name": "Poseidon Agent", @@ -658,12 +479,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "counting the ascent", "testing leverage", "setting the shoulder", "pushing uphill", "enduring the loop", ], - "wings": [ - ["⟪◉", "◉⟫"], - ["⟪◬", "◬⟫"], - ["⟪◌", "◌⟫"], - ["⟪⬤", "⬤⟫"], - ], + "wings": [["⟪◉", "◉⟫"], ["⟪◬", "◬⟫"], ["⟪◌", "◌⟫"], ["⟪⬤", "⬤⟫"]], }, "branding": { "agent_name": "Sisyphus Agent", @@ -738,12 +554,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "tracking ember fall", "setting wing angle", "holding the flame core", "plotting a hot landing", "coiling for lift", ], - "wings": [ - ["⟪✦", "✦⟫"], - ["⟪▲", "▲⟫"], - ["⟪◌", "◌⟫"], - ["⟪◇", "◇⟫"], - ], + "wings": [["⟪✦", "✦⟫"], ["⟪▲", "▲⟫"], ["⟪◌", "◌⟫"], ["⟪◇", "◇⟫"]], }, "branding": { "agent_name": "Charizard Agent", @@ -823,54 +634,41 @@ def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: # Start with default values as base for missing keys default = _BUILTIN_SKINS["default"] skin_name = str(data.get("name", "unknown")) - color_overrides = _mapping_or_empty(data.get("colors"), section="colors", skin_name=skin_name) - spinner_overrides = _mapping_or_empty(data.get("spinner"), section="spinner", skin_name=skin_name) - branding_overrides = _mapping_or_empty(data.get("branding"), section="branding", skin_name=skin_name) - emoji_overrides = _mapping_or_empty(data.get("tool_emojis"), section="tool_emojis", skin_name=skin_name) - colors = dict(default.get("colors", {})) - colors.update(color_overrides) - spinner = dict(default.get("spinner", {})) - spinner.update(spinner_overrides) - branding = dict(default.get("branding", {})) - branding.update(branding_overrides) + def section(key: str) -> Dict[str, Any]: + return _mapping_or_empty(data.get(key), section=key, skin_name=skin_name) + def merged(key: str) -> Dict[str, Any]: + return {**default.get(key, {}), **section(key)} + + colors, spinner, branding = merged("colors"), merged("spinner"), merged("branding") + tool_emojis = section("tool_emojis") # Paired palettes are NOT merged over the default skin's blocks: an empty # block means "this skin has no hand-tuned variant for that polarity", and # consumers (the TUI) fall back to `colors` + automatic adaptation. Merging # the default's gold light palette under a crimson skin would be worse # than adapting the crimson. - light_colors = _mapping_or_empty(data.get("light_colors"), section="light_colors", skin_name=skin_name) - dark_colors = _mapping_or_empty(data.get("dark_colors"), section="dark_colors", skin_name=skin_name) - return SkinConfig( name=skin_name, description=data.get("description", ""), colors=colors, - light_colors=light_colors, - dark_colors=dark_colors, + light_colors=section("light_colors"), + dark_colors=section("dark_colors"), spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), - tool_emojis=emoji_overrides, + tool_emojis=tool_emojis, banner_logo=data.get("banner_logo", ""), banner_hero=data.get("banner_hero", ""), ) def list_skins() -> List[Dict[str, str]]: - """List all available skins (built-in + user-installed). - - Returns list of {"name": ..., "description": ..., "source": "builtin"|"user"}. - """ - result = [] - for name, data in _BUILTIN_SKINS.items(): - result.append({ - "name": name, - "description": data.get("description", ""), - "source": "builtin", - }) - + """List all available skins (built-in + user-installed).""" + result = [ + {"name": name, "description": data.get("description", ""), "source": "builtin"} + for name, data in _BUILTIN_SKINS.items() + ] skins_path = _skins_dir() if skins_path.is_dir(): for f in sorted(skins_path.glob("*.yaml")): @@ -880,12 +678,7 @@ def list_skins() -> List[Dict[str, str]]: # Skip if it shadows a built-in if any(s["name"] == skin_name for s in result): continue - result.append({ - "name": skin_name, - "description": data.get("description", ""), - "source": "user", - }) - + result.append({"name": skin_name, "description": data.get("description", ""), "source": "user"}) return result @@ -930,18 +723,10 @@ def get_active_skin_name() -> str: def init_skin_from_config(config: dict) -> None: - """Initialize the active skin from CLI config at startup. - - Call this once during CLI init with the loaded config dict. - """ + """Initialize the active skin from CLI config at startup.""" display = config.get("display") or {} - if not isinstance(display, dict): - display = {} - skin_name = display.get("skin", "default") - if isinstance(skin_name, str) and skin_name.strip(): - set_active_skin(skin_name.strip()) - else: - set_active_skin("default") + skin_name = display.get("skin", "default") if isinstance(display, dict) else "default" + set_active_skin(skin_name.strip() if isinstance(skin_name, str) and skin_name.strip() else "default") # ============================================================================= @@ -949,48 +734,35 @@ def init_skin_from_config(config: dict) -> None: # ============================================================================= +def _active_branding(key: str, fallback: str) -> str: + try: + return get_active_skin().get_branding(key, fallback) + except Exception: + return fallback + + def get_active_prompt_symbol(fallback: str = "❯") -> str: """Return the interactive prompt symbol with a single trailing space. - Skins store ``prompt_symbol`` as a bare token (no spaces). The trailing - space is appended here so callers can drop it straight into a rendered - prompt without hand-rolling whitespace. + Skins store ``prompt_symbol`` as a bare token; the space is appended here so callers can drop it + straight into a rendered prompt. """ - try: - raw = get_active_skin().get_branding("prompt_symbol", fallback) - except Exception: - raw = fallback - - cleaned = (raw or fallback).strip() - + cleaned = (_active_branding("prompt_symbol", fallback) or fallback).strip() return f"{cleaned or fallback.strip()} " - def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str: """Get the /help header from the active skin.""" - try: - return get_active_skin().get_branding("help_header", fallback) - except Exception: - return fallback - + return _active_branding("help_header", fallback) def get_active_goodbye(fallback: str = "Goodbye! ⚕") -> str: """Get the goodbye line from the active skin.""" - try: - return get_active_skin().get_branding("goodbye", fallback) - except Exception: - return fallback - + return _active_branding("goodbye", fallback) def get_prompt_toolkit_style_overrides() -> Dict[str, str]: - """Return prompt_toolkit style overrides derived from the active skin. - - These are layered on top of the CLI's base TUI style so /skin can refresh - the live prompt_toolkit UI immediately without rebuilding the app. - """ + """Return prompt_toolkit style overrides derived from the active skin.""" try: skin = get_active_skin() except Exception: @@ -1000,27 +772,28 @@ def get_prompt_toolkit_style_overrides() -> Dict[str, str]: # the terminal's foreground color (readable in both light and dark # color schemes). Skins can opt into a colored prompt by setting # `prompt` explicitly in their YAML. - prompt = skin.get_color("prompt", "") - input_rule = skin.get_color("input_rule", "#CD7F32") - title = skin.get_color("banner_title", "#FFD700") - text = skin.get_color("banner_text", "#FFF8DC") - dim = skin.get_color("banner_dim", "#555555") - label = skin.get_color("ui_label", title) - warn = skin.get_color("ui_warn", "#FF8C00") - error = skin.get_color("ui_error", "#FF6B6B") - status_bg = skin.get_color("status_bar_bg", "#1a1a2e") - status_text = skin.get_color("status_bar_text", text) - status_strong = skin.get_color("status_bar_strong", title) - status_dim = skin.get_color("status_bar_dim", dim) - status_good = skin.get_color("status_bar_good", skin.get_color("ui_ok", "#8FBC8F")) - status_warn = skin.get_color("status_bar_warn", warn) - status_bad = skin.get_color("status_bar_bad", skin.get_color("banner_accent", warn)) - status_critical = skin.get_color("status_bar_critical", error) - voice_bg = skin.get_color("voice_status_bg", status_bg) - menu_bg = skin.get_color("completion_menu_bg", "#1a1a2e") - menu_current_bg = skin.get_color("completion_menu_current_bg", "#333355") - menu_meta_bg = skin.get_color("completion_menu_meta_bg", menu_bg) - menu_meta_current_bg = skin.get_color("completion_menu_meta_current_bg", menu_current_bg) + c = skin.get_color + prompt = c("prompt", "") + input_rule = c("input_rule", "#CD7F32") + title = c("banner_title", "#FFD700") + text = c("banner_text", "#FFF8DC") + dim = c("banner_dim", "#555555") + label = c("ui_label", title) + warn = c("ui_warn", "#FF8C00") + error = c("ui_error", "#FF6B6B") + status_bg = c("status_bar_bg", "#1a1a2e") + status_text = c("status_bar_text", text) + status_strong = c("status_bar_strong", title) + status_dim = c("status_bar_dim", dim) + status_good = c("status_bar_good", c("ui_ok", "#8FBC8F")) + status_warn = c("status_bar_warn", warn) + status_bad = c("status_bar_bad", c("banner_accent", warn)) + status_critical = c("status_bar_critical", error) + voice_bg = c("voice_status_bg", status_bg) + menu_bg = c("completion_menu_bg", "#1a1a2e") + menu_current_bg = c("completion_menu_current_bg", "#333355") + menu_meta_bg = c("completion_menu_meta_bg", menu_bg) + menu_meta_current_bg = c("completion_menu_meta_current_bg", menu_current_bg) return { # Typed input always uses terminal default fg/bg so it's diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index 584a991a75..df6c7f80cc 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -1,19 +1,8 @@ """``hermes slack ...`` CLI subcommands. -Today only ``hermes slack manifest`` is implemented — it generates the -Slack app manifest JSON for registering every gateway command as a native -Slack slash (``/btw``, ``/stop``, ``/model``, …) so users get the same -first-class slash UX Discord and Telegram already have. - -Typical workflow:: - - $ hermes slack manifest > slack-manifest.json - # or: - $ hermes slack manifest --write - -Then paste the printed JSON into the Slack app config (Features → App -Manifest → Edit) and click Save. Slack diffs the manifest and prompts -for reinstall when scopes/commands change. +Today only ``hermes slack manifest`` is implemented — it generates the Slack app manifest JSON for +registering every gateway command as a native Slack slash (``/btw``, ``/stop``, ``/model``, …) so +users get the same first-class slash UX Discord and Telegram already have. """ from __future__ import annotations @@ -36,19 +25,9 @@ def _build_full_manifest( ) -> dict: """Build a full Slack manifest merging display info + our slash list. - The slash-command list is always generated from ``COMMAND_REGISTRY`` so - it stays in sync with the rest of Hermes. Other manifest sections - (display info, OAuth scopes, socket mode) are set to sensible defaults - for a Hermes deployment — users can tweak them in the Slack UI after - pasting. - - By default, this keeps Hermes on Slack's older Assistant messaging - experience (``assistant_view``) for backward compatibility. Pass - ``messaging_experience="agent"`` (``--agent-view``) to emit Slack's Agent - messaging experience (``agent_view`` + ``app_home_opened``). Pass - ``include_assistant=False`` or ``messaging_experience="none"`` - (``--no-assistant``) to omit Slack AI messaging features and get a flat DM - surface where ``/help``, ``/new``, etc. work inline. + The slash-command list is always generated from ``COMMAND_REGISTRY`` so it stays in sync with + the rest of Hermes. Other manifest sections (display info, OAuth scopes, socket mode) are set to + sensible defaults for a Hermes deployment — users can tweak them in the Slack UI after pasting. """ from hermes_cli.commands import slack_app_manifest @@ -166,22 +145,14 @@ def _build_full_manifest( def slack_manifest_command(args) -> int: """Print or write a Slack app manifest JSON. - Flags (all parsed in ``hermes_cli/main.py``): - --write [PATH] Write to file instead of stdout (default path: - ``$HERMES_HOME/slack-manifest.json``) - --name NAME Override the bot display name (default: "Hermes") - --description DESC Override the bot description - --long-description TEXT Override the long app description (175-4,000 characters) - --long-description-file PATH Read the long app description from a UTF-8 file - --slashes-only Emit only the ``features.slash_commands`` array (for - merging into an existing manifest manually) - --no-assistant Omit Slack AI Assistant mode (assistant_view feature, - assistant:write scope, assistant_thread_* events) so - DMs render as a flat chat where bare slash commands - work inline instead of the Assistant thread pane. - --agent-view Use Slack's Agent messaging experience (agent_view, - app_home_opened + message.im) instead of the legacy - Assistant messaging experience. + Flags (all parsed in ``hermes_cli/main.py``): --write [PATH] Write to file instead of stdout + (default path: ``$HERMES_HOME/slack-manifest.json``) --name NAME Override the bot display name + (default: "Hermes") --description DESC Override the bot description --long-description TEXT + Override the long app description (175-4,000 characters) --long-description-file PATH Read the + long app description from a UTF-8 file --slashes-only Emit only the ``features.slash_commands`` + array (for merging into an existing manifest manually) --no-assistant Omit Slack AI Assistant + mode (assistant_view feature, assistant:write scope, assistant_thread_* events) so DMs render as + a flat chat where bare slash commands work inline instead of the Assistant thread pane. """ name = getattr(args, "name", None) or "Hermes" description = getattr(args, "description", None) or "Your Hermes agent on Slack" diff --git a/hermes_cli/slash_exec.py b/hermes_cli/slash_exec.py index 38b31f3c30..28c8163372 100644 --- a/hermes_cli/slash_exec.py +++ b/hermes_cli/slash_exec.py @@ -1,20 +1,8 @@ """Registry-owned slash command execution (thin slice). -Shared, surface-independent executors for informational slash commands. -``CommandDef.execute`` (hermes_cli/commands.py) names a key in -:data:`EXECUTORS`; each surface (CLI REPL, gateway, TUI slash worker via the -CLI) resolves that key through :func:`run_execute` and applies only its own -decoration (Rich markup, emoji/markdown, ``_telegramize_command_mentions``) -to the canonical :class:`CommandReply`. - -Invariant: an executor's output depends only on ``ctx.args`` / ``ctx.options`` -— never on ``ctx.surface`` — so the core text is identical across surfaces -for a fixed context (enforced by tests/hermes_cli/test_commands_execute.py). - -Import discipline: this module imports nothing heavy at module level and -``hermes_cli.commands`` does NOT import this module (the ``execute`` field is -a plain string), so the gateway can keep importing ``commands.py`` without -prompt_toolkit and without cycles. +Invariant: an executor's output depends only on ``ctx.args`` / ``ctx.options`` — never on +``ctx.surface`` — so the core text is identical across surfaces for a fixed context (enforced by +tests/hermes_cli/test_commands_execute.py). """ from __future__ import annotations @@ -51,10 +39,9 @@ class CommandContext: class CommandReply: """Canonical result of a shared executor. - ``text`` is the surface-independent core text. ``data`` carries the - structured values the executor derived so a surface may re-render them - with its own decoration (Rich columns, markdown bullets) without - duplicating the computation. ``format`` is a rendering hint only. + ``text`` is the surface-independent core text. ``data`` carries the structured values the + executor derived so a surface may re-render them with its own decoration (Rich columns, markdown + bullets) without duplicating the computation. ``format`` is a rendering hint only. """ text: str @@ -83,10 +70,9 @@ def _exec_egress(ctx: CommandContext) -> CommandReply: def _exec_profile(ctx: CommandContext) -> CommandReply: """Core /profile data — active profile name + home directory. - A multiplexed gateway may pre-resolve the per-source profile/home and pass - them via ``options`` (``profile_name`` / ``home_display``); otherwise the - process-level values are used (identical to the old CLI + non-multiplex - gateway behavior). + A multiplexed gateway may pre-resolve the per-source profile/home and pass them via + ``options`` (``profile_name`` / ``home_display``); otherwise process-level values are used, + matching the old CLI and non-multiplex gateway behavior. """ profile_name = str(ctx.options.get("profile_name") or "").strip() home_display = str(ctx.options.get("home_display") or "").strip() @@ -155,6 +141,16 @@ def _exec_bundles(ctx: CommandContext) -> CommandReply: ) +def _skill_commands() -> dict: + """Registered skill commands, or ``{}`` when the skill subsystem is unavailable.""" + try: + from agent.skill_commands import get_skill_commands + + return get_skill_commands() or {} + except Exception: + return {} + + def _exec_help(ctx: CommandContext) -> CommandReply: """Core gateway /help body (pre platform mention decoration).""" from agent.i18n import t @@ -164,9 +160,8 @@ def _exec_help(ctx: CommandContext) -> CommandReply: t("gateway.help.header"), *gateway_help_lines(), ] + skill_cmds = _skill_commands() try: - from agent.skill_commands import get_skill_commands - skill_cmds = get_skill_commands() if skill_cmds: lines.append(t("gateway.help.skill_header", count=len(skill_cmds))) # Show first 10, then point to /commands for the rest @@ -183,8 +178,8 @@ def _exec_help(ctx: CommandContext) -> CommandReply: def _exec_commands(ctx: CommandContext) -> CommandReply: """Core gateway /commands body — paginated command + skill listing. - ``ctx.options["page_size"]`` is a surface parameter (Telegram uses 15, - everything else 20) — for a fixed context the text is surface-invariant. + ``ctx.options["page_size"]`` is a surface parameter (Telegram uses 15, everything else 20) — for + a fixed context the text is surface-invariant. """ from agent.i18n import t from hermes_cli.commands import gateway_help_lines @@ -200,12 +195,10 @@ def _exec_commands(ctx: CommandContext) -> CommandReply: # Build combined entry list: built-in commands + skill commands entries = list(gateway_help_lines()) + skill_cmds = _skill_commands() try: - from agent.skill_commands import get_skill_commands - skill_cmds = get_skill_commands() if skill_cmds: - entries.append("") - entries.append(t("gateway.commands.skill_header")) + entries.extend(["", t("gateway.commands.skill_header")]) for cmd in sorted(skill_cmds): desc = skill_cmds[cmd].get("description", "").strip() or t("gateway.commands.default_desc") entries.append(f"`{cmd}` — {desc}") @@ -258,25 +251,20 @@ EXECUTORS: dict[str, Callable[[CommandContext], CommandReply]] = { def resolve_executor(cmd_def: Any) -> Callable[[CommandContext], CommandReply] | None: """Return the shared executor for ``cmd_def`` (or None when not migrated).""" - key = getattr(cmd_def, "execute", None) - if not key: - return None - return EXECUTORS.get(key) + return EXECUTORS.get(getattr(cmd_def, "execute", None) or "") def run_execute(cmd_def: Any, ctx: CommandContext) -> CommandReply | None: """Run ``cmd_def``'s registry-owned executor, if any.""" fn = resolve_executor(cmd_def) - if fn is None: - return None - return fn(ctx) + return None if fn is None else fn(ctx) def execute_command(name: str, ctx: CommandContext) -> CommandReply: """Run the shared executor for the command named ``name``. - Raises ``LookupError`` when the command is unknown or not migrated — - call sites use this only for commands they know carry ``execute``. + Raises ``LookupError`` when the command is unknown or not migrated — call sites use this only + for commands they know carry ``execute``. """ from hermes_cli.commands import resolve_command diff --git a/hermes_cli/sqlite_runtime.py b/hermes_cli/sqlite_runtime.py index 7465b08a7c..ba9f0ca23a 100644 --- a/hermes_cli/sqlite_runtime.py +++ b/hermes_cli/sqlite_runtime.py @@ -1,8 +1,7 @@ """Import-safe helpers for inspecting a Python interpreter's linked SQLite. -This module intentionally depends only on the standard library. Installer and -update code must be able to use it before Hermes' third-party dependencies are -healthy. +This module intentionally depends only on the standard library. Installer and update code must be +able to use it before Hermes' third-party dependencies are healthy. """ from __future__ import annotations @@ -26,15 +25,12 @@ def is_sqlite_wal_reset_vulnerable( ) -> bool: """Return whether *version_info* contains SQLite's WAL-reset bug.""" info = _version_tuple(version_info) - if info < (3, 7, 0): - return False - if info >= (3, 51, 3): - return False - if (3, 50, 7) <= info < (3, 51, 0): - return False - if (3, 44, 6) <= info < (3, 45, 0): - return False - return True + return not ( + info < (3, 7, 0) + or info >= (3, 51, 3) + or (3, 50, 7) <= info < (3, 51, 0) + or (3, 44, 6) <= info < (3, 45, 0) + ) @dataclass(frozen=True) @@ -75,17 +71,9 @@ print(json.dumps({ """ -def probe_sqlite_runtime( - python: str | Path, - *, - timeout: float = 30.0, -) -> SQLiteRuntimeInfo | None: - """Probe SQLite in *python*, never the caller's linked SQLite. - - ``None`` means the interpreter could not be executed or returned malformed - data. The child runs isolated from inherited Python path overrides. - """ - executable = Path(python) +def isolated_interpreter_env() -> dict[str, str]: + """Copy of ``os.environ`` with conda/uv/venv/PYTHON* overrides stripped, so a child interpreter + reports its *own* runtime rather than the caller's.""" env = dict(os.environ) for key in ( "CONDA_DEFAULT_ENV", @@ -97,14 +85,23 @@ def probe_sqlite_runtime( "VIRTUAL_ENV", ): env.pop(key, None) + return env + + +def probe_sqlite_runtime( + python: str | Path, + *, + timeout: float = 30.0, +) -> SQLiteRuntimeInfo | None: + """Probe SQLite in *python*, never the caller's linked SQLite.""" try: result = subprocess.run( - [str(executable), "-I", "-c", _PROBE_SCRIPT], + [str(python), "-I", "-c", _PROBE_SCRIPT], capture_output=True, text=True, timeout=timeout, check=False, - env=env, + env=isolated_interpreter_env(), ) except (OSError, subprocess.TimeoutExpired): return None diff --git a/hermes_cli/sqlite_safe_read.py b/hermes_cli/sqlite_safe_read.py index be58ab9dd8..e759f0437c 100644 --- a/hermes_cli/sqlite_safe_read.py +++ b/hermes_cli/sqlite_safe_read.py @@ -1,62 +1,11 @@ """Lock-safe inspection of SQLite database files. -Why this module exists ----------------------- -POSIX advisory locks are cancelled **process-wide** by ``close()`` on *any* -file descriptor for that file:: +Why this module exists ---------------------- POSIX advisory locks are cancelled **process-wide** by +``close()`` on *any* file descriptor for that file:: - the close() system call will cancel all POSIX advisory locks on the - same file for all threads and all file descriptors in the process - -- https://sqlite.org/howtocorrupt.html#_posix_advisory_locks_canceled_by_a_separate_thread_doing_close_ - -So a bare ``open(db_path, "rb") ... close()`` on a **live** database silently -drops every lock SQLite holds on it from this process -- including the -EXCLUSIVE lock a ``VACUUM`` is holding while it rewrites the whole file, and -the RESERVED lock an in-flight ``BEGIN IMMEDIATE`` is holding. Other processes -are then free to write into a file that a writer still believes it owns, which -is the documented route to "database disk image is malformed". - -Hermes is exactly the topology this hits: gateway, dispatcher, dashboard, -TUI, CLI, cron and kanban workers all open the same ``state.db`` / -``kanban.db``, and several code paths used to byte-probe those files while -connections were live. - -The rules ---------- -1. **Never** ``open()`` a database file that may have live connections in this - process. Ask SQLite instead -- :func:`page_count_bytes` reads the same - header field via ``PRAGMA``, over the existing connection, taking no new - descriptor. -2. Byte-level probes are only safe **before any connection exists** for that - path (first-open validation). Route those through - :func:`read_header_bytes_preopen`, which refuses once a connection has been - registered for the path. - -Concurrency contract --------------------- -The registry is not advisory bookkeeping -- it is the guard, so the -check and the byte read must be **atomic with respect to connection -lifecycle**. ``_live_lock`` is therefore held across three critical sections, -each of which spans the syscall *and* the registry mutation: - -* open + register (:func:`connect_tracked`) -* close + unregister (:meth:`TrackedConnection.close`) -* check + ``open``/``read``/``close`` (:func:`read_header_bytes_preopen`) - -Without that, a thread could pass the "no live connection" check, a second -thread could open a connection and take a write lock, and the first thread's -``close()`` would then cancel it -- reintroducing the exact bug this module -exists to prevent. The lock is never held while a caller *uses* a connection, -only across these transitions, so it does not serialise database work. - -Path identity -------------- -Connections are keyed by the **canonical database path**, resolved from -``PRAGMA database_list`` on the opened connection. The caller's spelling is -not trustworthy: ``SessionDB``'s read-only path opens -``file:/…/state.db?mode=ro`` with ``uri=True``, and treating that string as a -filesystem path yields a key like ``<cwd>/file:/…/state.db?mode=ro`` which no -later probe of the real ``Path`` can ever match. +So a bare ``open(db_path, "rb") ... close()`` on a **live** database silently drops every lock +SQLite holds on it from this process -- including the EXCLUSIVE lock a ``VACUUM`` is holding while +it rewrites the whole file, and the RESERVED lock an in-flight ``BEGIN IMMEDIATE`` is holding. """ from __future__ import annotations @@ -71,11 +20,6 @@ from typing import Optional logger = logging.getLogger(__name__) -SQLITE_HEADER_MAGIC = b"SQLite format 3\x00" - -# Offset of the 4-byte big-endian page-count field in the SQLite header. -_HEADER_PAGE_COUNT_OFFSET = 28 - # Guards BOTH the registry and the lifecycle syscalls it describes. Reentrant # because connect_tracked -> _canonical_db_path -> ... stays on one thread. _live_lock = threading.RLock() @@ -86,8 +30,8 @@ _live_connections: dict[str, int] = {} class UntrackableConnectionError(RuntimeError): """A connection to a probe-able database could not be tracked. - Raised rather than silently returning an untracked connection: on these - paths tracking is part of the correctness contract, not an optimisation. + Raised rather than silently returning an untracked connection: on these paths tracking is part + of the correctness contract, not an optimisation. """ @@ -102,31 +46,31 @@ def _key(path: Path | str) -> str: def _canonical_db_path(conn: sqlite3.Connection) -> Optional[str]: """The on-disk path of ``main``, as SQLite itself reports it. - Immune to the caller's spelling (``file:`` URIs, relative paths, symlinks). - Returns ``None`` for in-memory or unnamed databases, which cannot be - byte-probed and therefore need no tracking. + Immune to the caller's spelling (``file:`` URIs, relative paths, symlinks). Returns ``None`` for + in-memory or unnamed databases, which cannot be byte-probed and therefore need no tracking. """ try: row = conn.execute("PRAGMA database_list").fetchone() except sqlite3.Error: return None - if not row or len(row) < 3: + if not row or len(row) < 3 or not row[2]: return None - path_str = row[2] - if not path_str: - return None - return _key(path_str) + return _key(row[2]) def track_connection(path: Path | str) -> None: """Record that this process now holds a connection to *path*. - Prefer :func:`connect_tracked`; this exists for callers that manage their - own connection objects, and for tests. + Prefer :func:`connect_tracked`; this exists for callers that manage their own connection + objects, and for tests. """ - key = _key(path) with _live_lock: - _live_connections[key] = _live_connections.get(key, 0) + 1 + _track_key(_key(path)) + + +def _track_key(key: str) -> None: + """Bump the live count for an already-canonical key (caller holds ``_live_lock``).""" + _live_connections[key] = _live_connections.get(key, 0) + 1 def untrack_connection(path: Path | str) -> None: @@ -167,20 +111,13 @@ class _TrackingMixin: class TrackedConnection(_TrackingMixin, sqlite3.Connection): """A ``sqlite3.Connection`` that untracks its path exactly once on close. - Counting opens is easy; counting closes reliably is not, because callers - close connections in many places (and some hand them to - ``contextlib.closing``). Putting the decrement on ``close()`` — the one - method every close path must go through — keeps the registry from - drifting upward and permanently disabling byte-probes. + Counting opens is easy; counting closes reliably is not, because callers close connections in + many places (and some hand them to ``contextlib.closing``). - The real ``close()`` and the unregister happen together under - ``_live_lock`` so a concurrent probe can never observe "no live - connection" while this descriptor is still open. Unregister runs only - after ``close()`` succeeds; a raising close leaves the connection - tracked so the byte-probe guard keeps refusing. - - Note ``with conn:`` does NOT close a sqlite3 connection (it only commits or - rolls back), so this hook is not fired spuriously by transaction scopes. + The real ``close()`` and the unregister happen together under ``_live_lock`` so a concurrent + probe can never observe "no live connection" while this descriptor is still open. Unregister + runs only after ``close()`` succeeds; a raising close leaves the connection tracked so the byte- + probe guard keeps refusing. """ @@ -190,11 +127,9 @@ _tracked_factory_cache: dict[type, type] = {} def _tracking_factory(factory: type) -> type: """Return *factory* augmented with untrack-on-close. - Callers legitimately supply their own ``Connection`` subclasses (the test - suite uses them to simulate FTS5-less or pragma-failing runtimes). Rather - than refusing those — or silently leaving them untracked, which would - quietly unguard the database — we mix the tracking ``close()`` into the - caller's class so tracking is preserved either way. + Callers legitimately pass their own ``Connection`` subclasses (tests simulate FTS5-less or + pragma-failing runtimes); refusing them or leaving them untracked would quietly unguard the + database, so the tracking ``close()`` is mixed into the caller's class instead. """ if factory is sqlite3.Connection: return TrackedConnection @@ -216,28 +151,11 @@ def connect_tracked( ) -> sqlite3.Connection: """``sqlite3.connect`` that registers the connection for the lifetime of the fd. - Use for any connection to a database whose file might otherwise be - byte-probed (``state.db``, ``kanban.db``). The registration is released - automatically on ``close()``. + Use for any connection to a database whose file might otherwise be byte-probed (``state.db``, + ``kanban.db``). The registration is released automatically on ``close()``. - The open and the registration happen together under ``_live_lock``, so a - concurrent :func:`read_header_bytes_preopen` cannot slip between them and - cancel this connection's locks. - - The registry key is the canonical path reported by ``PRAGMA - database_list`` -- not *path*, which may be a ``file:`` URI. Pass - ``tracking_path`` to override when the caller already knows the real path. - - ``connect_fn`` lets a caller supply its own opener (defaults to - :func:`sqlite3.connect`), so a module that owns the connection — and any - test that patches that module's ``sqlite3.connect`` — keeps control of how - the connection is created while this helper owns tracking. - - A caller-supplied ``factory`` is honoured but is transparently augmented - with untrack-on-close, so tracking is never silently skipped. If a - file-backed connection still cannot be tracked, - :class:`UntrackableConnectionError` is raised rather than handing back a - connection whose database has quietly lost byte-probe protection. + The open and the registration happen together under ``_live_lock``, so a concurrent + :func:`read_header_bytes_preopen` cannot slip between them and cancel this connection's locks. """ opener = connect_fn if connect_fn is not None else sqlite3.connect kwargs["factory"] = _tracking_factory(kwargs.get("factory", sqlite3.Connection)) @@ -261,7 +179,7 @@ def connect_tracked( # connection whose database has silently lost probe safety. conn = _retrofit_tracking(conn, resolved) conn._hermes_tracked_path = resolved - _live_connections[resolved] = _live_connections.get(resolved, 0) + 1 + _track_key(resolved) return conn except Exception: try: @@ -276,13 +194,10 @@ def connect_tracked( def _retrofit_tracking(conn: sqlite3.Connection, resolved: str) -> sqlite3.Connection: """Give an already-open connection untrack-on-close semantics. - ``sqlite3.Connection`` subclasses are ordinary Python classes, so the - instance's ``__class__`` can be swapped for one that mixes in the tracking - ``close()``. Used when an opener ignored the factory we asked for. + ``sqlite3.Connection`` subclasses are ordinary classes, so ``__class__`` can be swapped for + one mixing in the tracking ``close()``. Used when an opener ignored the factory we asked for. """ cls = type(conn) - if issubclass(cls, _TrackingMixin): - return conn try: conn.__class__ = _tracking_factory(cls) # type: ignore[assignment] return conn @@ -297,9 +212,9 @@ def _retrofit_tracking(conn: sqlite3.Connection, resolved: str) -> sqlite3.Conne def page_count_bytes(conn: sqlite3.Connection) -> Optional[int]: """Logical database size in bytes, read through *conn*. - ``page_count * page_size`` is the same quantity the 4-byte header field at - offset 28 carries, but reading it via ``PRAGMA`` opens no new file - descriptor and therefore cannot cancel this process's POSIX locks. + ``page_count * page_size`` is the same quantity the 4-byte header field at offset 28 carries, + but reading it via ``PRAGMA`` opens no new file descriptor and therefore cannot cancel this + process's POSIX locks. Returns ``None`` when the pragmas cannot be read. """ @@ -318,17 +233,13 @@ def page_count_bytes(conn: sqlite3.Connection) -> Optional[int]: def file_length_matches_header(conn: sqlite3.Connection) -> Optional[bool]: """Whether the file on disk is at least as long as the header claims. - Detects the "torn extend" shape (file shorter than its own page count) - without ever opening the database file: the header side comes from - ``PRAGMA page_count`` over *conn*, and the on-disk side from ``stat()``, - which takes no descriptor and cannot break locks. + Detects the "torn extend" shape (file shorter than its own page count) without ever opening the + database file: the header side comes from ``PRAGMA page_count`` over *conn*, and the on-disk + side from ``stat()``, which takes no descriptor and cannot break locks. - Returns ``None`` when the check is not applicable (in-memory database, - unreadable pragmas, or a stat failure). - - Note: in WAL mode a freshly committed page may still live in the ``-wal`` - file, so the main file legitimately lags. Callers must treat this as - advisory unless the database is in a rollback journal mode. + Note: in WAL mode a freshly committed page may still live in the ``-wal`` file, so the main file + legitimately lags. Callers must treat this as advisory unless the database is in a rollback + journal mode. """ path_str = _canonical_db_path(conn) if path_str is None: @@ -352,19 +263,13 @@ def read_header_bytes_preopen( ) -> Optional[bytes]: """Read the first *length* bytes of *path* -- only when no connection is live. - This is the ONLY sanctioned byte-level read of a database file, and it is - restricted to first-open validation (is this file a real SQLite database, - is it zeroed, has it been overwritten by something else). Once any - connection to *path* exists in this process, the read is refused and - ``None`` is returned, because the ``close()`` would cancel that - connection's POSIX locks. + This is the ONLY sanctioned byte-level read of a database file, and it is restricted to first- + open validation (is this file a real SQLite database, is it zeroed, has it been overwritten by + something else). - The registry check and the ``open``/``read``/``close`` are performed - together under ``_live_lock``, so a connection cannot be opened in the - window between deciding "nothing is live" and closing this descriptor. - - Set ``force=True`` only for genuinely offline files (quarantined copies, - snapshot artifacts, archives) that no live connection can reference. + The registry check and the ``open``/``read``/``close`` are performed together under + ``_live_lock``, so a connection cannot be opened in the window between deciding "nothing is + live" and closing this descriptor. """ with _live_lock: if not force and _key(path) in _live_connections: @@ -389,20 +294,12 @@ class LiveConnectionError(RuntimeError): def offline_file_access(path: Path | str, *, what: str = "read"): """Hold the connection-lifecycle lock across a raw read of a database file. - Checking :func:`has_live_connection` and *then* doing the raw I/O is a - check/use race: a connection can be opened in the window between the two, - and the raw ``close()`` will cancel its POSIX advisory locks — the exact - failure class the registry exists to prevent. Any multi-step raw access - (copying a database plus its ``-wal``/``-shm``/``-journal`` sidecars, - hashing a file, moving a bundle aside) must therefore run *inside* this - context manager rather than after a bare check. + Checking :func:`has_live_connection` and *then* doing the raw I/O is a check/use race: a + connection can be opened in the window between the two, and the raw ``close()`` will cancel its + POSIX advisory locks — the exact failure class the registry exists to prevent. - While held, :func:`connect_tracked` blocks, so no new connection can - appear mid-copy. Raises :class:`LiveConnectionError` if a connection is - already live when the guard is entered. - - The lock is only held for the duration of the raw I/O; it never spans - caller work on an open connection, so it does not serialise database use. + The lock is only held for the duration of the raw I/O; it never spans caller work on an open + connection, so it does not serialise database use. """ with _live_lock: if _key(path) in _live_connections: diff --git a/hermes_cli/sqlite_util.py b/hermes_cli/sqlite_util.py index e12a84b030..920fad1ff0 100644 --- a/hermes_cli/sqlite_util.py +++ b/hermes_cli/sqlite_util.py @@ -1,9 +1,4 @@ -"""Shared SQLite primitives for the small per-profile / board stores. - -The projects and kanban stores open WAL SQLite files with the same two -primitives — an idempotent column-add migration and an IMMEDIATE write -transaction. One definition here keeps the two stores from drifting. -""" +"""Shared SQLite primitives for the small per-profile / board stores.""" from __future__ import annotations @@ -14,10 +9,9 @@ import sqlite3 def add_column_if_missing(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> bool: """``ALTER TABLE <table> ADD COLUMN <ddl>``, idempotent across races. - Returns ``True`` when this call added the column. Swallows the - ``duplicate column name`` error a concurrent migrator may have run first - (issue #21708). ``column`` is the human-readable name for the call site; - ``ddl`` carries the actual definition. + Returns True when this call added the column; swallows the ``duplicate column name`` error a + concurrent migrator may have caused. ``column`` is the human-readable name, ``ddl`` the + actual definition. """ try: conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") @@ -32,9 +26,9 @@ def add_column_if_missing(conn: sqlite3.Connection, table: str, column: str, ddl def write_txn(conn: sqlite3.Connection): """An IMMEDIATE write transaction: at most one concurrent writer wins. - The explicit ROLLBACK is guarded so a SQLite auto-rollback (no active - transaction left under EIO / lock contention / corruption) cannot shadow - the original exception with a spurious rollback error. + The explicit ROLLBACK is guarded so a SQLite auto-rollback (no active transaction left under EIO + / lock contention / corruption) cannot shadow the original exception with a spurious rollback + error. """ conn.execute("BEGIN IMMEDIATE") try: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index f5c435d912..bc56e8719c 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -1,9 +1,6 @@ -""" -Status command for hermes CLI. - -Shows the status of all Hermes Agent components. -""" +"""Status command for hermes CLI.""" +import json import os import sys import time @@ -28,17 +25,50 @@ from hermes_constants import OPENROUTER_MODELS_URL from tools.tool_backend_helpers import managed_nous_tools_enabled def check_mark(ok: bool) -> str: - if ok: - return color("✓", Colors.GREEN) - return color("✗", Colors.RED) + return color("✓", Colors.GREEN) if ok else color("✗", Colors.RED) + + +def _section(title: str) -> None: + """Print a blank line followed by a bold cyan ``◆`` section heading.""" + print() + print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + + +def _row(name: str, ok: bool, text: str, width: int = 12) -> None: + """Print one ``name ✓/✗ text`` status row.""" + print(f" {name:<{width}} {check_mark(ok)} {text}") + + +def _detail(label: str, value) -> None: + """Print an indented ``label: value`` detail line under a status row.""" + print(f" {label:<12}{value}") + + +def _oauth_block(name: str, status: dict, hint: str, details) -> bool: + """Print an OAuth provider row plus its conditional detail lines; returns logged-in state.""" + logged_in = bool(status.get("logged_in")) + _row(name, logged_in, "logged in" if logged_in else f"not logged in (run: {hint})") + for label, value, show in details(logged_in): + if show: + _detail(label, value) + return logged_in + + +def _first_env_value(names) -> str: + """Return the first non-empty env value among ``names`` (a str or tuple of names).""" + if isinstance(names, str): + names = (names,) + for candidate in names: + v = get_env_value(candidate) or "" + if v: + return v + return "" def redact_key(key: str) -> str: """Redact an API key for display. - Thin wrapper over :func:`agent.redact.mask_secret`. Preserves the - "(not set)" placeholder in dim color to match ``hermes config``'s - output (previously this variant was missing the DIM color — - consolidated via PR that also introduced ``mask_secret``). + Thin wrapper over :func:`agent.redact.mask_secret` that keeps the dim "(not set)" placeholder + consistent with ``hermes config`` output. """ from agent.redact import mask_secret return mask_secret(key, empty=color("(not set)", Colors.DIM)) @@ -46,12 +76,10 @@ def redact_key(key: str) -> str: def _format_iso_timestamp(value) -> str: """Format ISO timestamps for status output, converting to local timezone.""" - if not value or not isinstance(value, str): - return "(unknown)" - from datetime import datetime, timezone - text = value.strip() + text = value.strip() if isinstance(value, str) else "" if not text: return "(unknown)" + from datetime import datetime, timezone if text.endswith("Z"): text = text[:-1] + "+00:00" try: @@ -63,23 +91,14 @@ def _format_iso_timestamp(value) -> str: return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") -def _format_relative_ts(ts: float) -> str: - """Format an epoch timestamp as a short relative age for status output.""" - from hermes_cli.timefmt import relative_time - - return relative_time(ts) - - def _configured_model_label(config: dict) -> str: """Return the configured default model from config.yaml.""" model_cfg = config.get("model") if isinstance(model_cfg, dict): - model = (model_cfg.get("default") or model_cfg.get("name") or "").strip() - elif isinstance(model_cfg, str): - model = model_cfg.strip() - else: - model = "" - return model or "(not set)" + model_cfg = model_cfg.get("default") or model_cfg.get("name") or "" + elif not isinstance(model_cfg, str): + model_cfg = "" + return model_cfg.strip() or "(not set)" def _effective_provider_label() -> str: @@ -96,13 +115,11 @@ def _effective_provider_label() -> str: # config.yaml as the single source of truth) or via the legacy # OPENAI_BASE_URL env var. Either way, labeling it "OpenRouter" # is misleading (#3296). - config_base_url = "" try: model_cfg = load_config().get("model") - if isinstance(model_cfg, dict): - config_base_url = (model_cfg.get("base_url") or "").strip() + config_base_url = (model_cfg.get("base_url") or "").strip() if isinstance(model_cfg, dict) else "" except Exception: - pass + config_base_url = "" if config_base_url or get_env_value("OPENAI_BASE_URL"): effective = "custom" @@ -113,10 +130,7 @@ from hermes_constants import is_termux as _is_termux def _estop_status_line(): - """One-line pause banner for `hermes status`, or None when not paused. - - Cheap: a single stat on $HERMES_HOME/ESTOP via agent.estop. - """ + """One-line pause banner for `hermes status`, or None when not paused.""" try: from agent.estop import get_state except ImportError: @@ -125,8 +139,7 @@ def _estop_status_line(): if state is None: return None reason = state.get("reason") - suffix = f" — reason: {reason}" if reason else "" - return f"⏸️ PAUSED (global emergency stop{suffix}; `hermes resume` to lift)" + return f"⏸️ PAUSED (global emergency stop{f' — reason: {reason}' if reason else ''}; `hermes resume` to lift)" def show_status(args): @@ -146,13 +159,12 @@ def show_status(args): # ========================================================================= # Environment # ========================================================================= - print() - print(color("◆ Environment", Colors.CYAN, Colors.BOLD)) + _section("Environment") print(f" Project: {PROJECT_ROOT}") print(f" Python: {sys.version.split()[0]}") - env_path = get_env_path() - print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}") + env_exists = get_env_path().exists() + print(f" .env file: {check_mark(env_exists)} {'exists' if env_exists else 'not found'}") try: config = load_config() @@ -165,14 +177,12 @@ def show_status(args): # ========================================================================= # API Keys # ========================================================================= - print() - print(color("◆ API Keys", Colors.CYAN, Colors.BOLD)) + _section("API Keys") # Values may be a single env var name (str) or a tuple of alternates (first found wins). keys: dict[str, str | tuple[str, ...]] = { "OpenRouter": "OPENROUTER_API_KEY", "OpenAI": "OPENAI_API_KEY", - "Anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"), "Google / Gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY"), "DeepSeek": "DEEPSEEK_API_KEY", "xAI / Grok": "XAI_API_KEY", @@ -193,37 +203,19 @@ def show_status(args): "GitHub": "GITHUB_TOKEN", } - def _resolve_env(env_ref) -> str: - """Return first non-empty env var value from a str or tuple of names.""" - if isinstance(env_ref, tuple): - for candidate in env_ref: - v = get_env_value(candidate) or "" - if v: - return v - return "" - return get_env_value(env_ref) or "" - for name, env_ref in keys.items(): - # Anthropic already has a dedicated lookup below; keep that as the - # single source of truth (it also resolves OAuth tokens), skip here - # so we don't print two "Anthropic" rows. - if name == "Anthropic": - continue - value = _resolve_env(env_ref) - has_key = bool(value) - display = redact_key(value) - print(f" {name:<12} {check_mark(has_key)} {display}") + value = _first_env_value(env_ref) + _row(name, bool(value), redact_key(value)) + # Anthropic uses the dedicated lookup (it also resolves OAuth tokens). from hermes_cli.auth import get_anthropic_key anthropic_value = get_anthropic_key() - anthropic_display = redact_key(anthropic_value) - print(f" {'Anthropic':<12} {check_mark(bool(anthropic_value))} {anthropic_display}") + _row("Anthropic", bool(anthropic_value), redact_key(anthropic_value)) # ========================================================================= # Auth Providers (OAuth) # ========================================================================= - print() - print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) + _section("Auth Providers") try: from hermes_cli.auth import ( @@ -239,106 +231,78 @@ def show_status(args): qwen_status = get_qwen_auth_status() minimax_status = get_minimax_oauth_auth_status() except Exception: - nous_status = {} - codex_status = {} - qwen_status = {} - minimax_status = {} + nous_status = codex_status = qwen_status = minimax_status = {} nous_account_info = None - if ( - nous_status.get("logged_in") - or nous_status.get("access_token") - or nous_status.get("portal_base_url") - or nous_status.get("inference_credential_present") - or nous_status.get("error_code") - ): + if any(nous_status.get(k) for k in ( + "logged_in", "access_token", "portal_base_url", "inference_credential_present", "error_code" + )): try: nous_account_info = get_nous_portal_account_info() except Exception: nous_account_info = None - nous_logged_in = bool( - nous_status.get("logged_in") - or (nous_account_info and nous_account_info.logged_in) - ) + nous_logged_in = bool(nous_status.get("logged_in") or (nous_account_info and nous_account_info.logged_in)) nous_inference_present = bool( nous_status.get("inference_credential_present") or (nous_account_info and nous_account_info.inference_credential_present) ) nous_error = nous_status.get("error") - if nous_logged_in: - nous_label = "logged in" - elif nous_inference_present: - nous_label = "not logged in (Nous inference key configured)" - else: - nous_label = "not logged in (run: hermes portal)" - print( - f" {'Nous Portal':<12} {check_mark(nous_logged_in)} " - f"{nous_label}" + _row( + "Nous Portal", nous_logged_in, + "logged in" if nous_logged_in + else "not logged in (Nous inference key configured)" if nous_inference_present + else "not logged in (run: hermes portal)", ) portal_url = nous_status.get("portal_base_url") or "(unknown)" - inference_url = ( - nous_status.get("inference_base_url") - or (nous_account_info.inference_base_url if nous_account_info else None) + inference_url = nous_status.get("inference_base_url") or ( + nous_account_info.inference_base_url if nous_account_info else None ) - access_exp = _format_iso_timestamp(nous_status.get("access_expires_at")) - key_exp = _format_iso_timestamp(nous_status.get("agent_key_expires_at")) - refresh_label = "yes" if nous_status.get("has_refresh_token") else "no" - if nous_logged_in or portal_url != "(unknown)" or nous_error: - print(f" Portal URL: {portal_url}") - if nous_inference_present and inference_url: - print(f" Inference: {inference_url}") - if nous_logged_in or nous_status.get("access_expires_at"): - print(f" Access exp: {access_exp}") - if nous_logged_in or nous_inference_present or nous_status.get("agent_key_expires_at"): - print(f" Key exp: {key_exp}") - if nous_logged_in or nous_status.get("has_refresh_token"): - print(f" Refresh: {refresh_label}") - if nous_error: - print(f" Error: {nous_error}") + for label, value, show in ( + ("Portal URL:", portal_url, nous_logged_in or portal_url != "(unknown)" or nous_error), + ("Inference:", inference_url, nous_inference_present and inference_url), + ("Access exp:", _format_iso_timestamp(nous_status.get("access_expires_at")), + nous_logged_in or nous_status.get("access_expires_at")), + ("Key exp:", _format_iso_timestamp(nous_status.get("agent_key_expires_at")), + nous_logged_in or nous_inference_present or nous_status.get("agent_key_expires_at")), + ("Refresh:", "yes" if nous_status.get("has_refresh_token") else "no", + nous_logged_in or nous_status.get("has_refresh_token")), + ("Error:", nous_error, nous_error), + ): + if show: + _detail(label, value) - codex_logged_in = bool(codex_status.get("logged_in")) - print( - f" {'OpenAI Codex':<12} {check_mark(codex_logged_in)} " - f"{'logged in' if codex_logged_in else 'not logged in (run: hermes model)'}" - ) - codex_auth_file = codex_status.get("auth_store") - if codex_auth_file: - print(f" Auth file: {codex_auth_file}") - codex_last_refresh = _format_iso_timestamp(codex_status.get("last_refresh")) - if codex_status.get("last_refresh"): - print(f" Refreshed: {codex_last_refresh}") - if codex_status.get("error") and not codex_logged_in: - print(f" Error: {codex_status.get('error')}") + def _file_refresh_error(status, file_key): + return lambda logged_in: ( + ("Auth file:", status.get(file_key), status.get(file_key)), + ("Refreshed:", _format_iso_timestamp(status.get("last_refresh")), status.get("last_refresh")), + ("Error:", status.get("error"), status.get("error") and not logged_in), + ) - qwen_logged_in = bool(qwen_status.get("logged_in")) - print( - f" {'Qwen OAuth':<12} {check_mark(qwen_logged_in)} " - f"{'logged in' if qwen_logged_in else 'not logged in (run: qwen auth qwen-oauth)'}" - ) - qwen_auth_file = qwen_status.get("auth_file") - if qwen_auth_file: - print(f" Auth file: {qwen_auth_file}") - qwen_exp = qwen_status.get("expires_at_ms") - if qwen_exp: - from datetime import datetime, timezone - print(f" Access exp: {datetime.fromtimestamp(int(qwen_exp) / 1000, tz=timezone.utc).isoformat()}") - if qwen_status.get("error") and not qwen_logged_in: - print(f" Error: {qwen_status.get('error')}") + _oauth_block("OpenAI Codex", codex_status, "hermes model", _file_refresh_error(codex_status, "auth_store")) - minimax_logged_in = bool(minimax_status.get("logged_in")) - print( - f" {'MiniMax OAuth':<12} {check_mark(minimax_logged_in)} " - f"{'logged in' if minimax_logged_in else 'not logged in (run: hermes auth add minimax-oauth)'}" + def _qwen_details(logged_in): + qwen_exp = qwen_status.get("expires_at_ms") + exp_text = "" + if qwen_exp: + from datetime import datetime, timezone + exp_text = datetime.fromtimestamp(int(qwen_exp) / 1000, tz=timezone.utc).isoformat() + return ( + ("Auth file:", qwen_status.get("auth_file"), qwen_status.get("auth_file")), + ("Access exp:", exp_text, qwen_exp), + ("Error:", qwen_status.get("error"), qwen_status.get("error") and not logged_in), + ) + + _oauth_block("Qwen OAuth", qwen_status, "qwen auth qwen-oauth", _qwen_details) + + _oauth_block( + "MiniMax OAuth", minimax_status, "hermes auth add minimax-oauth", + lambda logged_in: ( + ("Region:", minimax_status.get("region"), logged_in and minimax_status.get("region")), + ("Access exp:", minimax_status.get("expires_at"), minimax_status.get("expires_at")), + ("Error:", minimax_status.get("error"), minimax_status.get("error") and not logged_in), + ), ) - minimax_region = minimax_status.get("region") - if minimax_logged_in and minimax_region: - print(f" Region: {minimax_region}") - minimax_exp = minimax_status.get("expires_at") - if minimax_exp: - print(f" Access exp: {minimax_exp}") - if minimax_status.get("error") and not minimax_logged_in: - print(f" Error: {minimax_status.get('error')}") # xAI OAuth — separate try/except so an import failure here cannot # disrupt the already-printed Nous/Codex/Qwen/MiniMax rows above. @@ -348,30 +312,18 @@ def show_status(args): except Exception: xai_oauth_status = {} - xai_oauth_logged_in = bool(xai_oauth_status.get("logged_in")) - print( - f" {'xAI OAuth':<12} {check_mark(xai_oauth_logged_in)} " - f"{'logged in' if xai_oauth_logged_in else 'not logged in (run: hermes auth add xai-oauth)'}" + _oauth_block( + "xAI OAuth", xai_oauth_status, "hermes auth add xai-oauth", + _file_refresh_error(xai_oauth_status, "auth_store"), ) - xai_auth_file = xai_oauth_status.get("auth_store") - if xai_auth_file: - print(f" Auth file: {xai_auth_file}") - if xai_oauth_status.get("last_refresh"): - print(f" Refreshed: {_format_iso_timestamp(xai_oauth_status.get('last_refresh'))}") - if xai_oauth_status.get("error") and not xai_oauth_logged_in: - print(f" Error: {xai_oauth_status.get('error')}") # ========================================================================= # Nous Subscription Features # ========================================================================= if managed_nous_tools_enabled(): features = get_nous_subscription_features(config) - print() - print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD)) - if not features.nous_auth_present: - print(" Nous Portal ✗ not logged in") - else: - print(" Nous Portal ✓ managed tools available") + _section("Nous Tool Gateway") + print(" Nous Portal ✓ managed tools available" if features.nous_auth_present else " Nous Portal ✗ not logged in") for feature in features.items(): if feature.managed_by_nous: state = "active via Nous subscription" @@ -388,21 +340,17 @@ def show_status(args): elif nous_logged_in or nous_inference_present: # Nous OAuth without entitlement, or an opaque inference key without # Portal account information, cannot enable the Tool Gateway. - print() - print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD)) + _section("Nous Tool Gateway") message = format_nous_portal_entitlement_message( - nous_account_info, - capability="managed web, image, TTS, STT, browser, and Modal tools", + nous_account_info, capability="managed web, image, TTS, STT, browser, and Modal tools" ) - if message: - for line in message.splitlines(): - print(f" {line}") + for line in (message or "").splitlines(): + print(f" {line}") # ========================================================================= # API-Key Providers # ========================================================================= - print() - print(color("◆ API-Key Providers", Colors.CYAN, Colors.BOLD)) + _section("API-Key Providers") apikey_providers = { "Z.AI / GLM": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), @@ -413,12 +361,7 @@ def show_status(args): "DeepInfra": ("DEEPINFRA_API_KEY",), } for pname, env_vars in apikey_providers.items(): - key_val = "" - for ev in env_vars: - key_val = get_env_value(ev) or "" - if key_val: - break - configured = bool(key_val) + configured = bool(_first_env_value(env_vars)) label = "configured" if configured else "not configured (run: hermes model)" print(f" {pname:<16} {check_mark(configured)} {label}") @@ -431,10 +374,8 @@ def show_status(args): base = (model_cfg.get("base_url") if isinstance(model_cfg, dict) else None) or get_env_value("LM_BASE_URL") or "http://127.0.0.1:1234/v1" try: models = probe_lmstudio_models(api_key=get_env_value("LM_API_KEY") or "", base_url=base, timeout=1.5) - if models is None: - ok, msg = False, f"unreachable at {base}" - else: - ok, msg = True, f"reachable ({len(models)} model(s)) at {base}" + ok = models is not None + msg = f"reachable ({len(models)} model(s)) at {base}" if ok else f"unreachable at {base}" except AuthError: ok, msg = False, "auth rejected — set LM_API_KEY" print(f" {'LM Studio':<16} {check_mark(ok)} {msg}") @@ -442,33 +383,27 @@ def show_status(args): # ========================================================================= # Terminal Configuration # ========================================================================= - print() - print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD)) + _section("Terminal Backend") terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {} - terminal_env = os.getenv("TERMINAL_ENV", "") - if not terminal_env: - terminal_env = terminal_cfg.get("backend", "local") + terminal_env = os.getenv("TERMINAL_ENV", "") or terminal_cfg.get("backend", "local") print(f" Backend: {terminal_env}") if terminal_env == "ssh": - ssh_host = os.getenv("TERMINAL_SSH_HOST", "") - ssh_user = os.getenv("TERMINAL_SSH_USER", "") - print(f" SSH Host: {ssh_host or '(not set)'}") - print(f" SSH User: {ssh_user or '(not set)'}") + print(f" SSH Host: {os.getenv('TERMINAL_SSH_HOST', '') or '(not set)'}") + print(f" SSH User: {os.getenv('TERMINAL_SSH_USER', '') or '(not set)'}") elif terminal_env == "docker": - docker_image = os.getenv("TERMINAL_DOCKER_IMAGE", "python:3.11-slim") - print(f" Docker Image: {docker_image}") + print(f" Docker Image: {os.getenv('TERMINAL_DOCKER_IMAGE', 'python:3.11-slim')}") elif terminal_env == "daytona": - daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20") - print(f" Daytona Image: {daytona_image}") + print(f" Daytona Image: {os.getenv('TERMINAL_DAYTONA_IMAGE', 'nikolaik/python-nodejs:python3.11-nodejs20')}") elif terminal_env == "vercel_sandbox": runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24" persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT") - if persist is None: - persist_enabled = bool(terminal_cfg.get("container_persistent", True)) - else: - persist_enabled = persist.lower() in {"1", "true", "yes", "on"} + persist_enabled = ( + bool(terminal_cfg.get("container_persistent", True)) + if persist is None + else persist.lower() in {"1", "true", "yes", "on"} + ) auth_status = describe_vercel_auth() sdk_ok = importlib.util.find_spec("vercel") is not None sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')" @@ -490,8 +425,8 @@ def show_status(args): _provider = get_provider(terminal_env) if _provider is not None: - for _ok, _label, _detail in _provider.doctor_checks(): - print(f" {_label}: {check_mark(bool(_ok))} {_detail}") + for _ok, _label, _text in _provider.doctor_checks(): + print(f" {_label}: {check_mark(bool(_ok))} {_text}") except Exception: pass @@ -501,8 +436,7 @@ def show_status(args): # ========================================================================= # Messaging Platforms # ========================================================================= - print() - print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD)) + _section("Messaging Platforms") platforms = { "Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"), @@ -523,21 +457,15 @@ def show_status(args): } for name, (token_var, home_var) in platforms.items(): - token = os.getenv(token_var, "") - has_token = bool(token) - - home_channel = "" - if home_var: - home_channel = os.getenv(home_var, "") + has_token = bool(os.getenv(token_var, "")) + home_channel = os.getenv(home_var, "") if home_var else "" # Back-compat: QQBot home channel was renamed from QQ_HOME_CHANNEL to QQBOT_HOME_CHANNEL if not home_channel and home_var == "QQBOT_HOME_CHANNEL": home_channel = os.getenv("QQ_HOME_CHANNEL", "") - status = "configured" if has_token else "not configured" if home_channel: status += f" (home: {home_channel})" - - print(f" {name:<12} {check_mark(has_token)} {status}") + _row(name, has_token, status) # Plugin-registered platforms try: @@ -550,17 +478,14 @@ def show_status(args): configured = bool(entry.check_fn()) except Exception: configured = False - status_str = "configured" if configured else "not configured" - label = entry.label - print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)") + _row(entry.label, configured, f"{'configured' if configured else 'not configured'} (plugin)") except Exception: pass # ========================================================================= # Gateway Status # ========================================================================= - print() - print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) + _section("Gateway Service") try: from hermes_cli.gateway import get_gateway_runtime_snapshot, _format_gateway_pids @@ -580,35 +505,30 @@ def show_status(args): print(" Service: installed but stopped") except Exception: if _is_termux(): - print(f" Status: {color('unknown', Colors.DIM)}") - print(" Manager: Termux / manual process") + status_text, manager = "unknown", "Termux / manual process" elif sys.platform.startswith('linux'): - print(f" Status: {color('unknown', Colors.DIM)}") - print(" Manager: systemd/manual") + status_text, manager = "unknown", "systemd/manual" elif sys.platform == 'darwin': - print(f" Status: {color('unknown', Colors.DIM)}") - print(" Manager: launchd") + status_text, manager = "unknown", "launchd" else: - print(f" Status: {color('N/A', Colors.DIM)}") - print(" Manager: (not supported on this platform)") + status_text, manager = "N/A", "(not supported on this platform)" + print(f" Status: {color(status_text, Colors.DIM)}") + print(f" Manager: {manager}") # ========================================================================= # Cron Jobs # ========================================================================= - print() - print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD)) + _section("Scheduled Jobs") jobs_file = get_hermes_home() / "cron" / "jobs.json" if jobs_file.exists(): - import json try: # utf-8-sig: same dialect as cron/jobs.load_jobs — Windows editors # may leave a UTF-8 BOM that plain utf-8 json.load rejects. with open(jobs_file, encoding="utf-8-sig") as f: - data = json.load(f) - jobs = data.get("jobs", []) - enabled_jobs = [j for j in jobs if j.get("enabled", True)] - print(f" Jobs: {len(enabled_jobs)} active, {len(jobs)} total") + jobs = json.load(f).get("jobs", []) + enabled = sum(1 for j in jobs if j.get("enabled", True)) + print(f" Jobs: {enabled} active, {len(jobs)} total") except Exception: print(" Jobs: (error reading jobs file)") else: @@ -617,8 +537,7 @@ def show_status(args): # ========================================================================= # Sessions # ========================================================================= - print() - print(color("◆ Sessions", Colors.CYAN, Colors.BOLD)) + _section("Sessions") # Gateway session count: state.db is the source of truth (#9006); # fall back to sessions.json for pre-migration installs. @@ -638,30 +557,25 @@ def show_status(args): _session_count = None _gateway_rows = [] - if _session_count is not None and _session_count > 0: + if _session_count: print(f" Active: {_session_count} session(s)") - freshest = max( - (float(r.get("last_active") or 0) for r in _gateway_rows), - default=0.0, - ) + freshest = max((float(r.get("last_active") or 0) for r in _gateway_rows), default=0.0) if freshest > 0: - print(f" Last activity:{_format_relative_ts(freshest):>13}") + from hermes_cli.timefmt import relative_time + + print(f" Last activity:{relative_time(freshest):>13}") else: sessions_file = get_hermes_home() / "sessions" / "sessions.json" if sessions_file.exists(): - import json try: with open(sessions_file, encoding="utf-8") as f: data = json.load(f) - _entries = { - k: v for k, v in data.items() - if not str(k).startswith("_") - } if isinstance(data, dict) else {} - print(f" Active: {len(_entries)} session(s)") + _entries = [k for k in data if not str(k).startswith("_")] if isinstance(data, dict) else [] + print(f" Active: {len(_entries)} session(s)") except Exception: print(" Active: (error reading sessions file)") else: - print(f" Active: {_session_count if _session_count is not None else 0}") + print(f" Active: {_session_count or 0}") # Slot usage, only when max_concurrent_sessions is set. The cap is shared # across CLI, desktop/TUI and the messaging gateway, so the surface that @@ -669,9 +583,7 @@ def show_status(args): # way to find out is reading runtime/active_sessions.json by hand. try: from hermes_cli.active_sessions import ( - active_session_registry_snapshot, - format_age, - resolve_max_concurrent_sessions, + active_session_registry_snapshot, format_age, resolve_max_concurrent_sessions, ) _cap = resolve_max_concurrent_sessions(config) @@ -683,12 +595,7 @@ def show_status(args): except Exception: _held = [] _full = len(_held) >= _cap - print( - " Slots: " - + color( - f"{len(_held)}/{_cap} in use", Colors.YELLOW if _full else Colors.GREEN - ) - ) + print(" Slots: " + color(f"{len(_held)}/{_cap} in use", Colors.YELLOW if _full else Colors.GREEN)) _now = time.time() for _entry in sorted(_held, key=lambda e: e.get("started_at") or 0): _age = format_age(_now - float(_entry.get("started_at") or _now)) @@ -701,19 +608,14 @@ def show_status(args): # Deep checks # ========================================================================= if deep: - print() - print(color("◆ Deep Checks", Colors.CYAN, Colors.BOLD)) - + _section("Deep Checks") + # Check OpenRouter connectivity openrouter_key = os.getenv("OPENROUTER_API_KEY", "") if openrouter_key: try: import httpx - response = httpx.get( - OPENROUTER_MODELS_URL, - headers={"Authorization": f"Bearer {openrouter_key}"}, - timeout=10 - ) + response = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {openrouter_key}"}, timeout=10) ok = response.status_code == 200 print(f" OpenRouter: {check_mark(ok)} {'reachable' if ok else f'error ({response.status_code})'}") except Exception as e: @@ -724,11 +626,8 @@ def show_status(args): import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1) - result = sock.connect_ex(('127.0.0.1', 18789)) + port_in_use = sock.connect_ex(('127.0.0.1', 18789)) == 0 # informational: gateway likely running sock.close() - # Port in use = gateway likely running - port_in_use = result == 0 - # This is informational, not necessarily bad print(f" Port 18789: {'in use' if port_in_use else 'available'}") except OSError: pass diff --git a/hermes_cli/stderr_timestamp.py b/hermes_cli/stderr_timestamp.py index b6dbac9966..c7263590d2 100644 --- a/hermes_cli/stderr_timestamp.py +++ b/hermes_cli/stderr_timestamp.py @@ -16,9 +16,7 @@ from typing import BinaryIO, Sequence, TextIO EXTERNAL_SUPERVISOR_FLAG = "--external-supervisor" -_TIMESTAMP_PREFIX = re.compile( - r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}(?:\s|$)" -) +_TIMESTAMP_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}(?:\s|$)") def _timestamp() -> str: @@ -33,18 +31,15 @@ def _write_timestamped_line(log_file: TextIO, line: str) -> None: log_file.flush() -def _copy_stderr_with_timestamps(stderr: BinaryIO, log_path: Path) -> None: +def _open_log(log_path: Path) -> TextIO: log_path.parent.mkdir(parents=True, exist_ok=True) - with log_path.open("a", encoding="utf-8", buffering=1) as log_file: + return log_path.open("a", encoding="utf-8", buffering=1) + + +def _copy_stderr_with_timestamps(stderr: BinaryIO, log_path: Path) -> None: + with _open_log(log_path) as log_file: for raw_line in iter(stderr.readline, b""): - line = raw_line.decode("utf-8", errors="replace") - _write_timestamped_line(log_file, line) - - -def _command_exit_code(returncode: int) -> int: - if returncode < 0: - return 128 + abs(returncode) - return returncode + _write_timestamped_line(log_file, raw_line.decode("utf-8", errors="replace")) def _install_signal_forwarders(proc: subprocess.Popen[bytes]) -> dict[int, object]: @@ -65,24 +60,11 @@ def _install_signal_forwarders(proc: subprocess.Popen[bytes]) -> dict[int, objec return previous -def _restore_signal_handlers(previous: dict[int, object]) -> None: - for signum, handler in previous.items(): - signal.signal(signum, handler) - - -def _is_launchd_supervised(environ: Mapping[str, str] | None = None) -> bool: - """True when this process is launchd's direct child (not an interactive shell).""" - env = os.environ if environ is None else environ - xpc_service = str(env.get("XPC_SERVICE_NAME", "")).strip() - return bool(xpc_service and xpc_service != "0") - - def _is_hermes_gateway_run_argv(command: Sequence[str]) -> bool: """True for Hermes ``gateway run`` argv this wrapper is allowed to upgrade. - The wrapper is generic. Only historical/current Hermes gateway shapes - get ``--external-supervisor``; an arbitrary launchd child must not be - marked as gateway-supervised (#87005). + The wrapper is generic. Only historical/current Hermes gateway shapes get ``--external- + supervisor``; an arbitrary launchd child must not be marked as gateway-supervised (#87005). """ try: from gateway.status import looks_like_gateway_command_line @@ -91,32 +73,27 @@ def _is_hermes_gateway_run_argv(command: Sequence[str]) -> bool: return bool(looks_like_gateway_command_line(" ".join(str(part) for part in command))) -def _with_external_supervisor_flag(command: Sequence[str]) -> list[str]: - argv = [str(part) for part in command] - if EXTERNAL_SUPERVISOR_FLAG not in argv: - argv.append(EXTERNAL_SUPERVISOR_FLAG) - return argv - - def _prepare_child_command( command: Sequence[str], environ: Mapping[str, str] | None = None, ) -> list[str]: """Return the argv to exec, upgrading stale launchd-wrapped gateway commands. - launchd stamps ``XPC_SERVICE_NAME=<job label>`` only on this wrapper. - The grandchild sees ``XPC_SERVICE_NAME=0``. Newly generated plists put - ``--external-supervisor`` on the inner ``gateway run`` so ``hermes update`` - can see the flag on the live process argv. Stale plists still wrap the - historical ``gateway run --replace`` shape without that flag; append it - here, and only for that shape. + launchd stamps ``XPC_SERVICE_NAME=<job label>`` only on this wrapper (its direct child; an + interactive shell has none, the grandchild sees ``XPC_SERVICE_NAME=0``). Newly generated + plists put ``--external-supervisor`` on the inner ``gateway run`` so ``hermes update`` can see + the flag on the live process argv. """ argv = [str(part) for part in command] - if not _is_launchd_supervised(environ): - return argv - if not _is_hermes_gateway_run_argv(argv): - return argv - return _with_external_supervisor_flag(argv) + env = os.environ if environ is None else environ + xpc_service = str(env.get("XPC_SERVICE_NAME", "")).strip() + if ( + EXTERNAL_SUPERVISOR_FLAG not in argv + and xpc_service and xpc_service != "0" + and _is_hermes_gateway_run_argv(argv) + ): + argv.append(EXTERNAL_SUPERVISOR_FLAG) + return argv def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: @@ -138,17 +115,10 @@ def main(argv: Sequence[str] | None = None) -> int: log_path: Path = args.error_log try: - proc = subprocess.Popen( - _prepare_child_command(args.command), - stderr=subprocess.PIPE, - ) + proc = subprocess.Popen(_prepare_child_command(args.command), stderr=subprocess.PIPE) except OSError as exc: - log_path.parent.mkdir(parents=True, exist_ok=True) - with log_path.open("a", encoding="utf-8", buffering=1) as log_file: - _write_timestamped_line( - log_file, - f"failed to start stderr-timestamped command: {exc}", - ) + with _open_log(log_path) as log_file: + _write_timestamped_line(log_file, f"failed to start stderr-timestamped command: {exc}") return 127 assert proc.stderr is not None @@ -157,8 +127,10 @@ def main(argv: Sequence[str] | None = None) -> int: _copy_stderr_with_timestamps(proc.stderr, log_path) finally: proc.stderr.close() - _restore_signal_handlers(previous_handlers) - return _command_exit_code(proc.wait()) + for signum, handler in previous_handlers.items(): + signal.signal(signum, handler) + returncode = proc.wait() + return 128 + abs(returncode) if returncode < 0 else returncode if __name__ == "__main__": diff --git a/hermes_cli/stdio.py b/hermes_cli/stdio.py index b8caf2b052..b927404e67 100644 --- a/hermes_cli/stdio.py +++ b/hermes_cli/stdio.py @@ -1,30 +1,8 @@ """Windows-safe stdio configuration. -On Windows, Python's ``sys.stdout``/``sys.stderr`` default to the console's -active code page (often ``cp1252``, sometimes ``cp437``, occasionally ``cp932`` -on Japanese locales, etc.). Hermes's banners, tool output feed, and slash -command listings all contain Unicode: box-drawing characters (``─┌┐└┘├┤``), -mathematical and geometric symbols (``◆ ◇ ◎ ▣ ⚔ ⚖ →``), and user-supplied -text in any language. Printing those to a cp1252 console raises -``UnicodeEncodeError: 'charmap' codec can't encode character…`` and kills the -whole CLI before the REPL even opens. - -The fix is to force UTF-8 on the Python side and also flip the console's -code page to UTF-8 (65001). Both matter: Python-level only helps when -Python's stdout is a real TTY; code-page flipping lets subprocesses and -child Python ``print()`` calls agree on encoding. - -This module is a no-op on every non-Windows platform, and idempotent. -Entry points (``cli.py`` ``main``, ``hermes_cli/main.py`` CLI dispatch, -``gateway/run.py`` startup) call :func:`configure_windows_stdio` exactly -once early in startup. - -Patterns cribbed from Claude Code (``src/utils/platform.ts``), OpenCode -(``packages/opencode/src/pty/index.ts`` env injection), and OpenAI Codex -(``codex-rs/core/src/unified_exec/process_manager.rs``). None of those -actually flip the console code page — they rely on their runtime (Node or -Rust) writing UTF-16 to the Win32 console API and letting the terminal -sort it out. Python doesn't get that luxury. +The fix is to force UTF-8 on the Python side and also flip the console's code page to UTF-8 (65001). +Both matter: Python-level only helps when Python's stdout is a real TTY; code-page flipping lets +subprocesses and child Python ``print()`` calls agree on encoding. """ from __future__ import annotations @@ -46,12 +24,8 @@ def is_windows() -> bool: def _flip_console_code_page_to_utf8() -> None: """Set the attached console's input and output code pages to UTF-8. - Uses ``SetConsoleCP`` / ``SetConsoleOutputCP`` via ``ctypes``. Failure - is silent — if there's no attached console (e.g. Hermes is running - behind a redirected stdout, under a service, or inside a PTY-less CI - runner) these calls simply return 0 and we move on. - - CP_UTF8 is 65001. + Uses ``SetConsoleCP`` / ``SetConsoleOutputCP`` (CP_UTF8 = 65001). Failure is silent: without an + attached console (redirected stdout, service, PTY-less CI) the calls return 0 and we move on. """ try: import ctypes @@ -69,9 +43,8 @@ def _flip_console_code_page_to_utf8() -> None: def _reconfigure_stream(stream, *, encoding: str = "utf-8", errors: str = "replace") -> None: """Reconfigure a text stream to UTF-8 in place. - Uses ``TextIOWrapper.reconfigure`` (Python 3.7+). If the stream isn't - a ``TextIOWrapper`` (e.g. it's been redirected to an ``io.StringIO`` - during tests), we skip rather than blow up. + Skips rather than raising when the stream isn't a ``TextIOWrapper`` (e.g. redirected to an + ``io.StringIO`` during tests). """ try: reconfigure = getattr(stream, "reconfigure", None) @@ -85,16 +58,10 @@ def _reconfigure_stream(stream, *, encoding: str = "utf-8", errors: str = "repla def configure_windows_stdio() -> bool: """Force UTF-8 stdio on Windows. No-op elsewhere. - Idempotent — safe to call multiple times from different entry points. - - Returns ``True`` if anything was actually changed, ``False`` on - non-Windows or on a repeat call. - - Set ``HERMES_DISABLE_WINDOWS_UTF8=1`` in the environment to opt out - (for diagnosing encoding-related bugs by forcing the old cp1252 path). - - Also sets a sensible default ``EDITOR`` on Windows if none is already - set — see :func:`_default_windows_editor`. + Idempotent; returns ``True`` only when something actually changed. Set + ``HERMES_DISABLE_WINDOWS_UTF8=1`` to opt out (forces the old cp1252 path for diagnosing + encoding bugs). Also sets a default ``EDITOR`` on Windows if none is set (see + ``_default_windows_editor``). """ global _CONFIGURED @@ -163,21 +130,9 @@ def _default_windows_editor() -> str: Priority order, first match wins: - 1. ``notepad`` — ships with every Windows install, no deps, works as a - blocking editor (``subprocess.call(["notepad", file])`` blocks until - the user closes the window). This is the "always-works" default. - - The prompt_toolkit buffer's ``open_in_editor`` and Hermes's - ``hermes config edit`` both honour ``$EDITOR``. Users who prefer a - different editor can override: - - - VSCode: ``$env:EDITOR = "code --wait"`` (``--wait`` is critical; - without it the editor returns immediately and any input is lost) - - Notepad++: ``$env:EDITOR = "'C:\\Program Files\\Notepad++\\notepad++.exe' -multiInst -nosession"`` - - Neovim: ``$env:EDITOR = "nvim"`` (if installed) - - Set this before launching Hermes (User env var in Windows Settings, or - export in a PowerShell profile) and Hermes picks it up automatically. + 1. ``notepad`` — ships with every Windows install, no deps, works as a blocking editor + (``subprocess.call(["notepad", file])`` blocks until the user closes the window). This is the + "always-works" default. """ import shutil @@ -191,32 +146,21 @@ def _default_windows_editor() -> str: return "" - def _augment_path_with_known_tools() -> None: - """Prepend well-known Hermes-managed tool directories to os.environ['PATH']. + r"""Prepend well-known Hermes-managed tool directories to os.environ['PATH']. - Fixes the "User PATH was just updated but my process can't see it" gap on - Windows. When install.ps1 runs, it adds entries like - ``%LOCALAPPDATA%\\hermes\\git\\bin`` to the User PATH via - ``SetEnvironmentVariable(..., "User")``. That write propagates to newly - *spawned* processes only — already-running shells (including the one the - user invokes ``hermes`` from right after install) retain their old PATH. + Fixes the "User PATH was just updated but my process can't see it" gap on Windows. When + install.ps1 runs, it adds entries like ``%LOCALAPPDATA%\hermes\git\bin`` to the User PATH via + ``SetEnvironmentVariable(..., "User")``. - Any subprocess Hermes spawns — bash, ``rg``, ``grep``, ``npm`` — inherits - that stale PATH and reports commands as missing even though they're on - disk. Symptom: ``search_files`` reports "rg/find not available" when - the user clearly just installed ripgrep. - - Patch-up strategy: add the known Hermes-managed tool directories to our - PATH at startup so subprocess calls resolve correctly. No-op on POSIX - and when the directories don't exist. The User PATH broadcast still - happens in the background for future shells; this just smooths over + Patch-up strategy: add the known Hermes-managed tool directories to our PATH at startup so + subprocess calls resolve correctly. No-op on POSIX and when the directories don't exist. The + User PATH broadcast still happens in the background for future shells; this just smooths over the first-launch gap. """ if not is_windows(): return - local_appdata = os.environ.get("LOCALAPPDATA", "") if not local_appdata: return diff --git a/hermes_cli/suggestions_cmd.py b/hermes_cli/suggestions_cmd.py index 79e019d1d4..6ad01104a9 100644 --- a/hermes_cli/suggestions_cmd.py +++ b/hermes_cli/suggestions_cmd.py @@ -1,15 +1,13 @@ """Shared ``/suggestions`` command logic for CLI and gateway. -Both surfaces call ``handle_suggestions_command(args, origin=...)`` and present -the returned text however they present command output. Keeping the logic here -(not in cli.py / gateway/run.py) means the two surfaces can never drift. +Both surfaces call ``handle_suggestions_command(args, origin=...)`` and present the returned text +however they present command output. Keeping the logic here (not in cli.py / gateway/run.py) means +the two surfaces can never drift. -Subcommands: - /suggestions list pending suggestions (numbered) - /suggestions accept <N|id> create the cron job for that suggestion - /suggestions dismiss <N|id> dismiss it (latched, never re-offered) - /suggestions catalog seed the curated starter automations as pending - /suggestions clear drop accepted records (housekeeping) +Subcommands: /suggestions list pending suggestions (numbered) /suggestions accept <N|id> create the +cron job for that suggestion /suggestions dismiss <N|id> dismiss it (latched, never re-offered) +/suggestions catalog seed the curated starter automations as pending /suggestions clear drop +accepted records (housekeeping) """ from __future__ import annotations @@ -42,9 +40,9 @@ def _fmt_pending(pending: list) -> str: def _resolve_origin() -> Optional[Dict[str, Any]]: """Best-effort current-chat origin from session env (CLI and gateway both set it). - Mirrors cron's ``_origin_from_env`` so an accepted suggestion's job delivers - back to the chat where it was accepted. Returns None if unavailable, in - which case create_job falls back to a configured home channel. + Mirrors cron's ``_origin_from_env`` so an accepted suggestion's job delivers back to the chat + where it was accepted. Returns None if unavailable, in which case create_job falls back to a + configured home channel. """ try: from gateway.session_context import get_session_env @@ -63,6 +61,78 @@ def _resolve_origin() -> Optional[Dict[str, Any]]: return None +def _accept(store, rest: str, origin, surface: str) -> str: + if not rest: + return "Usage: /suggestions accept <number|id>" + from cron.scheduler import CronSchedulerRegistrationError + + try: + job = store.accept_suggestion(rest, origin=origin) + except CronSchedulerRegistrationError as e: + return e.user_message() + if job is None: + return f"No pending suggestion matches '{rest}'. Run /suggestions to list them." + sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "") + name = job.get("name", "automation") + manage = ( + "Manage it with /cron." + if surface == "cli" + else "Ask me to list, pause, or remove it any time." + ) + return f"Scheduled '{name}'" + (f" ({sched})" if sched else "") + f". {manage}" + + +def _dismiss(store, rest: str, origin, surface: str) -> str: + if not rest: + return "Usage: /suggestions dismiss <number|id>" + if store.dismiss_suggestion(rest): + return "Dismissed. Won't suggest that again." + return f"No pending suggestion matches '{rest}'." + + +def _catalog(store, rest: str, origin, surface: str) -> str: + try: + from cron.suggestion_catalog import seed_catalog_suggestions + + created = seed_catalog_suggestions() + except Exception as e: + logger.debug("catalog seed failed: %s", e) + return "Couldn't load the catalog." + if not created: + return ( + "No new catalog automations to add (already offered, dismissed, " + "or your suggestion list is full). Run /suggestions to see pending." + ) + added = ", ".join(c.get("title", "?") for c in created) + return f"Added {len(created)} suggestion(s): {added}.\nRun /suggestions to review." + + +def _clear(store, rest: str, origin, surface: str) -> str: + return f"Cleared {store.clear_resolved()} resolved suggestion record(s)." + + +_SUBCOMMANDS = { + "": lambda store, rest, origin, surface: _fmt_pending(store.list_pending()), + "accept": _accept, + "add": _accept, + "schedule": _accept, + "dismiss": _dismiss, + "no": _dismiss, + "reject": _dismiss, + "catalog": _catalog, + "clear": _clear, +} + +_USAGE = ( + "Usage:\n" + " /suggestions list pending\n" + " /suggestions accept N schedule suggestion N\n" + " /suggestions dismiss N dismiss suggestion N\n" + " /suggestions catalog add curated starter automations\n" + " /suggestions clear housekeeping" +) + + def handle_suggestions_command( args: str, *, @@ -71,12 +141,9 @@ def handle_suggestions_command( ) -> str: """Dispatch a ``/suggestions`` invocation. Returns text to show the user. - ``args`` is everything after ``/suggestions`` (already stripped of the - command word). ``origin`` is the platform/chat dict so an accepted job's - "origin" delivery routes back to where the user accepted; when omitted it - is resolved from the session environment. ``surface`` (``"cli"`` | - ``"gateway"``) picks the wording for follow-up hints — ``/cron`` only - exists on the CLI. + ``args`` is everything after ``/suggestions`` (already stripped of the command word). ``origin`` + is the platform/chat dict so an accepted job's "origin" delivery routes back to where the user + accepted; when omitted it is resolved from the session environment. """ if origin is None: origin = _resolve_origin() @@ -90,69 +157,7 @@ def handle_suggestions_command( sub = parts[0].lower() if parts else "" rest = " ".join(parts[1:]).strip() - # Bare /suggestions -> list pending. - if not sub: - return _fmt_pending(store.list_pending()) - - if sub in ("accept", "add", "schedule"): - if not rest: - return "Usage: /suggestions accept <number|id>" - from cron.scheduler import CronSchedulerRegistrationError - - try: - job = store.accept_suggestion(rest, origin=origin) - except CronSchedulerRegistrationError as e: - return e.user_message() - if job is None: - return f"No pending suggestion matches '{rest}'. Run /suggestions to list them." - sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "") - name = job.get("name", "automation") - manage = ( - "Manage it with /cron." - if surface == "cli" - else "Ask me to list, pause, or remove it any time." - ) - return ( - f"Scheduled '{name}'" - + (f" ({sched})" if sched else "") - + f". {manage}" - ) - - if sub in ("dismiss", "no", "reject"): - if not rest: - return "Usage: /suggestions dismiss <number|id>" - ok = store.dismiss_suggestion(rest) - return ( - "Dismissed. Won't suggest that again." - if ok - else f"No pending suggestion matches '{rest}'." - ) - - if sub == "catalog": - try: - from cron.suggestion_catalog import seed_catalog_suggestions - - created = seed_catalog_suggestions() - except Exception as e: - logger.debug("catalog seed failed: %s", e) - return "Couldn't load the catalog." - if not created: - return ( - "No new catalog automations to add (already offered, dismissed, " - "or your suggestion list is full). Run /suggestions to see pending." - ) - added = ", ".join(c.get("title", "?") for c in created) - return f"Added {len(created)} suggestion(s): {added}.\nRun /suggestions to review." - - if sub == "clear": - removed = store.clear_resolved() - return f"Cleared {removed} resolved suggestion record(s)." - - return ( - "Usage:\n" - " /suggestions list pending\n" - " /suggestions accept N schedule suggestion N\n" - " /suggestions dismiss N dismiss suggestion N\n" - " /suggestions catalog add curated starter automations\n" - " /suggestions clear housekeeping" - ) + handler = _SUBCOMMANDS.get(sub) + if handler is None: + return _USAGE + return handler(store, rest, origin, surface) diff --git a/hermes_cli/telegram_managed_bot.py b/hermes_cli/telegram_managed_bot.py index 89395d5ff6..1d625a973b 100644 --- a/hermes_cli/telegram_managed_bot.py +++ b/hermes_cli/telegram_managed_bot.py @@ -1,8 +1,8 @@ """Telegram Managed Bot onboarding client. -Uses Telegram's Managed Bots feature to create a user-owned child bot without -manual BotFather token copy-paste. Hermes talks only to the Nous onboarding -service; the raw Telegram token is saved locally after one-time retrieval. +Uses Telegram's Managed Bots feature to create a user-owned child bot without manual BotFather token +copy-paste. Hermes talks only to the Nous onboarding service; the raw Telegram token is saved +locally after one-time retrieval. """ from __future__ import annotations @@ -12,7 +12,6 @@ import re import secrets import sys import time -import urllib.parse from dataclasses import dataclass from typing import Optional @@ -58,9 +57,7 @@ class TelegramBotSetupResult: def _api_url(api_url: str | None = None) -> str: """Resolve the onboarding API URL, honoring the PoC env override.""" - return ( - api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL - ).rstrip("/") + return (api_url or os.environ.get(TELEGRAM_ONBOARDING_URL_ENV) or DEFAULT_API_URL).rstrip("/") def is_valid_telegram_bot_token(token: object) -> bool: @@ -71,12 +68,9 @@ def is_valid_telegram_bot_token(token: object) -> bool: def _parse_owner_user_id(value: object) -> int | None: if isinstance(value, bool): return None - if isinstance(value, int): - return value if value > 0 else None if isinstance(value, str) and value.isdecimal(): - parsed = int(value) - return parsed if parsed > 0 else None - return None + value = int(value) + return value if isinstance(value, int) and value > 0 else None def render_qr_terminal(url: str) -> str: @@ -85,101 +79,48 @@ def render_qr_terminal(url: str) -> str: import io import qrcode # type: ignore[import-untyped] - - qr = qrcode.QRCode( - version=None, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=1, - border=1, - ) - qr.add_data(url) - qr.make(fit=True) - - buf = io.StringIO() - qr.print_ascii(out=buf, invert=True) - return buf.getvalue() except ImportError: return "" + qr = qrcode.QRCode(version=None, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(url) + qr.make(fit=True) + buf = io.StringIO() + qr.print_ascii(out=buf, invert=True) + return buf.getvalue() def print_qr_code(url: str, *, include_link: bool = True) -> None: """Print a QR code to stdout, with URL fallback if qrcode is missing.""" - qr_text = render_qr_terminal(url) - if qr_text: - print(qr_text) - else: - print(" (Install 'qrcode' for a scannable QR code: pip install qrcode)") + print(render_qr_terminal(url) or " (Install 'qrcode' for a scannable QR code: pip install qrcode)") if include_link: print(f" Link: {url}") def generate_username_slug(length: int = 16) -> str: - """Generate a base32-ish slug for Telegram username correlation. - - Sixteen characters from a 32-symbol alphabet gives 80 bits of entropy while - keeping ``hermes_<slug>_bot`` under Telegram's 32-character username limit. - """ + """Generate a base32-ish slug for Telegram username correlation.""" return "".join(secrets.choice(_USERNAME_SLUG_ALPHABET) for _ in range(length)) def generate_bot_username(profile_name: Optional[str] = None) -> str: """Generate a secure suggested bot username like ``hermes_<slug>_bot``. - ``profile_name`` is accepted for backward compatibility with the original - PoC, but is intentionally not embedded in the username. The username has to - carry enough entropy for backend correlation. + ``profile_name`` is accepted for backward compatibility but intentionally not embedded: the + username must carry enough entropy for backend correlation. """ _ = profile_name return f"hermes_{generate_username_slug()}_bot" -def generate_deep_link( - manager_bot: str = DEFAULT_MANAGER_BOT, - suggested_username: Optional[str] = None, - suggested_name: Optional[str] = None, -) -> str: - """Build a ``t.me/newbot`` deep link for managed bot creation.""" - manager = manager_bot.lstrip("@") - username = suggested_username or generate_bot_username() - base_url = ( - "https://t.me/newbot/" - f"{urllib.parse.quote(manager)}/" - f"{urllib.parse.quote(username)}" - ) - - if suggested_name: - params = urllib.parse.urlencode({"name": suggested_name}) - return f"{base_url}?{params}" - return base_url - - -def generate_pairing_nonce() -> str: - """Generate a legacy-compatible random nonce string. - - The new protocol uses service-created ``pairing_id`` + bearer - ``poll_token`` instead of a path nonce, but this helper is harmless and - still useful for callers/tests that need a generic random id. - """ - return secrets.token_hex(16) - - def create_pairing( - api_url: str | None = None, - bot_name: str = DEFAULT_BOT_NAME, - timeout: float = 10.0, + api_url: str | None = None, bot_name: str = DEFAULT_BOT_NAME, timeout: float = 10.0 ) -> TelegramPairing | None: """Create a Telegram onboarding pairing. - ``POST /v1/telegram/pairings`` returns the deep link, QR payload, public - pairing id, and secret poll token. The token is only used as a bearer - credential while polling. + ``POST /v1/telegram/pairings`` returns the deep link, QR payload, public pairing id, and secret + poll token. The token is only used as a bearer credential while polling. """ try: - resp = httpx.post( - f"{_api_url(api_url)}/v1/telegram/pairings", - json={"bot_name": bot_name}, - timeout=timeout, - ) + resp = httpx.post(f"{_api_url(api_url)}/v1/telegram/pairings", json={"bot_name": bot_name}, timeout=timeout) if resp.status_code not in (200, 201): return None data = resp.json() @@ -196,19 +137,14 @@ def create_pairing( expires_at = data.get("expires_at") return TelegramPairing( - pairing_id=data["pairing_id"], - poll_token=data["poll_token"], - suggested_username=data["suggested_username"], - deep_link=data["deep_link"], + *(data[key] for key in required), qr_payload=qr_payload, expires_at=expires_at if isinstance(expires_at, str) else None, ) def poll_pairing_result_once( - api_url: str | None, - pairing: TelegramPairing, - timeout: float = 10.0, + api_url: str | None, pairing: TelegramPairing, timeout: float = 10.0 ) -> TelegramBotSetupResult | None: """Poll the onboarding service once. Returns setup metadata when ready.""" resp = httpx.get( @@ -220,30 +156,23 @@ def poll_pairing_result_once( return None data = resp.json() - if data.get("status") != "ready": - return None token = data.get("token") - if not is_valid_telegram_bot_token(token): + if data.get("status") != "ready" or not is_valid_telegram_bot_token(token): return None - bot_username = data.get("bot_username") return TelegramBotSetupResult( token=token, - bot_username=bot_username - if isinstance(bot_username, str) and bot_username - else None, + bot_username=bot_username if isinstance(bot_username, str) and bot_username else None, owner_user_id=_parse_owner_user_id(data.get("owner_user_id")), ) -def poll_pairing_once( - api_url: str | None, - pairing: TelegramPairing, - timeout: float = 10.0, -) -> str | None: - """Poll the onboarding service once. Returns the token when ready.""" - result = poll_pairing_result_once(api_url, pairing, timeout=timeout) - return result.token if result else None +def _try_poll(api_url: str | None, pairing: TelegramPairing) -> TelegramBotSetupResult | None: + """One poll attempt; transport/JSON errors count as 'not ready yet'.""" + try: + return poll_pairing_result_once(api_url, pairing) + except (httpx.HTTPError, ValueError): + return None def poll_for_setup_result( @@ -255,27 +184,12 @@ def poll_for_setup_result( """Poll the pairing API until setup metadata is available or timeout.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: - try: - result = poll_pairing_result_once(api_url, pairing) - if result: - return result - except (httpx.HTTPError, ValueError): - pass + if result := _try_poll(api_url, pairing): + return result time.sleep(interval) return None -def poll_for_token( - api_url: str | None, - pairing: TelegramPairing, - timeout: float = DEFAULT_POLL_TIMEOUT, - interval: float = POLL_INTERVAL, -) -> Optional[str]: - """Poll the pairing API until the bot token is available or timeout.""" - result = poll_for_setup_result(api_url, pairing, timeout=timeout, interval=interval) - return result.token if result else None - - def auto_setup_telegram_bot_result( api_url: str | None = None, manager_bot: str = DEFAULT_MANAGER_BOT, @@ -297,16 +211,11 @@ def auto_setup_telegram_bot_result( print(" ✓ Pairing created") print(" Rendering QR code...") sys.stdout.flush() - print() - print(" Scan this QR code with your phone, or open the link below:") - print() + print("\n Scan this QR code with your phone, or open the link below:\n") print_qr_code(pairing.qr_payload, include_link=False) - print() - print(f" Link: {pairing.deep_link}") - print() + print(f"\n Link: {pairing.deep_link}\n") print(" When Telegram opens, tap 'Create Bot' to confirm.") - print(" (You can edit the bot display name before confirming)") - print() + print(" (You can edit the bot display name before confirming)\n") spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" start = time.monotonic() @@ -317,22 +226,14 @@ def auto_setup_telegram_bot_result( char = spinner_chars[idx % len(spinner_chars)] elapsed = int(time.monotonic() - start) remaining = max(0, int(poll_timeout - elapsed)) - sys.stdout.write( - f"\r {char} Waiting for bot creation... ({remaining}s remaining) " - ) + sys.stdout.write(f"\r {char} Waiting for bot creation... ({remaining}s remaining) ") sys.stdout.flush() idx += 1 - try: - result = poll_pairing_result_once(resolved_api_url, pairing) - if result: - sys.stdout.write( - "\r ✓ Bot created successfully! \n" - ) - sys.stdout.flush() - return result - except (httpx.HTTPError, ValueError): - pass + if result := _try_poll(resolved_api_url, pairing): + sys.stdout.write("\r ✓ Bot created successfully! \n") + sys.stdout.flush() + return result time.sleep(POLL_INTERVAL) sys.stdout.write("\r ✗ Timed out waiting for bot creation. \n") @@ -340,19 +241,3 @@ def auto_setup_telegram_bot_result( print(" The bot may still be created — check Telegram.") print(" You can paste the token manually below, or re-run setup.") return None - - -def auto_setup_telegram_bot( - api_url: str | None = None, - manager_bot: str = DEFAULT_MANAGER_BOT, - profile_name: Optional[str] = None, - poll_timeout: float = DEFAULT_POLL_TIMEOUT, -) -> Optional[str]: - """Run automatic Telegram bot creation and return only the bot token.""" - result = auto_setup_telegram_bot_result( - api_url=api_url, - manager_bot=manager_bot, - profile_name=profile_name, - poll_timeout=poll_timeout, - ) - return result.token if result else None diff --git a/hermes_cli/terminal_breadcrumbs.py b/hermes_cli/terminal_breadcrumbs.py index 8ea68af826..67defe1481 100644 --- a/hermes_cli/terminal_breadcrumbs.py +++ b/hermes_cli/terminal_breadcrumbs.py @@ -1,17 +1,9 @@ """Per-terminal session breadcrumbs for ``hermes -c`` / ``--continue``. -Each CLI session writes a tiny breadcrumb file -``$HERMES_HOME/terminal-sessions/<terminal-id>`` containing -``{"session_id": ..., "cwd": ..., "ts": ...}``. A bare ``hermes -c`` then -resumes the session that belongs to THIS terminal (tty / tmux pane / kitty -window / wezterm pane / ...) instead of the globally most-recent session — -so two terminals side by side each continue their own conversation. - -Everything here is strictly best-effort: no function raises, and when no -stable terminal identity can be derived (no tty and no known multiplexer -env var) breadcrumbs are skipped entirely and ``-c`` falls back to the -existing latest-session behavior. Gated by ``session.terminal_continue`` -in config.yaml (default true). +Everything here is strictly best-effort: no function raises, and when no stable terminal identity +can be derived (no tty and no known multiplexer env var) breadcrumbs are skipped entirely and ``-c`` +falls back to the existing latest-session behavior. Gated by ``session.terminal_continue`` in +config.yaml (default true). """ from __future__ import annotations @@ -57,9 +49,9 @@ def _sanitize(raw: str) -> str: def get_terminal_id() -> Optional[str]: """Derive a stable identity for the terminal this process runs in. - Prefers the real tty device path (stdin, then stdout), else the first - present multiplexer/emulator env var. Returns ``None`` when neither is - available — callers must then skip breadcrumbs entirely. + Prefers the real tty device path (stdin, then stdout), else the first present + multiplexer/emulator env var. Returns ``None`` when neither is available — callers must then + skip breadcrumbs entirely. """ for fd in (sys.stdin, sys.stdout): try: @@ -101,8 +93,8 @@ def _prune_stale(directory: Path, now: float) -> None: def write_breadcrumb(session_id: str, cwd: Optional[str] = None) -> None: """Record that this terminal's live session is ``session_id``. - Synchronous, best-effort, never raises. No-op when the feature is - disabled, the session id is empty, or no terminal identity exists. + Synchronous, best-effort, never raises. No-op when the feature is disabled, the session id is + empty, or no terminal identity exists. """ try: if not session_id or not is_enabled(): @@ -150,10 +142,9 @@ def read_breadcrumb() -> Optional[dict]: def resolve_breadcrumb_session() -> Optional[str]: """Resolve a bare ``-c`` for this terminal, or ``None`` to fall back. - Returns the breadcrumb's session id only when it still exists in the - session DB, projected forward through the compression chain so the - resume lands on the live tip rather than a dead compressed parent - (same projection as ``main._resolve_session_by_name_or_id``). + Returns the breadcrumb's session id only when it still exists in the session DB, projected + forward through the compression chain so the resume lands on the live tip rather than a dead + compressed parent (same projection as ``main._resolve_session_by_name_or_id``). """ if not is_enabled(): return None diff --git a/hermes_cli/terminal_notify.py b/hermes_cli/terminal_notify.py index 6c1877cd8c..47ff111761 100644 --- a/hermes_cli/terminal_notify.py +++ b/hermes_cli/terminal_notify.py @@ -1,18 +1,12 @@ """Terminal-native desktop notifications: OSC 9 and Warp's OSC 777 CLI-agent protocol. -Both emitters ride on the existing ``display.bell_on_prompt`` / -``display.bell_on_complete`` flags (see ``cli._ring_bell``) — no extra config. +- **OSC 9** (``ESC ] 9 ; <body> BEL``): Ghostty, iTerm2, Kitty and WezTerm raise an OS notification; +terminals that don't know the sequence drop it. - **OSC 777** (``ESC ] 777 ; notify ; warp://cli- +agent ; <json> BEL``): Warp's structured CLI-agent protocol (tab status + notification mailbox). -- **OSC 9** (``ESC ] 9 ; <body> BEL``): Ghostty, iTerm2, Kitty and WezTerm - raise an OS notification; terminals that don't know the sequence drop it. -- **OSC 777** (``ESC ] 777 ; notify ; warp://cli-agent ; <json> BEL``): Warp's - structured CLI-agent protocol (tab status + notification mailbox). Only sent - when Warp advertises support and the build is newer than the last release - that set the protocol var without being able to render the payload. - -Sequences are written to ``/dev/tty`` because prompt_toolkit's stdout wrapper -can buffer or strip raw escapes; when ``/dev/tty`` can't be opened (Windows, -no controlling terminal) they fall back to ``sys.stdout``. Never raises. +Sequences are written to ``/dev/tty`` because prompt_toolkit's stdout wrapper can buffer or strip +raw escapes; when ``/dev/tty`` can't be opened (Windows, no controlling terminal) they fall back to +``sys.stdout``. Never raises. """ from __future__ import annotations @@ -68,11 +62,7 @@ def warp_supported(env=None) -> bool: def warp_osc777(event: str, detail: str, session_id: str = "") -> str: - """OSC 777 ``warp://cli-agent`` notification; ``event`` is ``stop`` or ``permission_request``. - - Payload mirrors the reference plugin's build-payload.sh: common fields plus - ``summary`` (permission_request) or ``response`` (stop), truncated to 200. - """ + """OSC 777 ``warp://cli-agent`` notification; ``event`` is ``stop`` or ``permission_request``.""" try: advertised = int(os.environ.get("WARP_CLI_AGENT_PROTOCOL_VERSION", "1")) except ValueError: diff --git a/hermes_cli/timefmt.py b/hermes_cli/timefmt.py index cf32c926d8..eccb5afe2c 100644 --- a/hermes_cli/timefmt.py +++ b/hermes_cli/timefmt.py @@ -1,10 +1,4 @@ -"""Small shared time-formatting helpers for CLI output. - -Public home for helpers that used to live as private functions on -``hermes_cli.main`` — importing that module drags in the whole CLI -surface, which lightweight consumers (``hermes status``, dump tooling) -should not pay for. -""" +"""Small shared time-formatting helpers for CLI output.""" from __future__ import annotations diff --git a/hermes_cli/timeouts.py b/hermes_cli/timeouts.py index d4633fe206..7480634319 100644 --- a/hermes_cli/timeouts.py +++ b/hermes_cli/timeouts.py @@ -6,67 +6,48 @@ def _coerce_timeout(raw: object) -> float | None: timeout = float(raw) except (TypeError, ValueError): return None - if timeout <= 0: + return timeout if timeout > 0 else None + + +def _configured_timeout( + provider_id: str, model: str | None, model_key: str, provider_key: str +) -> float | None: + """Per-model ``providers.<id>.models.<model>.<model_key>`` wins over ``providers.<id>.<provider_key>``.""" + if not provider_id: return None - return timeout + + try: + from hermes_cli.config import load_config_readonly + config = load_config_readonly() + except Exception: + return None + + providers = config.get("providers", {}) if isinstance(config, dict) else {} + provider_config = providers.get(provider_id, {}) if isinstance(providers, dict) else {} + if not isinstance(provider_config, dict): + return None + + model_config = _get_model_config(provider_config, model) + if model_config is not None: + timeout = _coerce_timeout(model_config.get(model_key)) + if timeout is not None: + return timeout + + return _coerce_timeout(provider_config.get(provider_key)) def get_provider_request_timeout( provider_id: str, model: str | None = None ) -> float | None: """Return a configured provider request timeout in seconds, if any.""" - if not provider_id: - return None - - try: - from hermes_cli.config import load_config_readonly - config = load_config_readonly() - except Exception: - return None - - providers = config.get("providers", {}) if isinstance(config, dict) else {} - provider_config = ( - providers.get(provider_id, {}) if isinstance(providers, dict) else {} - ) - if not isinstance(provider_config, dict): - return None - - model_config = _get_model_config(provider_config, model) - if model_config is not None: - timeout = _coerce_timeout(model_config.get("timeout_seconds")) - if timeout is not None: - return timeout - - return _coerce_timeout(provider_config.get("request_timeout_seconds")) + return _configured_timeout(provider_id, model, "timeout_seconds", "request_timeout_seconds") def get_provider_stale_timeout( provider_id: str, model: str | None = None ) -> float | None: """Return a configured non-stream stale timeout in seconds, if any.""" - if not provider_id: - return None - - try: - from hermes_cli.config import load_config_readonly - config = load_config_readonly() - except Exception: - return None - - providers = config.get("providers", {}) if isinstance(config, dict) else {} - provider_config = ( - providers.get(provider_id, {}) if isinstance(providers, dict) else {} - ) - if not isinstance(provider_config, dict): - return None - - model_config = _get_model_config(provider_config, model) - if model_config is not None: - timeout = _coerce_timeout(model_config.get("stale_timeout_seconds")) - if timeout is not None: - return timeout - - return _coerce_timeout(provider_config.get("stale_timeout_seconds")) + return _configured_timeout(provider_id, model, "stale_timeout_seconds", "stale_timeout_seconds") def _get_model_config( @@ -77,6 +58,4 @@ def _get_model_config( models = provider_config.get("models", {}) model_config = models.get(model, {}) if isinstance(models, dict) else {} - if isinstance(model_config, dict): - return model_config - return None + return model_config if isinstance(model_config, dict) else None diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 0a8f89b4df..66463de97f 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -476,12 +476,7 @@ TIPS = [ def get_random_tip(exclude_recent: int = 0) -> str: - """Return a random tip string. - - Args: - exclude_recent: not used currently; reserved for future - deduplication across sessions. - """ + """Return a random tip string.""" return random.choice(TIPS) diff --git a/hermes_cli/urllib_security.py b/hermes_cli/urllib_security.py index ac34411ef7..f9dac6e429 100644 --- a/hermes_cli/urllib_security.py +++ b/hermes_cli/urllib_security.py @@ -41,6 +41,13 @@ def url_origin(url: str) -> tuple[str, str, int | None]: ) +def _strip_headers(request, keep: frozenset[str]) -> None: + """Drop every header on *request* whose lowercased name is not in *keep*.""" + for name, _value in list(request.header_items()): + if name.lower() not in keep: + request.remove_header(name) + + class SafeCredentialRedirectHandler(urllib.request.HTTPRedirectHandler): """Preserve request headers only while redirects stay on one origin.""" @@ -65,9 +72,7 @@ class SafeCredentialRedirectHandler(urllib.request.HTTPRedirectHandler): if url_origin(resolved_url) != self._original_origin: # Use an allowlist rather than guessing credential header names. # normalize_extra_headers permits arbitrary secret-bearing names. - for name, _value in list(redirected.header_items()): - if name.lower() not in self._cross_origin_safe_headers: - redirected.remove_header(name) + _strip_headers(redirected, self._cross_origin_safe_headers) return redirected @@ -87,9 +92,7 @@ class _CrossOriginRequestSanitizer(urllib.request.BaseHandler): def _sanitize(self, request: urllib.request.Request): if url_origin(request.full_url) != self._original_origin: - for name, _value in list(request.header_items()): - if name.lower() not in _CROSS_ORIGIN_SAFE_HEADERS: - request.remove_header(name) + _strip_headers(request, _CROSS_ORIGIN_SAFE_HEADERS) return request http_request = _sanitize @@ -99,13 +102,7 @@ class _CrossOriginRequestSanitizer(urllib.request.BaseHandler): def _resolved_https_context() -> ssl.SSLContext | None: """Return the explicit CA context for Hermes-owned urllib openers.""" ca_bundle = next( - ( - value - for name in _CA_BUNDLE_ENV_VARS - if (value := os.getenv(name, "").strip()) - ), - "", - ) + (value for name in _CA_BUNDLE_ENV_VARS if (value := os.getenv(name, "").strip())), "") if ca_bundle: ca_path = Path(ca_bundle).expanduser() if ca_path.is_file(): @@ -141,35 +138,26 @@ def _resolved_https_context() -> ssl.SSLContext | None: def _secure_opener_from_installed_policy(original_url: str, *, ssl_context=None): """Clone the installed opener's handlers, replacing redirect policy only. - When ``ssl_context`` is provided, the cloned HTTPS handler is replaced with - one bound to that context so per-provider TLS settings (``ssl_ca_cert`` / - ``ssl_verify``) apply to this request. When it is None, Hermes-owned - openers get an explicit CA default via ``_resolved_https_context`` (env - bundle first, certifi on macOS); an application-installed opener's TLS - policy is preserved unchanged. + With ``ssl_context`` the cloned HTTPS handler is bound to it so per-provider TLS settings + (``ssl_ca_cert``/``ssl_verify``) apply. With None, Hermes-owned openers get an explicit CA + default via ``_resolved_https_context``; an application-installed opener's TLS policy is + preserved. """ installed = getattr(urllib.request, "_opener", None) if installed is None: context = _resolved_https_context() - if context is None: - installed = urllib.request.build_opener() - else: - installed = urllib.request.build_opener( - urllib.request.HTTPSHandler(context=context) - ) + installed = urllib.request.build_opener( + *([] if context is None else [urllib.request.HTTPSHandler(context=context)])) _https_handler_cls = getattr(urllib.request, "HTTPSHandler", None) + replace_https = ssl_context is not None and _https_handler_cls is not None handlers = [ copy.copy(handler) for handler in getattr(installed, "handlers", ()) if not isinstance(handler, urllib.request.HTTPRedirectHandler) - and not ( - ssl_context is not None - and _https_handler_cls is not None - and isinstance(handler, _https_handler_cls) - ) + and not (replace_https and isinstance(handler, _https_handler_cls)) ] - if ssl_context is not None and _https_handler_cls is not None: + if replace_https: handlers.append(_https_handler_cls(context=ssl_context)) handlers.append(SafeCredentialRedirectHandler(original_url)) handlers.append(_CrossOriginRequestSanitizer(original_url)) @@ -177,11 +165,7 @@ def _secure_opener_from_installed_policy(original_url: str, *, ssl_context=None) # OpenerDirector injects addheaders after request processors, which would # bypass the sanitizer on redirects. Carry them on the initial request # instead, then leave the rebuilt opener's late-injection list empty. - setattr( - secured, - "_hermes_initial_addheaders", - list(getattr(installed, "addheaders", ())), - ) + secured._hermes_initial_addheaders = list(getattr(installed, "addheaders", ())) secured.addheaders = [] return secured @@ -195,15 +179,12 @@ def open_credentialed_url( ): """Open a request without forwarding credentials across origins. - The default preserves an application-installed opener's proxy, TLS, - cookies, custom protocol handlers, and instrumentation while replacing its - redirect handler. ``opener_factory`` is an explicit test seam; security is - never disabled based on global ``urlopen`` identity. + The default preserves an application-installed opener's proxy, TLS, cookies, custom protocol + handlers, and instrumentation while replacing its redirect handler. ``opener_factory`` is an + explicit test seam; security is never disabled based on global ``urlopen`` identity. - ``ssl_context`` (an ``ssl.SSLContext``) overrides the HTTPS handler's TLS - policy for this request only. It is used to honor a custom provider's - ``ssl_ca_cert`` / ``ssl_verify`` on the ``/models`` discovery path, which - otherwise falls back to the process-wide ``SSL_CERT_FILE`` / certifi bundle. + ``ssl_context`` (an ``ssl.SSLContext``) overrides the HTTPS handler's TLS policy for this + request only. """ if opener_factory is None: opener = _secure_opener_from_installed_policy( diff --git a/hermes_cli/verify_cmd.py b/hermes_cli/verify_cmd.py index aae05029e3..ec7c023f61 100644 --- a/hermes_cli/verify_cmd.py +++ b/hermes_cli/verify_cmd.py @@ -1,14 +1,4 @@ -"""``hermes verify`` — detect a project's run recipe and smoke-test it. - -Scoped port of superagent-ai/grok-cli's verify subsystem entrypoint. -Statically detects the project kind (or loads the saved manifest at -``.hermes/environment.json``), then runs bootstrap/build/test phases and an -optional background start + readiness poll, printing an evidence summary. - -Completed runs are recorded into the coding verification evidence ledger -(:mod:`agent.verification_evidence`), so a passing ``hermes verify`` satisfies -the verify-on-stop guard the same way a passing canonical test command does. -""" +"""``hermes verify`` — detect a project's run recipe and smoke-test it.""" from __future__ import annotations @@ -88,17 +78,8 @@ def run_verify_command(args) -> int: def _merge_project_facts_commands(root: Path, recipe) -> None: """Fold ``detect_project_facts`` verify commands into a detected recipe. - Layer ownership: ``agent.coding_context`` owns the cheap prompt-time facts - (test/lint/build commands surfaced in the workspace snapshot and the - verify-on-stop nudge); ``agent.verify.recipes`` owns the deep runtime - recipe (framework, start command, port, readiness). When the two disagree - the runtime recipe must not *lose* commands the prompt layer already - promised the model — e.g. ``scripts/run_tests.sh`` or a ``pytest`` config - the recipe detector doesn't know about — so any project-facts verify - command not already covered is appended to the recipe's test list. - - Never applied to a saved manifest (the user-edited manifest is the source - of truth) and never raises: this is a best-effort union. + Never applied to a saved manifest (the user-edited manifest is the source of truth) and never + raises: this is a best-effort union. """ try: from agent.coding_context import detect_project_facts @@ -117,10 +98,9 @@ def _merge_project_facts_commands(root: Path, recipe) -> None: def _record_evidence(root: Path, recipe, result, *, partial: bool) -> None: """Record the completed run into the verification evidence ledger. - Best-effort and fail-silent: a ledger problem must never change the CLI's - exit code or output. ``partial`` (an explicit ``--phase`` subset or - ``--skip-start``) downgrades the scope to ``targeted`` so a partial pass - is never presented as a full workspace green. + Best-effort and fail-silent: a ledger problem must never change the CLI's exit code or output. + ``partial`` (an explicit ``--phase`` subset or ``--skip-start``) downgrades the scope to + ``targeted`` so a partial pass is never presented as a full workspace green. """ try: from agent.verification_evidence import record_verify_run diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py index 3c4cddbb08..ee376f57e8 100644 --- a/hermes_cli/voice.py +++ b/hermes_cli/voice.py @@ -1,29 +1,11 @@ -"""Process-wide voice recording + TTS API for the TUI gateway. - -Wraps ``tools.voice_mode`` (recording/transcription) and ``tools.tts_tool`` -(text-to-speech) behind idempotent, stateful entry points that the gateway's -``voice.record``, ``voice.toggle``, and ``voice.tts`` JSON-RPC handlers can -call from a dedicated thread. The gateway imports this module lazily so that -missing optional audio deps (sounddevice, faster-whisper, numpy) surface as -an ``ImportError`` at call time, not at startup. - -Two usage modes are exposed: - -* **Push-to-talk** (``start_recording`` / ``stop_and_transcribe``) — single - manually-bounded capture used when the caller drives the start/stop pair - explicitly. -* **Continuous (VAD)** (``start_continuous`` / ``stop_continuous``) — mirrors - the classic CLI voice mode: recording auto-stops on silence, transcribes, - hands the result to a callback, and then auto-restarts for the next turn. - Three consecutive no-speech cycles stop the loop and fire - ``on_silent_limit`` so the UI can turn the mode off. -""" +"""Process-wide voice recording + TTS API for the TUI gateway.""" from __future__ import annotations import json import logging import os +import re import sys import threading from typing import Any, Callable, Optional @@ -87,62 +69,37 @@ _DEFAULT_PT_KEY = "c-b" def voice_record_key_from_config(cfg: Any) -> Any: """Shape-safe ``cfg.voice.record_key`` lookup. - ``load_config()`` deep-merges raw YAML and preserves scalar - overrides, so a hand-edited ``voice: true`` / ``voice: cmd+b`` - leaves ``cfg["voice"]`` as a bool/str instead of a dict, and the - naive ``.get("voice", {}).get("record_key")`` chain raises - AttributeError before voice can even start (Copilot round-11 on - #19835). Return ``None`` for malformed shapes so call sites can - feed the result straight into the normalizer/formatter and get - the documented default. + ``load_config()`` deep-merges raw YAML and preserves scalar overrides, so a hand-edited ``voice: + true`` / ``voice: cmd+b`` leaves ``cfg["voice"]`` as a bool/str instead of a dict, and the naive + ``.get("voice", {}).get("record_key")`` chain raises AttributeError before voice can even start + (Copilot round-11 on #19835). """ - if not isinstance(cfg, dict): - return None - - voice = cfg.get("voice") - if not isinstance(voice, dict): - return None - - return voice.get("record_key") + voice = cfg.get("voice") if isinstance(cfg, dict) else None + return voice.get("record_key") if isinstance(voice, dict) else None def normalize_voice_record_key_for_prompt_toolkit(raw: Any) -> str: """Coerce ``voice.record_key`` into prompt_toolkit's ``c-x`` / ``a-x`` format. - Mirrors the TUI parser contract (``ui-tui/src/lib/platform.ts``) - so one config value binds the same shortcut in both runtimes: + Mirrors the TUI parser contract (``ui-tui/src/lib/platform.ts``) so one config value binds the + same shortcut in both runtimes: - * non-string / empty / typo'd / bare-char / multi-modifier / reserved - ``ctrl+c|d|l`` → documented default ``c-b`` - * single-char keys: ``ctrl+o`` → ``c-o`` - * named keys: ``ctrl+space`` → ``c-space`` (aliases collapse: - ``ctrl+return`` → ``c-enter``) - * ``super`` / ``win`` / ``windows`` → ``c-b`` (TUI-only modifiers — - prompt_toolkit has no super mod; the CLI binding site is - expected to warn when this fallback fires so users see the - cross-runtime split, Copilot round-11 on #19835) + * non-string / empty / typo'd / bare-char / multi-modifier / reserved ``ctrl+c|d|l`` → + documented default ``c-b`` * single-char keys: ``ctrl+o`` → ``c-o`` * named keys: ``ctrl+space`` + → ``c-space`` (aliases collapse: ``ctrl+return`` → ``c-enter``) * ``super`` / ``win`` / + ``windows`` → ``c-b`` (TUI-only modifiers — prompt_toolkit has no super mod; the CLI binding + site is expected to warn when this fallback fires so users see the cross-runtime split, Copilot + round-11 on #19835) """ if not isinstance(raw, str): return _DEFAULT_PT_KEY - lowered = raw.strip().lower() - if not lowered: - return _DEFAULT_PT_KEY - - parts = [p.strip() for p in lowered.split("+") if p.strip()] - if not parts: - return _DEFAULT_PT_KEY - - # Multi-modifier chords like ``ctrl+alt+r`` bind different shortcuts - # in prompt_toolkit (a-c-r form) and hermes-ink rejects them; collapse - # to the documented default instead of silently diverging. - if len(parts) > 2: - return _DEFAULT_PT_KEY - - # Bare char / bare named key (no explicit modifier) — the CLI's - # prompt_toolkit binds the raw key without a modifier, which the TUI - # parser refuses; reject here too so both runtimes agree. - if len(parts) == 1: + parts = [p.strip() for p in raw.strip().lower().split("+") if p.strip()] + # Exactly ``modifier+key``. Multi-modifier chords like ``ctrl+alt+r`` bind + # different shortcuts in prompt_toolkit (a-c-r form) and hermes-ink rejects + # them; a bare char / named key (no modifier) is refused by the TUI parser. + # Both collapse to the documented default so the runtimes agree. + if len(parts) != 2: return _DEFAULT_PT_KEY modifier_token, key_token = parts @@ -163,32 +120,22 @@ def normalize_voice_record_key_for_prompt_toolkit(raw: Any) -> str: # Single-char key: reject reserved-ctrl chords that the TUI would # also block at parse time, plus the mac-only alt reservation. if len(key_token) == 1: - if normalized_mod == "c-" and key_token in _VOICE_RESERVED_CTRL_CHARS: - return _DEFAULT_PT_KEY - if ( - normalized_mod == "a-" - and sys.platform == "darwin" - and key_token in _VOICE_RESERVED_ALT_CHARS_MAC - ): - return _DEFAULT_PT_KEY - return f"{normalized_mod}{key_token}" + reserved = ( + _VOICE_RESERVED_CTRL_CHARS if normalized_mod == "c-" + else _VOICE_RESERVED_ALT_CHARS_MAC if sys.platform == "darwin" + else frozenset() + ) + return _DEFAULT_PT_KEY if key_token in reserved else f"{normalized_mod}{key_token}" # Multi-char key token must be a known named key; typos like # ``ctrl+spcae`` fall back to the default rather than being passed # through as ``c-spcae`` (which prompt_toolkit would reject). named = _VOICE_NAMED_KEYS.get(key_token) - if not named: - return _DEFAULT_PT_KEY - - return f"{normalized_mod}{named}" + return f"{normalized_mod}{named}" if named else _DEFAULT_PT_KEY def pt_key_to_sequence(pt_key: str) -> tuple[str, ...]: - """Convert a prompt_toolkit key specifier (e.g. 'c-b' or 'a-v') to a sequence tuple. - - prompt_toolkit's ``@kb.add`` rejects 'a-x' strings directly (raises ValueError), - expecting ('escape', 'x') instead for Alt-modifier shortcuts. - """ + """Convert a prompt_toolkit key specifier (e.g. 'c-b' or 'a-v') to a sequence tuple.""" if isinstance(pt_key, str) and pt_key.startswith("a-"): return ("escape", pt_key[2:]) return (pt_key,) @@ -197,31 +144,14 @@ def pt_key_to_sequence(pt_key: str) -> tuple[str, ...]: def format_voice_record_key_for_status(raw: Any) -> str: """Render ``voice.record_key`` for ``/voice status`` in CLI-friendly form. - Mirrors the TUI's ``formatVoiceRecordKey``: returns ``Ctrl+B`` / - ``Alt+Space`` / ``Ctrl+Enter``. Malformed configs surface as the - documented default so status never advertises a shortcut that + Mirrors the TUI's ``formatVoiceRecordKey``: returns ``Ctrl+B`` / ``Alt+Space`` / ``Ctrl+Enter``. + Malformed configs surface as the documented default so status never advertises a shortcut that won't bind (Copilot round-10 on #19835). """ + # The normalizer only ever yields ``c-<key>`` / ``a-<key>`` (or the default ``c-b``). normalized = normalize_voice_record_key_for_prompt_toolkit(raw) - - if normalized.startswith("c-"): - prefix, key = "Ctrl+", normalized[2:] - elif normalized.startswith("a-"): - prefix, key = "Alt+", normalized[2:] - elif "+" in normalized: - # ``super+<key>`` / ``win+<key>`` — CLI won't bind them, but - # render in title case so status output is still readable. - mod, key = normalized.split("+", 1) - prefix = mod[0].upper() + mod[1:] + "+" - else: - return "Ctrl+B" - - if not key: - return prefix.rstrip("+") - - if len(key) == 1: - return prefix + key.upper() - + prefix = "Alt+" if normalized.startswith("a-") else "Ctrl+" + key = normalized[2:] return prefix + key[0].upper() + key[1:] @@ -239,15 +169,14 @@ logger = logging.getLogger(__name__) def _debug(msg: str) -> None: """Emit a debug breadcrumb when HERMES_VOICE_DEBUG=1. - Goes to stderr so the TUI gateway wraps it as a gateway.stderr event, - which createGatewayEventHandler shows as an Activity line — exactly - what we need to diagnose "why didn't the loop auto-restart?" in the - user's real terminal without shipping a separate debug RPC. + Goes to stderr so the TUI gateway wraps it as a gateway.stderr event, which + createGatewayEventHandler shows as an Activity line — exactly what we need to diagnose "why + didn't the loop auto-restart?" in the user's real terminal without shipping a separate debug + RPC. - Any OSError / BrokenPipeError is swallowed because this fires from - background threads (silence callback, TTS daemon, beep) where a - broken stderr pipe must not kill the whole gateway — the main - command pipe (stdin+stdout) is what actually matters. + Any OSError / BrokenPipeError is swallowed because this fires from background threads (silence + callback, TTS daemon, beep) where a broken stderr pipe must not kill the whole gateway — the + main command pipe (stdin+stdout) is what actually matters. """ if os.environ.get("HERMES_VOICE_DEBUG", "").strip() != "1": return @@ -276,10 +205,9 @@ def _beeps_enabled() -> bool: def _play_beep(frequency: int, count: int = 1) -> None: """Audible cue matching cli.py's record/stop beeps. - 880 Hz single-beep on start (cli.py:_voice_start_recording line 7532), - 660 Hz double-beep on stop (cli.py:_voice_stop_and_transcribe line 7585). - Best-effort — sounddevice failures are silently swallowed so the - voice loop never breaks because a speaker was unavailable. + 880 Hz single-beep on start (cli.py:_voice_start_recording line 7532), 660 Hz double-beep on + stop (cli.py:_voice_stop_and_transcribe line 7585). Best-effort — sounddevice failures are + silently swallowed so the voice loop never breaks because a speaker was unavailable. """ if not _beeps_enabled(): return @@ -290,6 +218,61 @@ def _play_beep(frequency: int, count: int = 1) -> None: except Exception as e: _debug(f"beep {frequency}Hz failed: {e}") + +def _safe_call(cb: Optional[Callable], *args: Any, warn: Optional[str] = None) -> None: + """Invoke an optional consumer callback, swallowing its exceptions. + + ``warn`` is a ``logger.warning`` format with one ``%s`` slot for the exception; without it + failures are silently ignored (status/limit callbacks are fire-and-forget). + """ + if not cb: + return + try: + cb(*args) + except Exception as e: + if warn: + logger.warning(warn, e) + + +def _transcribe_wav(wav_path: str, fail_msg: str, debug_prefix: Optional[str] = None) -> Optional[str]: + """Transcribe ``wav_path``, unlink it, and return the cleaned transcript (or None). + + transcribe_recording returns {"success": bool, "transcript": str, "error": str?} — NOT + {"text": str}. Using the wrong key silently produced empty transcripts even when Groq/local + STT returned fine, which masqueraded as "not hearing the user" to the caller. Empty text and + Whisper hallucinations are dropped; failures are logged with ``fail_msg``. + """ + try: + result = transcribe_recording(wav_path) + success = bool(result.get("success")) + text = (result.get("transcript") or "").strip() + if debug_prefix: + _debug( + f"{debug_prefix}: transcribe -> success={success} " + f"text={text!r} err={result.get('error')!r}" + ) + if success and text and not is_whisper_hallucination(text): + return text + except Exception as e: + logger.warning(fail_msg, e) + if debug_prefix: + _debug(f"{debug_prefix}: transcribe raised {type(e).__name__}: {e}") + finally: + try: + if os.path.isfile(wav_path): + os.unlink(wav_path) + except Exception: + pass + return None + + +def _deactivate(on_status: Optional[Callable[[str], None]] = None) -> None: + """Mark the continuous loop inactive and (optionally) report ``"idle"``.""" + global _continuous_active + with _continuous_lock: + _continuous_active = False + _safe_call(on_status, "idle") + # ── Push-to-talk state ─────────────────────────────────────────────── _recorder = None _recorder_lock = threading.Lock() @@ -326,9 +309,9 @@ _voice_busy_probe: Optional[Callable[[], bool]] = None def set_voice_busy_probe(probe: Optional[Callable[[], bool]]) -> None: """Register a callable that returns True while the agent is mid-turn. - Called by the hosting surface (tui_gateway registers one that checks - every session's ``running`` flag). ``None`` clears it. The probe must - be cheap and thread-safe — it runs on the silence-callback thread. + Called by the hosting surface (tui_gateway registers one that checks every session's ``running`` + flag). ``None`` clears it. The probe must be cheap and thread-safe — it runs on the silence- + callback thread. """ global _voice_busy_probe _voice_busy_probe = probe @@ -337,9 +320,8 @@ def set_voice_busy_probe(probe: Optional[Callable[[], bool]]) -> None: def _voice_activity_held() -> bool: """True while silent cycles must NOT count toward the no-speech limit. - Held when TTS is playing (the user is listening) or when the - registered busy probe reports the agent mid-turn (the user is - waiting). Fail-open to "not held" so a broken probe can never make + Held when TTS is playing (the user is listening) or when the registered busy probe reports the + agent mid-turn (the user is waiting). Fail-open to "not held" so a broken probe can never make the voice chat immortal. """ if not _tts_playing.is_set(): @@ -370,10 +352,7 @@ _CONTINUOUS_NO_SPEECH_LIMIT = 3 def start_recording() -> None: - """Begin capturing from the default input device (push-to-talk). - - Idempotent — calling again while a recording is in progress is a no-op. - """ + """Begin capturing from the default input device (push-to-talk).""" global _recorder with _recorder_lock: @@ -385,11 +364,7 @@ def start_recording() -> None: def stop_and_transcribe() -> Optional[str]: - """Stop the active push-to-talk recording, transcribe, return text. - - Returns ``None`` when no recording is active, when the microphone - captured no speech, or when Whisper returned a known hallucination. - """ + """Stop the active push-to-talk recording, transcribe, return text.""" global _recorder with _recorder_lock: @@ -402,28 +377,7 @@ def stop_and_transcribe() -> Optional[str]: wav_path = rec.stop() if not wav_path: return None - - try: - result = transcribe_recording(wav_path) - except Exception as e: - logger.warning("voice transcription failed: %s", e) - return None - finally: - try: - if os.path.isfile(wav_path): - os.unlink(wav_path) - except Exception: - pass - - # transcribe_recording returns {"success": bool, "transcript": str, ...} - # — matches cli.py:_voice_stop_and_transcribe's result.get("transcript"). - if not result.get("success"): - return None - text = (result.get("transcript") or "").strip() - if not text or is_whisper_hallucination(text): - return None - - return text + return _transcribe_wav(wav_path, "voice transcription failed: %s") # ── Continuous (VAD) API ───────────────────────────────────────────── @@ -441,30 +395,13 @@ def start_continuous( ) -> bool: """Start a VAD-driven continuous recording loop. - The loop calls ``on_transcript(text)`` each time speech is detected and - transcribed successfully. If ``auto_restart`` is True, it auto-restarts - for the next turn and resets the no-speech counter for that loop. If - ``auto_restart`` is False, the first silence-triggered transcription ends - the loop and reports ``"idle"``; no-speech counts are retained across - starts so a push-to-talk caller can still enforce the three-strikes guard. - After ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech - picked up at all) the loop stops itself and calls ``on_silent_limit`` so the - UI can reflect "voice off". Returns False if a previous stop is still - transcribing/cleaning up; otherwise returns True. Idempotent — calling while - already active is a successful no-op. - - ``on_status`` is called with ``"listening"`` / ``"transcribing"`` / - ``"idle"`` so the UI can show a live indicator. - ``max_recording_seconds`` is the hard cap on a single recording's length - (``voice.max_recording_seconds``); any non-positive or non-numeric value - disables the cap, preserving the previous unbounded behaviour. + (``voice.max_recording_seconds``); any non-positive or non-numeric value disables the cap, + preserving the previous unbounded behaviour. - ``on_stop_phrase`` is called with the (stripped) transcript when the user - utters a bare voice stop phrase (``voice.stop_phrases``, default "stop"). - The loop halts first, so the consumer only needs to reflect "voice off" — - exactly like the user pressing the manual stop control. When omitted, - ``on_silent_limit`` fires instead so legacy callers still turn voice off. + ``on_stop_phrase`` is called with the (stripped) transcript when the user utters a bare voice + stop phrase (``voice.stop_phrases``, default "stop"). The loop halts first, so the consumer only + needs to reflect "voice off" — exactly like the user pressing the manual stop control. """ global _continuous_active, _continuous_recorder, _continuous_auto_restart global _continuous_on_transcript, _continuous_on_status, _continuous_on_silent_limit @@ -489,19 +426,18 @@ def start_continuous( if _continuous_recorder is None: _continuous_recorder = create_audio_recorder() - - _continuous_recorder._silence_threshold = silence_threshold - _continuous_recorder._silence_duration = silence_duration + rec = _continuous_recorder + rec._silence_threshold = silence_threshold + rec._silence_duration = silence_duration # Same numeric-with-bool-excluded guard as the CLI wiring in # cli.py:_voice_start_recording — <= 0 (or garbage) disables the cap. - _continuous_recorder._max_recording_seconds = ( + rec._max_recording_seconds = ( max_recording_seconds if isinstance(max_recording_seconds, (int, float)) and not isinstance(max_recording_seconds, bool) and max_recording_seconds > 0 else 0.0 ) - rec = _continuous_recorder _debug( f"start_continuous: begin (threshold={silence_threshold}, duration={silence_duration}s)" @@ -517,26 +453,19 @@ def start_continuous( except Exception as e: logger.error("failed to start continuous recording: %s", e) _debug(f"start_continuous: rec.start raised {type(e).__name__}: {e}") - with _continuous_lock: - _continuous_active = False + _deactivate() raise - if on_status: - try: - on_status("listening") - except Exception: - pass - + _safe_call(on_status, "listening") return True def stop_continuous(force_transcribe: bool = False) -> None: """Stop the active continuous loop and release the microphone. - Idempotent — calling while not active is a no-op. If ``force_transcribe`` is - True, the recorder stops synchronously, then transcription/cleanup runs on a - background thread before reporting ``"idle"``. Otherwise the buffer is - discarded. + Idempotent — calling while not active is a no-op. If ``force_transcribe`` is True, the recorder + stops synchronously, then transcription/cleanup runs on a background thread before reporting + ``"idle"``. Otherwise the buffer is discarded. """ global _continuous_active, _continuous_on_transcript, _continuous_stopping global _continuous_on_status, _continuous_on_silent_limit @@ -564,11 +493,7 @@ def stop_continuous(force_transcribe: bool = False) -> None: if rec is not None: if force_transcribe and on_transcript: - if on_status: - try: - on_status("transcribing") - except Exception: - pass + _safe_call(on_status, "transcribing") try: wav_path = rec.stop() except Exception as e: @@ -583,82 +508,57 @@ def stop_continuous(force_transcribe: bool = False) -> None: global _continuous_no_speech_count, _continuous_stopping transcript: Optional[str] = None should_halt = False + if wav_path: + transcript = _transcribe_wav(wav_path, "failed to stop/transcribe recorder: %s") - try: - if wav_path: - try: - result = transcribe_recording(wav_path) - if result.get("success"): - text = (result.get("transcript") or "").strip() - if text and not is_whisper_hallucination(text): - transcript = text - finally: - if os.path.isfile(wav_path): - os.unlink(wav_path) - except Exception as e: - logger.warning("failed to stop/transcribe recorder: %s", e) - finally: - stop_phrase = bool(transcript and is_voice_stop_phrase(transcript)) - if stop_phrase: - # Bare stop phrase — explicit user intent to end the - # voice chat. Never sent to the agent; fire the - # dedicated signal so the consumer (TUI / desktop) - # ends the conversation instead of silently re-arming - # the next capture (with auto_restart=False the CLIENT - # drives the loop, so discarding the transcript alone - # would leave the conversation running forever). - _debug( - f"stop_continuous: stop phrase {transcript!r} — ending voice chat" - ) - stop_text = transcript or "" - transcript = None - try: - if on_stop_phrase is not None: - on_stop_phrase(stop_text) - elif on_silent_limit is not None: - on_silent_limit() - except Exception: - pass - if transcript: - try: - on_transcript(transcript) - except Exception as e: - logger.warning("on_transcript callback raised: %s", e) + stop_phrase = bool(transcript and is_voice_stop_phrase(transcript)) + if stop_phrase: + # Bare stop phrase — explicit user intent to end the + # voice chat. Never sent to the agent; fire the + # dedicated signal so the consumer (TUI / desktop) + # ends the conversation instead of silently re-arming + # the next capture (with auto_restart=False the CLIENT + # drives the loop, so discarding the transcript alone + # would leave the conversation running forever). + _debug( + f"stop_continuous: stop phrase {transcript!r} — ending voice chat" + ) + stop_text = transcript or "" + transcript = None + if on_stop_phrase is not None: + _safe_call(on_stop_phrase, stop_text) + else: + _safe_call(on_silent_limit) + if transcript: + _safe_call(on_transcript, transcript, warn="on_transcript callback raised: %s") - if track_no_speech: - held = _voice_activity_held() - with _continuous_lock: - if transcript or stop_phrase: - _continuous_no_speech_count = 0 - elif held: - # Agent busy / TTS playing — the user is - # correctly silent; don't count the cycle. - _debug( - "stop_continuous: silent cycle ignored " - "(agent busy or TTS playing)" - ) - else: - _continuous_no_speech_count += 1 - should_halt = ( - _continuous_no_speech_count - >= _CONTINUOUS_NO_SPEECH_LIMIT - ) - if should_halt: - _continuous_no_speech_count = 0 - if should_halt and on_silent_limit: - try: - on_silent_limit() - except Exception: - pass - - _play_beep(frequency=660, count=2) + if track_no_speech: + held = _voice_activity_held() with _continuous_lock: - _continuous_stopping = False - if on_status: - try: - on_status("idle") - except Exception: - pass + if transcript or stop_phrase: + _continuous_no_speech_count = 0 + elif held: + # Agent busy / TTS playing — the user is + # correctly silent; don't count the cycle. + _debug( + "stop_continuous: silent cycle ignored " + "(agent busy or TTS playing)" + ) + else: + _continuous_no_speech_count += 1 + should_halt = ( + _continuous_no_speech_count + >= _CONTINUOUS_NO_SPEECH_LIMIT + ) + if should_halt: + _continuous_no_speech_count = 0 + if should_halt: + _safe_call(on_silent_limit) + + _play_beep(frequency=660, count=2) + with _continuous_lock: + _continuous_stopping = False + _safe_call(on_status, "idle") threading.Thread(target=_transcribe_and_cleanup, daemon=True).start() return @@ -676,12 +576,7 @@ def stop_continuous(force_transcribe: bool = False) -> None: # Audible "recording stopped" cue (CLI parity: same 660 Hz × 2 the # silence-auto-stop path plays). _play_beep(frequency=660, count=2) - - if on_status: - try: - on_status("idle") - except Exception: - pass + _safe_call(on_status, "idle") def is_continuous_active() -> bool: @@ -693,9 +588,9 @@ def is_continuous_active() -> bool: def _continuous_on_silence() -> None: """AudioRecorder silence callback — runs in a daemon thread. - Stops the current capture, transcribes, delivers the text via - ``on_transcript``, and — if the loop is still active — starts the - next capture. Three consecutive silent cycles end the loop. + Stops the current capture, transcribes, delivers text via ``on_transcript``, and — if the + loop is still active — starts the next capture. Three consecutive silent cycles end the + loop. """ global _continuous_active, _continuous_no_speech_count @@ -715,11 +610,7 @@ def _continuous_on_silence() -> None: _debug("_continuous_on_silence: no recorder — abort") return - if on_status: - try: - on_status("transcribing") - except Exception: - pass + _safe_call(on_status, "transcribing") wav_path = rec.stop() # Peak RMS is the critical diagnostic when stop() returns None despite @@ -735,32 +626,10 @@ def _continuous_on_silence() -> None: _play_beep(frequency=660, count=2) transcript: Optional[str] = None - if wav_path: - try: - result = transcribe_recording(wav_path) - # transcribe_recording returns {"success": bool, "transcript": str, - # "error": str?} — NOT {"text": str}. Using the wrong key silently - # produced empty transcripts even when Groq/local STT returned fine, - # which masqueraded as "not hearing the user" to the caller. - success = bool(result.get("success")) - text = (result.get("transcript") or "").strip() - err = result.get("error") - _debug( - f"_continuous_on_silence: transcribe -> success={success} " - f"text={text!r} err={err!r}" - ) - if success and text and not is_whisper_hallucination(text): - transcript = text - except Exception as e: - logger.warning("continuous transcription failed: %s", e) - _debug(f"_continuous_on_silence: transcribe raised {type(e).__name__}: {e}") - finally: - try: - if os.path.isfile(wav_path): - os.unlink(wav_path) - except Exception: - pass + transcript = _transcribe_wav( + wav_path, "continuous transcription failed: %s", "_continuous_on_silence" + ) stop_phrase = bool(transcript and is_voice_stop_phrase(transcript)) stop_text = (transcript or "") if stop_phrase else "" @@ -799,11 +668,8 @@ def _continuous_on_silence() -> None: ) no_speech = _continuous_no_speech_count - if transcript and on_transcript: - try: - on_transcript(transcript) - except Exception as e: - logger.warning("on_transcript callback raised: %s", e) + if transcript: + _safe_call(on_transcript, transcript, warn="on_transcript callback raised: %s") if should_halt: _debug( @@ -817,24 +683,11 @@ def _continuous_on_silence() -> None: # Explicit user-intent stop — distinct from the no-speech timeout # so consumers can report "voice chat ended" instead of "no # speech detected". - try: - on_stop_phrase(stop_text) - except Exception: - pass - elif on_silent_limit: - try: - on_silent_limit() - except Exception: - pass - try: - rec.cancel() - except Exception: - pass - if on_status: - try: - on_status("idle") - except Exception: - pass + _safe_call(on_stop_phrase, stop_text) + else: + _safe_call(on_silent_limit) + _safe_call(rec.cancel) + _safe_call(on_status, "idle") return # CLI parity (cli.py:10619-10621): wait for any in-flight TTS to @@ -863,52 +716,45 @@ def _continuous_on_silence() -> None: except Exception as e: logger.error("failed to restart continuous recording: %s", e) _debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}") - with _continuous_lock: - _continuous_active = False - if on_status: - try: - on_status("idle") - except Exception: - pass + _deactivate(on_status) return - if on_status: - try: - on_status("listening") - except Exception: - pass + _safe_call(on_status, "listening") else: # Do not auto-restart. Clean up state and notify idle. _debug("_continuous_on_silence: auto_restart=False, stopping loop") - with _continuous_lock: - _continuous_active = False - if on_status: - try: - on_status("idle") - except Exception: - pass + _deactivate(on_status) # ── TTS API ────────────────────────────────────────────────────────── +# Legacy markdown stripper used only when tools.tts_text_normalize is unavailable. +_LEGACY_TTS_STRIP = [ + (re.compile(r'```[\s\S]*?```'), ' '), # fenced code blocks + (re.compile(r'\[([^\]]+)\]\([^)]+\)'), r'\1'), # [text](url) → text + (re.compile(r'https?://\S+'), ''), # bare URLs + (re.compile(r'\*\*(.+?)\*\*'), r'\1'), # bold + (re.compile(r'\*(.+?)\*'), r'\1'), # italic + (re.compile(r'`(.+?)`'), r'\1'), # inline code + (re.compile(r'^#+\s*', re.MULTILINE), ''), # headers + (re.compile(r'^\s*[-*]\s+', re.MULTILINE), ''), # list bullets + (re.compile(r'---+'), ''), # horizontal rules + (re.compile(r'\n{3,}'), '\n\n'), # excess newlines +] + def _speak_text_streaming(text: str, stop_event: Optional[threading.Event] = None) -> bool: """Speak ``text`` via the generic streaming dispatcher; True on success. - Bridges the one-shot ``speak_text`` contract onto the shared - ``stream_tts_to_speaker`` pipeline (tools.tts_tool): the full reply is - fed as a single delta + end-of-text sentinel, and we block until the - pipeline's done event fires — same blocking semantics the sync path - has, so callers (and the mic re-arm logic in ``speak_text``) see no - behavioral difference beyond earlier first audio. + Bridges the one-shot ``speak_text`` contract onto the shared ``stream_tts_to_speaker`` pipeline + (tools.tts_tool): the full reply is fed as a single delta + end-of-text sentinel, and we block + until the pipeline's done event fires — same blocking semantics the sync path has, so callers + (and the mic re-arm logic in ``speak_text``) see no behavioral difference beyond earlier first + audio. - ``stop_event`` (optional) is wired straight into the pipeline so - external barge-in / stop paths can cut streaming playback — without - it the pipeline's stop event was private and speech over this path - was uninterruptible (the desktop/TUI fallback-speak hole). - - Returns False when playback produced nothing (caller falls back to the - whole-file sync path). + ``stop_event`` (optional) is wired straight into the pipeline so external barge-in / stop paths + can cut streaming playback — without it the pipeline's stop event was private and speech over + this path was uninterruptible (the desktop/TUI fallback-speak hole). """ import queue as _queue import threading as _threading @@ -928,21 +774,13 @@ def _speak_text_streaming(text: str, stop_event: Optional[threading.Event] = Non def speak_text(text: str, stop_event: Optional[threading.Event] = None) -> None: """Synthesize ``text`` with the configured TTS provider and play it. - Mirrors cli.py:_voice_speak_response exactly — same markdown strip - pipeline, same 4000-char cap, same explicit mp3 output path, same - MP3-over-OGG playback choice (afplay misbehaves on OGG), same cleanup - of both extensions. Keeping these in sync means a voice-mode TTS - session in the TUI sounds identical to one in the classic CLI. - - While playback is in flight the module-level _tts_playing Event is - cleared so the continuous-recording loop knows to wait before - re-arming the mic (otherwise the agent's spoken reply feedback-loops - through the microphone and the agent ends up replying to itself). + While playback is in flight the module-level _tts_playing Event is cleared so the continuous- + recording loop knows to wait before re-arming the mic (otherwise the agent's spoken reply + feedback-loops through the microphone and the agent ends up replying to itself). """ if not text or not text.strip(): return - import re import tempfile import time @@ -979,9 +817,11 @@ def speak_text(text: str, stop_event: Optional[threading.Event] = None) -> None: from tools.tts_streaming import resolve_streaming_provider from tools.tts_tool import _load_tts_config - if resolve_streaming_provider(_load_tts_config()) is not None: - if _speak_text_streaming(text, stop_event): - return + if ( + resolve_streaming_provider(_load_tts_config()) is not None + and _speak_text_streaming(text, stop_event) + ): + return except Exception as e: _debug(f"speak_text: streaming dispatch unavailable ({e}); using sync path") @@ -993,16 +833,9 @@ def speak_text(text: str, stop_event: Optional[threading.Event] = None) -> None: tts_text = prepare_spoken_text(text, max_chars=None) except Exception: # Legacy fallback pipeline — keep speak_text best-effort. - tts_text = re.sub(r'```[\s\S]*?```', ' ', text) # fenced code blocks - tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) → text - tts_text = re.sub(r'https?://\S+', '', tts_text) # bare URLs - tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold - tts_text = re.sub(r'\*(.+?)\*', r'\1', tts_text) # italic - tts_text = re.sub(r'`(.+?)`', r'\1', tts_text) # inline code - tts_text = re.sub(r'^#+\s*', '', tts_text, flags=re.MULTILINE) # headers - tts_text = re.sub(r'^\s*[-*]\s+', '', tts_text, flags=re.MULTILINE) # list bullets - tts_text = re.sub(r'---+', '', tts_text) # horizontal rules - tts_text = re.sub(r'\n{3,}', '\n\n', tts_text) # excess newlines + tts_text = text + for pattern, repl in _LEGACY_TTS_STRIP: + tts_text = pattern.sub(repl, tts_text) tts_text = tts_text.strip() if not tts_text: return @@ -1038,8 +871,7 @@ def speak_text(text: str, stop_event: Optional[threading.Event] = None) -> None: ) play_audio_file(play_path) played_any = True - cleanup_paths = set(play_paths + [mp3_path, mp3_path.rsplit(".", 1)[0] + ".ogg"]) - for path in cleanup_paths: + for path in set(play_paths + [mp3_path, mp3_path.rsplit(".", 1)[0] + ".ogg"]): if os.path.isfile(path): try: os.unlink(path) diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index 9b9de6cd5a..191a773d00 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -1,21 +1,14 @@ -"""hermes webhook — manage dynamic webhook subscriptions from the CLI. - -Usage: - hermes webhook subscribe <name> [options] - hermes webhook list - hermes webhook remove <name> - hermes webhook test <name> [--payload '{"key": "value"}'] - -Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are -hot-reloaded by the webhook adapter without a gateway restart. -""" +"""hermes webhook — manage dynamic webhook subscriptions from the CLI.""" +import hashlib +import hmac import json import os import re import secrets import tempfile import time +import urllib.request from pathlib import Path from typing import Dict @@ -149,14 +142,9 @@ def webhook_command(args): if not _require_webhook_enabled(): return - if sub in {"subscribe", "add"}: - _cmd_subscribe(args) - elif sub in {"list", "ls"}: - _cmd_list(args) - elif sub in {"remove", "rm"}: - _cmd_remove(args) - elif sub == "test": - _cmd_test(args) + handler = _ACTIONS.get(sub) + if handler is not None: + handler(args) def _cmd_subscribe(args): @@ -190,9 +178,9 @@ def _cmd_subscribe(args): return route["deliver_only"] = True - script = getattr(args, "script", "") or "" - if script.strip(): - route["script"] = script.strip() + script = (getattr(args, "script", "") or "").strip() + if script: + route["script"] = script if args.deliver_chat_id: route["deliver_extra"] = {"chat_id": args.deliver_chat_id} @@ -206,10 +194,7 @@ def _cmd_subscribe(args): print(f"\n {status} webhook subscription: {name}") print(f" URL: {base_url}/webhooks/{name}") print(f" Secret: {secret}") - if events: - print(f" Events: {', '.join(events)}") - else: - print(" Events: (all)") + print(f" Events: {', '.join(events) or '(all)'}") print(f" Deliver: {route['deliver']}") if route.get("deliver_only"): print(" Mode: direct delivery (no agent, zero LLM cost)") @@ -273,22 +258,15 @@ def _cmd_test(args): print(f" No subscription named '{name}'.") return - route = subs[name] - secret = route.get("secret", "") + secret = subs[name].get("secret", "") base_url = _get_webhook_base_url() url = f"{base_url}/webhooks/{name}" payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}' - - import hmac - import hashlib - sig = "sha256=" + hmac.new( - secret.encode(), payload.encode(), hashlib.sha256 - ).hexdigest() + sig = "sha256=" + hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() print(f" Sending test POST to {url}") try: - import urllib.request req = urllib.request.Request( url, data=payload.encode(), @@ -305,3 +283,11 @@ def _cmd_test(args): except Exception as e: print(f" Error: {e}") print(" Is the gateway running? (hermes gateway run)") + + +_ACTIONS = { + "subscribe": _cmd_subscribe, "add": _cmd_subscribe, + "list": _cmd_list, "ls": _cmd_list, + "remove": _cmd_remove, "rm": _cmd_remove, + "test": _cmd_test, +} diff --git a/hermes_cli/win_pty_bridge.py b/hermes_cli/win_pty_bridge.py index e9bc6ba541..04998df266 100644 --- a/hermes_cli/win_pty_bridge.py +++ b/hermes_cli/win_pty_bridge.py @@ -1,20 +1,7 @@ -"""Windows ConPTY bridge for the `hermes dashboard` chat tab. - -Drop-in counterpart to ``hermes_cli.pty_bridge.PtyBridge`` for native -Windows. Mirrors the exact public surface the ``/api/pty`` WebSocket -handler in ``hermes_cli.web_server`` consumes: ``spawn``, ``read``, -``write``, ``resize``, ``close``, ``is_available``, plus the -``PtyUnavailableError`` type. - -Backed by ``pywinpty`` (already a declared win32 dependency in -pyproject.toml) instead of ``ptyprocess``/``fcntl``/``termios``, none of -which exist on native Windows. The read/write/terminate calls here match -the working winpty usage already shipping in ``tools/process_registry.py``. -""" +"""Windows ConPTY bridge for the `hermes dashboard` chat tab.""" from __future__ import annotations -import os import sys import time from typing import Optional, Sequence @@ -57,9 +44,9 @@ class PtyUnavailableError(RuntimeError): class WinPtyBridge: """pywinpty-backed bridge with the same interface as ``PtyBridge``. - ``web_server`` calls :meth:`read` inside ``run_in_executor``, so a - blocking/polling read here never stalls the event loop. ConPTY exposes - no selectable fd, so we poll with a short sleep instead of ``select``. + ``web_server`` calls :meth:`read` inside ``run_in_executor``, so a blocking/polling read here + never stalls the event loop. ConPTY exposes no selectable fd, so we poll with a short sleep + instead of ``select``. """ def __init__(self, proc: "PtyProcess") -> None: # type: ignore[name-defined] @@ -121,11 +108,7 @@ class WinPtyBridge: # -- I/O -------------------------------------------------------------- def read(self, timeout: float = 0.2) -> Optional[bytes]: - """Up to 64 KiB of child output. - - Returns bytes, ``b""`` when nothing is available this tick, or - ``None`` once the child has exited (EOF). - """ + """Up to 64 KiB of child output.""" if self._closed: return None try: diff --git a/hermes_cli/write_approval_commands.py b/hermes_cli/write_approval_commands.py index 5f399e0cc5..8fa473359d 100644 --- a/hermes_cli/write_approval_commands.py +++ b/hermes_cli/write_approval_commands.py @@ -1,17 +1,5 @@ #!/usr/bin/env python3 -"""Shared handlers for the /memory and /skills write-approval subcommands. - -Both the interactive CLI (``cli.py``) and the gateway (``gateway/run.py``) call -into this module so the pending-review UX (list / approve / reject / diff / -mode) lives in one place. Each caller owns only its surface concerns: -formatting the returned text and, for the gateway, persisting config + evicting -the cached agent on a mode change. - -Every public handler returns a plain text string suitable for both a terminal -and a chat message. Skill diffs are intentionally NOT inlined here — the -``diff`` handler returns the full diff for the CLI pager, but on a messaging -platform the gateway truncates it and points the user at the dashboard / file. -""" +"""Shared handlers for the /memory and /skills write-approval subcommands.""" from __future__ import annotations @@ -39,9 +27,8 @@ def _fmt_pending_list(subsystem: str) -> str: origin = r.get("origin", "foreground") tag = " [auto]" if origin == "background_review" else "" lines.append(f" {r['id']}{tag} {r.get('summary', '')}") - where = "/{s} approve <id>".format(s=subsystem) lines.append("") - lines.append(f"Apply: {where} Reject: /{subsystem} reject <id>") + lines.append(f"Apply: /{subsystem} approve <id> Reject: /{subsystem} reject <id>") if subsystem == wa.SKILLS: lines.append("Review full diff: /skills diff <id>") return "\n".join(lines) @@ -58,21 +45,12 @@ def handle_pending_subcommand( memory_store=None, set_mode_fn=None, ) -> Optional[str]: - """Dispatch a /memory or /skills subcommand. + """Dispatch a /memory or /skills write-approval subcommand. - Args: - subsystem: ``memory`` or ``skills``. - args: tokens after the slash command (e.g. ``["approve", "a1b2"]``). - memory_store: live MemoryStore for applying approved memory writes - (CLI passes ``self.agent._memory_store``; gateway applies against a - freshly loaded store). - set_mode_fn: optional callable ``(enabled: bool) -> None`` that - persists the new write_approval boolean to config (gateway provides - this; CLI uses its own ``save_config_value`` and passes a closure). - - Returns a text string to show the user. Returns None when the args are not - a write-approval subcommand (caller falls through to its other handling, - e.g. /skills search). + ``memory_store`` applies approved memory writes (CLI passes its live store; gateway a freshly + loaded one); ``set_mode_fn`` persists the write_approval boolean. Returns text for the user, + or None when the args are not a write-approval subcommand so the caller falls through to its + other handling (e.g. /skills search). """ if not args: # Bare /memory or /skills with no sub → show pending + gate state. @@ -83,32 +61,25 @@ def handle_pending_subcommand( if sub == "pending": return _fmt_pending_list(subsystem) - if sub in {"approve", "apply"}: return _approve(subsystem, rest, memory_store) - if sub in {"reject", "deny", "drop"}: return _reject(subsystem, rest) - if sub == "diff" and subsystem == wa.SKILLS: return _diff(rest) - if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias return _set_approval(subsystem, rest, set_mode_fn) - return None # not ours — caller handles -def _resolve_one(subsystem: str, rest: List[str]): - if not rest: - return None, f"Usage: /{subsystem} approve|reject <id> (or 'all')" - return rest[0], None +def _usage(subsystem: str) -> str: + return f"Usage: /{subsystem} approve|reject <id> (or 'all')" def _approve(subsystem: str, rest: List[str], memory_store) -> str: - target, err = _resolve_one(subsystem, rest) - if err or target is None: - return err or f"Usage: /{subsystem} approve <id>" + if not rest: + return _usage(subsystem) + target = rest[0] records = wa.list_pending(subsystem) if not records: @@ -146,24 +117,20 @@ def _apply_one(subsystem: str, rec, memory_store): return False, "memory store unavailable" from tools.memory_tool import apply_memory_pending result = apply_memory_pending(payload, memory_store) - return bool(result.get("success")), result.get("error", "") else: from tools.skill_manager_tool import apply_skill_pending result = json.loads(apply_skill_pending(payload)) - return bool(result.get("success")), result.get("error", "") + return bool(result.get("success")), result.get("error", "") except Exception as e: return False, str(e) def _reject(subsystem: str, rest: List[str]) -> str: - target, err = _resolve_one(subsystem, rest) - if err or target is None: - return err or f"Usage: /{subsystem} reject <id>" + if not rest: + return _usage(subsystem) + target = rest[0] if target.lower() == "all": - n = 0 - for rec in wa.list_pending(subsystem): - if wa.discard_pending(subsystem, rec["id"]): - n += 1 + n = sum(1 for rec in wa.list_pending(subsystem) if wa.discard_pending(subsystem, rec["id"])) return f"Rejected {n} pending {subsystem} write(s)." if wa.discard_pending(subsystem, target): return f"Rejected pending {subsystem} write '{target}'." @@ -182,21 +149,16 @@ def _diff(rest: List[str]) -> str: def _set_approval(subsystem: str, rest: List[str], set_mode_fn) -> str: - """Turn the approval gate on/off for a subsystem. - - ``set_mode_fn`` (when provided) persists the new boolean to config. - """ + """Turn the approval gate on/off for a subsystem.""" if not rest: return (f"{_fmt_state(subsystem)}\n" f"Set with: /{subsystem} approval <on|off>") arg = rest[0].strip().lower() - truthy = {"on", "true", "yes", "1", "enable", "enabled"} - falsey = {"off", "false", "no", "0", "disable", "disabled"} - if arg in truthy: - enabled = True - elif arg in falsey: - enabled = False - else: + enabled = { + **dict.fromkeys(("on", "true", "yes", "1", "enable", "enabled"), True), + **dict.fromkeys(("off", "false", "no", "0", "disable", "disabled"), False), + }.get(arg) + if enabled is None: return f"Invalid value '{arg}'. Use: on or off." if set_mode_fn is None: val = "true" if enabled else "false" diff --git a/hermes_constants.py b/hermes_constants.py index f3350d4689..0b8b494cae 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -1205,7 +1205,7 @@ def wsl_unc_path_to_posix(path: str) -> str | None: def translate_cwd_for_wsl_backend(cwd: str) -> str: - """Normalize a cross-boundary cwd when Hermes itself runs inside WSL. + r"""Normalize a cross-boundary cwd when Hermes itself runs inside WSL. A Windows-host UI (native picker / drive path / ``\\wsl.localhost\`` UNC) can hand the WSL backend a path it can't ``chdir`` into. Map it to the POSIX equivalent so the picker, sidebar,