File size: 8,146 Bytes
4df9a41 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | """Secret-capture prompt for the interactive CLI (``_secret_capture_callback`` backend)."""
import queue
import time as _time
from hermes_cli.banner import cprint, _DIM, _RST
from hermes_cli.config import save_env_value_secure
from hermes_cli.secret_prompt import masked_secret_prompt
from hermes_constants import display_hermes_home
def _invalidate(cli) -> None:
if hasattr(cli, "_paint_now"):
cli._paint_now()
elif getattr(cli, "_app", None):
cli._app.invalidate()
def _clear_secret_input(cli) -> None:
"""Drop stale draft input so Enter never stores it as the secret."""
try:
if hasattr(cli, "_clear_secret_input_buffer"):
cli._clear_secret_input_buffer()
elif getattr(cli, "_app", None):
cli._app.current_buffer.reset()
except Exception:
pass
def _skipped(var_name: str, reason: str, message: str) -> dict:
return {
"success": True, "reason": reason, "stored_as": var_name,
"validated": False, "skipped": True, "message": message}
def _secret_result(var_name: str, value: str) -> dict:
"""Store ``value`` (or report a skip when empty) and build the callback result dict."""
if not value:
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
return _skipped(var_name, "cancelled", "Secret setup was skipped.")
stored = save_env_value_secure(var_name, value)
cprint(f"\n{_DIM} ✓ Stored secret in {display_hermes_home()}/.env as {var_name}{_RST}")
return {
**stored,
"skipped": False,
"message": "Secret stored securely. The secret value was not exposed to the model."}
def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
"""Prompt for a secret value through the TUI (e.g. API keys for skills).
Returns a dict with keys: success, stored_as, validated, skipped, message. The secret is stored
in ~/.hermes/.env and never exposed to the model.
"""
if not getattr(cli, "_app", None):
if not hasattr(cli, "_secret_state"):
cli._secret_state = None
if not hasattr(cli, "_secret_deadline"):
cli._secret_deadline = 0
try:
value = masked_secret_prompt(f"{prompt} (hidden, ESC or empty Enter to skip): ")
except (EOFError, KeyboardInterrupt):
value = ""
return _secret_result(var_name, value)
response_queue = queue.Queue()
cli._secret_state = {
"var_name": var_name,
"prompt": prompt,
"metadata": metadata or {},
"response_queue": response_queue}
cli._secret_deadline = _time.monotonic() + 120
if hasattr(cli, "_ring_bell"):
cli._ring_bell(prompt=True, context=f"secret needed ({var_name})")
_clear_secret_input(cli)
_invalidate(cli)
while True:
try:
value = response_queue.get(timeout=1)
except queue.Empty:
if cli._secret_deadline - _time.monotonic() <= 0:
break
_invalidate(cli)
continue
cli._secret_state = None
cli._secret_deadline = 0
_invalidate(cli)
return _secret_result(var_name, value)
cli._secret_state = None
cli._secret_deadline = 0
_clear_secret_input(cli)
_invalidate(cli)
cprint(f"\n{_DIM} ⏱ Timeout — secret capture cancelled{_RST}")
return _skipped(var_name, "timeout", "Secret setup timed out and was skipped.")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def approval_callback(cli, command: str, description: str) -> str:
"""Prompt for dangerous command approval through the TUI.
Shows a selection UI with choices: once / session / always / deny.
When the command is longer than 70 characters, a "view" option is
included so the user can reveal the full text before deciding.
Uses cli._approval_lock to serialize concurrent requests (e.g. from
parallel delegation subtasks) so each prompt gets its own turn.
"""
lock = getattr(cli, "_approval_lock", None)
if lock is None:
import threading
cli._approval_lock = threading.Lock()
lock = cli._approval_lock
with lock:
from cli import CLI_CONFIG
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 300)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
choices.append("view")
cli._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
return result
except queue.Empty:
remaining = cli._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "timeout"
def clarify_callback(cli, question, choices, multi_select=False):
"""Prompt for clarifying question through the TUI.
Sets up the interactive selection UI, then blocks until the user
responds. Returns the user's choice or a timeout message.
When ``multi_select`` is True, shows checkboxes and the user can
select multiple options with Space, confirming with Enter.
"""
from cli import CLI_CONFIG
from tools.clarify_gateway import resolve_clarify_timeout
# Canonical clarify timeout, shared with the gateway/TUI path. `<= 0`
# means unlimited (never auto-skip mid-think) → a null deadline.
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
effective_multi = multi_select and not is_open_ended
cli._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
"multi_select": effective_multi,
"selected_indices": set() if effective_multi else None,
"response_queue": response_queue,
}
cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
cli._clarify_freetext = is_open_ended
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._clarify_deadline = None
return result
except queue.Empty:
# None deadline = unlimited: never auto-skip, just keep polling.
if cli._clarify_deadline is not None:
remaining = cli._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = None
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
# ---- END PLUGIN-COMPAT ----
|