/ hermes_cli / callbacks.py
callbacks.py
  1  """Interactive prompt callbacks for terminal_tool integration.
  2  
  3  These bridge terminal_tool's interactive prompts (clarify, sudo, approval)
  4  into prompt_toolkit's event loop. Each function takes the HermesCLI instance
  5  as its first argument and uses its state (queues, app reference) to coordinate
  6  with the TUI.
  7  """
  8  
  9  import queue
 10  import time as _time
 11  import getpass
 12  
 13  from hermes_cli.banner import cprint, _DIM, _RST
 14  from hermes_cli.config import save_env_value_secure
 15  from hermes_constants import display_hermes_home
 16  
 17  
 18  def clarify_callback(cli, question, choices):
 19      """Prompt for clarifying question through the TUI.
 20  
 21      Sets up the interactive selection UI, then blocks until the user
 22      responds. Returns the user's choice or a timeout message.
 23      """
 24      from cli import CLI_CONFIG
 25  
 26      timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120)
 27      response_queue = queue.Queue()
 28      is_open_ended = not choices
 29  
 30      cli._clarify_state = {
 31          "question": question,
 32          "choices": choices if not is_open_ended else [],
 33          "selected": 0,
 34          "response_queue": response_queue,
 35      }
 36      cli._clarify_deadline = _time.monotonic() + timeout
 37      cli._clarify_freetext = is_open_ended
 38  
 39      if hasattr(cli, "_app") and cli._app:
 40          cli._app.invalidate()
 41  
 42      while True:
 43          try:
 44              result = response_queue.get(timeout=1)
 45              cli._clarify_deadline = 0
 46              return result
 47          except queue.Empty:
 48              remaining = cli._clarify_deadline - _time.monotonic()
 49              if remaining <= 0:
 50                  break
 51              if hasattr(cli, "_app") and cli._app:
 52                  cli._app.invalidate()
 53  
 54      cli._clarify_state = None
 55      cli._clarify_freetext = False
 56      cli._clarify_deadline = 0
 57      if hasattr(cli, "_app") and cli._app:
 58          cli._app.invalidate()
 59      cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
 60      return (
 61          "The user did not provide a response within the time limit. "
 62          "Use your best judgement to make the choice and proceed."
 63      )
 64  
 65  
 66  def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
 67      """Prompt for a secret value through the TUI (e.g. API keys for skills).
 68  
 69      Returns a dict with keys: success, stored_as, validated, skipped, message.
 70      The secret is stored in ~/.hermes/.env and never exposed to the model.
 71      """
 72      if not getattr(cli, "_app", None):
 73          if not hasattr(cli, "_secret_state"):
 74              cli._secret_state = None
 75          if not hasattr(cli, "_secret_deadline"):
 76              cli._secret_deadline = 0
 77          try:
 78              value = getpass.getpass(f"{prompt} (hidden, ESC or empty Enter to skip): ")
 79          except (EOFError, KeyboardInterrupt):
 80              value = ""
 81  
 82          if not value:
 83              cprint(f"\n{_DIM}  ⏭ Secret entry skipped{_RST}")
 84              return {
 85                  "success": True,
 86                  "reason": "cancelled",
 87                  "stored_as": var_name,
 88                  "validated": False,
 89                  "skipped": True,
 90                  "message": "Secret setup was skipped.",
 91              }
 92  
 93          stored = save_env_value_secure(var_name, value)
 94          _dhh = display_hermes_home()
 95          cprint(f"\n{_DIM}  ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
 96          return {
 97              **stored,
 98              "skipped": False,
 99              "message": "Secret stored securely. The secret value was not exposed to the model.",
100          }
101  
102      timeout = 120
103      response_queue = queue.Queue()
104  
105      cli._secret_state = {
106          "var_name": var_name,
107          "prompt": prompt,
108          "metadata": metadata or {},
109          "response_queue": response_queue,
110      }
111      cli._secret_deadline = _time.monotonic() + timeout
112      # Avoid storing stale draft input as the secret when Enter is pressed.
113      if hasattr(cli, "_clear_secret_input_buffer"):
114          try:
115              cli._clear_secret_input_buffer()
116          except Exception:
117              pass
118      elif hasattr(cli, "_app") and cli._app:
119          try:
120              cli._app.current_buffer.reset()
121          except Exception:
122              pass
123  
124      if hasattr(cli, "_app") and cli._app:
125          cli._app.invalidate()
126  
127      while True:
128          try:
129              value = response_queue.get(timeout=1)
130              cli._secret_state = None
131              cli._secret_deadline = 0
132              if hasattr(cli, "_app") and cli._app:
133                  cli._app.invalidate()
134  
135              if not value:
136                  cprint(f"\n{_DIM}  ⏭ Secret entry skipped{_RST}")
137                  return {
138                      "success": True,
139                      "reason": "cancelled",
140                      "stored_as": var_name,
141                      "validated": False,
142                      "skipped": True,
143                      "message": "Secret setup was skipped.",
144                  }
145  
146              stored = save_env_value_secure(var_name, value)
147              _dhh = display_hermes_home()
148              cprint(f"\n{_DIM}  ✓ Stored secret in {_dhh}/.env as {var_name}{_RST}")
149              return {
150                  **stored,
151                  "skipped": False,
152                  "message": "Secret stored securely. The secret value was not exposed to the model.",
153              }
154          except queue.Empty:
155              remaining = cli._secret_deadline - _time.monotonic()
156              if remaining <= 0:
157                  break
158              if hasattr(cli, "_app") and cli._app:
159                  cli._app.invalidate()
160  
161      cli._secret_state = None
162      cli._secret_deadline = 0
163      if hasattr(cli, "_clear_secret_input_buffer"):
164          try:
165              cli._clear_secret_input_buffer()
166          except Exception:
167              pass
168      elif hasattr(cli, "_app") and cli._app:
169          try:
170              cli._app.current_buffer.reset()
171          except Exception:
172              pass
173      if hasattr(cli, "_app") and cli._app:
174          cli._app.invalidate()
175      cprint(f"\n{_DIM}  ⏱ Timeout — secret capture cancelled{_RST}")
176      return {
177          "success": True,
178          "reason": "timeout",
179          "stored_as": var_name,
180          "validated": False,
181          "skipped": True,
182          "message": "Secret setup timed out and was skipped.",
183      }
184  
185  
186  def approval_callback(cli, command: str, description: str) -> str:
187      """Prompt for dangerous command approval through the TUI.
188  
189      Shows a selection UI with choices: once / session / always / deny.
190      When the command is longer than 70 characters, a "view" option is
191      included so the user can reveal the full text before deciding.
192  
193      Uses cli._approval_lock to serialize concurrent requests (e.g. from
194      parallel delegation subtasks) so each prompt gets its own turn.
195      """
196      lock = getattr(cli, "_approval_lock", None)
197      if lock is None:
198          import threading
199          cli._approval_lock = threading.Lock()
200          lock = cli._approval_lock
201  
202      with lock:
203          from cli import CLI_CONFIG
204          timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
205          response_queue = queue.Queue()
206          choices = ["once", "session", "always", "deny"]
207          if len(command) > 70:
208              choices.append("view")
209  
210          cli._approval_state = {
211              "command": command,
212              "description": description,
213              "choices": choices,
214              "selected": 0,
215              "response_queue": response_queue,
216          }
217          cli._approval_deadline = _time.monotonic() + timeout
218  
219          if hasattr(cli, "_app") and cli._app:
220              cli._app.invalidate()
221  
222          while True:
223              try:
224                  result = response_queue.get(timeout=1)
225                  cli._approval_state = None
226                  cli._approval_deadline = 0
227                  if hasattr(cli, "_app") and cli._app:
228                      cli._app.invalidate()
229                  return result
230              except queue.Empty:
231                  remaining = cli._approval_deadline - _time.monotonic()
232                  if remaining <= 0:
233                      break
234                  if hasattr(cli, "_app") and cli._app:
235                      cli._app.invalidate()
236  
237          cli._approval_state = None
238          cli._approval_deadline = 0
239          if hasattr(cli, "_app") and cli._app:
240              cli._app.invalidate()
241          cprint(f"\n{_DIM}  ⏱ Timeout — denying command{_RST}")
242          return "deny"