From 0e5190cb9577b89667eeadb49f1ca7f1cf0cbb5f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:27:46 -0700 Subject: [PATCH 01/16] =?UTF-8?q?refactor(hermes=5Fcli):=20console=5Fengin?= =?UTF-8?q?e=20=E2=80=94=20merge=20command=20tables,=20closing()=20for=20S?= =?UTF-8?q?essionDB,=20inline=20single-use=20helpers,=20compact=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/console_engine.py | 533 ++++++++++++----------------------- 1 file changed, 181 insertions(+), 352 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index ae892f2eb6..a95ffd9b67 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -10,6 +10,7 @@ import importlib import io import json import sys +from contextlib import closing from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable, Literal, NoReturn, Sequence @@ -48,45 +49,36 @@ class _ArgumentParser(argparse.ArgumentParser): def _capture_output(fn: Callable[[], object]) -> str: - stdout = io.StringIO() - stderr = io.StringIO() - code = 0 - message = "" + stdout, stderr = io.StringIO(), io.StringIO() + code, message = 0, "" with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): try: result = fn() if isinstance(result, int) and result: raise SystemExit(result) except SystemExit as exc: - # sys.exit("msg") / raise SystemExit("msg") is the standard non-zero-exit idiom: - # exc.code is the message string, not an int. int() would raise ValueError here, - # which escapes execute()'s ConsoleCommandError handler and crashes the REPL. + # sys.exit("msg") carries the message as exc.code (not an int); int() on it would + # raise ValueError past execute()'s handler and crash the REPL. if isinstance(exc.code, str): - message = exc.code - code = 1 + message, code = exc.code, 1 else: code = int(exc.code or 0) except ConsoleCommandError: raise except RuntimeError as exc: - # Fail-closed config write guards raise RuntimeError (e.g. - # require_readable_config_before_write refusing an unparseable - # config.yaml). Convert to a console error instead of letting it - # escape execute() and kill the REPL / websocket session. - message = str(exc) - code = 1 + # Fail-closed config write guards raise RuntimeError; surface it as a console error + # instead of killing the REPL / websocket session. + message, code = str(exc), 1 text = stdout.getvalue() + stderr.getvalue() if code: - raise ConsoleCommandError(message.strip() or text.strip() or f"Command exited with status {code}") + raise ConsoleCommandError( + message.strip() or text.strip() or f"Command exited with status {code}") return text.rstrip() def _is_status_footer_rule(line: str) -> bool: stripped = _strip_ansi(line).strip() - if len(stripped) < 8: - return False - normalized = stripped.replace("\u2500", "-") - return set(normalized) <= {"-"} + return len(stripped) >= 8 and set(stripped.replace("\u2500", "-")) <= {"-"} def _strip_console_status_footer(text: str) -> str: @@ -95,15 +87,9 @@ def _strip_console_status_footer(text: str) -> str: lines.pop() if len(lines) < 2: return text.rstrip() - - last = _strip_ansi(lines[-1]).strip() - prev = _strip_ansi(lines[-2]).strip() - if not ( - prev.startswith("Run 'hermes doctor'") - and last.startswith("Run 'hermes setup'") - ): + last, prev = (_strip_ansi(lines[i]).strip() for i in (-1, -2)) + if not (prev.startswith("Run 'hermes doctor'") and last.startswith("Run 'hermes setup'")): return text.rstrip() - lines = lines[:-2] while lines and not _strip_ansi(lines[-1]).strip(): lines.pop() @@ -114,43 +100,38 @@ def _strip_console_status_footer(text: str) -> str: def _table_summary(summary: str, *, limit: int = 76) -> str: summary = " ".join(summary.split()) - if len(summary) <= limit: - return summary - return f"{summary[: limit - 3].rstrip()}..." + return summary if len(summary) <= limit else f"{summary[: limit - 3].rstrip()}..." def _split_line(line: str) -> list[str]: - try: - # Windows-safe splitter: plain shlex posix=True eats backslashes, so - # `sessions export C:\Users\me\out.jsonl` silently became a mangled - # relative filename in the cwd (#83934). - from hermes_cli._subprocess_compat import split_command_line + # Windows-safe splitter: plain shlex posix=True eats backslashes in paths. + from hermes_cli._subprocess_compat import split_command_line + try: return split_command_line(line) except ValueError as exc: raise ConsoleCommandError(f"Could not parse command: {exc}") from exc +_SHELL_TOKENS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<", "2>", "2>>"} + + def _contains_shell_syntax(line: str, tokens: Sequence[str]) -> bool: - if "$(" in line or "`" in line: - return True - shell_tokens = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<", "2>", "2>>"} - if any(token in shell_tokens for token in tokens): - return True - return any(ch in line for ch in "|<>;") + return ( + "$(" in line or "`" in line + or any(token in _SHELL_TOKENS for token in tokens) + or any(ch in line for ch in "|<>;")) def _format_sessions(sessions: Sequence[dict]) -> str: if not sessions: return "No sessions found." - lines = [f"{'ID':<32} {'Source':<12} {'Msgs':>5} Title / Preview"] - lines.append("-" * 82) + lines = [f"{'ID':<32} {'Source':<12} {'Msgs':>5} Title / Preview", "-" * 82] for session in sessions: sid = str(session.get("id") or "")[:32] source = str(session.get("source") or "-")[:12] messages = session.get("message_count") or 0 - title = session.get("title") or session.get("preview") or "" - title = str(title).replace("\n", " ")[:60] + title = str(session.get("title") or session.get("preview") or "").replace("\n", " ")[:60] lines.append(f"{sid:<32} {source:<12} {messages:>5} {title}") return "\n".join(lines) @@ -159,53 +140,37 @@ def _format_job(job: dict, action: str) -> str: from cron.jobs import effective_job_state job_id = job.get("id") or job.get("job_id") or "?" - name = job.get("name") or "(unnamed)" - state = effective_job_state(job) - return f"{action} job: {name} ({job_id}) [{state}]" - - -def _parser_root() -> tuple[_ArgumentParser, argparse._SubParsersAction]: - parser = _ArgumentParser(prog="hermes", add_help=False) - subparsers = parser.add_subparsers(dest="_console_command") - return parser, subparsers - - -def _subparser_actions(parser: argparse.ArgumentParser) -> list[argparse._SubParsersAction]: - return [ - action - for action in parser._actions - if isinstance(action, argparse._SubParsersAction) - ] - - -def _choice_help(action: argparse._SubParsersAction, name: str) -> str: - for choice in action._choices_actions: - if getattr(choice, "dest", None) == name or getattr(choice, "metavar", None) == name: - help_text = getattr(choice, "help", None) - if help_text and help_text is not argparse.SUPPRESS: - return str(help_text) - return "" + return f"{action} job: {job.get('name') or '(unnamed)'} ({job_id}) [{effective_job_state(job)}]" def _clean_summary(text: str | None) -> str: if not text or text is argparse.SUPPRESS: return "" summary = " ".join(str(text).split()) - if not summary or summary.startswith("Run `hermes "): - return "" - return summary + return "" if summary.startswith("Run `hermes ") else summary + + +def _choice_help(action: argparse._SubParsersAction, name: str) -> str: + for choice in action._choices_actions: + if name in (getattr(choice, "dest", None), getattr(choice, "metavar", None)): + help_text = getattr(choice, "help", None) + if help_text and help_text is not argparse.SUPPRESS: + return str(help_text) + return "" def _summaries_from_parser(parser: argparse.ArgumentParser) -> dict[tuple[str, ...], str]: + """``{subcommand path: help}`` over the whole tree; choice help beats child description.""" summaries: dict[tuple[str, ...], str] = {} def walk(current: argparse.ArgumentParser, path: tuple[str, ...]) -> None: - for action in _subparser_actions(current): + for action in current._actions: + if not isinstance(action, argparse._SubParsersAction): + continue for name, child in action.choices.items(): child_path = (*path, name) summary = _clean_summary(_choice_help(action, name)) or _clean_summary( - child.description - ) + child.description) if summary: summaries.setdefault(child_path, summary) walk(child, child_path) @@ -222,11 +187,9 @@ def _noop_console_command(_args: argparse.Namespace) -> None: 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``: ``extracted`` = ``builder(subparsers, =fn)`` (fn from hermes_cli.main); + ``registered`` = ``register(subparsers.add_parser(root))`` with optional module ``handler``; + ``builder`` = ``top = builder(subparsers)``, func from hermes_cli.main; ``adder`` wires itself. """ kind: Literal["extracted", "registered", "builder", "adder"] @@ -235,37 +198,32 @@ class _CliSurface: 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() + """Build a throwaway parser; ``live=False`` wires no-op handlers (summary extraction).""" + parser = _ArgumentParser(prog="hermes", add_help=False) + subparsers = parser.add_subparsers(dest="_console_command") module = importlib.import_module(self.module) entry = getattr(module, self.builder) + main_handler = ( + (lambda: getattr(importlib.import_module("hermes_cli.main"), self.handler)) + if live else (lambda: _noop_console_command)) 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}) + entry(subparsers, **{self.handler: main_handler()}) 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)) + top_parser.set_defaults(func=main_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. +# Memoized: the surface is process-static, but the dashboard opens a fresh engine per +# /api/console connection and would otherwise re-import + re-parse it on every reconnect. @functools.lru_cache(maxsize=None) def _surface_summaries(surface: _CliSurface, root: str) -> dict[tuple[str, ...], str]: try: @@ -274,30 +232,26 @@ def _surface_summaries(surface: _CliSurface, root: str) -> dict[tuple[str, ...], return {} -def _invoke_namespace(args: argparse.Namespace) -> object: - func = getattr(args, "func", None) - if not callable(func): - raise ConsoleCommandError("No handler is available for that console command.") - return func(args) - - def _dispatch( surface: _CliSurface, root: str, fixed: Sequence[str], args: Sequence[str], - namespace_update: Callable[[argparse.Namespace], None] | None = None, -) -> str: - parser = surface.build(root, live=True) - namespace = parser.parse_args([root, *fixed, *args]) + namespace_update: Callable[[argparse.Namespace], None] | None = None) -> str: + namespace = surface.build(root, live=True).parse_args([root, *fixed, *args]) if namespace_update: namespace_update(namespace) - return _capture_output(lambda: _invoke_namespace(namespace)) + func = getattr(namespace, "func", None) + if not callable(func): + raise ConsoleCommandError("No handler is available for that console command.") + return _capture_output(lambda: func(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()] + """``"list, snapshot export"`` -> ``[("list",), ("snapshot", "export")]``; ``"."`` is root.""" + return [ + () if item.strip() == "." else tuple(item.split()) + for item in spec.split(",") if item.strip()] def _sub(module: str, builder: str, handler: str) -> _CliSurface: @@ -321,58 +275,49 @@ _CLI_FAMILIES: dict[str, tuple[_CliSurface, str, str]] = { "tools": ( _sub("tools", "build_tools_parser", "cmd_tools"), "list, enable, disable, post-setup", - "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", - ), + "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", - ), + "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"), + "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", - ), + "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", - ), + "approve, revoke, clear-pending"), "webhook": ( _sub("webhook", "build_webhook_parser", "cmd_webhook"), "list, subscribe, remove, test", - "subscribe, remove", - ), + "subscribe, remove"), "hooks": ( _sub("hooks", "build_hooks_parser", "cmd_hooks"), "list, test, doctor, revoke", - "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", - ), + "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", - ), + "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"), @@ -388,30 +333,24 @@ _CLI_FAMILIES: dict[str, tuple[_CliSurface, str, str]] = { "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", - ), + "block, schedule, unblock, promote, archive"), "bundles": ( _reg("bundles", "bundles_command"), "list, show, create, delete, reload", - "create, delete, reload", - ), + "create, delete, reload"), "checkpoints": ( _reg("checkpoints"), "status, list, prune, clear, clear-legacy", - "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", - ), + "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", - ), -} + "install, select, off, scale, remove")} # Only extracted/registered families skip nested prompts after console confirmation # (builder/adder families never did). @@ -421,11 +360,7 @@ _SEND_SURFACE = _CliSurface("adder", "hermes_cli.send_cmd", "register_send_subpa def _register_command_family( - engine: "HermesConsoleEngine", - root: str, - surface: _CliSurface, - paths: str, - mutating: str, + engine: "HermesConsoleEngine", root: str, surface: _CliSurface, paths: str, mutating: str ) -> None: summaries = _surface_summaries(surface, root) mutating_paths = set(_paths(mutating)) @@ -443,14 +378,12 @@ def _register_command_family( summaries.get(full_path) or f"Run `hermes {usage}`.", handler, mutating=child_path in mutating_paths, - confirmation=f"Run `hermes {usage}`?", - ) + 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() -) + "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.", @@ -486,37 +419,28 @@ class HermesConsoleEngine: raw_line = line.strip() if not raw_line: return ConsoleResult("ok") - try: tokens = _split_line(raw_line) if tokens and tokens[0] == "hermes": tokens = tokens[1:] if not tokens: return ConsoleResult("ok", output=self.help_text()) - if _contains_shell_syntax(raw_line, tokens): raise ConsoleCommandError( "Hermes Console does not run shell syntax. Use one supported " - "Hermes command at a time." - ) - + "Hermes command at a time.") builtin = self._execute_builtin(tokens) if builtin is not None: if raw_line not in {"history", "clear"}: self.history.append(raw_line) return builtin - command, args = self._resolve_command(tokens) if command.mutating and not confirmed: return ConsoleResult( "confirm_required", command=raw_line, - confirmation_message=command.confirmation - or f"Run `{command.usage}`?", - ) - - output = command.handler(self, args).rstrip() - output = self._cap_output(output) + confirmation_message=command.confirmation or f"Run `{command.usage}`?") + output = self._cap_output(command.handler(self, args).rstrip()) self.history.append(raw_line) return ConsoleResult("ok", output=output, command=raw_line) except ConsoleCommandError as exc: @@ -524,32 +448,23 @@ class HermesConsoleEngine: def help_text(self, subject: str | None = None) -> str: if subject: - tokens = subject.split() - command, _args = self._resolve_command(tokens) + command, _args = self._resolve_command(subject.split()) return f"{command.usage}\n{command.summary}" - - lines = [ - "Hermes Console", - "", - "Supported commands:", - ] + lines = ["Hermes Console", "", "Supported commands:"] for command in sorted(self.commands.values(), key=lambda c: c.usage): marker = " *" if command.mutating else " " lines.append(f"{marker} {command.usage:<32} {_table_summary(command.summary)}") - lines.extend( - [ - "", - "* requires confirmation", - "Built-ins: help, help , history, clear, exit, quit", - ] - ) + lines += [ + "", + "* requires confirmation", + "Built-ins: help, help , history, clear, exit, quit"] return "\n".join(lines) def _register_defaults(self) -> None: - 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 path, usage, summary, handler, confirmation in _BUILTIN_COMMANDS: + self.register( + path, usage, summary, handler, + mutating=bool(confirmation), confirmation=confirmation) for root, (surface, paths, mutating) in _CLI_FAMILIES.items(): _register_command_family(self, root, surface, paths, mutating) self.register( @@ -558,8 +473,7 @@ class HermesConsoleEngine: "Send a message to a configured platform.", lambda _engine, args: _dispatch(_SEND_SURFACE, "send", (), args), mutating=True, - confirmation="Send this message?", - ) + confirmation="Send this message?") def register( self, @@ -569,17 +483,9 @@ class HermesConsoleEngine: handler: Callable[["HermesConsoleEngine", list[str]], str], *, mutating: bool = False, - confirmation: str = "", - ) -> None: + confirmation: str = "") -> None: key = tuple(path) - self.commands[key] = ConsoleCommand( - path=key, - usage=usage, - summary=summary, - handler=handler, - mutating=mutating, - confirmation=confirmation, - ) + self.commands[key] = ConsoleCommand(key, usage, summary, handler, mutating, confirmation) def _execute_builtin(self, tokens: list[str]) -> ConsoleResult | None: head = tokens[0] @@ -602,13 +508,10 @@ class HermesConsoleEngine: rejected = self._rejection_for(tokens) if rejected: raise ConsoleCommandError(rejected) - for size in range(min(len(tokens), 3), 0, -1): - key = tuple(tokens[:size]) - command = self.commands.get(key) + command = self.commands.get(tuple(tokens[:size])) if command: return command, list(tokens[size:]) - available = [" ".join(path) for path in self.commands] probe = " ".join(tokens[:2]) if len(tokens) > 1 else tokens[0] suggestions = difflib.get_close_matches(probe, available, n=3, cutoff=0.45) @@ -637,39 +540,30 @@ def _expect_no_args(args: Sequence[str], usage: str) -> None: def _apply_confirmed_defaults(args: argparse.Namespace) -> None: """Skip nested prompts after the console-level confirmation has happened.""" - - for attr in ("yes",): - if hasattr(args, attr): - setattr(args, attr, True) + if hasattr(args, "yes"): + args.yes = True if getattr(args, "_console_command", None) == "import": - setattr(args, "force", True) - # Every checkpoints subcommand the console registers as mutating gates its - # own confirmation on --force, so all three belong here. `prune` reaches - # _confirm() for its orphan preview, and the console never redirects stdin. + args.force = True + # Every mutating checkpoints subcommand gates its own confirmation on --force; `prune` + # reaches _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) + args.force = 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) + and not getattr(args, "no_enable", False)): + 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): - raise ConsoleCommandError("auth add --type api-key requires --api-key in Hermes Console.") + raise ConsoleCommandError( + "auth add --type api-key requires --api-key in Hermes Console.") if getattr(args, "import_name", None) is not None: - # profile import has no prompt flag; leave it alone. - return - if getattr(args, "skills_action", None) in { - "install", - "reset", - "opt-out", - "repair-official", - }: - setattr(args, "yes", True) + return # profile import has no prompt flag; leave it alone. + if getattr(args, "skills_action", None) in {"install", "reset", "opt-out", "repair-official"}: + args.yes = True if getattr(args, "memory_command", None) == "reset": - setattr(args, "yes", True) + args.yes = True def _version(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -704,29 +598,26 @@ def _logs(_engine: HermesConsoleEngine, args: list[str]) -> str: parser = _ArgumentParser(prog="logs", add_help=False) parser.add_argument("log_name", nargs="?", default="agent") parser.add_argument("-n", "--lines", type=int, default=50) - parser.add_argument("--level") - parser.add_argument("--session") - parser.add_argument("--since") - parser.add_argument("--component") + for flag in ("--level", "--session", "--since", "--component"): + parser.add_argument(flag) ns = parser.parse_args(args) if ns.lines < 1 or ns.lines > 500: raise ConsoleCommandError("logs --lines must be between 1 and 500") - from hermes_cli.logs import list_logs, tail_log if ns.log_name == "list": return _capture_output(list_logs) return _capture_output( lambda: tail_log( - ns.log_name, - num_lines=ns.lines, - follow=False, - level=ns.level, - session=ns.session, - since=ns.since, - component=ns.component, - ) - ) + ns.log_name, num_lines=ns.lines, follow=False, level=ns.level, + session=ns.session, since=ns.since, component=ns.component)) + + +def _session_db(): + """``with _session_db() as db:`` — SessionDB closed on exit.""" + from hermes_state import SessionDB + + return closing(SessionDB()) def _sessions_list(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -735,42 +626,26 @@ def _sessions_list(_engine: HermesConsoleEngine, args: list[str]) -> str: ns = parser.parse_args(args) if ns.limit < 1 or ns.limit > 200: raise ConsoleCommandError("sessions list --limit must be between 1 and 200") - - from hermes_state import SessionDB - - db = SessionDB() - try: + with _session_db() as db: sessions = db.list_sessions_rich( - exclude_sources=["kanban", "tool"], - limit=ns.limit, - order_by_last_active=True, - ) - finally: - db.close() + exclude_sources=["kanban", "tool"], limit=ns.limit, order_by_last_active=True) return _format_sessions(sessions) def _sessions_stats(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "sessions stats") - from hermes_state import SessionDB - - db = SessionDB() - try: + with _session_db() as db: total = db.session_count() listable = db.session_count(exclude_children=True, exclude_sources=["kanban", "tool"]) - messages = db.message_count() lines = [ f"Total sessions: {total}", f"Listable sessions: {listable}", - f"Total messages: {messages}", - ] + f"Total messages: {db.message_count()}"] for source in ["cli", "tui", "telegram", "discord", "slack", "cron"]: count = db.session_count(source=source) if count: lines.append(f" {source}: {count}") return "\n".join(lines) - finally: - db.close() def _config_show(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -790,11 +665,9 @@ def _config_path(_engine: HermesConsoleEngine, args: list[str]) -> str: def _config_set(_engine: HermesConsoleEngine, args: list[str]) -> str: if len(args) < 2: raise ConsoleCommandError("Usage: config set ") - key = args[0] - value = " ".join(args[1:]) from hermes_cli.config import set_config_value - return _capture_output(lambda: set_config_value(key, value)) + return _capture_output(lambda: set_config_value(args[0], " ".join(args[1:]))) def _config_migrate(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -808,8 +681,7 @@ def _config_migrate(_engine: HermesConsoleEngine, args: list[str]) -> str: print("Configuration updated.") else: print("Configuration is up to date.") - warnings = results.get("warnings") or [] - for warning in warnings: + for warning in results.get("warnings") or []: print(f"Warning: {warning}") return _capture_output(_run) @@ -823,19 +695,12 @@ def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> str: ns = parser.parse_args(args) def _run() -> None: - from hermes_state import ( - SessionDB, - SessionExportTooLargeError, - resolved_max_export_messages, - ) + from hermes_state import SessionExportTooLargeError, resolved_max_export_messages - db = SessionDB() - try: + with _session_db() as db: def _guard_exports(session_ids: list[str]) -> None: - # Per-session budget: each session is checked independently - # against the configured limit, so a full-DB backup of many - # small sessions never trips the guard — only an individual - # runaway transcript does. 0 disables the guard. + # Per-session budget: a full-DB backup of many small sessions never trips the + # guard, only an individual runaway transcript does. 0 disables it. limit = resolved_max_export_messages() if limit <= 0: return @@ -861,15 +726,10 @@ def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> str: raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") rows = [data] else: - session_ids = [ - session["id"] - for session in db.search_sessions(source=ns.source, limit=100000) - ] - _guard_exports(session_ids) + found = db.search_sessions(source=ns.source, limit=100000) + _guard_exports([session["id"] for session in found]) rows = db.export_all(source=ns.source) - - lines = [json.dumps(row, ensure_ascii=False) for row in rows] - text = "\n".join(lines) + text = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) if text: text += "\n" if ns.output == "-": @@ -877,8 +737,6 @@ def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> str: else: Path(ns.output).expanduser().write_text(text, encoding="utf-8") print(f"Exported {len(rows)} session(s) to {ns.output}") - finally: - db.close() return _capture_output(_run) @@ -890,10 +748,7 @@ def _sessions_rename(_engine: HermesConsoleEngine, args: list[str]) -> str: ns = parser.parse_args(args) def _run() -> None: - from hermes_state import SessionDB - - db = SessionDB() - try: + with _session_db() as db: resolved_session_id = db.resolve_session_id(ns.session_id) if not resolved_session_id: raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") @@ -901,8 +756,6 @@ def _sessions_rename(_engine: HermesConsoleEngine, args: list[str]) -> str: if not db.set_session_title(resolved_session_id, title): raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") print(f"Session '{resolved_session_id}' renamed to: {title}") - finally: - db.close() return _capture_output(_run) @@ -911,14 +764,8 @@ def _sessions_optimize(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "sessions optimize") def _run() -> None: - from hermes_state import SessionDB - - db = SessionDB() - try: - count = db.vacuum() - print(f"Optimized {count} FTS index(es).") - finally: - db.close() + with _session_db() as db: + print(f"Optimized {db.vacuum()} FTS index(es).") return _capture_output(_run) @@ -944,13 +791,12 @@ def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> str: if ns.check_only: return report = repair_state_db_schema(db_path, backup=not ns.no_backup) - if report.get("repaired"): - if report.get("backup_path"): - print(f"backup: {report['backup_path']}") - print(f"strategy: {report.get('strategy')}") - print("Repaired session database.") - return - raise ConsoleCommandError(f"Repair failed: {report.get('error')}") + if not report.get("repaired"): + raise ConsoleCommandError(f"Repair failed: {report.get('error')}") + if report.get("backup_path"): + print(f"backup: {report['backup_path']}") + print(f"strategy: {report.get('strategy')}") + print("Repaired session database.") return _capture_output(_run) @@ -996,8 +842,7 @@ def _cron_pause(_engine: HermesConsoleEngine, args: list[str]) -> str: return _cron_job_action( args, "cron pause ", "Paused", - lambda ref: pause_job(ref, reason="paused from hermes console"), - ) + lambda ref: pause_job(ref, reason="paused from hermes console")) def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -1028,60 +873,51 @@ def _cron_run(_engine: HermesConsoleEngine, args: list[str]) -> str: return _cron_job_action(args, "cron run ", "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 = ( +# (path, usage, summary, handler, confirmation prompt) — a non-empty prompt marks it mutating. +_BUILTIN_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, ""), (("config", "set"), "config set ", "Set a configuration value.", _config_set, "Update Hermes configuration?"), - (("cron", "pause"), "cron pause ", "Pause a scheduled job.", _cron_pause, "Pause this cron job?"), - (("cron", "resume"), "cron resume ", "Resume a paused cron job.", _cron_resume, "Resume this cron job?"), + (("cron", "pause"), "cron pause ", "Pause a scheduled job.", _cron_pause, + "Pause this cron job?"), + (("cron", "resume"), "cron resume ", "Resume a paused cron job.", _cron_resume, + "Resume this cron job?"), (("cron", "run"), "cron run ", "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 [--source SOURCE] [--session-id ID]", "Export sessions to JSONL.", _sessions_export, "Export session data?"), - (("sessions", "rename"), "sessions rename ", "Rename a session.", _sessions_rename, - "Rename this session?"), - (("sessions", "optimize"), "sessions optimize", "Optimize the session store.", _sessions_optimize, - "Optimize the session database?"), + (("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?"), -) + "Repair a malformed session database schema.", _sessions_repair, + "Repair the session database?")) def run_console_repl( - *, - stdin=None, - stdout=None, - stderr=None, - interactive: bool | None = None, -) -> int: + *, stdin=None, stdout=None, stderr=None, interactive: bool | None = None) -> int: """Run the local ``hermes console`` REPL.""" - - stdin = stdin or sys.stdin - stdout = stdout or sys.stdout - stderr = stderr or sys.stderr + stdin, stdout, stderr = stdin or sys.stdin, stdout or sys.stdout, stderr or sys.stderr if interactive is None: interactive = bool(getattr(stdin, "isatty", lambda: False)()) - engine = HermesConsoleEngine() if interactive: print("Hermes Console. Type `help` for commands, `exit` to quit.", file=stdout) - while True: if interactive: print("hermes> ", end="", file=stdout, flush=True) @@ -1090,24 +926,17 @@ def run_console_repl( if interactive: print(file=stdout) return 0 - result = engine.execute(line) if result.status == "confirm_required": if not interactive: - print( - f"Confirmation required: {result.confirmation_message}", - file=stderr, - ) + print(f"Confirmation required: {result.confirmation_message}", file=stderr) return 1 print(f"{result.confirmation_message} [y/N] ", end="", file=stdout, flush=True) - answer = stdin.readline() - if answer.strip().lower() not in {"y", "yes"}: + if stdin.readline().strip().lower() not in {"y", "yes"}: print("Cancelled.", file=stdout) continue result = engine.execute(result.command, confirmed=True) - if result.output: - stream = stderr if result.status == "error" else stdout - print(result.output, file=stream) + print(result.output, file=stderr if result.status == "error" else stdout) if result.status == "exit": return 0 From 3da7321eb76452aa58745bd8699e635bce92be54 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:35:15 -0700 Subject: [PATCH 02/16] =?UTF-8?q?refactor(hermes=5Fcli):=20curses=5Fui=20?= =?UTF-8?q?=E2=80=94=20dict-dispatch=20key=20decoding,=20extract=20=5Fdeco?= =?UTF-8?q?de=5Fescape=5Fsequence,=20compact=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/curses_ui.py | 414 +++++++++++++++------------------------- 1 file changed, 159 insertions(+), 255 deletions(-) diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 9cc660d497..8edecb6fb3 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -1,8 +1,7 @@ """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. +Curses checklist / radiolist / single-select menus with keyboard navigation and fuzzy ``/`` +search, plus a numbered text fallback for terminals without curses. """ import sys from contextvars import ContextVar, Token @@ -12,8 +11,7 @@ from typing import Callable, List, Optional, Protocol, Sequence, Set, Tuple, Uni from hermes_cli.colors import Colors, color -# Rich radiolist rows: (text, style). style is None | "yellow" | "dim". -# Plain ``str`` items remain fully supported. +# Rich radiolist rows: (text, style). style is None | "yellow" | "dim". Plain ``str`` works too. RadioItem = Union[str, Sequence[Tuple[str, Optional[str]]]] @@ -42,16 +40,18 @@ 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 -) + "hermes_menu_navigation_handler", default=None) _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) @@ -62,7 +62,6 @@ def reset_menu_navigation_handler(token: Token[MenuNavigationHandler | None]) -> 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(event) @@ -77,10 +76,10 @@ _NAV_ABORT = object() def _read_numbered_choice(prompt_text: str) -> int | None | object: - """Read a numbered fallback choice as a 0-based index. + """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). + ``None`` for empty input; ``_NAV_ABORT`` when cancelled, backed out of, interrupted, or + non-integer (scoped navigation is notified for cancel/back). """ try: val = _read_numbered_input(prompt_text) @@ -97,11 +96,10 @@ def _read_numbered_choice(prompt_text: str) -> int | None | object: def _read_numbered_input(prompt_text: str) -> str | _NumberedNavigation: - """Read a numbered fallback choice with setup navigation key bindings. + """Read a numbered fallback choice. - 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. + Plain ``input()`` outside setup flows. Inside a scoped flow, prompt_toolkit supplies + portable Escape / Ctrl+C / Left bindings on POSIX and native Windows. """ if _MENU_NAVIGATION_HANDLER.get() is None: return input(prompt_text) @@ -111,8 +109,7 @@ def _read_numbered_input(prompt_text: str) -> str | _NumberedNavigation: from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys - # Setup can be invoked without importing the classic CLI, which normally - # installs Ghostty/Kitty CSI-u aliases at process startup. + # Setup may run without the classic CLI, which normally installs CSI-u aliases at startup. from hermes_cli.pt_input_extras import install_modify_other_keys_aliases install_modify_other_keys_aliases() @@ -134,9 +131,7 @@ def _read_numbered_input(prompt_text: str) -> str | _NumberedNavigation: def radio_item_plain(item: RadioItem) -> str: """Flatten a radiolist item to searchable/plain display text.""" - if isinstance(item, str): - return item - return "".join(text for text, _style in item) + return item if isinstance(item, str) else "".join(text for text, _style in item) def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool): @@ -149,9 +144,8 @@ def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool): if style == "dim": attr = curses.A_DIM if has_colors: - # Pair 3 is the dim-gray status color (extra_color_pairs). try: - attr |= curses.color_pair(3) + attr |= curses.color_pair(3) # dim-gray status pair (extra_color_pairs) except curses.error: pass return attr @@ -169,7 +163,7 @@ def _addnstr(stdscr, y: int, x: int, text: str, n: int, attr) -> None: 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``.""" + """Bold/yellow menu title on row 0, dim key hint on ``hint_row``.""" import curses hattr = curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0) @@ -177,15 +171,8 @@ def _draw_title_and_hint(stdscr, title: str, hint: str, max_x: int, *, hint_row: _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.""" + """Draw ``(text, attr)`` segments left to right from column ``x``, clipped at the edge.""" col = x for text, attr in segments: remaining = max_x - 1 - col @@ -210,7 +197,8 @@ def _draw_description_line(stdscr, y: int, text: str, max_x: int) -> None: _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: +def _draw_radio_item( + stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_cursor: bool) -> None: """Draw a plain or segmented radiolist item starting at column ``x``.""" import curses @@ -218,46 +206,43 @@ def _draw_radio_item(stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_ attr = _curses_style_attr(curses, None, is_cursor=is_cursor) _addnstr(stdscr, y, x, item, max(0, max_x - 1 - x), attr) return - _draw_segments( stdscr, y, x, ((text, _curses_style_attr(curses, style, is_cursor=is_cursor)) for text, style in item), - max_x, - ) + max_x) + + +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)) _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, after a separator, or lower->Upper.""" 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. + """Score one token against a target; None if not a subsequence. - 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. + Faithful port of ``fuzzyScore`` in ui-tui / web ``fuzzy.ts`` so all surfaces rank model ids + identically. Matches run against ``lower``; boundary detection uses ``orig`` for camelCase. """ score = 0.0 prev = -1 search_from = 0 positions: list[int] = [] - for ch in token: idx = lower.find(ch, search_from) if idx < 0: @@ -274,27 +259,15 @@ def _token_score(orig: str, lower: str, token: str) -> float | None: score += 5 prev = idx search_from = idx + 1 - - # Prefix bonus: the token matched a contiguous prefix of the target. if positions and positions[0] == 0 and positions[-1] == len(positions) - 1: - score += 8 - - # Exact full match dominates everything else. + score += 8 # contiguous prefix if lower == token: - score += 20 - - # Slightly prefer shorter targets when scores are otherwise close. - score -= len(lower) * 0.01 - - return score + score += 20 # exact match dominates + return score - len(lower) * 0.01 # slightly prefer shorter targets 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. - """ + """Multi-token AND score (``fuzzyScoreMulti``): sum of per-token scores, None if any fails.""" lower = label.lower() total = 0.0 for token in query.lower().split(): @@ -306,16 +279,11 @@ def _fuzzy_score(label: str, query: str) -> float | None: 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. - """ + """Item indices matching *query*, best-first; ties keep catalog order. Empty query = all.""" q = query.strip() if not q: return list(range(len(items))) - scored = [(i, score) for i, label in enumerate(items) if (score := _fuzzy_score(label, q)) is not None] + scored = [(i, s) for i, label in enumerate(items) if (s := _fuzzy_score(label, q)) is not None] scored.sort(key=lambda pair: (-pair[1], pair[0])) return [i for i, _ in scored] @@ -342,7 +310,9 @@ def _move_filtered_cursor(filtered: List[int], cursor: int, cursor_pos: int, del 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: @@ -352,23 +322,21 @@ def _scroll_for_cursor(scroll_offset: int, cursor_pos: int, visible_rows: int, t 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.""" +def _handle_active_search_key( + curses_mod, key: int, search: _SearchState +) -> tuple[bool, bool, bool]: + """Handle a key while the search prompt is active -> ``(handled, confirm, changed)``.""" if not search.active: return False, False, False - if key == 27: - # Esc stops search AND clears the query, restoring the full list (so a - # no-match filter can't strand the user on an empty list). Signals - # `changed` when there was a query so the driver resets scroll/cursor. + # Esc stops search AND clears the query so a no-match filter can't strand the user on + # an empty list; `changed` when there was a query so the driver resets scroll/cursor. had_query = bool(search.query) search.active = False search.query = "" return True, False, had_query - if key in (curses_mod.KEY_ENTER, 10, 13): return True, True, False - if key in (curses_mod.KEY_BACKSPACE, 127, 8): search.query = search.query[:-1] elif key == 21: # Ctrl+U @@ -381,11 +349,9 @@ def _handle_active_search_key(curses_mod, key: int, search: _SearchState) -> tup def flush_stdin() -> None: - """Flush any stray bytes from the stdin input buffer. + """Drain stray stdin bytes after ``curses.wrapper()`` and before the next ``input()``. - 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. + ``curses.endwin()`` restores the terminal but does NOT drain the OS input buffer. """ try: if sys.stdin.isatty(): @@ -395,8 +361,7 @@ def flush_stdin() -> None: pass -# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps -# every menu's key-handling branch identical and free of raw escape-byte logic. +# Normalized menu actions returned by ``read_menu_key``. NAV_UP = "up" NAV_DOWN = "down" NAV_BACK = "back" @@ -408,11 +373,10 @@ NAV_NONE = "none" def read_menu_key(stdscr) -> str: - """Read one keypress and normalize it to a menu action. + """Read one keypress and normalize it to a ``NAV_*`` action. - 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. + A lone ESC (no continuation byte within a short window) and ``q`` cancel; unknown sequences + map to ``NAV_NONE`` so the caller ignores them rather than misfiring. """ return _decode_menu_key(stdscr, stdscr.getch()) @@ -425,7 +389,7 @@ def _parse_int(value: str, default=0): 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.""" + """Kitty/CSI-u key -> ``(codepoint, modifier, event_type)``; None without a codepoint.""" parts = raw.split(";") codepoint = _parse_int(parts[0].split(":", 1)[0]) if not codepoint: @@ -437,22 +401,19 @@ def _parse_csi_u_key(raw: str) -> tuple[int, int, int] | None: def _parse_csi_numbers(raw: str) -> list[int]: - """Parse semicolon-delimited CSI numbers for modifyOtherKeys.""" + """Semicolon-delimited CSI numbers for modifyOtherKeys.""" return [_parse_int(part.split(":", 1)[0]) for part in raw.split(";")] +_ENHANCED_NAV = {10: NAV_SELECT, 13: NAV_SELECT, 27: NAV_CANCEL, 32: NAV_TOGGLE} + + def _enhanced_key_action(codepoint: int, modifier: int = 1) -> str: """Map CSI-u/modifyOtherKeys codepoints to setup menu actions.""" - if codepoint in (10, 13): - return NAV_SELECT - if codepoint == 27: - return NAV_CANCEL - if codepoint == 32: - return NAV_TOGGLE - - # CSI-u encodes Ctrl+C as codepoint `c` plus the Ctrl modifier. Lock-state - # bits may be added to the modifier, so inspect the Ctrl bit rather than - # matching only the canonical value 5. + if codepoint in _ENHANCED_NAV: + return _ENHANCED_NAV[codepoint] + # CSI-u encodes Ctrl+C as `c` plus the Ctrl modifier bit; lock-state bits may be added, so + # test the Ctrl bit rather than the canonical value 5. has_ctrl = bool((max(1, modifier) - 1) & 4) if codepoint == 3 or (codepoint in (ord("c"), ord("C")) and has_ctrl): return NAV_INTERRUPT @@ -472,68 +433,57 @@ def _read_csi_tail(stdscr) -> tuple[str, int | None]: 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} +_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. - """ - import curses - - if key in (curses.KEY_UP, ord("k")): - return NAV_UP - if key in (curses.KEY_DOWN, ord("j")): - return NAV_DOWN - if key == curses.KEY_LEFT: - return NAV_BACK - if key == 3: # Ctrl+C in curses raw/cbreak mode. - return NAV_INTERRUPT - if key in (curses.KEY_ENTER, 10, 13): - return NAV_SELECT - if key == ord(" "): - return NAV_TOGGLE - if key == ord("q"): - return NAV_CANCEL - - if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence. - # Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the - # bytes of an arrow key can arrive across separate reads, so a tiny - # timeout avoids misreading a split sequence as a bare ESC. - try: - stdscr.timeout(60) - nxt = stdscr.getch() - if nxt == -1: - return NAV_CANCEL # genuine lone ESC - - if nxt in (ord("["), ord("O")): # CSI / SS3 introducer - raw_params, final = _read_csi_tail(stdscr) - 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: - codepoint, modifier, event_type = enhanced - if event_type == 3: # key release - return NAV_NONE - return _enhanced_key_action(codepoint, modifier) - if final == ord("~"): - params = _parse_csi_numbers(raw_params) - if len(params) >= 3 and params[0] == 27: - return _enhanced_key_action(params[2], params[1]) +def _decode_escape_sequence(stdscr) -> str: + """Decode the bytes after an ESC: lone ESC cancels, CSI/SS3 sequences map to actions.""" + nxt = stdscr.getch() + if nxt == -1: + return NAV_CANCEL # genuine lone ESC + if nxt not in (ord("["), ord("O")): # not a CSI / SS3 introducer — swallow + return NAV_NONE + raw_params, final = _read_csi_tail(stdscr) + 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: + codepoint, modifier, event_type = enhanced + if event_type == 3: # key release return NAV_NONE - # ESC followed by some other byte we don't handle — swallow it. - return NAV_NONE - finally: - stdscr.timeout(-1) # restore blocking mode - + return _enhanced_key_action(codepoint, modifier) + if final == ord("~"): + params = _parse_csi_numbers(raw_params) + if len(params) >= 3 and params[0] == 27: + return _enhanced_key_action(params[2], params[1]) return NAV_NONE -# Sentinel: an on_action reducer returns this to mean "keep looping" (the -# keypress changed cursor/selection state but didn't resolve the menu). +def _decode_menu_key(stdscr, key: int) -> str: + """Normalize an already-read keypress to a menu action (lets loops peek the raw key first).""" + import curses + + plain = { + curses.KEY_UP: NAV_UP, ord("k"): NAV_UP, curses.KEY_DOWN: NAV_DOWN, ord("j"): NAV_DOWN, + curses.KEY_LEFT: NAV_BACK, 3: NAV_INTERRUPT, # 3 = Ctrl+C in raw/cbreak mode + curses.KEY_ENTER: NAV_SELECT, 10: NAV_SELECT, 13: NAV_SELECT, + ord(" "): NAV_TOGGLE, ord("q"): NAV_CANCEL} + if key in plain: + return plain[key] + if key != 27: + return NAV_NONE + # ESC: wait briefly for a continuation byte. On slow PTYs (SSH/tmux) an arrow key's bytes + # can arrive across separate reads, so a tiny timeout avoids misreading it as a bare ESC. + try: + stdscr.timeout(60) + return _decode_escape_sequence(stdscr) + finally: + stdscr.timeout(-1) # restore blocking mode + + +# An on_action reducer returns this to keep looping (state changed, menu not resolved). _KEEP = object() @@ -550,18 +500,15 @@ def _run_curses_menu( fallback, cancel_value, searchable=False, - search_labels=None, -): + search_labels=None): """Shared curses single-/multi-select event loop. - 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. + Owns the non-TTY guard, ``curses.wrapper`` setup, clear/refresh cycle, scroll math, key + dispatch with cursor wrap, and the KeyboardInterrupt / curses-unavailable fallback. + ``draw_row`` always receives the ORIGINAL item index; ``on_action`` returns ``_KEEP`` to + continue or any other value to resolve; a ``draw_footer`` row budget must be included in + ``reserve_bottom``; with ``searchable``, ``/`` filters over ``search_labels`` (length == + ``item_count``) and results are original indices. """ navigation_handler = _MENU_NAVIGATION_HANDLER.get() @@ -577,9 +524,8 @@ def _run_curses_menu( _notify(MenuNavigationEvent.RESOLVE, navigation_start.replay_value) return navigation_start.replay_value - # Non-TTY (piped/redirected stdin): curses and input() both hang or spin, - # so return the cancel value directly — matching the pre-refactor guard in - # each menu (the numbered fallback is only for curses errors on a real TTY). + # Non-TTY stdin: curses and input() both hang or spin, so return the cancel value directly + # (the numbered fallback is only for curses errors on a real TTY). if not sys.stdin.isatty(): return cancel_value @@ -626,35 +572,30 @@ def _run_curses_menu( while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() - - filtered = _filter_indices(search_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: _addnstr(stdscr, items_start, 0, " No matches", max_x - 1, curses.A_DIM) - 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 draw_row(stdscr, y, i, i == cursor, max_x) - if draw_footer is not None: draw_footer(stdscr, max_y, max_x) - stdscr.refresh() 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. + # Enhanced keys (Ghostty/Kitty) also start with ESC: decode the full + # sequence before treating a genuine Escape as "stop search". action = _decode_menu_key(stdscr, key) if action == NAV_CANCEL: search.active = False @@ -664,14 +605,12 @@ def _run_curses_menu( 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. + # Active search consumes query-editing keys; nav keys fall through. 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 - ) + _filter_indices(search_labels, search.query), cursor) if confirm: if filtered and _resolve(on_action(NAV_SELECT, cursor)): return @@ -690,8 +629,7 @@ def _run_curses_menu( 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 ( - action == NAV_BACK and allow_back - ): + action == NAV_BACK and allow_back): if action == NAV_SELECT and use_search and not filtered: continue if action in (NAV_CANCEL, NAV_INTERRUPT): @@ -704,7 +642,6 @@ def _run_curses_menu( curses.wrapper(_draw) flush_stdin() return result_holder[0] if result_holder[0] is not _KEEP else cancel_value - except KeyboardInterrupt: _notify(MenuNavigationEvent.CANCEL) return cancel_value @@ -718,17 +655,14 @@ def curses_checklist( selected: Set[int], *, cancel_returns: Set[int] | None = None, - status_fn: Optional[Callable[[Set[int]], str]] = None, -) -> Set[int]: + status_fn: Optional[Callable[[Set[int]], str]] = None) -> Set[int]: """Curses multi-select checklist. Returns set of selected indices. ``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. + ``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_row(stdscr, y, i, is_cursor, max_x): @@ -739,8 +673,7 @@ def curses_checklist( def _draw_footer(stdscr, max_y, max_x): import curses status_text = status_fn(chosen) - if status_text: - # Right-align on the bottom row + if status_text: # right-aligned 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) @@ -763,8 +696,7 @@ def curses_checklist( draw_footer=_draw_footer if status_fn else None, extra_color_pairs=bool(status_fn), fallback=lambda: _numbered_fallback(title, items, selected, cancel_returns, status_fn), - cancel_value=cancel_returns, - ) + cancel_value=cancel_returns) def _search_hint(search, searchable: bool, confirm: str, cancel: str, back_enabled: bool) -> str: @@ -797,49 +729,37 @@ def curses_radiolist( cancel_returns: int | None = None, description: str | None = None, searchable: bool = False, - search_labels: List[str] | None = None, -) -> int: + search_labels: List[str] | None = None) -> int: """Curses single-select radio list. Returns the selected index. - 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. + Items are plain strings or ``(text, style)`` segment sequences (``None``/``"yellow"``/ + ``"dim"``); the cursor row is forced green. ``description`` is shown between title and list + so context survives the curses screen clear. With ``searchable``, ``/`` filters over + ``search_labels`` (default: display labels); the return value is always the ORIGINAL index. """ if cancel_returns is None: cancel_returns = selected - desc_lines = description.splitlines() if description else [] - 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): - # Description lines — paint ★ yellow so the sale legend matches rows. row = 1 - for dline in desc_lines[: max(0, max_y - 2)]: + for dline in desc_lines[: max(0, max_y - 2)]: # ★ painted yellow to match rows _draw_description_line(stdscr, row, dline, max_x) row += 1 - 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 + 2 + return row + 2 # one blank row between the hint and the item list def _draw_row(stdscr, y, i, is_cursor, max_x): radio = "\u25cf" if i == selected else "\u25cb" arrow = "\u2192" if is_cursor else " " prefix = f" {arrow} ({radio}) " _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 - ) + _draw_radio_item(stdscr, y, len(prefix), items[i], max_x, is_cursor=is_cursor) def _on_action(action, cursor): - if action in (NAV_SELECT, NAV_TOGGLE): - return cursor - return cancel_returns # NAV_CANCEL + return cursor if action in (NAV_SELECT, NAV_TOGGLE) else cancel_returns # NAV_CANCEL return _run_curses_menu( initial_cursor=selected, @@ -848,13 +768,16 @@ def curses_radiolist( draw_row=_draw_row, on_action=_on_action, reserve_bottom=1, - # Dim gray (pair 3) for unselected "was …" sale chrome. - extra_color_pairs=True, + extra_color_pairs=True, # dim gray (pair 3) for unselected "was …" sale chrome fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns), cancel_value=cancel_returns, searchable=searchable, - search_labels=(list(search_labels) if 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)) + + +_ANSI_STYLE = {"yellow": Colors.YELLOW, "dim": Colors.DIM} def format_radio_item_ansi(item: RadioItem) -> str: @@ -862,23 +785,14 @@ def format_radio_item_ansi(item: RadioItem) -> str: if isinstance(item, str): return item 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} + color(text, _ANSI_STYLE[style]) if style in _ANSI_STYLE else text for text, style in item) def _radio_numbered_fallback( - title: str, - items: List[RadioItem], - selected: int, - cancel_returns: int, -) -> int: + title: str, items: List[RadioItem], selected: int, cancel_returns: int) -> int: """Text-based numbered fallback for radio selection.""" print(color(f"\n {title}", Colors.YELLOW)) print(color(" Select by number, Enter to confirm.\n", Colors.DIM)) - for i, label in enumerate(items): marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)" print(f" {marker} {i + 1:>2}. {format_radio_item_ansi(label)}") @@ -895,12 +809,11 @@ def curses_single_select( default_index: int = 0, *, cancel_label: str = "Cancel", - searchable: bool = False, -) -> int | None: + searchable: bool = False) -> int | None: """Curses single-select menu. Returns selected index or None on cancel. - When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the returned value is always - the original item index (or None for cancel). + With ``searchable``, ``/`` opens a type-to-filter prompt; the return value is always the + original item index (or None for cancel). """ all_items = list(items) + [cancel_label] cancel_idx = len(items) @@ -910,9 +823,7 @@ def curses_single_select( _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: - # Selecting the synthetic cancel row resolves to None, mirroring - # the old post-loop ``>= cancel_idx`` guard. + if action == NAV_SELECT: # the synthetic cancel row resolves to None return None if cursor >= cancel_idx else cursor if action in (NAV_CANCEL, NAV_INTERRUPT): return None @@ -928,15 +839,10 @@ def curses_single_select( fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx), cancel_value=None, searchable=searchable, - search_labels=list(all_items) if searchable else None, - ) + search_labels=list(all_items) if searchable else None) -def _numbered_single_fallback( - title: str, - items: List[str], - cancel_idx: int, -) -> int | None: +def _numbered_single_fallback(title: str, items: List[str], cancel_idx: int) -> int | None: """Text-based numbered fallback for single-select.""" print(f"\n {title}\n") for i, label in enumerate(items, 1): @@ -951,13 +857,11 @@ def _numbered_fallback( items: List[str], selected: Set[int], cancel_returns: Set[int], - status_fn: Optional[Callable[[Set[int]], str]] = None, -) -> Set[int]: + status_fn: Optional[Callable[[Set[int]], str]] = None) -> Set[int]: """Text-based toggle fallback for terminals without curses.""" chosen = set(selected) print(color(f"\n {title}", Colors.YELLOW)) print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM)) - while True: for i, label in enumerate(items): marker = color("[✓]", Colors.GREEN) if i in chosen else "[ ]" From f482f5c6ef7e70d9fe0f00e50e1651b6e4f7806e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:44:59 -0700 Subject: [PATCH 03/16] =?UTF-8?q?refactor(hermes=5Fcli):=20curator=20?= =?UTF-8?q?=E2=80=94=20extract=20ledger=20rollback=20+=20shared=20result?= =?UTF-8?q?=20reporter,=20collapse=20tallies,=20compact=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/curator.py | 519 ++++++++++++++---------------------------- 1 file changed, 170 insertions(+), 349 deletions(-) diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 65801fcc7a..44da160bc1 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -56,17 +56,14 @@ def _print_skill_rows(title: str, rows: list) -> None: 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'))}" - ) + 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. + Only background-review creations get ``created_by: agent``; older skills and every foreground + ``skill_manage(create)`` are eligible but unmanaged, so no automatic transition touches them. """ from tools import skill_usage @@ -81,10 +78,7 @@ def _print_unmanaged_summary() -> None: print(f"\nunmanaged (no provenance marker): {len(unmanaged)} total") print(f" pre-dates marker {legacy}") print(f" foreground-created {foreground}") - print( - " never auto-staled or archived — " - "`hermes curator adopt <name>` hands one over" - ) + print(" never auto-staled or archived — `hermes curator adopt <name>` hands one over") def _cmd_status(args) -> int: @@ -92,46 +86,29 @@ def _cmd_status(args) -> int: from tools import skill_usage state = curator.load_state() - enabled = curator.is_enabled() paused = state.get("paused", False) - last_run = state.get("last_run_at") summary = state.get("last_run_summary") or "(none)" - runs = state.get("run_count", 0) - - status_line = ( - "ENABLED" if enabled and not paused else - "PAUSED" if paused else - "DISABLED" - ) + status_line = "PAUSED" if paused else "ENABLED" if curator.is_enabled() else "DISABLED" print(f"curator: {status_line}") - print(f" runs: {runs}") - print(f" last run: {_fmt_ts(last_run)}") - # Summary may be multi-line when the curator archived skills (the rename - # map gets appended as `name → umbrella` lines). Indent continuation - # lines so the block reads as one logical field. - if "\n" in summary: - first, *rest = summary.splitlines() - print(f" last summary: {first}") - for line in rest: - print(f" {line}") - else: - print(f" last summary: {summary}") - _report = state.get("last_report_path") - if _report: - suffix = "" if Path(_report).exists() else " (missing)" - print(f" last report: {_report}{suffix}") - _ih = curator.get_interval_hours() - _interval_label = ( - f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24 - else f"{_ih}h" - ) - print(f" interval: every {_interval_label}") + print(f" runs: {state.get('run_count', 0)}") + print(f" last run: {_fmt_ts(state.get('last_run_at'))}") + # Multi-line when the curator archived skills (rename map appended as `name → umbrella` + # lines); indent continuation lines so the block reads as one field. + first, *rest = summary.splitlines() if "\n" in summary else [summary] + print(f" last summary: {first}") + for line in rest: + print(f" {line}") + report = state.get("last_report_path") + if report: + print(f" last report: {report}{'' if Path(report).exists() else ' (missing)'}") + ih = curator.get_interval_hours() + print(f" interval: every {f'{ih // 24}d' if ih % 24 == 0 and ih >= 24 else f'{ih}h'}") print(f" stale after: {curator.get_stale_after_days()}d unused") print(f" archive after: {curator.get_archive_after_days()}d unused") + consolidate = curator.get_consolidate() print( - f" consolidate: {'on' if curator.get_consolidate() else 'off'}" - f"{'' if curator.get_consolidate() else ' (prune-only; LLM merge pass opt-in)'}" - ) + f" consolidate: {'on' if consolidate else 'off'}" + f"{'' if consolidate else ' (prune-only; LLM merge pass opt-in)'}") rows = skill_usage.curated_report() if not rows: @@ -139,61 +116,35 @@ def _cmd_status(args) -> int: _print_unmanaged_summary() return 0 - by_state = {"active": [], "stale": [], "archived": []} - pinned = [] - agent_count = 0 - bundled_count = 0 + by_state: dict = {} for r in rows: - state_name = r.get("state", "active") - by_state.setdefault(state_name, []).append(r) - if r.get("pinned"): - pinned.append(r["name"]) - prov = r.get("provenance", "agent") - if prov == "agent": - agent_count += 1 - elif prov == "bundled": - bundled_count += 1 - + by_state.setdefault(r.get("state", "active"), []).append(r) + pinned = [r["name"] for r in rows if r.get("pinned")] + provenance = [r.get("provenance", "agent") for r in rows] print(f"\ncurator-managed skills: {len(rows)} total " - f"(agent-created={agent_count} bundled={bundled_count})") + f"(agent-created={provenance.count('agent')} bundled={provenance.count('bundled')})") for state_name in ("active", "stale", "archived"): - bucket = by_state.get(state_name, []) - print(f" {state_name:10s} {len(bucket)}") - + print(f" {state_name:10s} {len(by_state.get(state_name, []))}") if pinned: print(f"\npinned ({len(pinned)}): {', '.join(pinned)}") + _print_unmanaged_summary() # the curation blind spot matters on the managed path too - # Surface the curation blind spot on the managed path too. - _print_unmanaged_summary() - - # Show top 5 least-recently-active skills. Views and edits are activity too: - # curator should not report a skill as "never used" right after skill_view() - # or skill_manage() touched it. - active = sorted( - by_state.get("active", []), - key=lambda r: r.get("last_activity_at") or r.get("created_at") or "", - )[:5] - if active: - _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 - # least-recently-active: activity_count reflects frequency, - # last_activity_at reflects recency. A skill touched 30 times a year - # ago is high-frequency but stale; a skill touched once yesterday is - # recent but low-frequency. Both can matter. + # Views and edits count as activity: a skill must not read as "never used" right after + # skill_view()/skill_manage() touched it. Recency (last_activity_at) and frequency + # (activity_count) are different signals, so both top-5s are shown. active_all = by_state.get("active", []) if active_all: + recency = sorted( + active_all, key=lambda r: r.get("last_activity_at") or r.get("created_at") or "") + _print_skill_rows("least recently active (top 5)", recency[:5]) + def _freq(r): return (r.get("activity_count") or 0, r.get("last_activity_at") or "") most_active = sorted(active_all, key=_freq, reverse=True)[:5] - if most_active and (most_active[0].get("activity_count") or 0) > 0: + if (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_skill_rows("least active (top 5)", least_active) - + _print_skill_rows("least active (top 5)", sorted(active_all, key=_freq)[:5]) return 0 @@ -206,57 +157,41 @@ def _cmd_run(args) -> int: dry = bool(getattr(args, "dry_run", False)) background = bool(getattr(args, "background", False)) synchronous = bool(getattr(args, "synchronous", False)) or not background - # --consolidate forces the LLM umbrella-building pass on for this run, - # overriding the config default (off). When the flag is absent, pass None - # so run_curator_review reads curator.consolidate from config. - consolidate = True if bool(getattr(args, "consolidate", False)) else None - if dry: - print("curator: running DRY-RUN (report only, no mutations)...") - else: - print("curator: running review pass...") + # --consolidate forces the LLM umbrella pass on; absent => None so run_curator_review reads + # curator.consolidate from config. + consolidate = True if getattr(args, "consolidate", False) else None + print( + "curator: running DRY-RUN (report only, no mutations)..." if dry + else "curator: running review pass...") if consolidate is None and not curator.get_consolidate(): print( "curator: consolidation is off — running prune-only " "(deterministic stale/archive). Pass --consolidate or set " - "`curator.consolidate: true` to enable the LLM merge pass." - ) - - def _on_summary(msg: str) -> None: - print(msg) + "`curator.consolidate: true` to enable the LLM merge pass.") result = curator.run_curator_review( - on_summary=_on_summary, - synchronous=synchronous, - dry_run=dry, - consolidate=consolidate, - ) + on_summary=print, synchronous=synchronous, dry_run=dry, consolidate=consolidate) auto = result.get("auto_transitions", {}) - if auto: - if dry: - print( - f"auto (preview): {auto.get('checked', 0)} candidate skill(s) " - "— no transitions applied in dry-run" - ) - else: - print( - f"auto: checked={auto.get('checked', 0)} " - f"stale={auto.get('marked_stale', 0)} " - f"archived={auto.get('archived', 0)} " - f"reactivated={auto.get('reactivated', 0)}" - ) + if auto and dry: + print( + f"auto (preview): {auto.get('checked', 0)} candidate skill(s) " + "— no transitions applied in dry-run") + elif auto: + print( + f"auto: checked={auto.get('checked', 0)} " + f"stale={auto.get('marked_stale', 0)} " + f"archived={auto.get('archived', 0)} " + f"reactivated={auto.get('reactivated', 0)}") if not synchronous: print("llm pass running in background — check `hermes curator status` later") - if dry: - if synchronous: - print( - "dry-run: no changes applied. Read the report with " - "`hermes curator status` and run `hermes curator run` (no flag) to apply." - ) - else: - print( - "dry-run: no changes applied. When the report lands, read it with " - "`hermes curator status` and run `hermes curator run` (no flag) to apply." - ) + if dry and synchronous: + print( + "dry-run: no changes applied. Read the report with " + "`hermes curator status` and run `hermes curator run` (no flag) to apply.") + elif dry: + print( + "dry-run: no changes applied. When the report lands, read it with " + "`hermes curator status` and run `hermes curator run` (no flag) to apply.") return 0 @@ -267,12 +202,8 @@ def _set_paused(paused: bool) -> int: return 0 -def _cmd_pause(args) -> int: - return _set_paused(True) - - -def _cmd_resume(args) -> int: - return _set_paused(False) +def _cmd_pause(args) -> int: return _set_paused(True) +def _cmd_resume(args) -> int: return _set_paused(False) _PIN_MESSAGES = { @@ -280,23 +211,18 @@ _PIN_MESSAGES = { "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`. + # Unmanaged skills are never auto-transitioned, so the pin is recorded 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)", - ), + "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}'", - ), -} + "unpinned '{skill}'")} def _set_pin(args, pinned: bool) -> int: @@ -316,20 +242,12 @@ def _set_pin(args, pinned: bool) -> int: return 0 -def _cmd_pin(args) -> int: - return _set_pin(args, True) - - -def _cmd_unpin(args) -> int: - return _set_pin(args, False) +def _cmd_pin(args) -> int: return _set_pin(args, True) +def _cmd_unpin(args) -> int: 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`. - """ + """Itemize the unmanaged population that `status` summarizes (input for `adopt`).""" from tools import skill_usage rows = skill_usage.unmanaged_report() @@ -345,8 +263,7 @@ def _cmd_list_unmanaged(args) -> int: f" {r['name']:44s} " f"activity={r.get('activity_count', 0):4d} " f"last_activity={last:14s} " - f"({why})" - ) + f"({why})") print("\nadopt one with `hermes curator adopt <name>`, " "or all with `hermes curator adopt --all-unmanaged`") return 0 @@ -356,8 +273,7 @@ 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). + skill, not that it AUTHORED it. """ from tools import skill_usage @@ -375,16 +291,14 @@ def _cmd_adopt(args) -> int: print("curator: name a skill to adopt, or pass --all-unmanaged") return 1 - dry_run = bool(getattr(args, "dry_run", False)) - if dry_run: + if getattr(args, "dry_run", False): print(f"curator: would adopt {len(names)} skill(s) (dry run):") for n in names: print(f" + {n}") return 0 - # Bulk adoption is a real lifecycle change (adopted skills become - # archivable), so confirm unless the caller opted out. - if adopt_all and not bool(getattr(args, "yes", False)): + # Bulk adoption is a lifecycle change (adopted skills become archivable): confirm. + if adopt_all and not getattr(args, "yes", False): print(f"curator: adopt {len(names)} unmanaged skill(s) into curator management?") print(" they become eligible for automatic staleness + archival") if not _confirm(" proceed? [y/N] ", "curator: aborted", eof_prefix=""): @@ -394,8 +308,7 @@ def _cmd_adopt(args) -> int: for n in names: ok, msg = skill_usage.adopt_skill(n) print(f"curator: {msg}") - if not ok: - failed += 1 + failed += not ok if len(names) > 1: print(f"curator: adopted {len(names) - failed}/{len(names)}") return 1 if failed else 0 @@ -424,18 +337,15 @@ def _cmd_archive(args) -> int: 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}`" - ) + f"`hermes curator unpin {args.skill}`") return 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). + """Days since last activity; falls back to ``created_at`` so never-used skills aren't immortal. - 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. + None only when both fields are missing or unparseable. """ ts = record.get("last_activity_at") or record.get("created_at") dt = _parse_ts(str(ts)) if ts else None @@ -445,31 +355,19 @@ def _idle_days(record: dict) -> Optional[int]: def _cmd_prune(args) -> int: - """Bulk-archive curator-managed skills idle for >= N days. - - 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. - """ + """Bulk-archive curator-managed skills idle for >= N days (pinned exempt, archived skipped).""" from tools import skill_usage days = getattr(args, "days", 90) if days < 1: print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr) return 2 - - dry_run = bool(getattr(args, "dry_run", False)) - skip_confirm = bool(getattr(args, "yes", False)) - candidates = [] for r in skill_usage.curated_report(): - if r.get("pinned"): - continue - if r.get("state") == skill_usage.STATE_ARCHIVED: + if r.get("pinned") or r.get("state") == skill_usage.STATE_ARCHIVED: continue idle = _idle_days(r) - if idle is None or idle < days: - continue - candidates.append((r["name"], idle)) - + if idle is not None and idle >= days: + candidates.append((r["name"], idle)) if not candidates: print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)") return 0 @@ -479,23 +377,15 @@ def _cmd_prune(args) -> int: for name, idle in candidates: print(f" {name:40s} idle {idle}d") - if dry_run: + if getattr(args, "dry_run", False): print("\n(dry run — no changes made)") return 0 - - if not skip_confirm and not _confirm(f"\nArchive {len(candidates)} skill(s)? [y/N] ", "curator: aborted"): + if not getattr(args, "yes", False) and not _confirm( + f"\nArchive {len(candidates)} skill(s)? [y/N] ", "curator: aborted"): return 1 - - archived = 0 - failures = [] - for name, _ in candidates: - ok, msg = skill_usage.archive_skill(name) - if ok: - archived += 1 - else: - failures.append((name, msg)) - - print(f"\ncurator: archived {archived}/{len(candidates)}") + results = [(name, *skill_usage.archive_skill(name)) for name, _ in candidates] + failures = [(name, msg) for name, ok, msg in results if not ok] + print(f"\ncurator: archived {len(results) - len(failures)}/{len(candidates)}") if failures: print("failures:") for name, msg in failures: @@ -505,17 +395,14 @@ def _cmd_prune(args) -> int: def _cmd_backup(args) -> int: - """Take a manual snapshot of the skills tree. Same mechanism as the - automatic pre-run snapshot, just user-initiated.""" + """Manual skills-tree snapshot (same mechanism as the automatic pre-run snapshot).""" from agent import curator_backup if not curator_backup.is_enabled(): print( "curator: backups are disabled via config " - "(`curator.backup.enabled: false`); re-enable to snapshot" - ) + "(`curator.backup.enabled: false`); re-enable to snapshot") return 1 - reason = getattr(args, "reason", None) or "manual" - snap = curator_backup.snapshot_skills(reason=reason) + snap = curator_backup.snapshot_skills(reason=getattr(args, "reason", None) or "manual") if snap is None: print("curator: snapshot failed — check logs (backup disabled or IO error)") return 1 @@ -528,9 +415,7 @@ def _cmd_ledger(args) -> int: from tools import skill_ledger rows = skill_ledger.list_entries( - skill=getattr(args, "skill", None), - limit=getattr(args, "limit", None) or 20, - ) + skill=getattr(args, "skill", None), limit=getattr(args, "limit", None) or 20) if not rows: print("curator: ledger is empty (or skills.ledger is disabled).") return 0 @@ -545,21 +430,18 @@ def _cmd_ledger(args) -> int: print( f"{r.get('id', '?'):<14} {_fmt_ts(r.get('ts')):<12} " f"{r.get('actor', '?'):<8} {r.get('action', '?'):<12} " - f"{r.get('skill', '?')}{extra}" - ) + f"{r.get('skill', '?')}{extra}") print( "\nRoll back a single mutation with `hermes curator rollback <id>`; " - "whole-tree snapshots remain available via `hermes curator rollback --list`." - ) + "whole-tree snapshots remain available via `hermes curator rollback --list`.") return 0 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 only — never automatic. Each purged skill is captured (before-blobs) and recorded + as a 'purge' ledger entry, so even a purge is auditable and blob-recoverable. """ from hermes_cli.config import cfg_get, load_config from tools import skill_ledger @@ -571,8 +453,7 @@ def _cmd_purge(args) -> int: if ttl_days <= 0: print( "curator: purge disabled (curator.archive_ttl_days is 0). Set the " - "config key or pass --days N to purge archives older than N days." - ) + "config key or pass --days N to purge archives older than N days.") return 1 archive_root = _archive_dir() @@ -584,86 +465,77 @@ def _cmd_purge(args) -> int: import time cutoff = time.time() - ttl_days * 86400 - candidates = [ - p for p in archive_root.iterdir() - if p.is_dir() and p.stat().st_mtime < cutoff - ] + candidates = sorted( + p for p in archive_root.iterdir() if p.is_dir() and p.stat().st_mtime < cutoff) if not candidates: print(f"curator: no archived skills older than {ttl_days}d.") return 0 print(f"Archived skills older than {ttl_days}d:") - for p in sorted(candidates): + for p in candidates: print(f" {p.name}") if getattr(args, "dry_run", False): print("(dry run — nothing deleted)") return 0 if not getattr(args, "yes", False) and not _confirm( - f"Permanently delete {len(candidates)} archived skill(s)? [y/N] " - ): + f"Permanently delete {len(candidates)} archived skill(s)? [y/N] "): return 1 purged = 0 - for p in sorted(candidates): - before = skill_ledger.capture_before( - p, complete_package=True, skill=p.name - ) + for p in candidates: + before = skill_ledger.capture_before(p, complete_package=True, skill=p.name) try: shutil.rmtree(p) except OSError as e: print(f"curator: failed to purge {p.name}: {e}") continue skill_ledger.append_entry( - "purge", - p.name, - before=before or [], - after=[], - actor="user", - evidence={"ttl_days": ttl_days}, - ) + "purge", p.name, before=before or [], after=[], actor="user", + evidence={"ttl_days": ttl_days}) purged += 1 print(f"curator: purged {purged} archived skill(s). Ledger entries recorded.") return 0 -def _cmd_rollback(args) -> int: - """Restore the skills tree from a snapshot, or a single mutation from the audit ledger. +def _report_rollback(ok: bool, msg: str) -> int: + print(f"curator: {msg}" if ok else f"curator: rollback failed — {msg}") + return 0 if ok else 1 - 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. + +def _rollback_ledger_entry(args, entry_id: str) -> int: + """Restore exactly the files touched by one ledger entry (from content-addressed blobs). + + A pre-rollback safety ledger entry is taken first; fails closed if that capture fails. """ + from tools import skill_ledger + + entry = skill_ledger.get_entry(entry_id) + if entry is None: + print( + f"curator: no ledger entry '{entry_id}'. " + "See `hermes curator ledger` for entry ids, or use " + "`--id <snapshot>` for whole-tree snapshot rollback.") + return 1 + print(f"Rollback target: ledger entry {entry_id}") + print(f" action: {entry.get('action', '?')}") + print(f" skill: {entry.get('skill', '?')}") + print(f" actor: {entry.get('actor', '?')}") + 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) and not _confirm( + "Restore this mutation's before-state? [y/N] "): + return 1 + return _report_rollback(*skill_ledger.rollback_entry(entry_id)) + + +def _cmd_rollback(args) -> int: + """Restore the skills tree from a snapshot, or a single mutation from the audit ledger.""" from agent import curator_backup entry_id = getattr(args, "entry_id", None) if entry_id: - from tools import skill_ledger - - entry = skill_ledger.get_entry(entry_id) - if entry is None: - print( - f"curator: no ledger entry '{entry_id}'. " - "See `hermes curator ledger` for entry ids, or use " - "`--id <snapshot>` for whole-tree snapshot rollback." - ) - return 1 - print(f"Rollback target: ledger entry {entry_id}") - print(f" action: {entry.get('action', '?')}") - print(f" skill: {entry.get('skill', '?')}") - print(f" actor: {entry.get('actor', '?')}") - 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) 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}") - return 0 - print(f"curator: rollback failed — {msg}") - return 1 - + return _rollback_ledger_entry(args, entry_id) if getattr(args, "list", False): print(curator_backup.summarize_backups()) return 0 @@ -675,13 +547,11 @@ def _cmd_rollback(args) -> int: if not rows: print( "curator: no snapshots exist yet. Take one with " - "`hermes curator backup` or wait for the next curator run." - ) + "`hermes curator backup` or wait for the next curator run.") else: print( f"curator: no snapshot matching " - f"{'id ' + repr(backup_id) if backup_id else 'your query'}." - ) + f"{'id ' + repr(backup_id) if backup_id else 'your query'}.") print("Available:") print(curator_backup.summarize_backups()) return 1 @@ -697,8 +567,7 @@ def _cmd_rollback(args) -> int: if cron.get("backed_up"): print( f" cron jobs: {cron.get('jobs_count', 0)} " - f"(will be restored for skill-link fields only)" - ) + f"(will be restored for skill-link fields only)") else: reason = cron.get("reason", "not captured") print(f" cron jobs: not in snapshot ({reason})") @@ -706,49 +575,33 @@ def _cmd_rollback(args) -> int: "\nThis will replace the current ~/.hermes/skills/ tree (a safety " "snapshot of the current state is taken first so this is undoable). " "Cron jobs that still exist will have their skills/skill fields " - "restored from the snapshot; all other cron fields are left alone." - ) + "restored from the snapshot; all other cron fields are left alone.") 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: - print(f"curator: {msg}") - return 0 - print(f"curator: rollback failed — {msg}") - return 1 + return _report_rollback(ok, msg) def _cmd_list_archived(args) -> int: """List archived (recoverable) skills.""" from tools import skill_usage names = skill_usage.list_archived_skill_names() - if not names: - print("curator: no archived skills") - return 0 - for name in names: - print(name) + print("\n".join(names) if names else "curator: no archived skills") return 0 def _cmd_usage(args) -> int: - """Show usage telemetry for ALL skills, with provenance. - - 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. - """ + """Usage telemetry for ALL skills on disk (bundled + hub included), with provenance.""" import json as _json from tools import skill_usage rows = skill_usage.usage_report() - prov_filter = getattr(args, "provenance", None) 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. + # name: alphabetical; recent: most-recently-active first (never-active sinks); activity + # (default): most-used first. sort_key = getattr(args, "sort", "activity") if sort_key == "name": rows.sort(key=lambda r: r["name"]) @@ -756,30 +609,22 @@ def _cmd_usage(args) -> int: rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True) else: rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True) - if getattr(args, "json", False): print(_json.dumps(rows, indent=2, ensure_ascii=False)) return 0 - if not rows: print("curator: no skills found") return 0 - - # Provenance tallies for a quick header. - counts = {"agent": 0, "bundled": 0, "hub": 0} - for r in rows: - counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1 + provenance = [r.get("provenance", "agent") for r in rows] + counts = {k: provenance.count(k) for k in ("agent", "bundled", "hub")} print( f"skills: {len(rows)} total " - f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})" - ) + f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})") print() print( f" {'skill':40s} {'origin':8s} " - f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity" - ) + f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity") for r in rows: - last = _fmt_ts(r.get("last_activity_at")) print( f" {r['name'][:40]:40s} " f"{r.get('provenance', 'agent'):8s} " @@ -787,16 +632,11 @@ def _cmd_usage(args) -> int: f"{r.get('view_count', 0):>4d} " f"{r.get('patch_count', 0):>5d} " f"{r.get('activity_count', 0):>4d} " - f"{last}" - ) + f"{_fmt_ts(r.get('last_activity_at'))}") return 0 -# --------------------------------------------------------------------------- -# argparse wiring (called from hermes_cli.main) -# --------------------------------------------------------------------------- - -# (name, parser kwargs, handler, ((flags...), add_argument kwargs) ...) +# argparse wiring: (name, parser kwargs, handler, ((flags...), add_argument kwargs) ...) _SUBCOMMANDS = ( ("status", {"help": "Show curator status and skill stats"}, _cmd_status), ( @@ -806,8 +646,7 @@ _SUBCOMMANDS = ( (("--sort",), dict( choices=("activity", "recent", "name"), default="activity", help="Sort order: activity (most-used first, default), recent " - "(most-recently-active first), or name (alphabetical)", - )), + "(most-recently-active first), or name (alphabetical)")), (("--provenance",), dict( choices=("agent", "bundled", "hub"), default=None, help="Only show skills of this origin", )), @@ -819,36 +658,29 @@ _SUBCOMMANDS = ( _cmd_run, (("--sync", "--synchronous"), dict( dest="synchronous", action="store_true", - help="Wait for the LLM review pass to finish (default for manual runs)", - )), + 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", - )), + 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)", - )), + "(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.", - )), - ), + "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")), - ), + (("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, - ), + _cmd_list_unmanaged), ( "adopt", {"help": "Hand unmanaged skills to the curator (provenance is a user declaration)"}, @@ -866,8 +698,7 @@ _SUBCOMMANDS = ( "archive", {"help": "Manually archive a skill (move to .archive/, excluded from prompt)"}, _cmd_archive, - (("skill",), dict(help="Skill name")), - ), + (("skill",), dict(help="Skill name"))), ( "prune", {"help": "Bulk-archive curator-managed skills idle for >= N days (default 90)"}, @@ -876,8 +707,7 @@ _SUBCOMMANDS = ( (("-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/ " @@ -893,19 +723,16 @@ _SUBCOMMANDS = ( (("entry_id",), dict( nargs="?", default=None, help="Ledger entry id for single-mutation rollback (from " - "`hermes curator ledger`). Omit for whole-tree snapshot rollback.", - )), + "`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")), - ), + (("-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)")), - ), + (("--limit",), dict(type=int, default=20, help="Max entries to show (default: 20)"))), ( "purge", {"help": "Delete archived skills older than curator.archive_ttl_days " @@ -913,9 +740,7 @@ _SUBCOMMANDS = ( _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")), - ), -) + (("-y", "--yes"), dict(action="store_true", help="Skip the confirmation prompt")))) def register_cli(parent: argparse.ArgumentParser) -> None: @@ -934,11 +759,7 @@ def cli_main(argv=None) -> int: parser = argparse.ArgumentParser(prog="hermes curator") register_cli(parser) args = parser.parse_args(argv) - fn = getattr(args, "func", None) - if fn is None: - parser.print_help() - return 0 - return int(fn(args) or 0) + return int(args.func(args) or 0) if __name__ == "__main__": # pragma: no cover From 41ab6c5e1066481c1719bbcad187a31a3fb52723 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:52:08 -0700 Subject: [PATCH 04/16] =?UTF-8?q?refactor(hermes=5Fcli):=20commands=5Fcomp?= =?UTF-8?q?letion=20=E2=80=94=20flatten=20argument=20routing,=20inline=20s?= =?UTF-8?q?ingle-use=20helpers,=20compact=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/commands_completion.py | 278 ++++++++++++------------------ 1 file changed, 112 insertions(+), 166 deletions(-) diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py index 6a6af2bd80..8affdf22ab 100644 --- a/hermes_cli/commands_completion.py +++ b/hermes_cli/commands_completion.py @@ -1,8 +1,7 @@ """prompt_toolkit completer + inline auto-suggest for slash commands. -Extracted from :mod:`hermes_cli.commands` (which re-exports -``SlashCommandCompleter`` / ``SlashCommandAutoSuggest``). The registry module -stays prompt_toolkit-free so the gateway can import it without the dependency. +Kept out of :mod:`hermes_cli.commands` (which re-exports both classes) so the registry module +stays prompt_toolkit-free for the gateway. """ from __future__ import annotations @@ -29,10 +28,8 @@ _personalities_memo: Optional[ def _personalities_from_cli_config() -> Dict[str, Any]: """``available_personalities(load_cli_config())`` memoised on config path+mtime+size. - load_cli_config() does a full YAML parse + deep merge and the completer - runs per keystroke; the result only changes when config.yaml changes on - disk (same pattern as load_env). Falls back to a fresh load when the file - cannot be stat'ed. + load_cli_config() is a full YAML parse + deep merge and the completer runs per keystroke. + Falls back to a fresh load when the file cannot be stat'ed. """ global _personalities_memo from cli import load_cli_config @@ -69,15 +66,16 @@ def _file_size_label(path: str) -> str: return "" if size < 1024: return f"{size}B" - if size < 1024 * 1024: + if size < 1024 ** 2: return f"{size / 1024:.0f}K" - if size < 1024 * 1024 * 1024: - return f"{size / (1024 * 1024):.1f}M" - return f"{size / (1024 * 1024 * 1024):.1f}G" + if size < 1024 ** 3: + return f"{size / 1024 ** 2:.1f}M" + return f"{size / 1024 ** 3:.1f}G" -def _prefix_completions(rows: Iterable[tuple[str, Any]], partial: str, *, skip_exact: bool = True): - """Yield a Completion per ``(name, meta)`` whose name starts with *partial* (case-folded partial).""" +def _prefix_completions( + rows: Iterable[tuple[str, Any]], partial: str, *, skip_exact: bool = True): + """A Completion per ``(name, meta)`` whose name starts with the case-folded *partial*.""" lowered = partial.lower() for name, meta in rows: if name.startswith(lowered) and not (skip_exact and name == lowered): @@ -85,16 +83,15 @@ def _prefix_completions(rows: Iterable[tuple[str, Any]], partial: str, *, skip_e def _split_args(sub_text: str) -> tuple[list[str], str]: - """``(completed_words, partial)`` for multi-word argument text; a trailing space means a fresh word.""" + """``(completed_words, partial)``; a trailing space means a fresh word.""" parts = sub_text.split() - if sub_text.endswith(" "): + if sub_text.endswith(" ") or not parts: return parts, "" - return parts[:-1], (parts[-1] if parts else "") + return parts[:-1], parts[-1] -# --------------------------------------------------------------------------- # Dynamic argument completers: (sub_text, sub_lower) -> Completion iterator -# --------------------------------------------------------------------------- + def _skin_completions(sub_text: str, sub_lower: str): """/skin — available skins.""" @@ -112,8 +109,9 @@ def _personality_completions(sub_text: str, sub_lower: str): from hermes_cli.personality import describe_personality personalities = _personalities_from_cli_config() - rows = chain([("none", "clear personality overlay")], - ((name, describe_personality(prompt)) for name, prompt in personalities.items())) + rows = chain( + [("none", "clear personality overlay")], + ((name, describe_personality(prompt)) for name, prompt in personalities.items())) yield from _prefix_completions(rows, sub_text) except Exception: pass @@ -122,9 +120,8 @@ def _personality_completions(sub_text: str, sub_lower: str): def _tools_completions(sub_text: str, sub_lower: str): """/tools — subcommand, then toolset / MCP-server names for enable|disable. - Toolsets are offered only when the subcommand would change their state - (enable → currently off, disable → currently on); MCP server prefixes are - always offered. + Toolsets are offered only when the subcommand would change their state; MCP server + prefixes are always offered. """ completed, partial = _split_args(sub_text) if not completed: @@ -136,28 +133,29 @@ def _tools_completions(sub_text: str, sub_lower: str): already = set(completed[1:]) try: from hermes_cli.config import load_config_readonly - from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_platform_tools, _get_plugin_toolset_keys + from hermes_cli.tools_config import ( + CONFIGURABLE_TOOLSETS, _get_platform_tools, _get_plugin_toolset_keys) - # Readonly loader: this runs per keystroke and never mutates config, - # so skip the defensive deepcopy of load_config(). + # Readonly loader: per keystroke and never mutates, so skip load_config()'s deepcopy. config = load_config_readonly() enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False) mcp_servers = config.get("mcp_servers") or {} want_enabled = subcommand != "enable" - rows = [(k, label) for k, label, _d in CONFIGURABLE_TOOLSETS if (k in enabled) == want_enabled] - rows += [(k, "plugin toolset") for k in sorted(_get_plugin_toolset_keys()) if (k in enabled) == want_enabled] + rows = [(k, label) for k, label, _d in CONFIGURABLE_TOOLSETS] + rows += [(k, "plugin toolset") for k in sorted(_get_plugin_toolset_keys())] + rows = [(k, m) for k, m in rows if (k in enabled) == want_enabled] if isinstance(mcp_servers, dict): rows += [(f"{srv}:", f"MCP server '{srv}'") for srv in sorted(mcp_servers)] - yield from _prefix_completions(((k, m) for k, m in rows if k not in already), partial, skip_exact=False) + yield from _prefix_completions( + ((k, m) for k, m in rows if k not in already), partial, skip_exact=False) except Exception: return def _handoff_completions(sub_text: str, sub_lower: str): - """/handoff — connected (enabled + configured) gateway platforms, first arg only. + """/handoff — connected gateway platforms, first arg only. - A recorded home channel is NOT required to list a platform — it's often - learned at runtime — so the meta hints whether one is set yet. + A home channel is not required (often learned at runtime); the meta hints whether one is set. """ completed, partial = _split_args(sub_text) if completed: @@ -178,51 +176,42 @@ def _handoff_completions(sub_text: str, sub_lower: str): home = gw.get_home_channel(platform) except Exception: home = None - meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here" + home_name = getattr(home, "name", None) if home else None + meta = f"→ {home_name}" if home_name else "send this session here" yield Completion(name, start_position=-len(partial), display=name, display_meta=meta) -# base command -> (handler(sub_text, sub_lower), single_word_only). -# Single-word handlers only run while the first argument is being typed; -# /tools and /handoff parse multi-word input themselves, bypassing the -# static SUBCOMMANDS branch. +# base command -> (handler(sub_text, sub_lower), single_word_only). Single-word handlers only +# run while the first argument is typed; /tools and /handoff parse multi-word input themselves. _DYNAMIC_COMPLETIONS: dict[str, tuple[Callable[..., Any], bool]] = { "/skin": (_skin_completions, True), "/personality": (_personality_completions, True), "/tools": (_tools_completions, False), - "/handoff": (_handoff_completions, False), -} + "/handoff": (_handoff_completions, False)} -# --------------------------------------------------------------------------- # Path / @-context completion -# --------------------------------------------------------------------------- + def _extract_path_word(text: str) -> str | None: - """Path-like word under the cursor (``./``, ``../``, ``~/``, ``/`` or contains ``/``), else None. - - Tokens with a ``://`` scheme are excluded — treating a pasted URL as a - path fires os.listdir per keystroke for no useful result. - """ + """Word under the cursor when it contains ``/`` and no ``://`` scheme (URLs aren't paths).""" word = text.rpartition(" ")[2] - if not word or "://" in word: - return None - return word if "/" in word else None + return word if word and "://" not in word and "/" in word else None -def _dir_completions(expanded: str, word: str, limit: int, text_for: Callable[[str], str], want_dir: bool | None = None): - """Yield directory-listing completions for the path *expanded*. +def _dir_completions( + expanded: str, word: str, limit: int, text_for: Callable[[str], str], + want_dir: bool | None = None): + """Directory-listing completions for *expanded*. - Entries of the parent dir are matched case-insensitively on the typed - basename (all entries after a trailing ``/``), sorted by name, and - limited to *limit*. ``text_for(full_path)`` builds the completion text - (without the trailing ``/``); *want_dir* restricts to dirs / files. + Parent-dir entries are matched case-insensitively on the typed basename (all entries after + a trailing ``/``), sorted, limited to *limit*. ``text_for(full_path)`` builds the completion + text (without trailing ``/``); *want_dir* restricts to dirs / files. """ if expanded.endswith("/"): search_dir, prefix = expanded, "" else: - search_dir = os.path.dirname(expanded) or "." - prefix = os.path.basename(expanded) + search_dir, prefix = os.path.dirname(expanded) or ".", os.path.basename(expanded) try: entries = os.listdir(search_dir) except OSError: @@ -230,7 +219,7 @@ def _dir_completions(expanded: str, word: str, limit: int, text_for: Callable[[s prefix_lower = prefix.lower() count = 0 for entry in sorted(entries): - if prefix and not entry.lower().startswith(prefix_lower): + if not entry.lower().startswith(prefix_lower): continue full_path = os.path.join(search_dir, entry) is_dir = os.path.isdir(full_path) @@ -243,19 +232,16 @@ def _dir_completions(expanded: str, word: str, limit: int, text_for: Callable[[s text_for(full_path) + suffix, start_position=-len(word), display=entry + suffix, - display_meta="dir" if is_dir else _file_size_label(full_path), - ) + display_meta="dir" if is_dir else _file_size_label(full_path)) count += 1 def _path_completions(word: str, limit: int = 30): - """Yield Completion objects for file paths matching *word*, keeping the user's path style (~, absolute, relative).""" + """Path completions for *word*, keeping the user's style (~, absolute, relative).""" if word.startswith("~"): text_for = lambda fp: "~/" + os.path.relpath(fp, os.path.expanduser("~")) # noqa: E731 - elif os.path.isabs(word): - text_for = lambda fp: fp # noqa: E731 else: - text_for = os.path.relpath + text_for = str if os.path.isabs(word) else os.path.relpath yield from _dir_completions(os.path.expanduser(word), word, limit, text_for) @@ -265,14 +251,13 @@ _STATIC_CONTEXT_REFS = ( ("@file:", "Attach a file"), ("@folder:", "Attach a folder"), ("@git:", "Git log with diffs (e.g. @git:5)"), - ("@url:", "Fetch web content"), -) + ("@url:", "Fetch web content")) def _score_path(filepath: str, query: str) -> int: - """Score a file path against a fuzzy query. Higher = better match; 0 = no match.""" + """Score a file path against a fuzzy query. Higher = better; 0 = no match; 1 = empty query.""" if not query: - return 1 # show everything when query is empty + return 1 lower_file = os.path.basename(filepath).lower() lower_q = query.lower() if lower_file == lower_q: @@ -283,8 +268,8 @@ def _score_path(filepath: str, query: str) -> int: return 60 if lower_q in filepath.lower(): return 40 - # Abbreviation match: query chars appear in order in the filename ("fo" ~ - # "file_operations"); bonus when >= half land on word boundaries (_-./). + # Abbreviation: query chars in order in the filename ("fo" ~ "file_operations"); bonus when + # >= half land on word boundaries (_-./). qi = boundary_hits = 0 prev = "_" # treat start as boundary for c in lower_file: @@ -300,8 +285,7 @@ def _score_path(filepath: str, query: str) -> int: class SlashCommandCompleter(Completer): """Autocomplete for built-in slash commands, subcommands, and skill commands.""" - # Commands that open pickers when run bare. No trailing space for these: - # the TUI applies the completion on Enter, and "/model " blocks the picker. + # Bare-run picker commands get no trailing space: "/model " would block the picker. _PICKER_COMMANDS = frozenset({"model", "skin", "personality"}) # Module-level helpers exposed as staticmethods for existing callers/tests. @@ -319,8 +303,7 @@ class SlashCommandCompleter(Completer): self, skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None, command_filter: Callable[[str], bool] | None = None, - skill_bundles_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None, - ) -> None: + skill_bundles_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None) -> None: self._skill_commands_provider = skill_commands_provider self._command_filter = command_filter self._skill_bundles_provider = skill_bundles_provider @@ -330,45 +313,35 @@ class SlashCommandCompleter(Completer): self._file_cache_cwd: str = "" def _command_allowed(self, slash_command: str) -> bool: - if self._command_filter is None: - return True try: - return bool(self._command_filter(slash_command)) + return self._command_filter is None or bool(self._command_filter(slash_command)) except Exception: return True @staticmethod def _call_provider(provider) -> Mapping[str, dict[str, Any]]: - if provider is None: - return {} try: - return provider() or {} + return (provider() if provider is not None else None) or {} except Exception: return {} def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]: return self._call_provider(self._skill_commands_provider) - def _iter_skill_bundles(self) -> Mapping[str, dict[str, Any]]: - return self._call_provider(self._skill_bundles_provider) - - # -- stacked slash-skill completion helpers --------------------------- - @staticmethod def _normalize_skill_token(token: str) -> str: - """Canonical hyphenated /slug form; mirrors resolve_skill_command_key() (underscores == hyphens).""" + """Canonical hyphenated /slug; mirrors resolve_skill_command_key() (``_`` == ``-``).""" return "/" + token.lstrip("/").replace("_", "-").lower() def _is_skill_command(self, token: str) -> bool: return self._normalize_skill_token(token) in self._iter_skill_commands() def _stacked_skill_completions(self, text: str): - """Offer skill-command completions for stacked invocations (``/skill-a /skill-b do XYZ``). + """Skill-command completions for stacked invocations (``/skill-a /skill-b do XYZ``). - Keep suggesting while every completed token is a distinct skill - command, the cap is not reached, and the current word starts with - ``/``; once the chain breaks, offer nothing — instruction text must - never be polluted with skill suggestions. + Suggest only while every completed token is a distinct skill command, the cap is not + reached, and the current word starts with ``/`` — instruction text must never be + polluted with skill suggestions. """ try: from agent.skill_commands import _MAX_STACKED_SKILLS as _cap @@ -383,27 +356,23 @@ class SlashCommandCompleter(Completer): if key not in skill_cmds or key in seen: return seen.add(key) - # A bare space after the chain means they may be starting the instruction. if len(seen) >= _cap or not current_word.startswith("/"): - return - + return # a bare space after the chain may start the instruction word_key = self._normalize_skill_token(current_word) for cmd, info in skill_cmds.items(): if cmd in seen or not cmd.startswith(word_key): continue - # Exact match: trailing space keeps the dropdown visible so the - # next stacked token can be typed immediately (see _completion_text). + # Exact match: trailing space keeps the dropdown open for the next stacked token. yield Completion( f"{cmd} " if cmd == word_key else cmd, start_position=-len(current_word), display=cmd, - display_meta=f"⚡ {_short_desc(info, 'Skill command')}", - ) + display_meta=f"⚡ {_short_desc(info, 'Skill command')}") @staticmethod def _completion_text(cmd_name: str, word: str) -> str: - """Replacement text: on an exact match a no-op replacement makes prompt_toolkit - suppress the menu, so a trailing space is appended — except for _PICKER_COMMANDS.""" + """Replacement text; exact matches get a trailing space (else prompt_toolkit hides the + menu on a no-op replacement) — except _PICKER_COMMANDS.""" if cmd_name != word or cmd_name in SlashCommandCompleter._PICKER_COMMANDS: return cmd_name return f"{cmd_name} " @@ -415,33 +384,30 @@ class SlashCommandCompleter(Completer): return word if word.startswith("@") else None def _context_completions(self, word: str, limit: int = 30): - """Claude Code-style @ completions: static refs, ``@file:``/``@folder:`` paths, else fuzzy project files.""" + """@ completions: static refs, ``@file:``/``@folder:`` paths, else fuzzy project files.""" lowered = word.lower() for candidate, meta in _STATIC_CONTEXT_REFS: if candidate.startswith(lowered) and candidate != lowered: - yield Completion(candidate, start_position=-len(word), display=candidate, display_meta=meta) - - # Accepting the bare `@file` / `@folder` (no colon yet) lets the picker - # surface entries without first accepting the static hint. + yield Completion( + candidate, start_position=-len(word), display=candidate, display_meta=meta) + # Bare `@file` / `@folder` (no colon yet) already opens the picker. for prefix in ("@file:", "@folder:"): bare = prefix[:-1] if word == bare or word.startswith(prefix): expanded = os.path.expanduser("" if word == bare else word[len(prefix):]) if not expanded or expanded == ".": expanded = "./" - # `@folder:` surfaces only directories, `@file:` only regular - # files — otherwise `@folder:` lists every dotfile in cwd. + # `@folder:` = dirs only, `@file:` = files only (else `@folder:` lists dotfiles). yield from _dir_completions( expanded, word, limit, lambda fp: f"{prefix}{os.path.relpath(fp)}", - want_dir=(prefix == "@folder:"), - ) + want_dir=(prefix == "@folder:")) return yield from self._fuzzy_file_completions(word, word[1:], limit) def _get_project_files(self) -> list[str]: - """Return cached list of project files (refreshed every 5s); rg (gitignore-aware) then fd.""" + """Cached project file list (5s TTL); rg (gitignore-aware) then fd.""" cwd = os.getcwd() now = time.monotonic() if self._file_cache and self._file_cache_cwd == cwd and now - self._file_cache_time < 5.0: @@ -451,15 +417,13 @@ class SlashCommandCompleter(Completer): for cmd in ( ["rg", "--files", "--sortr=modified", cwd], ["rg", "--files", cwd], - ["fd", "--type", "f", "--base-directory", cwd], - ): + ["fd", "--type", "f", "--base-directory", cwd]): if not shutil.which(cmd[0]): continue try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=2, - cwd=cwd, encoding="utf-8", errors="replace", - ) + cwd=cwd, encoding="utf-8", errors="replace") except (subprocess.TimeoutExpired, OSError): continue if proc.returncode != 0 or not proc.stdout.strip(): @@ -468,26 +432,21 @@ class SlashCommandCompleter(Completer): try: files.append(os.path.relpath(p, cwd) if os.path.isabs(p) else p) except ValueError: - # Windows: relpath raises for paths on a different mount than - # cwd (\\.\nul, other drive letter). One bad entry must not - # crash the @ autocomplete event loop. - continue + continue # Windows: relpath raises across mounts/drive letters break self._file_cache, self._file_cache_time, self._file_cache_cwd = files, now, cwd return files def _fuzzy_file_completions(self, word: str, query: str, limit: int = 20): - """Yield fuzzy file completions for bare @query (no query = recently modified files).""" + """Fuzzy file completions for bare @query (no query = recently modified files).""" files = self._get_project_files() - if not query: - ranked = files[:limit] - else: - scored = [(s, fp) for fp in files if (s := _score_path(fp, query)) > 0] - scored.sort(key=lambda x: (-x[0], x[1])) - ranked = [fp for _, fp in scored[:limit]] - - for fp in ranked: + if query: + scored = sorted( + ((s, fp) for fp in files if (s := _score_path(fp, query)) > 0), + key=lambda x: (-x[0], x[1])) + files = [fp for _, fp in scored] + for fp in files[:limit]: is_dir = fp.endswith("/") meta = "dir" if is_dir else _file_size_label(os.path.join(os.getcwd(), fp)) if query: @@ -496,18 +455,16 @@ class SlashCommandCompleter(Completer): f"@{'folder' if is_dir else 'file'}:{fp}", start_position=-len(word), display=os.path.basename(fp), - display_meta=meta, - ) + display_meta=meta) def get_completions(self, document, complete_event): text = document.text_before_cursor if not text.startswith("/"): ctx_word = self._extract_context_word(text) + path_word = _extract_path_word(text) if ctx_word is not None: yield from self._context_completions(ctx_word) - return - path_word = _extract_path_word(text) - if path_word is not None: + elif path_word is not None: yield from _path_completions(path_word) return @@ -516,23 +473,17 @@ class SlashCommandCompleter(Completer): if len(parts) > 1 or text.endswith(" "): # Completing arguments: base command already typed. sub_text = parts[1] if len(parts) > 1 else "" - sub_lower = sub_text.lower() - - # Stacked slash-skill chain (`/skill-a /skill-b …`), see - # split_stacked_skill_commands in agent/skill_commands.py. + first_arg = " " not in sub_text + # Stacked slash-skill chain (see split_stacked_skill_commands in agent/skill_commands). if self._is_skill_command(base_cmd): yield from self._stacked_skill_completions(text) return - - dynamic = _DYNAMIC_COMPLETIONS.get(base_cmd) - if dynamic is not None: - handler, single_word = dynamic - if not single_word or " " not in sub_text: - yield from handler(sub_text, sub_lower) - return - - if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): - yield from _prefix_completions(((s, None) for s in SUBCOMMANDS[base_cmd]), sub_text) + handler, single_word = _DYNAMIC_COMPLETIONS.get(base_cmd, (None, False)) + if handler is not None and (not single_word or first_arg): + yield from handler(sub_text, sub_text.lower()) + elif first_arg and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): + yield from _prefix_completions( + ((s, None) for s in SUBCOMMANDS[base_cmd]), sub_text) return word = text[1:] @@ -542,45 +493,46 @@ class SlashCommandCompleter(Completer): self._completion_text(cmd_name, word), start_position=-len(word), display=f"/{cmd_name}", - display_meta=meta, - ) + display_meta=meta) for cmd, desc in COMMANDS.items(): if self._command_allowed(cmd) and cmd[1:].startswith(word): yield _cmd_completion(cmd[1:], desc) - for cmd, info in self._iter_skill_bundles().items(): + for cmd, info in self._call_provider(self._skill_bundles_provider).items(): if cmd[1:].startswith(word): skill_count = len(info.get("skills", [])) - yield _cmd_completion(cmd[1:], f"▣ {_short_desc(info, 'Skill bundle')} ({skill_count} skills)") - + yield _cmd_completion( + cmd[1:], f"▣ {_short_desc(info, 'Skill bundle')} ({skill_count} skills)") for cmd, info in self._iter_skill_commands().items(): if cmd[1:].startswith(word): yield _cmd_completion(cmd[1:], f"⚡ {_short_desc(info, 'Skill command')}") - try: from hermes_cli.plugins import get_plugin_commands for cmd_name, cmd_info in get_plugin_commands().items(): if cmd_name.startswith(word): - yield _cmd_completion(cmd_name, f"🔌 {_short_desc(cmd_info, 'Plugin command')}") + yield _cmd_completion( + cmd_name, f"🔌 {_short_desc(cmd_info, 'Plugin command')}") except Exception: pass class SlashCommandAutoSuggest(AutoSuggest): - """Inline ghost-text for slash commands and their subcommands; history fallback for other input.""" + """Inline ghost-text for slash commands and subcommands; history fallback for other input.""" def __init__( self, history_suggest: AutoSuggest | None = None, - completer: SlashCommandCompleter | None = None, - ) -> None: + completer: SlashCommandCompleter | None = None) -> None: self._history = history_suggest self._completer = completer # Reuse its model cache def _allowed(self, cmd: str) -> bool: return self._completer is None or self._completer._command_allowed(cmd) + def _history_suggestion(self, buffer, document): + return self._history.get_suggestion(buffer, document) if self._history else None + def get_suggestion(self, buffer, document): text = document.text_before_cursor if not text.startswith("/"): @@ -590,8 +542,7 @@ class SlashCommandAutoSuggest(AutoSuggest): base_cmd = parts[0].lower() if len(parts) == 1 and not text.endswith(" "): - # Still typing the command name: /upd → "ate". Prefer the SHORTEST - # match so /he ghosts "lp" (/help), not "artbeat" (/heartbeat). + # Still typing the name: prefer the SHORTEST match so /he ghosts "lp", not "artbeat". word = text[1:].lower() for cmd in sorted(COMMANDS, key=len): cmd_name = cmd[1:] @@ -601,13 +552,11 @@ class SlashCommandAutoSuggest(AutoSuggest): sub_text = parts[1] if len(parts) > 1 else "" sub_lower = sub_text.lower() - - # Stacked skill chain: ghost-suggest the rest of the next skill name; - # otherwise fall through to the history fallback for instruction text. + # Stacked skill chain: ghost the rest of the next skill name, else history fallback. if self._completer is not None and self._completer._is_skill_command(base_cmd): for completion in self._completer._stacked_skill_completions(text): - remainder = completion.text[-completion.start_position:] \ - if completion.start_position else completion.text + start = completion.start_position + remainder = completion.text[-start:] if start else completion.text if remainder.strip(): return Suggestion(remainder) @@ -618,6 +567,3 @@ class SlashCommandAutoSuggest(AutoSuggest): if sub.startswith(sub_lower) and sub != sub_lower: return Suggestion(sub[len(sub_text):]) return self._history_suggestion(buffer, document) - - def _history_suggestion(self, buffer, document): - return self._history.get_suggestion(buffer, document) if self._history else None From a9b2cae683140079c31c853b235720bf6f7652b4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:57:25 -0700 Subject: [PATCH 05/16] =?UTF-8?q?refactor(hermes=5Fcli):=20completion=20?= =?UTF-8?q?=E2=80=94=20shared=20sorted-subcommand=20walk,=20single=20profi?= =?UTF-8?q?le-action=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/completion.py | 98 +++++++++++++--------------------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index c74892216a..82eab4b2b6 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -1,7 +1,6 @@ """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. +Walks the live argparse parser tree, so completion scripts never go stale; no extra dependencies. """ from __future__ import annotations @@ -17,23 +16,15 @@ def _walk(parser: argparse.ArgumentParser) -> dict[str, Any]: for action in parser._actions: if isinstance(action, argparse._SubParsersAction): - # _choices_actions has one entry per canonical name; aliases are - # omitted, which keeps completion lists clean. - seen: set[str] = set() + # _choices_actions has one entry per canonical name (aliases omitted). for pseudo in action._choices_actions: name = pseudo.dest - if name in seen: - continue - seen.add(name) subparser = action.choices.get(name) - if subparser is None: + if name in subcommands or subparser is None: continue - info = _walk(subparser) - info["help"] = _clean(pseudo.help or "") - subcommands[name] = info + subcommands[name] = {**_walk(subparser), "help": _clean(pseudo.help or "")} elif action.option_strings: flags.extend(o for o in action.option_strings if o.startswith("-")) - return {"flags": flags, "subcommands": subcommands} @@ -42,22 +33,24 @@ def _clean(text: str, maxlen: int = 60) -> str: return text.replace("'", "").replace('"', "").replace("\\", "")[:maxlen] -# --------------------------------------------------------------------------- -# Bash -# --------------------------------------------------------------------------- +# Profile actions that take a profile name as their next argument. +_PROFILE_NAME_ACTIONS = ("use", "delete", "show", "alias", "rename", "export") + + +def _sorted_subcommands(parser: argparse.ArgumentParser) -> list[tuple[str, dict[str, Any]]]: + tree = _walk(parser) + return sorted(tree["subcommands"].items()) + def generate_bash(parser: argparse.ArgumentParser) -> str: - tree = _walk(parser) - top_cmds = " ".join(sorted(tree["subcommands"])) - + subcommands = _sorted_subcommands(parser) + top_cmds = " ".join(cmd for cmd, _ in subcommands) cases: list[str] = [] - for cmd in sorted(tree["subcommands"]): - info = tree["subcommands"][cmd] + for cmd, info in subcommands: if cmd == "profile" and info["subcommands"]: - # Profile subcommand: complete actions, then profile names for - # actions that accept a profile argument. + # Complete actions, then profile names for actions that accept a profile argument. subcmds = " ".join(sorted(info["subcommands"])) - profile_actions = "use delete show alias rename export" + profile_actions = " ".join(_PROFILE_NAME_ACTIONS) cases.append( f" profile)\n" f" case \"$prev\" in\n" @@ -82,7 +75,6 @@ def generate_bash(parser: argparse.ArgumentParser) -> str: ) cases_str = "\n".join(cases) - return f"""# Hermes Agent bash completion # Add to ~/.bashrc: # eval "$(hermes completion bash)" @@ -125,10 +117,6 @@ 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( @@ -138,22 +126,19 @@ def _zsh_describe_lines(subcommands: dict[str, Any], indent: str) -> str: def generate_zsh(parser: argparse.ArgumentParser) -> str: - tree = _walk(parser) - top_cmds_str = _zsh_describe_lines(tree["subcommands"], " " * 16) - + subcommands = _sorted_subcommands(parser) + top_cmds_str = _zsh_describe_lines(dict(subcommands), " " * 16) sub_cases: list[str] = [] - for cmd in sorted(tree["subcommands"]): - info = tree["subcommands"][cmd] + for cmd, info in subcommands: if not info["subcommands"]: continue if cmd == "profile": - # Profile subcommand: complete actions, then profile names for - # actions that accept a profile argument. + # Complete actions, then profile names for actions that accept a profile argument. sub_str = _zsh_describe_lines(info["subcommands"], " " * 24) sub_cases.append( f" profile)\n" f" case ${{line[2]}} in\n" - f" use|delete|show|alias|rename|export)\n" + f" {'|'.join(_PROFILE_NAME_ACTIONS)})\n" f" _hermes_profiles\n" f" ;;\n" f" *)\n" @@ -179,7 +164,6 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str: f" ;;" ) sub_cases_str = "\n".join(sub_cases) - return f"""#compdef hermes # Hermes Agent zsh completion # Add to ~/.zshrc: @@ -225,15 +209,9 @@ compdef _hermes hermes """ -# --------------------------------------------------------------------------- -# Fish -# --------------------------------------------------------------------------- - def generate_fish(parser: argparse.ArgumentParser) -> str: - tree = _walk(parser) - top_cmds = sorted(tree["subcommands"]) - top_cmds_str = " ".join(top_cmds) - + subcommands = _sorted_subcommands(parser) + top_cmds_str = " ".join(cmd for cmd, _ in subcommands) lines: list[str] = [ "# Hermes Agent fish completion", "# Add to your config:", @@ -259,42 +237,30 @@ def generate_fish(parser: argparse.ArgumentParser) -> str: "# Top-level subcommands", ] - for cmd in top_cmds: - info = tree["subcommands"][cmd] - help_text = _clean(info.get("help", "")) + for cmd, info in subcommands: lines.append( f"complete -c hermes -f " f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' " - f"-a {cmd} -d '{help_text}'" + f"-a {cmd} -d '{_clean(info.get('help', ''))}'" ) - - lines.append("") - lines.append("# Subcommand completions") - - profile_name_actions = {"use", "delete", "show", "alias", "rename", "export"} - - for cmd in top_cmds: - info = tree["subcommands"][cmd] + lines += ["", "# Subcommand completions"] + for cmd, info in subcommands: if not info["subcommands"]: continue lines.append(f"# {cmd}") - for sc in sorted(info["subcommands"]): - sinfo = info["subcommands"][sc] - sh = _clean(sinfo.get("help", "")) + for sc, sinfo in sorted(info["subcommands"].items()): lines.append( f"complete -c hermes -f " f"-n '__fish_seen_subcommand_from {cmd}' " - f"-a {sc} -d '{sh}'" + f"-a {sc} -d '{_clean(sinfo.get('help', ''))}'" ) - # For profile subcommand, complete profile names for relevant actions - if cmd == "profile": - for action in sorted(profile_name_actions): + if cmd == "profile": # profile names for the actions that take one + for action in sorted(_PROFILE_NAME_ACTIONS): lines.append( f"complete -c hermes -f " f"-n '__fish_seen_subcommand_from {action}; " f"and __fish_seen_subcommand_from profile' " f"-a '(__hermes_profiles)' -d 'Profile name'" ) - lines.append("") return "\n".join(lines) From 7b369086319d15150e036a3396f8e6b94e337ec5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:06:07 -0700 Subject: [PATCH 06/16] =?UTF-8?q?refactor(hermes=5Fcli):=20console=5Fengin?= =?UTF-8?q?e=20=E2=80=94=20=5Fcaptured=20decorator,=20=5Fsimple=5Fcommand?= =?UTF-8?q?=20factory,=20hoist=20export=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/console_engine.py | 237 ++++++++++++++++------------------- 1 file changed, 110 insertions(+), 127 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index a95ffd9b67..f0889bcc4d 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -13,6 +13,7 @@ import sys from contextlib import closing from dataclasses import dataclass from pathlib import Path +from types import SimpleNamespace from typing import Callable, Iterable, Literal, NoReturn, Sequence from tools.ansi_strip import strip_ansi as _strip_ansi @@ -538,6 +539,23 @@ def _expect_no_args(args: Sequence[str], usage: str) -> None: raise ConsoleCommandError(f"Usage: {usage}") +def _captured(fn): + """Handler decorator: run ``fn(engine, args)`` under ``_capture_output`` and return its text.""" + @functools.wraps(fn) + def wrapper(engine: HermesConsoleEngine, args: list[str]) -> str: + return _capture_output(lambda: fn(engine, args)) + return wrapper + + +def _simple_command(usage: str, module: str, name: str, make_args=lambda: ()): + """Handler for no-arg commands that just capture ``module.name(*make_args())``.""" + def handler(_engine: HermesConsoleEngine, args: list[str]) -> str: + _expect_no_args(args, usage) + fn = getattr(importlib.import_module(module), name) + return _capture_output(lambda: fn(*make_args())) + return handler + + def _apply_confirmed_defaults(args: argparse.Namespace) -> None: """Skip nested prompts after the console-level confirmation has happened.""" if hasattr(args, "yes"): @@ -575,21 +593,16 @@ def _version(_engine: HermesConsoleEngine, args: list[str]) -> str: def _status(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "status") - from types import SimpleNamespace - from hermes_cli.status import show_status output = _capture_output(lambda: show_status(SimpleNamespace(all=False, deep=False))) return _strip_console_status_footer(output) -def _doctor(_engine: HermesConsoleEngine, args: list[str]) -> str: - _expect_no_args(args, "doctor") - from types import SimpleNamespace - - from hermes_cli.doctor import run_doctor - - return _capture_output(lambda: run_doctor(SimpleNamespace(fix=False, ack=None))) +_doctor = _simple_command( + "doctor", "hermes_cli.doctor", "run_doctor", lambda: (SimpleNamespace(fix=False, ack=None),)) +_config_show = _simple_command("config show", "hermes_cli.config", "show_config") +_cron_status = _simple_command("cron status", "hermes_cli.cron", "cron_status") def _logs(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -648,13 +661,6 @@ def _sessions_stats(_engine: HermesConsoleEngine, args: list[str]) -> str: return "\n".join(lines) -def _config_show(_engine: HermesConsoleEngine, args: list[str]) -> str: - _expect_no_args(args, "config show") - from hermes_cli.config import show_config - - return _capture_output(show_config) - - def _config_path(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "config path") from hermes_cli.config import get_config_path @@ -670,135 +676,119 @@ def _config_set(_engine: HermesConsoleEngine, args: list[str]) -> str: return _capture_output(lambda: set_config_value(args[0], " ".join(args[1:]))) -def _config_migrate(_engine: HermesConsoleEngine, args: list[str]) -> str: +@_captured +def _config_migrate(_engine: HermesConsoleEngine, args: list[str]) -> None: _expect_no_args(args, "config migrate") + from hermes_cli.config import migrate_config - def _run() -> None: - from hermes_cli.config import migrate_config - - results = migrate_config(interactive=False, quiet=False) - if results.get("env_added") or results.get("config_added"): - print("Configuration updated.") - else: - print("Configuration is up to date.") - for warning in results.get("warnings") or []: - print(f"Warning: {warning}") - - return _capture_output(_run) + results = migrate_config(interactive=False, quiet=False) + if results.get("env_added") or results.get("config_added"): + print("Configuration updated.") + else: + print("Configuration is up to date.") + for warning in results.get("warnings") or []: + print(f"Warning: {warning}") -def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> str: +def _guard_exports(db, session_ids: list[str]) -> None: + """Per-session export budget: only an individual runaway transcript trips it; 0 disables.""" + from hermes_state import SessionExportTooLargeError, resolved_max_export_messages + + limit = resolved_max_export_messages() + if limit <= 0: + return + try: + for session_id in session_ids: + db.assert_export_safe(session_id, max_messages=limit) + except SessionExportTooLargeError as exc: + raise ConsoleCommandError( + f"Session '{exc.session_id}' has more than {limit:,} active " + "messages; in-memory export is capped per session. " + "Use the Sessions page's streaming Export action, or set " + "sessions.max_export_messages: 0 in config.yaml to disable " + "the guard.") from exc + + +@_captured +def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> None: parser = _ArgumentParser(prog="sessions export", add_help=False) parser.add_argument("output") parser.add_argument("--source") parser.add_argument("--session-id") ns = parser.parse_args(args) - - def _run() -> None: - from hermes_state import SessionExportTooLargeError, resolved_max_export_messages - - with _session_db() as db: - def _guard_exports(session_ids: list[str]) -> None: - # Per-session budget: a full-DB backup of many small sessions never trips the - # guard, only an individual runaway transcript does. 0 disables it. - limit = resolved_max_export_messages() - if limit <= 0: - return - try: - for session_id in session_ids: - db.assert_export_safe(session_id, max_messages=limit) - except SessionExportTooLargeError as exc: - raise ConsoleCommandError( - f"Session '{exc.session_id}' has more than {limit:,} active " - "messages; in-memory export is capped per session. " - "Use the Sessions page's streaming Export action, or set " - "sessions.max_export_messages: 0 in config.yaml to disable " - "the guard." - ) from exc - - if ns.session_id: - resolved_session_id = db.resolve_session_id(ns.session_id) - if not resolved_session_id: - raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") - _guard_exports([resolved_session_id]) - data = db.export_session(resolved_session_id) - if not data: - raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") - rows = [data] - else: - found = db.search_sessions(source=ns.source, limit=100000) - _guard_exports([session["id"] for session in found]) - rows = db.export_all(source=ns.source) - text = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) - if text: - text += "\n" - if ns.output == "-": - sys.stdout.write(text) - else: - Path(ns.output).expanduser().write_text(text, encoding="utf-8") - print(f"Exported {len(rows)} session(s) to {ns.output}") - - return _capture_output(_run) + with _session_db() as db: + if ns.session_id: + resolved_session_id = db.resolve_session_id(ns.session_id) + if not resolved_session_id: + raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") + _guard_exports(db, [resolved_session_id]) + data = db.export_session(resolved_session_id) + if not data: + raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") + rows = [data] + else: + found = db.search_sessions(source=ns.source, limit=100000) + _guard_exports(db, [session["id"] for session in found]) + rows = db.export_all(source=ns.source) + text = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + if text: + text += "\n" + if ns.output == "-": + sys.stdout.write(text) + else: + Path(ns.output).expanduser().write_text(text, encoding="utf-8") + print(f"Exported {len(rows)} session(s) to {ns.output}") -def _sessions_rename(_engine: HermesConsoleEngine, args: list[str]) -> str: +@_captured +def _sessions_rename(_engine: HermesConsoleEngine, args: list[str]) -> None: parser = _ArgumentParser(prog="sessions rename", add_help=False) parser.add_argument("session_id") parser.add_argument("title", nargs="+") ns = parser.parse_args(args) - - def _run() -> None: - with _session_db() as db: - resolved_session_id = db.resolve_session_id(ns.session_id) - if not resolved_session_id: - raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") - title = " ".join(ns.title) - if not db.set_session_title(resolved_session_id, title): - raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") - print(f"Session '{resolved_session_id}' renamed to: {title}") - - return _capture_output(_run) + with _session_db() as db: + resolved_session_id = db.resolve_session_id(ns.session_id) + if not resolved_session_id: + raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") + title = " ".join(ns.title) + if not db.set_session_title(resolved_session_id, title): + raise ConsoleCommandError(f"Session '{ns.session_id}' not found.") + print(f"Session '{resolved_session_id}' renamed to: {title}") -def _sessions_optimize(_engine: HermesConsoleEngine, args: list[str]) -> str: +@_captured +def _sessions_optimize(_engine: HermesConsoleEngine, args: list[str]) -> None: _expect_no_args(args, "sessions optimize") - - def _run() -> None: - with _session_db() as db: - print(f"Optimized {db.vacuum()} FTS index(es).") - - return _capture_output(_run) + with _session_db() as db: + print(f"Optimized {db.vacuum()} FTS index(es).") -def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> str: +@_captured +def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> None: parser = _ArgumentParser(prog="sessions repair", add_help=False) parser.add_argument("--check-only", action="store_true") parser.add_argument("--no-backup", action="store_true") ns = parser.parse_args(args) + from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema - def _run() -> None: - from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema - - db_path = DEFAULT_DB_PATH - if not db_path.exists(): - print(f"No session database at {db_path} (nothing to repair).") - return - reason = _db_opens_cleanly(db_path) - if reason is None: - print(f"{db_path} opens cleanly; no repair needed.") - return - print(f"{db_path} does not open cleanly: {reason}") - if ns.check_only: - return - report = repair_state_db_schema(db_path, backup=not ns.no_backup) - if not report.get("repaired"): - raise ConsoleCommandError(f"Repair failed: {report.get('error')}") - if report.get("backup_path"): - print(f"backup: {report['backup_path']}") - print(f"strategy: {report.get('strategy')}") - print("Repaired session database.") - - return _capture_output(_run) + db_path = DEFAULT_DB_PATH + if not db_path.exists(): + print(f"No session database at {db_path} (nothing to repair).") + return + reason = _db_opens_cleanly(db_path) + if reason is None: + print(f"{db_path} opens cleanly; no repair needed.") + return + print(f"{db_path} does not open cleanly: {reason}") + if ns.check_only: + return + report = repair_state_db_schema(db_path, backup=not ns.no_backup) + if not report.get("repaired"): + raise ConsoleCommandError(f"Repair failed: {report.get('error')}") + if report.get("backup_path"): + print(f"backup: {report['backup_path']}") + print(f"strategy: {report.get('strategy')}") + print("Repaired session database.") def _profile_status(_engine: HermesConsoleEngine, args: list[str]) -> str: @@ -815,13 +805,6 @@ def _cron_list(_engine: HermesConsoleEngine, args: list[str]) -> str: return _capture_output(lambda: cron_list(show_all=ns.all)) -def _cron_status(_engine: HermesConsoleEngine, args: list[str]) -> str: - _expect_no_args(args, "cron status") - from hermes_cli.cron import cron_status - - return _capture_output(cron_status) - - 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: From a8c8c12fa4689607f2345d3e1a0eaa6b486e0189 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:11:22 -0700 Subject: [PATCH 07/16] refactor(hermes_cli): group C layout compaction (AST-neutral closer hugging) --- hermes_cli/completion.py | 28 +++++++++------------------- hermes_cli/curses_ui.py | 6 ++---- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index 82eab4b2b6..280d7cd3fd 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -63,16 +63,14 @@ def generate_bash(parser: argparse.ArgumentParser) -> str: f" return\n" f" ;;\n" f" esac\n" - f" ;;" - ) + f" ;;") 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 \"{words}\" -- \"$cur\"))\n" f" return\n" - f" ;;" - ) + f" ;;") cases_str = "\n".join(cases) return f"""# Hermes Agent bash completion @@ -120,9 +118,7 @@ complete -F _hermes_completion hermes 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) - ) + f"{indent}'{sc}:{_clean(subcommands[sc].get('help', ''))}'" for sc in sorted(subcommands)) def generate_zsh(parser: argparse.ArgumentParser) -> str: @@ -149,8 +145,7 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str: f" _describe 'profile command' profile_cmds\n" f" ;;\n" f" esac\n" - f" ;;" - ) + f" ;;") else: sub_str = _zsh_describe_lines(info["subcommands"], " " * 20) safe = cmd.replace("-", "_") @@ -161,8 +156,7 @@ def generate_zsh(parser: argparse.ArgumentParser) -> str: f"{sub_str}\n" f" )\n" f" _describe '{cmd} command' {safe}_cmds\n" - f" ;;" - ) + f" ;;") sub_cases_str = "\n".join(sub_cases) return f"""#compdef hermes # Hermes Agent zsh completion @@ -234,15 +228,13 @@ def generate_fish(parser: argparse.ArgumentParser) -> str: "complete -c hermes -f -s p -l profile" " -d 'Profile name' -xa '(__hermes_profiles)'", "", - "# Top-level subcommands", - ] + "# Top-level subcommands"] for cmd, info in subcommands: lines.append( f"complete -c hermes -f " f"-n 'not __fish_seen_subcommand_from {top_cmds_str}' " - f"-a {cmd} -d '{_clean(info.get('help', ''))}'" - ) + f"-a {cmd} -d '{_clean(info.get('help', ''))}'") lines += ["", "# Subcommand completions"] for cmd, info in subcommands: if not info["subcommands"]: @@ -252,15 +244,13 @@ def generate_fish(parser: argparse.ArgumentParser) -> str: lines.append( f"complete -c hermes -f " f"-n '__fish_seen_subcommand_from {cmd}' " - f"-a {sc} -d '{_clean(sinfo.get('help', ''))}'" - ) + f"-a {sc} -d '{_clean(sinfo.get('help', ''))}'") if cmd == "profile": # profile names for the actions that take one for action in sorted(_PROFILE_NAME_ACTIONS): lines.append( f"complete -c hermes -f " f"-n '__fish_seen_subcommand_from {action}; " f"and __fish_seen_subcommand_from profile' " - f"-a '(__hermes_profiles)' -d 'Profile name'" - ) + f"-a '(__hermes_profiles)' -d 'Profile name'") lines.append("") return "\n".join(lines) diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 8edecb6fb3..fbf522730a 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -311,8 +311,7 @@ def _move_filtered_cursor(filtered: List[int], cursor: int, cursor_pos: int, del def _scroll_for_cursor( - scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int -) -> int: + 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: @@ -323,8 +322,7 @@ def _scroll_for_cursor( def _handle_active_search_key( - curses_mod, key: int, search: _SearchState -) -> tuple[bool, bool, bool]: + curses_mod, key: int, search: _SearchState) -> tuple[bool, bool, bool]: """Handle a key while the search prompt is active -> ``(handled, confirm, changed)``.""" if not search.active: return False, False, False From 12e3ae890bd1cfc213efba6a26e0c0927fb8333b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:33:21 -0700 Subject: [PATCH 08/16] =?UTF-8?q?refactor(hermes=5Fcli):=20console=5Fengin?= =?UTF-8?q?e=20=E2=80=94=20=5Fparse()=20helper=20for=20inline=20argparse?= =?UTF-8?q?=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/console_engine.py | 55 +++++++++++++++--------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index f0889bcc4d..8aa70e3160 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -501,9 +501,7 @@ class HermesConsoleEngine: return ConsoleResult("ok", output=output or "No history yet.") if head == "clear": return ConsoleResult("clear", output="\033[2J\033[H") - if head in {"exit", "quit"}: - return ConsoleResult("exit") - return None + return ConsoleResult("exit") if head in {"exit", "quit"} else None def _resolve_command(self, tokens: Sequence[str]) -> tuple[ConsoleCommand, list[str]]: rejected = self._rejection_for(tokens) @@ -539,6 +537,15 @@ def _expect_no_args(args: Sequence[str], usage: str) -> None: raise ConsoleCommandError(f"Usage: {usage}") +def _parse(prog: str, args: Sequence[str], *specs) -> argparse.Namespace: + """Parse ``args`` with a help-less parser; each spec is ``(flags, kwargs)`` or a bare flag.""" + parser = _ArgumentParser(prog=prog, add_help=False) + for spec in specs: + flags, kwargs = spec if isinstance(spec, tuple) else ((spec,), {}) + parser.add_argument(*flags, **kwargs) + return parser.parse_args(args) + + def _captured(fn): """Handler decorator: run ``fn(engine, args)`` under ``_capture_output`` and return its text.""" @functools.wraps(fn) @@ -608,12 +615,10 @@ _cron_status = _simple_command("cron status", "hermes_cli.cron", "cron_status") def _logs(_engine: HermesConsoleEngine, args: list[str]) -> str: if "-f" in args or "--follow" in args: raise ConsoleCommandError("`logs -f` is not available in Hermes Console.") - parser = _ArgumentParser(prog="logs", add_help=False) - parser.add_argument("log_name", nargs="?", default="agent") - parser.add_argument("-n", "--lines", type=int, default=50) - for flag in ("--level", "--session", "--since", "--component"): - parser.add_argument(flag) - ns = parser.parse_args(args) + ns = _parse( + "logs", args, (("log_name",), dict(nargs="?", default="agent")), + (("-n", "--lines"), dict(type=int, default=50)), + "--level", "--session", "--since", "--component") if ns.lines < 1 or ns.lines > 500: raise ConsoleCommandError("logs --lines must be between 1 and 500") from hermes_cli.logs import list_logs, tail_log @@ -634,9 +639,7 @@ def _session_db(): def _sessions_list(_engine: HermesConsoleEngine, args: list[str]) -> str: - parser = _ArgumentParser(prog="sessions list", add_help=False) - parser.add_argument("--limit", type=int, default=20) - ns = parser.parse_args(args) + ns = _parse("sessions list", args, (("--limit",), dict(type=int, default=20))) if ns.limit < 1 or ns.limit > 200: raise ConsoleCommandError("sessions list --limit must be between 1 and 200") with _session_db() as db: @@ -711,11 +714,7 @@ def _guard_exports(db, session_ids: list[str]) -> None: @_captured def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> None: - parser = _ArgumentParser(prog="sessions export", add_help=False) - parser.add_argument("output") - parser.add_argument("--source") - parser.add_argument("--session-id") - ns = parser.parse_args(args) + ns = _parse("sessions export", args, "output", "--source", "--session-id") with _session_db() as db: if ns.session_id: resolved_session_id = db.resolve_session_id(ns.session_id) @@ -742,10 +741,7 @@ def _sessions_export(_engine: HermesConsoleEngine, args: list[str]) -> None: @_captured def _sessions_rename(_engine: HermesConsoleEngine, args: list[str]) -> None: - parser = _ArgumentParser(prog="sessions rename", add_help=False) - parser.add_argument("session_id") - parser.add_argument("title", nargs="+") - ns = parser.parse_args(args) + ns = _parse("sessions rename", args, "session_id", (("title",), dict(nargs="+"))) with _session_db() as db: resolved_session_id = db.resolve_session_id(ns.session_id) if not resolved_session_id: @@ -765,10 +761,9 @@ def _sessions_optimize(_engine: HermesConsoleEngine, args: list[str]) -> None: @_captured def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> None: - parser = _ArgumentParser(prog="sessions repair", add_help=False) - parser.add_argument("--check-only", action="store_true") - parser.add_argument("--no-backup", action="store_true") - ns = parser.parse_args(args) + ns = _parse( + "sessions repair", args, (("--check-only",), dict(action="store_true")), + (("--no-backup",), dict(action="store_true"))) from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema db_path = DEFAULT_DB_PATH @@ -797,9 +792,7 @@ def _profile_status(_engine: HermesConsoleEngine, args: list[str]) -> str: def _cron_list(_engine: HermesConsoleEngine, args: list[str]) -> str: - parser = _ArgumentParser(prog="cron list", add_help=False) - parser.add_argument("--all", action="store_true") - ns = parser.parse_args(args) + ns = _parse("cron list", args, (("--all",), dict(action="store_true"))) from hermes_cli.cron import cron_list return _capture_output(lambda: cron_list(show_all=ns.all)) @@ -829,11 +822,7 @@ def _cron_pause(_engine: HermesConsoleEngine, args: list[str]) -> str: def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: - parser = _ArgumentParser(prog="cron resume", add_help=False) - parser.add_argument("job") - parser.add_argument("--at") - parser.add_argument("--run-now", action="store_true") - ns = parser.parse_args(args) + ns = _parse("cron resume", args, "job", "--at", (("--run-now",), dict(action="store_true"))) if ns.at and ns.run_now: raise ConsoleCommandError("Use exactly one of --at or --run-now.") from cron.jobs import AmbiguousJobReference, _hermes_now, rearm_oneshot, resume_job From 381d88f433d7e27f09f83518b156d2209ccc4e8b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:36:19 -0700 Subject: [PATCH 09/16] =?UTF-8?q?refactor(hermes=5Fcli):=20group=20C=20?= =?UTF-8?q?=E2=80=94=20drop=20blank=20lines=20after=20function-local=20imp?= =?UTF-8?q?orts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/commands_completion.py | 4 ---- hermes_cli/console_engine.py | 16 ---------------- hermes_cli/curator.py | 10 ---------- hermes_cli/curses_ui.py | 7 ------- 4 files changed, 37 deletions(-) diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py index 8affdf22ab..9352bf0ee2 100644 --- a/hermes_cli/commands_completion.py +++ b/hermes_cli/commands_completion.py @@ -34,10 +34,8 @@ def _personalities_from_cli_config() -> Dict[str, Any]: global _personalities_memo from cli import load_cli_config from hermes_cli.personality import available_personalities - try: from hermes_cli.config import get_config_path - cfg_path = get_config_path() st = cfg_path.stat() sig = (str(cfg_path), st.st_mtime_ns, st.st_size) @@ -107,7 +105,6 @@ def _personality_completions(sub_text: str, sub_lower: str): """/personality — ``none`` plus configured personalities.""" try: from hermes_cli.personality import describe_personality - personalities = _personalities_from_cli_config() rows = chain( [("none", "clear personality overlay")], @@ -162,7 +159,6 @@ def _handoff_completions(sub_text: str, sub_lower: str): return try: from gateway.config import load_gateway_config - gw = load_gateway_config() platforms = gw.get_connected_platforms() except Exception: diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 8aa70e3160..d651ee86d2 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -107,7 +107,6 @@ def _table_summary(summary: str, *, limit: int = 76) -> str: def _split_line(line: str) -> list[str]: # Windows-safe splitter: plain shlex posix=True eats backslashes in paths. from hermes_cli._subprocess_compat import split_command_line - try: return split_command_line(line) except ValueError as exc: @@ -139,7 +138,6 @@ def _format_sessions(sessions: Sequence[dict]) -> str: def _format_job(job: dict, action: str) -> str: from cron.jobs import effective_job_state - job_id = job.get("id") or job.get("job_id") or "?" return f"{action} job: {job.get('name') or '(unnamed)'} ({job_id}) [{effective_job_state(job)}]" @@ -594,14 +592,12 @@ def _apply_confirmed_defaults(args: argparse.Namespace) -> None: def _version(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "version") from hermes_cli._startup_fast import print_fast_version_info - return _capture_output(lambda: print_fast_version_info(check_updates=True)) def _status(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "status") from hermes_cli.status import show_status - output = _capture_output(lambda: show_status(SimpleNamespace(all=False, deep=False))) return _strip_console_status_footer(output) @@ -622,7 +618,6 @@ def _logs(_engine: HermesConsoleEngine, args: list[str]) -> str: if ns.lines < 1 or ns.lines > 500: raise ConsoleCommandError("logs --lines must be between 1 and 500") from hermes_cli.logs import list_logs, tail_log - if ns.log_name == "list": return _capture_output(list_logs) return _capture_output( @@ -634,7 +629,6 @@ def _logs(_engine: HermesConsoleEngine, args: list[str]) -> str: def _session_db(): """``with _session_db() as db:`` — SessionDB closed on exit.""" from hermes_state import SessionDB - return closing(SessionDB()) @@ -667,7 +661,6 @@ def _sessions_stats(_engine: HermesConsoleEngine, args: list[str]) -> str: def _config_path(_engine: HermesConsoleEngine, args: list[str]) -> str: _expect_no_args(args, "config path") from hermes_cli.config import get_config_path - return str(get_config_path()) @@ -675,7 +668,6 @@ def _config_set(_engine: HermesConsoleEngine, args: list[str]) -> str: if len(args) < 2: raise ConsoleCommandError("Usage: config set <key> <value>") from hermes_cli.config import set_config_value - return _capture_output(lambda: set_config_value(args[0], " ".join(args[1:]))) @@ -683,7 +675,6 @@ def _config_set(_engine: HermesConsoleEngine, args: list[str]) -> str: def _config_migrate(_engine: HermesConsoleEngine, args: list[str]) -> None: _expect_no_args(args, "config migrate") from hermes_cli.config import migrate_config - results = migrate_config(interactive=False, quiet=False) if results.get("env_added") or results.get("config_added"): print("Configuration updated.") @@ -696,7 +687,6 @@ def _config_migrate(_engine: HermesConsoleEngine, args: list[str]) -> None: def _guard_exports(db, session_ids: list[str]) -> None: """Per-session export budget: only an individual runaway transcript trips it; 0 disables.""" from hermes_state import SessionExportTooLargeError, resolved_max_export_messages - limit = resolved_max_export_messages() if limit <= 0: return @@ -765,7 +755,6 @@ def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> None: "sessions repair", args, (("--check-only",), dict(action="store_true")), (("--no-backup",), dict(action="store_true"))) from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema - db_path = DEFAULT_DB_PATH if not db_path.exists(): print(f"No session database at {db_path} (nothing to repair).") @@ -794,7 +783,6 @@ def _profile_status(_engine: HermesConsoleEngine, args: list[str]) -> str: def _cron_list(_engine: HermesConsoleEngine, args: list[str]) -> str: ns = _parse("cron list", args, (("--all",), dict(action="store_true"))) from hermes_cli.cron import cron_list - return _capture_output(lambda: cron_list(show_all=ns.all)) @@ -803,7 +791,6 @@ def _cron_job_action(args: list[str], usage: str, action: str, run) -> str: if len(args) != 1: raise ConsoleCommandError(f"Usage: {usage}") from cron.jobs import AmbiguousJobReference - try: job = run(args[0]) except AmbiguousJobReference as exc: @@ -815,7 +802,6 @@ def _cron_job_action(args: list[str], usage: str, action: str, run) -> str: 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")) @@ -826,7 +812,6 @@ def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: if ns.at and ns.run_now: raise ConsoleCommandError("Use exactly one of --at or --run-now.") from cron.jobs import AmbiguousJobReference, _hermes_now, rearm_oneshot, resume_job - try: if ns.at or ns.run_now: job = rearm_oneshot(ns.job, _hermes_now().isoformat() if ns.run_now else ns.at) @@ -841,7 +826,6 @@ def _cron_resume(_engine: HermesConsoleEngine, args: list[str]) -> str: def _cron_run(_engine: HermesConsoleEngine, args: list[str]) -> str: from cron.jobs import trigger_job - return _cron_job_action(args, "cron run <job>", "Triggered", trigger_job) diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 44da160bc1..3f0995bf8b 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -66,7 +66,6 @@ def _print_unmanaged_summary() -> None: ``skill_manage(create)`` are eligible but unmanaged, so no automatic transition touches them. """ from tools import skill_usage - try: unmanaged = skill_usage.unmanaged_report() except Exception: @@ -84,7 +83,6 @@ def _print_unmanaged_summary() -> None: def _cmd_status(args) -> int: from agent import curator from tools import skill_usage - state = curator.load_state() paused = state.get("paused", False) summary = state.get("last_run_summary") or "(none)" @@ -249,7 +247,6 @@ def _cmd_unpin(args) -> int: return _set_pin(args, False) def _cmd_list_unmanaged(args) -> int: """Itemize the unmanaged population that `status` summarizes (input for `adopt`).""" from tools import skill_usage - rows = skill_usage.unmanaged_report() if not rows: print("curator: no unmanaged skills — every eligible skill is managed") @@ -276,7 +273,6 @@ def _cmd_adopt(args) -> int: skill, not that it AUTHORED it. """ from tools import skill_usage - names = list(getattr(args, "skill", None) or []) adopt_all = bool(getattr(args, "all_unmanaged", False)) if adopt_all: @@ -413,7 +409,6 @@ def _cmd_backup(args) -> int: def _cmd_ledger(args) -> int: """List per-mutation audit ledger entries (newest first).""" from tools import skill_ledger - rows = skill_ledger.list_entries( skill=getattr(args, "skill", None), limit=getattr(args, "limit", None) or 20) if not rows: @@ -446,7 +441,6 @@ def _cmd_purge(args) -> int: from hermes_cli.config import cfg_get, load_config from tools import skill_ledger from tools.skill_usage import _archive_dir - ttl_days = getattr(args, "days", None) if ttl_days is None: ttl_days = int(cfg_get(load_config(), "curator", "archive_ttl_days", default=0) or 0) @@ -463,7 +457,6 @@ def _cmd_purge(args) -> int: import shutil import time - cutoff = time.time() - ttl_days * 86400 candidates = sorted( p for p in archive_root.iterdir() if p.is_dir() and p.stat().st_mtime < cutoff) @@ -508,7 +501,6 @@ def _rollback_ledger_entry(args, entry_id: str) -> int: A pre-rollback safety ledger entry is taken first; fails closed if that capture fails. """ from tools import skill_ledger - entry = skill_ledger.get_entry(entry_id) if entry is None: print( @@ -532,7 +524,6 @@ def _rollback_ledger_entry(args, entry_id: str) -> int: def _cmd_rollback(args) -> int: """Restore the skills tree from a snapshot, or a single mutation from the audit ledger.""" from agent import curator_backup - entry_id = getattr(args, "entry_id", None) if entry_id: return _rollback_ledger_entry(args, entry_id) @@ -595,7 +586,6 @@ def _cmd_usage(args) -> int: """Usage telemetry for ALL skills on disk (bundled + hub included), with provenance.""" import json as _json from tools import skill_usage - rows = skill_usage.usage_report() prov_filter = getattr(args, "provenance", None) if prov_filter: diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index fbf522730a..ffe252a497 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -111,7 +111,6 @@ def _read_numbered_input(prompt_text: str) -> str | _NumberedNavigation: # Setup may run without the classic CLI, which normally installs CSI-u aliases at startup. from hermes_cli.pt_input_extras import install_modify_other_keys_aliases - install_modify_other_keys_aliases() bindings = KeyBindings() @@ -155,7 +154,6 @@ def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool): 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: @@ -165,7 +163,6 @@ def _addnstr(stdscr, y: int, x: int, text: str, n: int, attr) -> None: def _draw_title_and_hint(stdscr, title: str, hint: str, max_x: int, *, hint_row: int = 1) -> None: """Bold/yellow menu title on row 0, 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) @@ -186,7 +183,6 @@ def _draw_segments(stdscr, y: int, x: int, segments, max_x: int) -> None: 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 - star_attr = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL segments = [] for i, part in enumerate(text.split("★")): @@ -201,7 +197,6 @@ def _draw_radio_item( stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_cursor: bool) -> None: """Draw a plain or segmented radiolist item starting at column ``x``.""" import curses - if isinstance(item, str): attr = _curses_style_attr(curses, None, is_cursor=is_cursor) _addnstr(stdscr, y, x, item, max(0, max_x - 1 - x), attr) @@ -215,7 +210,6 @@ def _draw_radio_item( 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)) @@ -462,7 +456,6 @@ def _decode_escape_sequence(stdscr) -> str: def _decode_menu_key(stdscr, key: int) -> str: """Normalize an already-read keypress to a menu action (lets loops peek the raw key first).""" import curses - plain = { curses.KEY_UP: NAV_UP, ord("k"): NAV_UP, curses.KEY_DOWN: NAV_DOWN, ord("j"): NAV_DOWN, curses.KEY_LEFT: NAV_BACK, 3: NAV_INTERRUPT, # 3 = Ctrl+C in raw/cbreak mode From c397fad52981f7551fefd56193df0f3e2284a89a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:46:33 -0700 Subject: [PATCH 10/16] =?UTF-8?q?refactor(hermes=5Fcli):=20curses=5Fui=20?= =?UTF-8?q?=E2=80=94=20split=20=5Frun=5Fcurses=5Fmenu=20into=20=5Finit=5Fc?= =?UTF-8?q?olors/=5Froute=5Fkey=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/curses_ui.py | 86 +++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index ffe252a497..3ce99260ad 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -477,6 +477,49 @@ def _decode_menu_key(stdscr, key: int) -> str: # An on_action reducer returns this to keep looping (state changed, menu not resolved). _KEEP = object() +# Sentinel from ``_route_key``: the key was consumed by the search prompt; redraw, no action. +_CONSUMED = object() + + +def _init_colors(curses, extra_color_pairs: bool) -> None: + curses.curs_set(0) + 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) + if extra_color_pairs: + curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) + + +def _route_key(curses, stdscr, key: int, search: _SearchState, use_search: bool): + """Turn a raw key into a ``NAV_*`` action, honoring an active ``/`` search prompt. + + Returns ``(action, changed)``; ``action`` may be ``_CONSUMED`` (redraw only) or + ``NAV_SELECT`` for Enter inside the prompt; ``changed`` means the query changed. + """ + if use_search and search.active and key == 27: + # Enhanced keys (Ghostty/Kitty) also start with ESC: decode the full sequence before + # treating a genuine Escape as "stop search". + action = _decode_menu_key(stdscr, key) + if action == NAV_CANCEL: + search.active = False + search.query = "" + return _CONSUMED, True + return (_CONSUMED if action == NAV_NONE else action), False + if use_search and search.active: + # Active search consumes query-editing keys; nav keys fall through. + handled, confirm, changed = _handle_active_search_key(curses, key, search) + if confirm: + return NAV_SELECT, changed + if handled: + return _CONSUMED, changed + return _decode_menu_key(stdscr, key), changed + if use_search and key == ord("/"): + search.active = True + return _CONSUMED, False + return _decode_menu_key(stdscr, key), False + def _run_curses_menu( *, @@ -548,14 +591,7 @@ def _run_curses_menu( return True def _draw(stdscr): - curses.curs_set(0) - 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) - if extra_color_pairs: - curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) + _init_colors(curses, extra_color_pairs) cursor = initial_cursor scroll_offset = 0 search = _SearchState() @@ -583,38 +619,14 @@ def _run_curses_menu( draw_footer(stdscr, max_y, max_x) stdscr.refresh() - key = stdscr.getch() - if use_search and search.active and key == 27: - # Enhanced keys (Ghostty/Kitty) also start with ESC: decode the full - # sequence before treating a genuine Escape as "stop search". - 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 use_search and search.active: - # Active search consumes query-editing keys; nav keys fall through. - handled, confirm, changed = _handle_active_search_key(curses, key, search) - if changed: - scroll_offset = 0 + action, changed = _route_key(curses, stdscr, stdscr.getch(), search, use_search) + if changed: + scroll_offset = 0 + if search.active: # Esc-clear resets scroll only; the loop re-reconciles 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 + if action is _CONSUMED: continue - else: - action = _decode_menu_key(stdscr, key) - if action == NAV_UP: cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1) elif action == NAV_DOWN: From 717163501fc7d9270b7a8cfd186610fbebc0971d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:48:51 -0700 Subject: [PATCH 11/16] =?UTF-8?q?refactor(hermes=5Fcli):=20curator=20?= =?UTF-8?q?=E2=80=94=20extract=20=5Fprint=5Fcurator=5Fconfig=20from=20=5Fc?= =?UTF-8?q?md=5Fstatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/console_engine.py | 6 +++--- hermes_cli/curator.py | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index d651ee86d2..0eedf26db6 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -186,9 +186,9 @@ def _noop_console_command(_args: argparse.Namespace) -> None: class _CliSurface: """How a CLI subcommand module hangs its argparse tree off a root subparsers action. - ``kind``: ``extracted`` = ``builder(subparsers, <handler>=fn)`` (fn from hermes_cli.main); - ``registered`` = ``register(subparsers.add_parser(root))`` with optional module ``handler``; - ``builder`` = ``top = builder(subparsers)``, func from hermes_cli.main; ``adder`` wires itself. + ``extracted``: ``builder(subparsers, <handler>=fn)``, fn from hermes_cli.main. + ``registered``: ``register(subparsers.add_parser(root))``, optional module-level ``handler``. + ``builder``: ``top = builder(subparsers)``, func from hermes_cli.main. ``adder``: self-wiring. """ kind: Literal["extracted", "registered", "builder", "adder"] diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 3f0995bf8b..06d43fb8dc 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -80,9 +80,7 @@ def _print_unmanaged_summary() -> None: print(" never auto-staled or archived — `hermes curator adopt <name>` hands one over") -def _cmd_status(args) -> int: - from agent import curator - from tools import skill_usage +def _print_curator_config(curator) -> None: state = curator.load_state() paused = state.get("paused", False) summary = state.get("last_run_summary") or "(none)" @@ -108,6 +106,11 @@ def _cmd_status(args) -> int: f" consolidate: {'on' if consolidate else 'off'}" f"{'' if consolidate else ' (prune-only; LLM merge pass opt-in)'}") + +def _cmd_status(args) -> int: + from agent import curator + from tools import skill_usage + _print_curator_config(curator) rows = skill_usage.curated_report() if not rows: print("\nno curator-managed skills") From 47f2122e829fb20e6db9a5f7f924952dd70a7350 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:13:00 -0700 Subject: [PATCH 12/16] =?UTF-8?q?refactor(hermes=5Fcli):=20console=5Fengin?= =?UTF-8?q?e=20=E2=80=94=20one=20path=20spec=20per=20CLI=20family=20(*=20m?= =?UTF-8?q?arks=20mutating)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/console_engine.py | 136 +++++++++++++++-------------------- 1 file changed, 58 insertions(+), 78 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 0eedf26db6..9eb5fb622e 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -246,11 +246,19 @@ def _dispatch( return _capture_output(lambda: func(namespace)) -def _paths(spec: str) -> list[tuple[str, ...]]: - """``"list, snapshot export"`` -> ``[("list",), ("snapshot", "export")]``; ``"."`` is root.""" - return [ - () if item.strip() == "." else tuple(item.split()) - for item in spec.split(",") if item.strip()] +def _paths(spec: str) -> list[tuple[tuple[str, ...], bool]]: + """``"list, *snapshot export, ."`` -> ``[(("list",), False), (("snapshot", "export"), True), ...]``. + + ``*`` marks a mutating path; ``.`` is the bare root. + """ + out = [] + for item in spec.split(","): + item = item.strip() + if item: + mutating = item.startswith("*") + item = item.lstrip("*") + out.append((() if item == "." else tuple(item.split()), mutating)) + return out def _sub(module: str, builder: str, handler: str) -> _CliSurface: @@ -261,95 +269,68 @@ def _reg(module: str, handler: str | None = None) -> _CliSurface: return _CliSurface("registered", f"hermes_cli.{module}", "register_cli", handler) -# 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", ""), +# root -> (surface, comma-separated subcommand paths; `.` = bare root, `*` marks mutating). +# Registered in this order. +_CLI_FAMILIES: dict[str, tuple[_CliSurface, str]] = { + "dump": (_sub("dump", "build_dump_parser", "cmd_dump"), "."), + "debug": (_sub("debug", "build_debug_parser", "cmd_debug"), "*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"), + "list, *enable, *disable, *post-setup"), "plugins": ( _sub("plugins", "build_plugins_parser", "cmd_plugins"), - "list, enable, disable, install, update, remove", - "enable, disable, install, update, remove"), + "list, *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"), + "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"), "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"), + "list, catalog, test, *add, *remove, *install, *login, *reauth, *configure, *picker"), + "memory": (_sub("memory", "build_memory_parser", "cmd_memory"), "status, *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"), + "list, status, *reset, *add, *remove, *logout, spotify status, *spotify login, " + "*spotify logout"), "pairing": ( _sub("pairing", "build_pairing_parser", "cmd_pairing"), - "list, approve, revoke, clear-pending", - "approve, revoke, clear-pending"), + "list, *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", ""), + "list, *subscribe, *remove, test"), + "hooks": (_sub("hooks", "build_hooks_parser", "cmd_hooks"), "list, *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", ""), + "list, show, info, *create, *use, *describe, *rename, *delete, *export, *import, " + "*install, *update"), + "cron": (_sub("cron", "build_cron_parser", "cmd_cron"), "*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", - ), + "list, show, *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"), + "*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"), + "bundles": (_reg("bundles", "bundles_command"), "list, show, *create, *delete, *reload"), + "checkpoints": (_reg("checkpoints"), "status, list, *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")} + "status, *run, *pause, *resume, *pin, *unpin, *restore, list-archived, *archive, *prune, " + "*backup, *rollback"), + "pets": (_reg("pets"), "list, *install, *select, show, *off, *scale, *remove, doctor"), +} # Only extracted/registered families skip nested prompts after console confirmation # (builder/adder families never did). @@ -359,12 +340,11 @@ _SEND_SURFACE = _CliSurface("adder", "hermes_cli.send_cmd", "register_send_subpa def _register_command_family( - engine: "HermesConsoleEngine", root: str, surface: _CliSurface, paths: str, mutating: str + engine: "HermesConsoleEngine", root: str, surface: _CliSurface, paths: str ) -> None: 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): + for child_path, mutating in _paths(paths): full_path = (root, *child_path) usage = " ".join(full_path) @@ -376,7 +356,7 @@ def _register_command_family( usage, summaries.get(full_path) or f"Run `hermes {usage}`.", handler, - mutating=child_path in mutating_paths, + mutating=mutating, confirmation=f"Run `hermes {usage}`?") @@ -464,8 +444,8 @@ class HermesConsoleEngine: self.register( path, usage, summary, handler, mutating=bool(confirmation), confirmation=confirmation) - for root, (surface, paths, mutating) in _CLI_FAMILIES.items(): - _register_command_family(self, root, surface, paths, mutating) + for root, (surface, paths) in _CLI_FAMILIES.items(): + _register_command_family(self, root, surface, paths) self.register( ("send",), "send --to <target> <message>", From 91d7b118b6d8eba1db336896b353bc36721f32c3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:25:29 -0700 Subject: [PATCH 13/16] =?UTF-8?q?refactor(hermes=5Fcli):=20curator=20?= =?UTF-8?q?=E2=80=94=20=5Farg()=20spec=20helper,=20shared=20flag=20specs?= =?UTF-8?q?=20in=20=5FSUBCOMMANDS=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/console_engine.py | 2 +- hermes_cli/curator.py | 166 ++++++++++++++++------------------- 2 files changed, 78 insertions(+), 90 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 9eb5fb622e..13fb29a6a8 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -247,7 +247,7 @@ def _dispatch( def _paths(spec: str) -> list[tuple[tuple[str, ...], bool]]: - """``"list, *snapshot export, ."`` -> ``[(("list",), False), (("snapshot", "export"), True), ...]``. + """``"list, *snapshot export"`` -> ``[(("list",), False), (("snapshot", "export"), True)]``. ``*`` marks a mutating path; ``.`` is the bare root. """ diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 06d43fb8dc..0c3ec191ac 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -629,119 +629,107 @@ def _cmd_usage(args) -> int: return 0 -# argparse wiring: (name, parser kwargs, handler, ((flags...), add_argument kwargs) ...) +def _arg(*flags, **kwargs): + return flags, kwargs + + +_SKILL = _arg("skill", help="Skill name") +_YES = _arg("-y", "--yes", action="store_true", help="Skip the confirmation prompt") +_STORE_TRUE = dict(action="store_true") + +# argparse wiring: (name, help, handler, *(flags, add_argument kwargs)) _SUBCOMMANDS = ( - ("status", {"help": "Show curator status and skill stats"}, _cmd_status), + ("status", "Show curator status and skill stats", _cmd_status), ( - "usage", - {"help": "Show usage telemetry for ALL skills (built-in, hub, agent) with provenance"}, + "usage", "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")), - ), + _arg("--sort", choices=("activity", "recent", "name"), default="activity", + help="Sort order: activity (most-used first, default), recent " + "(most-recently-active first), or name (alphabetical)"), + _arg("--provenance", choices=("agent", "bundled", "hub"), default=None, + help="Only show skills of this origin"), + _arg("--json", **_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), + "run", "Trigger a curator review now", _cmd_run, + _arg("--sync", "--synchronous", dest="synchronous", **_STORE_TRUE, + help="Wait for the LLM review pass to finish (default for manual runs)"), + _arg("--background", dest="background", **_STORE_TRUE, + help="Start the LLM review pass in a background thread and return immediately"), + _arg("--dry-run", dest="dry_run", **_STORE_TRUE, + help="Report only — no state changes, no archives, no consolidation " + "(use this to preview what curator would do)"), + _arg("--consolidate", dest="consolidate", **_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", "Pause the curator until resumed", _cmd_pause), + ("resume", "Resume a paused curator", _cmd_resume), + ("pin", "Pin a skill so the curator never auto-transitions it", _cmd_pin, _SKILL), + ("unpin", "Unpin a skill", _cmd_unpin, _SKILL), + ("list-unmanaged", "List curation-eligible skills with no provenance marker", + _cmd_list_unmanaged), ( - "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)"}, + "adopt", "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), + _arg("skill", nargs="*", help="Skill name(s) to adopt. Omit when using --all-unmanaged."), + _arg("--all-unmanaged", **_STORE_TRUE, + help="Adopt every curation-eligible skill that has no provenance marker"), + _arg("--dry-run", **_STORE_TRUE, + help="List what would be adopted without writing anything"), + _arg("--yes", **_STORE_TRUE, help="Skip the confirmation prompt for --all-unmanaged")), + ("restore", "Restore an archived skill", _cmd_restore, _SKILL), + ("list-archived", "List archived skills", _cmd_list_archived), + ("archive", "Manually archive a skill (move to .archive/, excluded from prompt)", _cmd_archive, + _SKILL), ( - "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", - ))), + "prune", "Bulk-archive curator-managed skills idle for >= N days (default 90)", _cmd_prune, + _arg("--days", type=int, default=90, + help="Archive skills idle for at least N days (default: 90)"), + _YES, + _arg("--dry-run", dest="dry_run", **_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)"}, + "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')")), - ), + _arg("--reason", 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`)"}, + "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"))), + _arg("entry_id", nargs="?", default=None, + help="Ledger entry id for single-mutation rollback (from " + "`hermes curator ledger`). Omit for whole-tree snapshot rollback."), + _arg("--list", **_STORE_TRUE, help="List available snapshots and exit without restoring"), + _arg("--id", dest="backup_id", default=None, + help="Snapshot id to restore (see `--list`); default: newest"), + _arg("-y", "--yes", **_STORE_TRUE, help="Skip confirmation prompt")), ( - "ledger", - {"help": "List the per-mutation skill audit ledger (all actors: curator/agent/user)"}, + "ledger", "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)"))), + _arg("--skill", default=None, help="Only show entries for this skill"), + _arg("--limit", 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)"}, + "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")))) + _arg("--days", type=int, default=None, + help="Override curator.archive_ttl_days for this invocation"), + _arg("--dry-run", dest="dry_run", **_STORE_TRUE, + help="Show what would be purged without deleting"), + _YES)) 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") - for name, kwargs, handler, *arguments in _SUBCOMMANDS: - sub = subs.add_parser(name, **kwargs) + for name, help_text, handler, *arguments in _SUBCOMMANDS: + sub = subs.add_parser(name, help=help_text) for flags, arg_kwargs in arguments: sub.add_argument(*flags, **arg_kwargs) sub.set_defaults(func=handler) From 711bec92198a6b5fa8e5474f67166ab440cd0d40 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:39:57 -0700 Subject: [PATCH 14/16] =?UTF-8?q?refactor(hermes=5Fcli):=20commands=5Fcomp?= =?UTF-8?q?letion=20=E2=80=94=20=5Fquiet=20generator=20decorator;=20comple?= =?UTF-8?q?tion=20=E2=80=94=20single=20profile-action=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/commands_completion.py | 38 +++++++++++++++++++------------ hermes_cli/completion.py | 3 +-- hermes_cli/curses_ui.py | 1 - 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py index 9352bf0ee2..55420dceff 100644 --- a/hermes_cli/commands_completion.py +++ b/hermes_cli/commands_completion.py @@ -6,6 +6,7 @@ stays prompt_toolkit-free for the gateway. from __future__ import annotations +import functools import os import shutil import subprocess @@ -91,27 +92,34 @@ def _split_args(sub_text: str) -> tuple[list[str], str]: # Dynamic argument completers: (sub_text, sub_lower) -> Completion iterator +def _quiet(gen_fn): + """Generator decorator: any exception while producing completions just ends the stream.""" + @functools.wraps(gen_fn) + def wrapper(*args): + try: + yield from gen_fn(*args) + except Exception: + return + return wrapper + + +@_quiet def _skin_completions(sub_text: str, sub_lower: str): """/skin — available skins.""" - try: - from hermes_cli.skin_engine import list_skins - rows = ((s["name"], s.get("description", "") or s.get("source", "")) for s in list_skins()) - yield from _prefix_completions(rows, sub_text) - except Exception: - pass + from hermes_cli.skin_engine import list_skins + rows = ((s["name"], s.get("description", "") or s.get("source", "")) for s in list_skins()) + yield from _prefix_completions(rows, sub_text) +@_quiet def _personality_completions(sub_text: str, sub_lower: str): """/personality — ``none`` plus configured personalities.""" - try: - from hermes_cli.personality import describe_personality - personalities = _personalities_from_cli_config() - rows = chain( - [("none", "clear personality overlay")], - ((name, describe_personality(prompt)) for name, prompt in personalities.items())) - yield from _prefix_completions(rows, sub_text) - except Exception: - pass + from hermes_cli.personality import describe_personality + personalities = _personalities_from_cli_config() + rows = chain( + [("none", "clear personality overlay")], + ((name, describe_personality(prompt)) for name, prompt in personalities.items())) + yield from _prefix_completions(rows, sub_text) def _tools_completions(sub_text: str, sub_lower: str): diff --git a/hermes_cli/completion.py b/hermes_cli/completion.py index 280d7cd3fd..a1d09213de 100644 --- a/hermes_cli/completion.py +++ b/hermes_cli/completion.py @@ -50,7 +50,6 @@ def generate_bash(parser: argparse.ArgumentParser) -> str: if cmd == "profile" and info["subcommands"]: # Complete actions, then profile names for actions that accept a profile argument. subcmds = " ".join(sorted(info["subcommands"])) - profile_actions = " ".join(_PROFILE_NAME_ACTIONS) cases.append( f" profile)\n" f" case \"$prev\" in\n" @@ -58,7 +57,7 @@ def generate_bash(parser: argparse.ArgumentParser) -> str: f" COMPREPLY=($(compgen -W \"{subcmds}\" -- \"$cur\"))\n" f" return\n" f" ;;\n" - f" {profile_actions.replace(' ', '|')})\n" + f" {'|'.join(_PROFILE_NAME_ACTIONS)})\n" f" COMPREPLY=($(compgen -W \"$(_hermes_profiles)\" -- \"$cur\"))\n" f" return\n" f" ;;\n" diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 3ce99260ad..928aa958d8 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -715,7 +715,6 @@ def _search_hint(search, searchable: bool, confirm: str, cancel: str, back_enabl 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) From 3ab50d7ddb480b606aeb552186fbfa25ef589d59 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:43:12 -0700 Subject: [PATCH 15/16] =?UTF-8?q?refactor(hermes=5Fcli):=20commands=5Fcomp?= =?UTF-8?q?letion=20=E2=80=94=20drop=20unreferenced=20staticmethod=20re-ex?= =?UTF-8?q?ports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/commands_completion.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py index 55420dceff..a68116fd20 100644 --- a/hermes_cli/commands_completion.py +++ b/hermes_cli/commands_completion.py @@ -294,14 +294,9 @@ class SlashCommandCompleter(Completer): # Module-level helpers exposed as staticmethods for existing callers/tests. _extract_path_word = staticmethod(_extract_path_word) - _dir_completions = staticmethod(_dir_completions) _path_completions = staticmethod(_path_completions) - _score_path = staticmethod(_score_path) - _skin_completions = staticmethod(_skin_completions) _personality_completions = staticmethod(_personality_completions) _tools_completions = staticmethod(_tools_completions) - _handoff_completions = staticmethod(_handoff_completions) - _DYNAMIC_COMPLETIONS = _DYNAMIC_COMPLETIONS def __init__( self, From e1a22b7845fe03e71e5abcea38200153b60f6f11 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:52:11 -0700 Subject: [PATCH 16/16] =?UTF-8?q?refactor(hermes=5Fcli):=20group=20C=20?= =?UTF-8?q?=E2=80=94=20pack=20short=20keyword-argument=20lists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/commands_completion.py | 13 ++++--------- hermes_cli/console_engine.py | 8 ++------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/hermes_cli/commands_completion.py b/hermes_cli/commands_completion.py index a68116fd20..3bd19dff05 100644 --- a/hermes_cli/commands_completion.py +++ b/hermes_cli/commands_completion.py @@ -233,9 +233,7 @@ def _dir_completions( break suffix = "/" if is_dir else "" yield Completion( - text_for(full_path) + suffix, - start_position=-len(word), - display=entry + suffix, + text_for(full_path) + suffix, start_position=-len(word), display=entry + suffix, display_meta="dir" if is_dir else _file_size_label(full_path)) count += 1 @@ -398,8 +396,7 @@ class SlashCommandCompleter(Completer): expanded = "./" # `@folder:` = dirs only, `@file:` = files only (else `@folder:` lists dotfiles). yield from _dir_completions( - expanded, word, limit, - lambda fp: f"{prefix}{os.path.relpath(fp)}", + expanded, word, limit, lambda fp: f"{prefix}{os.path.relpath(fp)}", want_dir=(prefix == "@folder:")) return @@ -489,10 +486,8 @@ class SlashCommandCompleter(Completer): def _cmd_completion(cmd_name: str, meta: str): return Completion( - self._completion_text(cmd_name, word), - start_position=-len(word), - display=f"/{cmd_name}", - display_meta=meta) + self._completion_text(cmd_name, word), start_position=-len(word), + display=f"/{cmd_name}", display_meta=meta) for cmd, desc in COMMANDS.items(): if self._command_allowed(cmd) and cmd[1:].startswith(word): diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 13fb29a6a8..448045e832 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -352,12 +352,8 @@ def _register_command_family( return _dispatch(surface, root, fixed, args, namespace_update) engine.register( - full_path, - usage, - summaries.get(full_path) or f"Run `hermes {usage}`.", - handler, - mutating=mutating, - confirmation=f"Run `hermes {usage}`?") + full_path, usage, summaries.get(full_path) or f"Run `hermes {usage}`.", handler, + mutating=mutating, confirmation=f"Run `hermes {usage}`?") _BLOCKED_TOP = frozenset(