From 9bc478d617251204eadd999283a3bcb79e71fb36 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:13:38 -0700 Subject: [PATCH] refactor(tui_gateway): commands.catalog phase helpers, complete.path listing split, slash formatter table, captured-exec + oauth/plugin helpers --- tui_gateway/methods_complete.py | 153 +++++---- tui_gateway/methods_complete_helpers.py | 43 +-- tui_gateway/methods_slash.py | 60 ++-- tui_gateway/methods_tools.py | 402 ++++++++++++------------ 4 files changed, 313 insertions(+), 345 deletions(-) diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index 7f2869070c..821f5d6998 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -13,15 +13,10 @@ _profile_scoped = _registry.profile_scoped _BUILTIN_AT_PREFIXES = frozenset({"file", "folder", "url", "git", "diff", "staged"}) _AT_DIRECTIVE_HINTS = [ - ("@diff", "git diff"), - ("@staged", "staged diff"), - ("@file:", "attach file"), - ("@folder:", "attach folder"), - ("@url:", "fetch url"), - ("@git:", "git log")] + ("@diff", "git diff"), ("@staged", "staged diff"), ("@file:", "attach file"), + ("@folder:", "attach folder"), ("@url:", "fetch url"), ("@git:", "git log")] _SLASH_EXTRAS = [ - ("/density", "Toggle compact display mode"), - ("/details", "Control agent detail visibility"), + ("/density", "Toggle compact display mode"), ("/details", "Control agent detail visibility"), ("/logs", "Show recent gateway log lines"), ("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]")] @@ -138,35 +133,81 @@ def _fuzzy_basename_items(root: str, path_part: str, prefix_tag: str) -> list[di for _, rel, basename, is_dir in ranked[:30]] +def _at_root_items() -> list[dict]: + """Completions for a bare ``@``: directive hints, agent profiles, plugin ``@:`` providers.""" + items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] + items.extend(_profile_mention_items("")) + try: + from agent.context_references import get_context_reference_providers + for pfx, prov in sorted(get_context_reference_providers().items()): + items.append(_item(f"@{pfx}:", prov.description or f"plugin: {pfx}")) + except Exception: + pass + return items + + +def _dir_listing_items(root: str, word: str, path_part: str, prefix_tag: str, is_context: bool) -> list[dict]: + """Prefix-match entries of the directory ``path_part`` points at (max 30).""" + expanded = _normalize_completion_path(path_part) if path_part else "." + if expanded == "." or not expanded: + search_dir, match = ".", "" + elif expanded.endswith("/"): + search_dir, match = expanded, "" + else: + search_dir = os.path.dirname(expanded) or "." + match = os.path.basename(expanded) + search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) + items: list[dict] = [] + if not os.path.isdir(search_dir): + return items + want_dir = prefix_tag == "folder" + match_lower = match.lower() + for entry in sorted(os.listdir(search_dir)): + if match and not entry.lower().startswith(match_lower): + continue + if is_context and (entry in _FUZZY_FALLBACK_EXCLUDES or (not prefix_tag and entry.startswith("."))): + continue + full = os.path.join(search_dir, entry) + is_dir = os.path.isdir(full) + # Explicit `@folder:` / `@file:` skip the opposite kind (never rewrite the tag). + if prefix_tag and want_dir != is_dir: + continue + rel = os.path.relpath(full, root).replace(os.sep, "/") + suffix = "/" if is_dir else "" + if is_context and prefix_tag: + text = f"@{prefix_tag}:{rel}{suffix}" + elif is_context: + text = f"@{'folder' if is_dir else 'file'}:{rel}{suffix}" + elif word.startswith("~"): + text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix + elif word.startswith("./"): + text = "./" + rel + suffix + else: + text = rel + suffix + items.append(_item(text, "dir" if is_dir else "", entry + suffix)) + if len(items) >= 30: + break + return items + + @method("complete.path") def _(rid, params: dict) -> dict: word = params.get("word", "") if not word: return _ok(rid, {"items": []}) - items: list[dict] = [] try: root = _completion_cwd(params) is_context = word.startswith("@") query = word[1:] if is_context else word if is_context and not query: - items = [_item(t, m) for t, m in _AT_DIRECTIVE_HINTS] - items.extend(_profile_mention_items("")) # `@` alone reveals agent profiles too - try: - from agent.context_references import get_context_reference_providers - for _pfx, _prov in sorted(get_context_reference_providers().items()): - items.append(_item(f"@{_pfx}:", _prov.description or f"plugin: {_pfx}")) - except Exception: - pass - return _ok(rid, {"items": items}) - + return _ok(rid, {"items": _at_root_items()}) # Plugin `@:` runs before the built-in file/folder branching. if is_context and ":" in query: - _pfx, _, _qval = query.partition(":") - if _pfx not in _BUILTIN_AT_PREFIXES: - plugin_items = _plugin_reference_items(_pfx, _qval) + pfx, _, qval = query.partition(":") + if pfx not in _BUILTIN_AT_PREFIXES: + plugin_items = _plugin_reference_items(pfx, qval) if plugin_items is not None: return _ok(rid, {"items": plugin_items}) - # Bare `@folder` lists as soon as the keyword is typed (the static `@folder:` hint is not accepted). if is_context and query in {"file", "folder"}: prefix_tag, path_part = query, "" @@ -174,66 +215,23 @@ def _(rid, params: dict) -> dict: prefix_tag, _, path_part = query.partition(":") else: prefix_tag, path_part = "", query - # `@/foo` usually means "foo, from here": absolute only when that prefix exists, # else resolve relative to cwd (`@/Desktop` must not dead-end; `@/usr/local` still resolves). - if is_context and path_part.startswith("/") and not path_part.startswith("//"): - if not _abs_completion_prefix_exists(path_part): - path_part = path_part.lstrip("/") + if ( + is_context and path_part.startswith("/") and not path_part.startswith("//") + and not _abs_completion_prefix_exists(path_part)): + path_part = path_part.lstrip("/") + bare_mention = is_context and not prefix_tag and path_part and "/" not in path_part if is_context and path_part and len(path_part.strip()) >= 2 and "/" not in path_part and prefix_tag != "folder": items = _fuzzy_basename_items(root, path_part, prefix_tag) - if not prefix_tag: # bare `@name` may be an agent mention: profiles rank ABOVE file hits - items = _profile_mention_items(path_part) + items - return _ok(rid, {"items": items}) - expanded = _normalize_completion_path(path_part) if path_part else "." - if expanded == "." or not expanded: - search_dir, match = ".", "" - elif expanded.endswith("/"): - search_dir, match = expanded, "" else: - search_dir = os.path.dirname(expanded) or "." - match = os.path.basename(expanded) - search_dir = search_dir if os.path.isabs(search_dir) else os.path.join(root, search_dir) - if not os.path.isdir(search_dir): - return _ok(rid, {"items": []}) - want_dir = prefix_tag == "folder" - match_lower = match.lower() - for entry in sorted(os.listdir(search_dir)): - if match and not entry.lower().startswith(match_lower): - continue - if is_context and entry in _FUZZY_FALLBACK_EXCLUDES: - continue - if is_context and not prefix_tag and entry.startswith("."): - continue - full = os.path.join(search_dir, entry) - is_dir = os.path.isdir(full) - # Explicit `@folder:` / `@file:` skip the opposite kind (never rewrite the tag). - if prefix_tag and want_dir != is_dir: - continue - rel = os.path.relpath(full, root).replace(os.sep, "/") - suffix = "/" if is_dir else "" - if is_context and prefix_tag: - text = f"@{prefix_tag}:{rel}{suffix}" - elif is_context: - text = f"@{'folder' if is_dir else 'file'}:{rel}{suffix}" - elif word.startswith("~"): - text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix - elif word.startswith("./"): - text = "./" + rel + suffix - else: - text = rel + suffix - items.append(_item(text, "dir" if is_dir else "", entry + suffix)) - if len(items) >= 30: - break + items = _dir_listing_items(root, word, path_part, prefix_tag, is_context) except Exception as e: return _err(rid, 5021, str(e)) - - # Bare-word `@name` (incl. single chars, which skip the fuzzy branch): profiles rank above paths. - try: - if is_context and not prefix_tag and path_part and "/" not in path_part: + # Bare-word `@name` may be an agent mention: profiles rank ABOVE file hits. + if bare_mention: + with contextlib.suppress(Exception): items = _profile_mention_items(path_part) + items - except Exception: - pass return _ok(rid, {"items": items}) @@ -303,10 +301,8 @@ def _catch(fail_code: int): return body(rid, params) except Exception as e: return _err(rid, fail_code, str(e)) - handler.__doc__ = body.__doc__ return handler - return deco @@ -324,8 +320,7 @@ def _(rid, params: dict) -> dict: # NOT clobber disk config (with_overrides is truthy-only). ctx = _model_picker_context(_session_agent(params)) payload = build_model_options_payload( - ctx, - explicit_only=bool(params.get("explicit_only")), + ctx, explicit_only=bool(params.get("explicit_only")), include_unconfigured=bool(params.get("include_unconfigured")), refresh=bool(params.get("refresh"))) return _ok(rid, payload) diff --git a/tui_gateway/methods_complete_helpers.py b/tui_gateway/methods_complete_helpers.py index b6631ab069..4bb500ef48 100644 --- a/tui_gateway/methods_complete_helpers.py +++ b/tui_gateway/methods_complete_helpers.py @@ -135,14 +135,6 @@ def _abs_completion_prefix_exists(path_part: str) -> bool: return False -def _details_completion_item(value: str, meta: str = "") -> dict: - return {"text": value, "display": value, "meta": meta} - - -def _details_root_completion_item(value: str, meta: str, needs_leading_space: bool) -> dict: - return _details_completion_item(f" {value}" if needs_leading_space else value, meta) - - _DETAILS_SECTIONS = ("thinking", "tools", "subagents", "activity") _DETAILS_MODES = ("hidden", "collapsed", "expanded") @@ -154,39 +146,34 @@ def _details_root_meta(candidate: str) -> str: def _details_completions(text: str) -> list[dict] | None: + """Argument completions for ``/details [section] [mode]``; None when ``text`` is not that command.""" if not text.lower().startswith("/details"): return None stripped = text.strip() if stripped and not "/details".startswith(stripped.lower().split()[0]): return None - body = text[len("/details") :] - if body.startswith(" "): - body = body[1:] + body = text[len("/details") :].removeprefix(" ") parts = body.split() - has_trailing_space = text.endswith(" ") - sections, modes = _DETAILS_SECTIONS, _DETAILS_MODES - root_candidates = (*modes, "cycle", *sections) - if not body or (len(parts) == 0 and has_trailing_space): - return [_details_root_completion_item(c, _details_root_meta(c), not has_trailing_space) for c in root_candidates] - if len(parts) == 1 and not has_trailing_space: + trailing = text.endswith(" ") + root_candidates = (*_DETAILS_MODES, "cycle", *_DETAILS_SECTIONS) + if not body or (not parts and trailing): + lead = "" if trailing else " " + return [_item(f"{lead}{c}", _details_root_meta(c)) for c in root_candidates] + if len(parts) == 1 and not trailing: prefix = parts[0].lower() - return [ - _details_completion_item(c, _details_root_meta(c)) - for c in root_candidates - if c.startswith(prefix) and c != prefix] + return [_item(c, _details_root_meta(c)) for c in root_candidates if c.startswith(prefix) and c != prefix] section = parts[0].lower() if parts else "" - if section not in sections: + if section not in _DETAILS_SECTIONS: return [] def section_meta(candidate: str) -> str: return f"clear {section} override" if candidate == "reset" else f"set {section}" - if len(parts) == 1 and has_trailing_space: - return [_details_completion_item(c, section_meta(c)) for c in (*modes, "reset")] - if len(parts) == 2 and not has_trailing_space: + mode_candidates = (*_DETAILS_MODES, "reset") + if len(parts) == 1: # trailing space after the section + return [_item(c, section_meta(c)) for c in mode_candidates] + if len(parts) == 2 and not trailing: prefix = parts[1].lower() - return [ - _details_completion_item(c, section_meta(c)) for c in (*modes, "reset") if c.startswith(prefix) and c != prefix - ] + return [_item(c, section_meta(c)) for c in mode_candidates if c.startswith(prefix) and c != prefix] return [] diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index 270da69700..11497865d5 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -16,9 +16,6 @@ _registry = HandlerRegistry() # ── Live-session slash output ──────────────────────────────────────── -_LIVE_SESSION_DIRECT_COMMANDS = frozenset( - {"clear", "compress", "effort", "history", "models", "prompt", "rename", "review", "status", "usage"} -) # Answered from the live session ONLY when the agent lives on a compute host. _ISOLATED_SESSION_READ_COMMANDS = frozenset({"context", "tools", "help"}) @@ -26,7 +23,7 @@ _NO_AGENT_USAGE = "(._.) No active agent -- send a message first." _NO_AGENT = "No active agent -- send a message first." -def _format_live_review_output(session: Optional[dict], arg: str) -> str: +def _format_live_review_output(sid: str, session: Optional[dict], arg: str) -> str: """Dispatch /review against the live session's agent. The reviewer subagent runs on the async delegation rail; the TUI notification @@ -57,7 +54,7 @@ def _format_live_review_output(session: Optional[dict], arg: str) -> str: return format_dispatch_note(result, arg or "") -def _format_live_usage_output(session: dict) -> str: +def _format_live_usage_output(sid: str, session: dict, arg: str) -> str: agent = session.get("agent") usage = _session_usage_snapshot(session) if agent is None and not usage: @@ -105,7 +102,7 @@ def _live_session_messages(session: dict) -> Optional[list]: return None -def _format_live_history_output(session: dict) -> str: +def _format_live_history_output(sid: str, session: dict, arg: str) -> str: with session["history_lock"]: history = list(session.get("history", [])) db_history = _live_session_messages(session) @@ -123,7 +120,7 @@ def _format_live_history_output(session: dict) -> str: return "\n".join(lines) -def _format_live_prompt_output(session: dict) -> str: +def _format_live_prompt_output(sid: str, session: dict, arg: str) -> str: agent = session.get("agent") mirror = _metadata_mirror(session) if agent is None and "system_prompt" not in mirror: @@ -138,7 +135,7 @@ def _format_live_prompt_output(session: dict) -> str: return f"Current system prompt:\n{prompt}" -def _format_live_context_output(session: dict) -> str: +def _format_live_context_output(sid: str, session: dict, arg: str) -> str: try: messages = _history_to_messages(_live_session_messages(session) or []) except Exception: @@ -173,7 +170,7 @@ def _format_live_context_output(session: dict) -> str: return "\n".join(lines) -def _format_live_tools_output(session: dict) -> str: +def _format_live_tools_output(sid: str, session: dict, arg: str) -> str: info = _session_info(session.get("agent"), session) groups = info.get("tools") if isinstance(info, dict) else {} if not isinstance(groups, dict) or not groups: @@ -184,7 +181,7 @@ def _format_live_tools_output(session: dict) -> str: return "Available tools ({}):\n{}".format(len(names), "\n".join(f" {name}" for name in names)) -def _format_live_help_output() -> str: +def _format_live_help_output(sid: str, session: dict, arg: str) -> str: try: from hermes_cli.commands import COMMANDS_BY_CATEGORY lines = ["Available commands:", ""] @@ -205,29 +202,33 @@ def _format_live_model_output(session: dict) -> str: return f"Current model: {model}" if model else "Current model: (unknown)" -def _format_live_status_output(sid: str) -> str: +def _format_live_status_output(sid: str, session: dict, arg: str) -> str: response = _methods["session.status"]("status", {"session_id": sid}) if response.get("error"): return str(response["error"].get("message") or "status unavailable") return str(response.get("result", {}).get("output") or "") -# name → (reply when there is no session, formatter(sid, session, arg)). A None -# no-session reply means the formatter handles a missing session itself. +def _format_live_compress_output(sid: str, session: dict, arg: str) -> str: + return _mirror_slash_side_effects(sid, session, f"/compress {arg}".strip()) + + +# name → (reply when there is no session, formatter(sid, session, arg) or a fixed reply). +# A None no-session reply means the formatter handles a missing session itself. _LIVE_SLASH_OUTPUT = { - "compress": ("no active session for /compress", lambda sid, s, a: _mirror_slash_side_effects(sid, s, f"/compress {a}".strip())), - "usage": (_NO_AGENT_USAGE, lambda sid, s, a: _format_live_usage_output(s)), - "review": (None, lambda sid, s, a: _format_live_review_output(s, a)), - "history": ("No conversation history yet.", lambda sid, s, a: _format_live_history_output(s)), - "prompt": (_NO_AGENT, lambda sid, s, a: _format_live_prompt_output(s)), - "status": (None, lambda sid, s, a: _format_live_status_output(sid)), - "context": ("Conversation is empty (no messages yet).", lambda sid, s, a: _format_live_context_output(s)), - "tools": ("No tools available.", lambda sid, s, a: _format_live_tools_output(s)), - "help": (None, lambda sid, s, a: _format_live_help_output()), - "clear": (None, lambda sid, s, a: "Screen clear is terminal-only; desktop/TUI chat left unchanged."), - "models": (None, lambda sid, s, a: "Use /model to view or switch the current model; desktop users can also open the model picker."), - "rename": (None, lambda sid, s, a: "Use /title to rename this session."), - "effort": (None, lambda sid, s, a: "Use /reasoning to change reasoning effort.")} + "compress": ("no active session for /compress", _format_live_compress_output), + "usage": (_NO_AGENT_USAGE, _format_live_usage_output), + "review": (None, _format_live_review_output), + "history": ("No conversation history yet.", _format_live_history_output), + "prompt": (_NO_AGENT, _format_live_prompt_output), + "status": (None, _format_live_status_output), + "context": ("Conversation is empty (no messages yet).", _format_live_context_output), + "tools": ("No tools available.", _format_live_tools_output), + "help": (None, _format_live_help_output), + "clear": (None, "Screen clear is terminal-only; desktop/TUI chat left unchanged."), + "models": (None, "Use /model to view or switch the current model; desktop users can also open the model picker."), + "rename": (None, "Use /title to rename this session."), + "effort": (None, "Use /reasoning to change reasoning effort.")} def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg: str) -> Optional[str]: @@ -236,10 +237,7 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg arg = arg or "" if name == "model" and not arg.strip(): return _format_live_model_output(session or {}) - if name in _ISOLATED_SESSION_READ_COMMANDS: - if not (session is not None and _session_uses_compute_host(session)): - return None - elif name not in _LIVE_SESSION_DIRECT_COMMANDS: + if name in _ISOLATED_SESSION_READ_COMMANDS and not (session is not None and _session_uses_compute_host(session)): return None entry = _LIVE_SLASH_OUTPUT.get(name) if entry is None: @@ -247,7 +245,7 @@ def _live_slash_command_output(sid: str, session: Optional[dict], name: str, arg no_session_reply, fmt = entry if session is None and no_session_reply is not None: return no_session_reply - return fmt(sid, session, arg) + return fmt(sid, session, arg) if callable(fmt) else fmt # ── Side-effect mirroring ──────────────────────────────────────────── diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 5657537088..65e325e0ca 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -134,19 +134,30 @@ def _clip(text: str, n: int = 120) -> str: return text[:n] + ("…" if len(text) > n else "") +def _exec_out(rid, output: str) -> dict: + """command.dispatch display-only result.""" + return _ok(rid, {"type": "exec", "output": output}) + + def _capture_run_kwargs(timeout: int) -> dict: """subprocess.run kwargs shared by cli.exec / shell.exec / quick commands: captured text, UTF-8 + lossy decode (non-UTF-8 child output must not crash the gateway thread on locale-mismatched Windows), no stdin, no console flash under the desktop parent.""" from hermes_cli._subprocess_compat import windows_hide_flags return dict( - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - stdin=subprocess.DEVNULL, - creationflags=windows_hide_flags()) + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, + stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + + +def _captured_exec(rid, cmd, timeout: int, *, on_result, timeout_err: tuple, fail_code: int, **kw) -> dict: + """Run ``cmd`` captured (see ``_capture_run_kwargs``) and hand the CompletedProcess to + ``on_result``; TimeoutExpired → ``timeout_err`` (code, message), other errors → ``fail_code``.""" + try: + return on_result(subprocess.run(cmd, cwd=os.getcwd(), **kw, **_capture_run_kwargs(timeout))) + except subprocess.TimeoutExpired: + return _err(rid, *timeout_err) + except Exception as e: + return _err(rid, fail_code, str(e)) def _toolset_rows(params: dict, *, with_tools: bool) -> list[dict]: @@ -329,102 +340,119 @@ def _(rid, params: dict) -> dict: # ─── Command catalog / dispatch ────────────────────────────────────────────── +class _Catalog: + """Accumulator for commands.catalog: ``pairs`` (every [key, desc]), ``canon`` (lowercase + key/alias → canonical key), ``commands`` (key → desktop meta) and ordered categories.""" + + def __init__(self) -> None: + self.pairs: list[list[str]] = [] + self.canon: dict[str, str] = {} + self.commands: dict[str, dict[str, str | None]] = {} + self.cat_map: dict[str, list[list[str]]] = {} + self.cat_order: list[str] = [] + + def bucket(self, cat: str) -> list[list[str]]: + if cat not in self.cat_map: + self.cat_map[cat] = [] + self.cat_order.append(cat) + return self.cat_map[cat] + + def add(self, key: str, desc: str, cat: str) -> None: + self.canon[key.lower()] = key + self.pairs.append([key, desc]) + self.bucket(cat).append([key, desc]) + + +def _catalog_registry(cat: _Catalog) -> None: + from hermes_cli.commands import COMMAND_REGISTRY, _build_description, command_desktop_meta + for cmd in COMMAND_REGISTRY: + meta = command_desktop_meta(cmd) + for key in (cmd.name, *cmd.aliases): + cat.commands[f"/{key}"] = dict(meta) + if cmd.name in _TUI_HIDDEN or cmd.gateway_only: + continue + cat.add(f"/{cmd.name}", _build_description(cmd), cmd.category) + for a in cmd.aliases: + cat.canon[f"/{a}".lower()] = f"/{cmd.name}" + for name, desc, category in _TUI_EXTRA: + # Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions). + if name.lower() not in cat.canon: + cat.add(name, desc, category) + + +def _catalog_quick_commands(cat: _Catalog) -> None: + qcmds = _load_cfg().get("quick_commands", {}) or {} + if not (isinstance(qcmds, dict) and qcmds): + return + cat.bucket("User commands") # category exists even when every entry is malformed + for qname, qc in sorted(qcmds.items()): + if not isinstance(qc, dict): + continue + qtype = qc.get("type", "") + default_desc = { + "exec": f"exec: {qc.get('command', '')}", "alias": f"alias → {qc.get('target', '')}" + }.get(qtype, qtype or "quick command") + cat.add(f"/{qname}", _clip(str(qc.get("description") or default_desc)), "User commands") + + +def _catalog_plugin_commands(cat: _Catalog) -> None: + from hermes_cli.plugins import get_plugin_commands + plugin_cmds = get_plugin_commands() or {} + if plugin_cmds: + cat.bucket("Plugin commands") + for pname, info in sorted(plugin_cmds.items()): + key = f"/{pname}" + if not isinstance(info, dict) or key.lower() in cat.canon: + continue + cat.add(key, _clip(str(info.get("description") or "Plugin command")), "Plugin commands") + mode = info.get("argument_mode") + if mode not in {"options", "text", "mixed"}: + mode = "text" if str(info.get("args_hint") or "").strip() else None + cat.commands[key] = {"argument_mode": mode, "desktop": None} + + +def _catalog_skills(cat: _Catalog, skills: dict[str, dict]) -> None: + """Append skill pairs and fill ``skills`` = ``{key: {usage, origin}}`` (usage + origin ride + along — not a second RPC — because every catalog consumer also ranks by them).""" + from agent.skill_commands import scan_skill_commands + usage, origin_of = _skill_usage_lookup() + for k, info in sorted(scan_skill_commands().items()): + cat.pairs.append([k, _clip(str(info.get("description", "Skill")))]) + name = str(info.get("name") or k.lstrip("/")) + skills[k] = {"usage": usage(name), "origin": origin_of(name)} + + @method("commands.catalog") @_guarded(5020) def _(rid, params: dict) -> dict: - """Registry-backed slash metadata for the TUI — categorized, no aliases.""" - from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description, command_desktop_meta - all_pairs: list[list[str]] = [] - canon: dict[str, str] = {} - commands: dict[str, dict[str, str | None]] = {} - cat_map: dict[str, list[list[str]]] = {} - cat_order: list[str] = [] - - def bucket(cat: str) -> list[list[str]]: - if cat not in cat_map: - cat_map[cat] = [] - cat_order.append(cat) - return cat_map[cat] - - def add(key: str, desc: str, rows: list[list[str]]) -> None: - canon[key.lower()] = key - all_pairs.append([key, desc]) - rows.append([key, desc]) - for cmd in COMMAND_REGISTRY: - meta = command_desktop_meta(cmd) - commands[f"/{cmd.name}"] = dict(meta) - for alias in cmd.aliases: - commands[f"/{alias}"] = dict(meta) - if cmd.name in _TUI_HIDDEN or cmd.gateway_only: - continue - c = f"/{cmd.name}" - add(c, _build_description(cmd), bucket(cmd.category)) - for a in cmd.aliases: - canon[f"/{a}".lower()] = c - for name, desc, cat in _TUI_EXTRA: - # Registry command/alias wins over a colliding TUI extra (e.g. /compact, /sessions). - if name.lower() not in canon: - add(name, desc, bucket(cat)) + """Registry-backed slash metadata for the TUI — categorized, no aliases. Discovery + failures land in ``warning`` (skills' message wins, then quick commands', then plugins').""" + from hermes_cli.commands import SUBCOMMANDS + cat = _Catalog() + _catalog_registry(cat) warning = "" try: - qcmds = _load_cfg().get("quick_commands", {}) or {} - if isinstance(qcmds, dict) and qcmds: - rows = bucket("User commands") - for qname, qc in sorted(qcmds.items()): - if not isinstance(qc, dict): - continue - qtype = qc.get("type", "") - default_desc = { - "exec": f"exec: {qc.get('command', '')}", - "alias": f"alias → {qc.get('target', '')}", - }.get(qtype, qtype or "quick command") - add(f"/{qname}", _clip(str(qc.get("description") or default_desc)), rows) + _catalog_quick_commands(cat) except Exception as e: warning = f"quick_commands discovery unavailable: {e}" try: - from hermes_cli.plugins import get_plugin_commands - plugin_cmds = get_plugin_commands() or {} - if plugin_cmds: - rows = bucket("Plugin commands") - for pname, info in sorted(plugin_cmds.items()): - if not isinstance(info, dict): - continue - key = f"/{pname}" - if key.lower() in canon: - continue - add(key, _clip(str(info.get("description") or "Plugin command")), rows) - hint = str(info.get("args_hint") or "").strip() - mode = info.get("argument_mode") - if mode not in {"options", "text", "mixed"}: - mode = "text" if hint else None - commands[key] = {"argument_mode": mode, "desktop": None} + _catalog_plugin_commands(cat) except Exception as e: - if not warning: - warning = f"plugin command discovery unavailable: {e}" - skill_count = 0 + warning = warning or f"plugin command discovery unavailable: {e}" skills: dict[str, dict] = {} try: - from agent.skill_commands import scan_skill_commands - - # Usage + origin ride along (not a second RPC): every catalog consumer also ranks it. - usage, origin_of = _skill_usage_lookup() - for k, info in sorted(scan_skill_commands().items()): - all_pairs.append([k, _clip(str(info.get("description", "Skill")))]) - name = str(info.get("name") or k.lstrip("/")) - skills[k] = {"usage": usage(name), "origin": origin_of(name)} - skill_count += 1 + _catalog_skills(cat, skills) except Exception as e: warning = f"skill discovery unavailable: {e}" - payload = { - "pairs": all_pairs, + return _ok(rid, { + "pairs": cat.pairs, "sub": {k: v[:] for k, v in SUBCOMMANDS.items()}, - "canon": canon, - "commands": commands, - "categories": [{"name": cat, "pairs": cat_map[cat]} for cat in cat_order], + "canon": cat.canon, + "commands": cat.commands, + "categories": [{"name": c, "pairs": cat.cat_map[c]} for c in cat.cat_order], "skills": skills, - "skill_count": skill_count, - "warning": warning} - return _ok(rid, payload) + "skill_count": len(skills), + "warning": warning}) @method("cli.exec") @@ -436,20 +464,16 @@ def _(rid, params: dict) -> dict: hint = _cli_exec_blocked(argv) if hint: return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""}) - try: - r = subprocess.run( - [sys.executable, "-m", "hermes_cli.main", *argv], - cwd=os.getcwd(), - # Can drive the agent → needs provider credentials; tier-1 secrets still stripped. - env=hermes_subprocess_env(inherit_credentials=True), - **_capture_run_kwargs(min(int(params.get("timeout", 240)), 600))) - parts = [r.stdout or "", r.stderr or ""] - out = "\n".join(p for p in parts if p).strip() or "(no output)" + + def done(r): + out = "\n".join(p for p in (r.stdout or "", r.stderr or "") if p).strip() or "(no output)" return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) - except subprocess.TimeoutExpired: - return _err(rid, 5016, "cli.exec: timeout") - except Exception as e: - return _err(rid, 5017, str(e)) + + # Can drive the agent → needs provider credentials; tier-1 secrets still stripped. + return _captured_exec( + rid, [sys.executable, "-m", "hermes_cli.main", *argv], min(int(params.get("timeout", 240)), 600), + on_result=done, timeout_err=(5016, "cli.exec: timeout"), fail_code=5017, + env=hermes_subprocess_env(inherit_credentials=True)) @method("command.resolve") @@ -482,7 +506,7 @@ def _dispatch_quick(rid, params, session, name, arg): output = redact_sensitive_text(output) if r.returncode != 0: return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) if qc.get("type") == "alias": return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) return None @@ -544,9 +568,7 @@ def _dispatch_bundle(rid, params, session, name, arg): from agent.skill_bundles import build_bundle_invocation_message, get_skill_bundles try: bundle_result = build_bundle_invocation_message( - bundle_key, - arg, - task_id=session.get("session_key", "") if session else "", + bundle_key, arg, task_id=session.get("session_key", "") if session else "", platform=_resolve_session_platform()) except Exception as exc: return _err(rid, 4018, f"bundle dispatch failed: {exc}") @@ -588,19 +610,17 @@ def _cmd_queue(rid, params, session, name, arg): def _cmd_learn(rid, params, session, name, arg): - # Submitted as a normal turn; the live agent gathers sources and authors the skill via skill_manage. + # Normal turn: the live agent gathers sources and authors the skill via skill_manage. from agent.learn_prompt import build_learn_prompt return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)}) def _cmd_plan(rid, params, session, name, arg): - # Normal turn (as /learn); the agent saves the plan under .hermes/plans/ via write_file. from agent.plan_prompt import build_plan_prompt return _ok(rid, {"type": "send", "message": build_plan_prompt(arg)}) def _cmd_init(rid, params, session, name, arg): - # Generate-or-update AGENTS.md as a normal turn (as /learn). from hermes_cli.init_command import build_init_prompt_for_cwd return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)}) @@ -621,29 +641,22 @@ def _cmd_moa(rid, params, session, name, arg): agent = session.get("agent") session["moa_one_shot_restore"] = { "override": session.get("model_override"), - "model": getattr(agent, "model", None) if agent else None, - "provider": getattr(agent, "provider", None) if agent else None} + "model": getattr(agent, "model", None), + "provider": getattr(agent, "provider", None)} if agent is not None: try: + # persist_override=False: turn-scoped, never persist the MoA provider to config.yaml _apply_model_switch( - sid, - session, - f"{preset} --provider moa", - confirm_expensive_model=False, - pin_session_override=True, - persist_override=False, # turn-scoped: never persist the MoA provider to config.yaml - ) + sid, session, f"{preset} --provider moa", confirm_expensive_model=False, + pin_session_override=True, persist_override=False) except Exception as exc: session.pop("moa_one_shot_restore", None) return _err(rid, 5030, f"moa unavailable: {exc}") else: # Lazy/fresh session: the override is consumed by the first build. session["model_override"] = { - "provider": "moa", - "model": preset, - "base_url": "moa://local", - "api_key": "moa-virtual-provider", - "api_mode": "chat_completions"} + "provider": "moa", "model": preset, "base_url": "moa://local", + "api_key": "moa-virtual-provider", "api_mode": "chat_completions"} notice = f"MoA one-shot queued with preset {preset}; previous model will be restored after this turn." return _ok(rid, {"type": "send", "notice": notice, "message": arg}) except Exception as exc: @@ -661,14 +674,14 @@ def _cmd_focus(rid, params, session, name, arg): return _err(rid, 4004, "usage: /focus [on|off|status]") if action == "status": saved = display.get("focus_saved_tool_progress") or _load_tool_progress_mode() - return _ok(rid, {"type": "exec", "output": format_focus_status(cur, saved)}) + return _exec_out(rid, format_focus_status(cur, saved)) res = _methods["config.set"]( rid, {"key": "focus", "value": "on" if target else "off", "session_id": params.get("session_id", "")} ) if "error" in res: return res output = format_focus_toggle_message(bool(target), (res.get("result") or {}).get("tool_progress") or "all") - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) def _cmd_retry(rid, params, session, name, arg): @@ -709,11 +722,10 @@ def _cmd_steer(rid, params, session, name, arg): try: if agent.steer(arg): shown = f"{arg[:80]}{'...' if len(arg) > 80 else ''}" - return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {shown}"}) + return _exec_out(rid, f"⏩ Steer queued — arrives after the next tool call: {shown}") except Exception: pass - # No active run: treat as next-turn message. - return _ok(rid, {"type": "send", "message": arg}) + return _ok(rid, {"type": "send", "message": arg}) # no active run: next-turn message def _cmd_goal(rid, params, session, name, arg): @@ -731,26 +743,26 @@ def _cmd_goal(rid, params, session, name, arg): mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns) lower = arg.strip().lower() if not arg.strip() or lower == "status": - return _ok(rid, {"type": "exec", "output": mgr.status_line()}) + return _exec_out(rid, mgr.status_line()) if lower == "pause": state = mgr.pause(reason="user-paused") out = "No goal set." if state is None else f"⏸ Goal paused: {state.goal}" - return _ok(rid, {"type": "exec", "output": out}) + return _exec_out(rid, out) if lower == "resume": state = mgr.resume() if state is None: - return _ok(rid, {"type": "exec", "output": "No goal to resume."}) + return _exec_out(rid, "No goal to resume.") # Resume must restart work: `exec` is display-only, so return a `send` with the # continuation prompt; `display` keeps model-facing scaffolding out of the transcript. prompt = mgr.next_continuation_prompt() if not prompt: - return _ok(rid, {"type": "exec", "output": f"▶ Goal resumed: {state.goal}"}) + return _exec_out(rid, f"▶ Goal resumed: {state.goal}") notice = f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step." return _ok(rid, {"type": "send", "notice": notice, "message": prompt, "display": "/goal resume"}) if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() - return _ok(rid, {"type": "exec", "output": "✓ Goal cleared." if had else "No active goal."}) + return _exec_out(rid, "✓ Goal cleared." if had else "No active goal.") # Remaining text = new goal. Client renders `notice`, submits `message`; the post-turn judge takes over. try: @@ -765,7 +777,6 @@ def _cmd_goal(rid, params, session, name, arg): def _cmd_loop(rid, params, session, name, arg): - # Recurring in-session wakeups; the notification poller fires due ones while the session is idle. sid_key, err = _session_key_or_err(rid, session) if err: return err @@ -782,11 +793,10 @@ def _cmd_loop(rid, params, session, name, arg): output += ( "\nNote: an active /goal is driving this session — loop " "wakeups defer until the goal finishes, pauses, or parks.") - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) def _cmd_undo(rid, params, session, name, arg): - # /undo [N]: back up N user turns, soft-delete truncated rows on disk, prefill the composer. if not session: return _err(rid, 4001, "no active session to undo") if busy := _busy_error(rid, session, "undo"): @@ -843,7 +853,7 @@ def _cmd_snapshot(rid, params, session, name, arg): "/snapshot restore is blocked in the TUI because it changes config/state on disk " "while the live agent has cached settings. Run it in the classic CLI, then restart the TUI." ) - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) def _cmd_compress(rid, params, session, name, arg): @@ -861,13 +871,12 @@ def _cmd_compress(rid, params, session, name, arg): return _ok(rid, payload) try: output = _compress_live_with_feedback(sid, session, session["agent"], arg, snapshot_kwargs=True) - return _ok(rid, {"type": "exec", "output": output}) + return _exec_out(rid, output) except Exception as exc: finalize_context_engine_compression_notification(session["agent"], committed=False) return _err(rid, 5009, f"compress failed: {exc}") -# name → built-in handler (values are rebound onto server globals by bind_module). _SLASH_BUILTINS = { "queue": _cmd_queue, "q": _cmd_queue, "learn": _cmd_learn, "plan": _cmd_plan, "init": _cmd_init, "moa": _cmd_moa, "focus": _cmd_focus, "retry": _cmd_retry, "steer": _cmd_steer, "goal": _cmd_goal, @@ -1189,9 +1198,7 @@ def _(rid, params: dict) -> dict: if action == "add": # Optional repeat / continuity / deliver ('bot-chat[:name]'): None keeps each cronjob() default. raw = cronjob( - action="create", - name=jid, - schedule=params.get("schedule", ""), + action="create", name=jid, schedule=params.get("schedule", ""), prompt=params.get("prompt", ""), repeat=int(params["repeat"]) if str(params.get("repeat", "")).strip().isdigit() else None, continuity=is_truthy_value(params.get("continuity")) if params.get("continuity") is not None else None, @@ -1236,6 +1243,11 @@ for _rpc, _fn, _keys in ( del _rpc, _fn, _keys +class _QuietConsole: + def print(self, *a, **k): + pass + + def _skills_list(rid, params, query): from hermes_cli.banner import get_available_skills return _ok(rid, {"skills": get_available_skills()}) @@ -1249,11 +1261,7 @@ def _skills_search(rid, params, query): def _skills_install(rid, params, query): from hermes_cli.skills_hub import do_install - - class _Q: - def print(self, *a, **k): - pass - do_install(query, skip_confirm=True, console=_Q()) + do_install(query, skip_confirm=True, console=_QuietConsole()) return _ok(rid, {"installed": True, "name": query}) @@ -1268,21 +1276,20 @@ def _skills_inspect(rid, params, query): return _ok(rid, {"info": inspect_skill(query) or {}}) +_SKILLS_ACTIONS = { + "list": _skills_list, "search": _skills_search, "install": _skills_install, "browse": _skills_browse, + "inspect": _skills_inspect} + + @method("skills.manage") @_profile_scoped_rpc(5024) def _(rid, params: dict) -> dict: """list/install use the scoped profile's skills dir; search/browse/inspect hit the shared hub.""" - action, query = params.get("action", "list"), params.get("query", "") - handler = { - "list": _skills_list, - "search": _skills_search, - "install": _skills_install, - "browse": _skills_browse, - "inspect": _skills_inspect, - }.get(action) + action = params.get("action", "list") + handler = _SKILLS_ACTIONS.get(action) if handler is None: return _err(rid, 4017, f"unknown skills action: {action}") - return handler(rid, params, query) + return handler(rid, params, params.get("query", "")) @method("skills.reload") @@ -1321,14 +1328,13 @@ def _(rid, params: dict) -> dict: except Exception: requires = [] transport = getattr(entry, "transport", None) # TransportSpec → its kind string - out.append( - { - "name": entry.name, - "description": getattr(entry, "description", "") or "", - "installed": bool(mcp_catalog.is_installed(entry.name)), - "enabled": bool(mcp_catalog.is_enabled(entry.name)), - "requires": requires, - "transport": str(getattr(transport, "kind", "") or transport or "stdio")}) + out.append({ + "name": entry.name, + "description": getattr(entry, "description", "") or "", + "installed": bool(mcp_catalog.is_installed(entry.name)), + "enabled": bool(mcp_catalog.is_enabled(entry.name)), + "requires": requires, + "transport": str(getattr(transport, "kind", "") or transport or "stdio")}) return _ok(rid, {"servers": out}) @@ -1357,11 +1363,8 @@ def _(rid, params: dict) -> dict: server_config: dict = dict(raw_cfg) if isinstance(raw_cfg, dict) else {} if preset: # fills url/command/args when omitted; mutates server_config in place _apply_mcp_preset( - name, - preset_name=preset, - url=server_config.get("url"), - command=server_config.get("command"), - cmd_args=list(server_config.get("args") or []), + name, preset_name=preset, url=server_config.get("url"), + command=server_config.get("command"), cmd_args=list(server_config.get("args") or []), server_config=server_config) if not server_config.get("url") and not server_config.get("command"): return _err(rid, 4063, "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'") @@ -1402,10 +1405,8 @@ def _(rid, params: dict) -> dict: else: save_env_value(env_var, str(value)) env_block = entry.get("env") - if not isinstance(env_block, dict): - env_block = {} + entry["env"] = env_block = env_block if isinstance(env_block, dict) else {} env_block[env_var] = f"${{{env_var}}}" - entry["env"] = env_block cfg = load_config() cfg.setdefault("mcp_servers", {})[name] = entry save_config(cfg) @@ -1489,15 +1490,18 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "session_id": result["session_id"], "auth_url": result["auth_url"], "flow": result["flow"]}) +def _oauth_flow_ids(params: dict) -> tuple[str, str]: + """(session_id, name) as stripped strings.""" + return str(params.get("session_id") or "").strip(), str(params.get("name") or "").strip() + + @method("mcp.servers.oauth.poll") @_profile_scoped_rpc(5024, required=_NAME_SESSION, catch_resolve=False) def _(rid, params: dict) -> dict: """Poll a flow → ``{ok, status: pending|approved|error, error_message?, auth_url?, tools?}``. On ``approved`` tokens persist for that server/profile (profile scope applies here too).""" from tui_gateway import mcp_oauth_sessions - name = str(params.get("name") or "").strip() - session_id = str(params.get("session_id") or "").strip() - result = mcp_oauth_sessions.poll_flow(session_id, name) + result = mcp_oauth_sessions.poll_flow(*_oauth_flow_ids(params)) return _ok(rid, {"ok": True, **result}) @@ -1507,15 +1511,9 @@ def _(rid, params: dict) -> dict: """Relay a client-captured redirect (``code``/``state``/``error``) into a flow started with ``client_redirect_uri``. ``{ok: true}`` once accepted (state verified), else ``{ok: false, error_message}``.""" from tui_gateway import mcp_oauth_sessions - name = str(params.get("name") or "").strip() - session_id = str(params.get("session_id") or "").strip() - result = mcp_oauth_sessions.deliver_callback_flow( - session_id, - name, - code=str(params.get("code") or "") or None, - state=str(params.get("state") or "") or None, - error=str(params.get("error") or "") or None) - return _ok(rid, result) + code, state, error = (str(params.get(k) or "") or None for k in ("code", "state", "error")) + session_id, name = _oauth_flow_ids(params) + return _ok(rid, mcp_oauth_sessions.deliver_callback_flow(session_id, name, code=code, state=state, error=error)) # ─── Plugins ───────────────────────────────────────────────────────────────── @@ -1523,12 +1521,8 @@ def _(rid, params: dict) -> dict: def _plugin_rows() -> list[dict]: from hermes_cli.plugins_cmd import ( - _bundled_default_on, - _discover_all_plugins, - _get_disabled_set, - _get_enabled_set, - _is_portable_plugin_dir, - _plugin_status) + _bundled_default_on, _discover_all_plugins, _get_disabled_set, _get_enabled_set, + _is_portable_plugin_dir, _plugin_status) enabled = _get_enabled_set() disabled = _get_disabled_set() out = [] @@ -1538,16 +1532,11 @@ def _plugin_rows() -> list[dict]: # truthful default instead of "not enabled" (reads as OFF). if status == "not enabled" and source == "bundled" and _bundled_default_on(_dir): status = "enabled" - out.append( - { - "name": name, - "key": key, # canonical registry key (``image_gen/fal``): names collide across category dirs - "version": str(version or ""), - "description": desc or "", - "source": source, - "status": status, - "portable": _is_portable_plugin_dir(_dir), # Agent Plugins v1 package vs native Hermes plugin - }) + # key = canonical registry key (``image_gen/fal``; names collide across category dirs); + # portable = Agent Plugins v1 package vs native Hermes plugin. + out.append({ + "name": name, "key": key, "version": str(version or ""), "description": desc or "", + "source": source, "status": status, "portable": _is_portable_plugin_dir(_dir)}) return out @@ -1582,6 +1571,9 @@ def _plugins_install(rid, params): return _ok(rid, result) +_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install} + + @method("plugins.manage") @_profile_scoped_rpc(5026, catch_resolve=False) def _(rid, params: dict) -> dict: @@ -1591,7 +1583,7 @@ def _(rid, params: dict) -> dict: - ``install`` → git-clone ``identifier``/``repo`` into ~/.hermes/plugins/ (``force``, ``enable`` default True) Optional ``profile`` scopes HERMES_HOME (mcp.servers.* contract).""" action = params.get("action", "list") - handler = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}.get(action) + handler = _PLUGINS_ACTIONS.get(action) if handler is None: return _err(rid, 4017, f"unknown plugins action: {action}") return handler(rid, params) @@ -1612,13 +1604,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.") except ImportError: return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable") - try: - r = subprocess.run(cmd, shell=True, cwd=os.getcwd(), **_capture_run_kwargs(30)) - return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) - except subprocess.TimeoutExpired: - return _err(rid, 5002, "command timed out (30s)") - except Exception as e: - return _err(rid, 5003, str(e)) + return _captured_exec( + rid, cmd, 30, shell=True, fail_code=5003, timeout_err=(5002, "command timed out (30s)"), + on_result=lambda r: _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode})) def register(server) -> None: