ntermqt 0.1.5__py3-none-any.whl → 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,11 +15,6 @@ Quick Start (IPython):
15
15
  api.credentials() # List credentials
16
16
 
17
17
  api.help() # Show all commands
18
-
19
- Quick Start (CLI):
20
- nterm-cli devices
21
- nterm-cli search leaf
22
- nterm-cli credentials --unlock
23
18
  """
24
19
 
25
20
  from .api import (
@@ -29,9 +24,14 @@ from .api import (
29
24
  get_api,
30
25
  reset_api,
31
26
  )
27
+ from nterm.scripting.repl import REPLPolicy, NTermREPL
28
+ from nterm.scripting.repl_interactive import add_repl_to_api
32
29
 
33
30
  # Convenience: pre-instantiated API
34
- api = get_api()
31
+ api = get_api() # <-- This FIRST
32
+
33
+ # Make api.repl() available
34
+ add_repl_to_api(api) # <-- Then this
35
35
 
36
36
  __all__ = [
37
37
  "NTermAPI",
@@ -40,4 +40,6 @@ __all__ = [
40
40
  "get_api",
41
41
  "reset_api",
42
42
  "api",
43
+ 'REPLPolicy',
44
+ 'NTermREPL'
43
45
  ]
nterm/scripting/api.py CHANGED
@@ -835,21 +835,25 @@ class NTermAPI:
835
835
 
836
836
  return '\n'.join(cleaned_lines).strip()
837
837
 
838
- def connect(self, device: str, credential: str = None) -> ActiveSession:
838
+ def connect(self, device: str, credential: str = None, debug: bool = False) -> ActiveSession:
839
839
  """
840
840
  Connect to a device and detect platform.
841
841
 
842
842
  Args:
843
843
  device: Device name (from saved sessions) or hostname
844
844
  credential: Optional credential name (auto-resolved if not specified)
845
+ debug: Enable verbose connection debugging
845
846
 
846
847
  Returns:
847
848
  ActiveSession handle for sending commands
848
-
849
- Examples:
850
- session = api.connect("spine1")
851
- session = api.connect("192.168.1.1", credential="lab-admin")
852
849
  """
850
+ debug_log = []
851
+
852
+ def _debug(msg):
853
+ if debug:
854
+ debug_log.append(msg)
855
+ print(f"[DEBUG] {msg}")
856
+
853
857
  # Look up device from saved sessions first
854
858
  device_info = self.device(device)
855
859
 
@@ -859,21 +863,21 @@ class NTermAPI:
859
863
  device_name = device_info.name
860
864
  saved_cred = device_info.credential
861
865
  else:
862
- # Treat as hostname directly
863
866
  hostname = device
864
867
  port = 22
865
868
  device_name = device
866
869
  saved_cred = None
867
870
 
871
+ _debug(f"Target: {hostname}:{port}")
872
+
868
873
  # Resolve credentials
869
874
  if not self.vault_unlocked:
870
875
  raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
871
876
 
872
- # Get credential - either specified or from saved session or auto-resolve
873
877
  cred_name = credential or saved_cred
878
+ _debug(f"Credential: {cred_name or '(auto-resolve)'}")
874
879
 
875
880
  if cred_name:
876
- # User specified a credential name - use resolver's method
877
881
  try:
878
882
  profile = self._resolver.create_profile_for_credential(
879
883
  credential_name=cred_name,
@@ -883,7 +887,6 @@ class NTermAPI:
883
887
  except Exception as e:
884
888
  raise ValueError(f"Failed to get credential '{cred_name}': {e}")
885
889
  else:
886
- # Auto-resolve based on hostname patterns
887
890
  try:
888
891
  profile = self._resolver.resolve_for_device(hostname, port=port)
889
892
  except Exception as e:
@@ -892,10 +895,6 @@ class NTermAPI:
892
895
  if not profile:
893
896
  raise ValueError(f"No credentials available for {hostname}")
894
897
 
895
- # Create SSH client
896
- client = paramiko.SSHClient()
897
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
898
-
899
898
  # Apply legacy algorithm support
900
899
  _apply_global_transport_settings()
901
900
 
@@ -909,24 +908,27 @@ class NTermAPI:
909
908
  }
910
909
 
911
910
  # Add authentication from profile
912
- # ConnectionProfile has username in first auth method
911
+ auth_method_used = None
913
912
  if profile.auth_methods:
914
913
  first_auth = profile.auth_methods[0]
915
914
  connect_kwargs['username'] = first_auth.username
915
+ _debug(f"Username: {first_auth.username}")
916
916
 
917
- # Try each auth method in order
918
917
  for auth in profile.auth_methods:
919
918
  if auth.method == AuthMethod.PASSWORD:
920
919
  connect_kwargs['password'] = auth.password
920
+ auth_method_used = "password"
921
+ _debug("Auth method: password")
921
922
  break
922
923
  elif auth.method == AuthMethod.KEY_FILE:
923
- connect_kwargs['key_filename'] = auth.key_file
924
+ connect_kwargs['key_filename'] = auth.key_path
925
+ if auth.key_passphrase:
926
+ connect_kwargs['passphrase'] = auth.key_passphrase
927
+ auth_method_used = f"key_file:{auth.key_path}"
928
+ _debug(f"Auth method: key_file ({auth.key_path})")
924
929
  break
925
930
  elif auth.method == AuthMethod.KEY_STORED:
926
- # KEY_STORED has key data as string, need to write to temp file
927
931
  import tempfile
928
- from io import StringIO
929
-
930
932
  key_file = tempfile.NamedTemporaryFile(
931
933
  mode='w',
932
934
  delete=False,
@@ -934,42 +936,93 @@ class NTermAPI:
934
936
  )
935
937
  key_file.write(auth.key_data)
936
938
  key_file.close()
937
-
938
939
  connect_kwargs['key_filename'] = key_file.name
939
940
  if auth.key_passphrase:
940
941
  connect_kwargs['passphrase'] = auth.key_passphrase
942
+ auth_method_used = "key_stored"
943
+ _debug(f"Auth method: key_stored (temp: {key_file.name})")
941
944
  break
942
945
 
943
- try:
944
- # Try connection with modern algorithms
945
- client.connect(**connect_kwargs)
946
- except paramiko.SSHException as e:
947
- # If RSA SHA-1 issue, retry with disabled algorithms
948
- if "rsa-sha2" in str(e).lower() or "server-sig-algs" in str(e).lower():
949
- client.close()
950
- client = paramiko.SSHClient()
951
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
946
+ # Detect key type if using key auth
947
+ if 'key_filename' in connect_kwargs:
948
+ key_path = connect_kwargs['key_filename']
949
+ key_type = "unknown"
950
+ key_bits = None
951
+ try:
952
+ key = paramiko.RSAKey.from_private_key_file(key_path)
953
+ key_type = "RSA"
954
+ key_bits = key.get_bits()
955
+ except:
956
+ try:
957
+ key = paramiko.Ed25519Key.from_private_key_file(key_path)
958
+ key_type = "Ed25519"
959
+ except:
960
+ try:
961
+ key = paramiko.ECDSAKey.from_private_key_file(key_path)
962
+ key_type = "ECDSA"
963
+ except:
964
+ pass
965
+ _debug(f"Key type: {key_type}" + (f" ({key_bits} bits)" if key_bits else ""))
966
+
967
+ # Connection attempt sequence
968
+ attempts = [
969
+ ("modern", None),
970
+ ("rsa-sha1", RSA_SHA1_DISABLED_ALGORITHMS),
971
+ ]
952
972
 
953
- # Apply RSA SHA-1 fallback
973
+ last_error = None
974
+ connected = False
975
+ client = None
976
+
977
+ for attempt_name, disabled_algs in attempts:
978
+ client = paramiko.SSHClient()
979
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
980
+
981
+ _debug(f"Attempt: {attempt_name}")
982
+
983
+ attempt_kwargs = connect_kwargs.copy()
984
+ if disabled_algs:
985
+ attempt_kwargs['disabled_algorithms'] = disabled_algs
986
+ _debug(f" disabled_algorithms: {disabled_algs}")
987
+
988
+ try:
989
+ client.connect(**attempt_kwargs)
990
+ connected = True
991
+
992
+ # Log successful negotiation
954
993
  transport = client.get_transport()
955
994
  if transport:
956
- transport.get_security_options().digests = tuple(
957
- alg for alg in transport.get_security_options().digests
958
- if alg not in RSA_SHA1_DISABLED_ALGORITHMS
959
- )
995
+ _debug(f" SUCCESS - cipher: {transport.remote_cipher}, mac: {transport.remote_mac}")
996
+ _debug(f" host_key_type: {transport.host_key_type}")
997
+ break
960
998
 
961
- client.connect(**connect_kwargs)
962
- else:
963
- raise
999
+ except paramiko.AuthenticationException as e:
1000
+ _debug(f" FAILED (auth): {e}")
1001
+ last_error = str(e)
1002
+ client.close()
1003
+ except paramiko.SSHException as e:
1004
+ _debug(f" FAILED (ssh): {e}")
1005
+ last_error = str(e)
1006
+ client.close()
1007
+ except Exception as e:
1008
+ _debug(f" FAILED (other): {e}")
1009
+ last_error = str(e)
1010
+ client.close()
1011
+
1012
+ if not connected:
1013
+ # Build detailed error message
1014
+ error_detail = f"Connection failed: {last_error}"
1015
+ if debug:
1016
+ error_detail += f"\n\nDebug log:\n" + "\n".join(debug_log)
1017
+ raise paramiko.AuthenticationException(error_detail)
964
1018
 
965
1019
  # Open interactive shell
966
1020
  shell = client.invoke_shell(width=200, height=50)
967
1021
  shell.settimeout(0.5)
968
1022
 
969
- # Wait for initial prompt
970
1023
  prompt = self._wait_for_prompt(shell)
1024
+ _debug(f"Prompt detected: {prompt}")
971
1025
 
972
- # Create session object
973
1026
  session = ActiveSession(
974
1027
  device_name=device_name,
975
1028
  hostname=hostname,
@@ -984,10 +1037,11 @@ class NTermAPI:
984
1037
  version_output = self._send_command(shell, "show version", prompt)
985
1038
  platform = self._detect_platform(version_output)
986
1039
  session.platform = platform
1040
+ _debug(f"Platform detected: {platform}")
987
1041
  except Exception as e:
988
- print(f"Warning: Failed to detect platform: {e}")
1042
+ _debug(f"Platform detection failed: {e}")
989
1043
 
990
- # Disable terminal paging for cleaner output
1044
+ # Disable terminal paging
991
1045
  try:
992
1046
  if session.platform and 'cisco' in session.platform:
993
1047
  self._send_command(shell, "terminal length 0", prompt, timeout=5)
@@ -996,11 +1050,9 @@ class NTermAPI:
996
1050
  elif session.platform == 'arista_eos':
997
1051
  self._send_command(shell, "terminal length 0", prompt, timeout=5)
998
1052
  except Exception as e:
999
- print(f"Warning: Failed to disable paging: {e}")
1053
+ _debug(f"Failed to disable paging: {e}")
1000
1054
 
1001
- # Track active session
1002
1055
  self._active_sessions[device_name] = session
1003
-
1004
1056
  return session
1005
1057
 
1006
1058
  def send(
@@ -0,0 +1,410 @@
1
+ # nterm/scripting/repl.py
2
+ #
3
+ # A single "front door" that BOTH humans (GUI REPL) and MCP use.
4
+ # Guardrails live here, not in the agent.
5
+ #
6
+ # - Allow-list commands (or allow-list verbs + deny-list verbs)
7
+ # - Optional read-only mode
8
+ # - Session scoping (one device/session per REPL unless explicitly allowed)
9
+ # - Audit log of everything that ran
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import json
15
+ import time
16
+ import shlex
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from typing import Optional, Dict, Any, List
20
+
21
+ from .api import NTermAPI, ActiveSession, CommandResult
22
+
23
+
24
+ @dataclass
25
+ class REPLPolicy:
26
+ mode: str = "read_only" # "read_only" or "ops"
27
+ max_output_chars: int = 250000
28
+ max_command_seconds: int = 60
29
+
30
+ # Simple and surprisingly effective "don't brick the network" guardrails
31
+ deny_substrings: List[str] = field(default_factory=list)
32
+ allow_prefixes: List[str] = field(default_factory=list)
33
+
34
+ def is_allowed(self, command: str) -> bool:
35
+ cmd = command.strip().lower()
36
+
37
+ i = 0
38
+ while i < len(self.deny_substrings):
39
+ bad = self.deny_substrings[i].lower()
40
+ if bad in cmd:
41
+ return False
42
+ i += 1
43
+
44
+ if self.mode == "read_only":
45
+ # Cheap "write reminder": block common config verbs
46
+ # (You can tighten to an allow-list only if you want)
47
+ write_verbs = [
48
+ "conf t",
49
+ "configure",
50
+ "copy ",
51
+ "write",
52
+ "wr ",
53
+ "reload",
54
+ "commit",
55
+ "delete",
56
+ "set ",
57
+ "unset ",
58
+ "clear ",
59
+ "shutdown",
60
+ "no shutdown",
61
+ "format",
62
+ "upgrade",
63
+ "install",
64
+ ]
65
+ j = 0
66
+ while j < len(write_verbs):
67
+ if cmd.startswith(write_verbs[j]):
68
+ return False
69
+ j += 1
70
+
71
+ # If allow_prefixes provided, require one of them
72
+ if self.allow_prefixes:
73
+ ok = False
74
+ k = 0
75
+ while k < len(self.allow_prefixes):
76
+ pref = self.allow_prefixes[k].lower()
77
+ if cmd.startswith(pref):
78
+ ok = True
79
+ break
80
+ k += 1
81
+ return ok
82
+
83
+ return True
84
+
85
+
86
+ @dataclass
87
+ class REPLState:
88
+ api: NTermAPI
89
+ policy: REPLPolicy
90
+ vault_unlocked: bool = False
91
+ session: Optional[ActiveSession] = None
92
+ connected_device: Optional[str] = None
93
+ output_mode: str = "parsed" # "raw" or "parsed"
94
+ output_format: str = "text" # "text", "rich", or "json" (for parsed mode only)
95
+ platform_hint: Optional[str] = None # Override platform for TextFSM
96
+ debug_mode: bool = False # Show full result data
97
+
98
+
99
+ class NTermREPL:
100
+ """
101
+ A minimal command router. This is the "tool surface".
102
+ MCP can call `handle_line()`; humans can type into it.
103
+
104
+ Commands:
105
+ :unlock
106
+ :lock
107
+ :creds [pattern]
108
+ :devices [pattern]
109
+ :connect <device> [--cred name]
110
+ :disconnect
111
+ :policy [read_only|ops]
112
+ :mode [raw|parsed]
113
+ :format [text|rich|json]
114
+ :set_hint <platform>
115
+ :clear_hint
116
+ :debug [on|off]
117
+ :dbinfo
118
+ (anything else runs as CLI on the connected session)
119
+ :help
120
+ :exit
121
+ """
122
+
123
+ def __init__(self, api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
124
+ if api is None:
125
+ api = NTermAPI()
126
+ if policy is None:
127
+ policy = REPLPolicy(
128
+ mode="read_only",
129
+ deny_substrings=[
130
+ "terminal monitor", # example if you don't want interactive spam
131
+ ],
132
+ allow_prefixes=[],
133
+ )
134
+
135
+ self.state = REPLState(api=api, policy=policy)
136
+
137
+ def handle_line(self, line: str) -> Dict[str, Any]:
138
+ line = (line or "").strip()
139
+ if not line:
140
+ return self._ok({"type": "noop"})
141
+
142
+ if line.startswith(":"):
143
+ return self._handle_meta(line)
144
+
145
+ # Default: treat as CLI to send
146
+ return self._handle_send(line)
147
+
148
+ # -----------------------
149
+ # Meta / REPL commands
150
+ # -----------------------
151
+
152
+ def _handle_meta(self, line: str) -> Dict[str, Any]:
153
+ parts = shlex.split(line)
154
+ cmd = parts[0].lower()
155
+
156
+ if cmd == ":help":
157
+ return self._ok({"type": "help", "text": self._help_text()})
158
+
159
+ if cmd == ":exit":
160
+ if self.state.session:
161
+ self._safe_disconnect()
162
+ return self._ok({"type": "exit"})
163
+
164
+ if cmd == ":unlock":
165
+ # Password should be provided separately, not in the command line
166
+ if len(parts) > 1:
167
+ return self._err(":unlock takes no arguments. Password will be prompted securely.")
168
+ return self._ok({"type": "unlock_prompt", "message": "Please provide vault password"})
169
+
170
+ if cmd == ":lock":
171
+ self.state.api.lock()
172
+ self.state.vault_unlocked = False
173
+ return self._ok({"type": "lock", "vault_unlocked": False})
174
+
175
+ if cmd == ":creds":
176
+ if not self.state.api.vault_unlocked:
177
+ return self._err("Vault is locked. Run :unlock <password> first.")
178
+
179
+ pattern = None
180
+ if len(parts) >= 2:
181
+ pattern = parts[1]
182
+
183
+ creds = self.state.api.credentials(pattern=pattern)
184
+ rows: List[Dict[str, Any]] = []
185
+ i = 0
186
+ while i < len(creds):
187
+ rows.append(creds[i].to_dict())
188
+ i += 1
189
+ return self._ok({"type": "credentials", "credentials": rows})
190
+
191
+ if cmd == ":devices":
192
+ pattern = None
193
+ if len(parts) >= 2:
194
+ pattern = parts[1]
195
+ devs = self.state.api.devices(pattern=pattern)
196
+ # No comprehensions
197
+ rows: List[Dict[str, Any]] = []
198
+ i = 0
199
+ while i < len(devs):
200
+ rows.append(devs[i].to_dict())
201
+ i += 1
202
+ return self._ok({"type": "devices", "devices": rows})
203
+
204
+ if cmd == ":connect":
205
+ if len(parts) < 2:
206
+ return self._err("Usage: :connect <device> [--cred name]")
207
+
208
+ device = parts[1]
209
+ cred = None
210
+
211
+ i = 2
212
+ while i < len(parts):
213
+ if parts[i] == "--cred":
214
+ if i + 1 < len(parts):
215
+ cred = parts[i + 1]
216
+ i += 1
217
+ i += 1
218
+
219
+ if not self.state.api.vault_unlocked:
220
+ return self._err("Vault is locked. Run :unlock <password> first.")
221
+
222
+ if self.state.session:
223
+ self._safe_disconnect()
224
+
225
+ try:
226
+ # Pass debug flag from REPL state
227
+ sess = self.state.api.connect(
228
+ device,
229
+ credential=cred,
230
+ debug=self.state.debug_mode # <-- This line
231
+ )
232
+ self.state.session = sess
233
+ self.state.connected_device = sess.device_name
234
+
235
+ return self._ok({
236
+ "type": "connect",
237
+ "device": sess.device_name,
238
+ "hostname": sess.hostname,
239
+ "port": sess.port,
240
+ "platform": sess.platform,
241
+ "prompt": sess.prompt,
242
+ })
243
+ except Exception as e:
244
+ return self._err(f"Connection failed: {e}")
245
+
246
+ if cmd == ":disconnect":
247
+ self._safe_disconnect()
248
+ return self._ok({"type": "disconnect"})
249
+
250
+ if cmd == ":policy":
251
+ if len(parts) < 2:
252
+ return self._ok({"type": "policy", "mode": self.state.policy.mode})
253
+ mode = parts[1].lower()
254
+ if mode not in ["read_only", "ops"]:
255
+ return self._err("Policy must be read_only or ops")
256
+ self.state.policy.mode = mode
257
+ return self._ok({"type": "policy", "mode": mode})
258
+
259
+ if cmd == ":mode":
260
+ if len(parts) < 2:
261
+ return self._ok({
262
+ "type": "mode",
263
+ "mode": self.state.output_mode,
264
+ "platform_hint": self.state.platform_hint,
265
+ })
266
+ mode = parts[1].lower()
267
+ if mode not in ["raw", "parsed"]:
268
+ return self._err("Mode must be 'raw' or 'parsed'")
269
+ self.state.output_mode = mode
270
+ return self._ok({"type": "mode", "mode": mode})
271
+
272
+ if cmd == ":format":
273
+ if len(parts) < 2:
274
+ return self._ok({
275
+ "type": "format",
276
+ "format": self.state.output_format,
277
+ })
278
+ fmt = parts[1].lower()
279
+ if fmt not in ["text", "rich", "json"]:
280
+ return self._err("Format must be 'text', 'rich', or 'json'")
281
+ self.state.output_format = fmt
282
+ return self._ok({"type": "format", "format": fmt})
283
+
284
+ if cmd == ":set_hint":
285
+ if len(parts) < 2:
286
+ return self._err("Usage: :set_hint <platform> (e.g., cisco_ios, arista_eos)")
287
+ platform = parts[1].lower()
288
+ self.state.platform_hint = platform
289
+ return self._ok({"type": "set_hint", "platform_hint": platform})
290
+
291
+ if cmd == ":clear_hint":
292
+ self.state.platform_hint = None
293
+ return self._ok({"type": "clear_hint"})
294
+
295
+ if cmd == ":debug":
296
+ if len(parts) >= 2:
297
+ mode = parts[1].lower()
298
+ if mode in ["on", "true", "1"]:
299
+ self.state.debug_mode = True
300
+ elif mode in ["off", "false", "0"]:
301
+ self.state.debug_mode = False
302
+ else:
303
+ return self._err("Debug mode must be on or off")
304
+ else:
305
+ # Toggle
306
+ self.state.debug_mode = not self.state.debug_mode
307
+ return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
308
+
309
+ if cmd == ":dbinfo":
310
+ try:
311
+ db_info = self.state.api.db_info()
312
+ return self._ok({"type": "dbinfo", "db_info": db_info})
313
+ except Exception as e:
314
+ return self._err(f"Failed to get DB info: {e}")
315
+
316
+ return self._err(f"Unknown REPL command: {cmd}")
317
+
318
+ # -----------------------
319
+ # CLI send path
320
+ # -----------------------
321
+
322
+ def _handle_send(self, cli: str) -> Dict[str, Any]:
323
+ if not self.state.session:
324
+ return self._err("Not connected. Use :connect <device>")
325
+
326
+ if not self.state.policy.is_allowed(cli):
327
+ return self._err(f"Blocked by policy ({self.state.policy.mode}): {cli}")
328
+
329
+ try:
330
+ started = time.time()
331
+
332
+ # Determine if we should parse based on output mode
333
+ should_parse = (self.state.output_mode == "parsed")
334
+
335
+ # Apply platform hint if set (modify session platform temporarily)
336
+ original_platform = self.state.session.platform
337
+ if self.state.platform_hint:
338
+ self.state.session.platform = self.state.platform_hint
339
+
340
+ try:
341
+ res: CommandResult = self.state.api.send(
342
+ self.state.session,
343
+ cli,
344
+ timeout=self.state.policy.max_command_seconds,
345
+ parse=should_parse,
346
+ normalize=True,
347
+ )
348
+ finally:
349
+ # Restore original platform
350
+ if self.state.platform_hint:
351
+ self.state.session.platform = original_platform
352
+
353
+ elapsed = time.time() - started
354
+
355
+ # Clip raw output for safety/transport
356
+ raw = res.raw_output or ""
357
+ if len(raw) > self.state.policy.max_output_chars:
358
+ raw = raw[: self.state.policy.max_output_chars] + "\n...<truncated>..."
359
+
360
+ payload = res.to_dict()
361
+ payload["raw_output"] = raw
362
+ payload["elapsed_seconds"] = round(elapsed, 3)
363
+
364
+ return self._ok({"type": "result", "result": payload})
365
+ except Exception as e:
366
+ return self._err(f"Command execution failed: {e}")
367
+
368
+ def _safe_disconnect(self) -> None:
369
+ if self.state.session:
370
+ try:
371
+ self.state.api.disconnect(self.state.session)
372
+ finally:
373
+ self.state.session = None
374
+ self.connected_device = None
375
+
376
+ def do_unlock(self, password: str) -> Dict[str, Any]:
377
+ """Internal method to perform unlock with password."""
378
+ try:
379
+ ok = self.state.api.unlock(password)
380
+ self.state.vault_unlocked = bool(ok)
381
+ return self._ok({"type": "unlock", "vault_unlocked": self.state.vault_unlocked})
382
+ except Exception as e:
383
+ return self._err(f"Unlock failed: {e}")
384
+
385
+ def _help_text(self) -> str:
386
+ return (
387
+ "Commands:\n"
388
+ " :unlock (prompts for vault password securely)\n"
389
+ " :lock\n"
390
+ " :creds [pattern]\n"
391
+ " :devices [pattern]\n"
392
+ " :connect <device> [--cred name]\n"
393
+ " :disconnect\n"
394
+ " :policy [read_only|ops]\n"
395
+ " :mode [raw|parsed] (control output format, default: parsed)\n"
396
+ " :format [text|rich|json] (parsed mode display format, default: text)\n"
397
+ " :set_hint <platform> (override TextFSM platform, e.g., cisco_ios)\n"
398
+ " :clear_hint (use auto-detected platform)\n"
399
+ " :debug [on|off] (show full result data for troubleshooting)\n"
400
+ " :dbinfo (show TextFSM database status)\n"
401
+ " (anything else runs as CLI on the connected session)\n"
402
+ " :help\n"
403
+ " :exit\n"
404
+ )
405
+
406
+ def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
407
+ return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
408
+
409
+ def _err(self, message: str) -> Dict[str, Any]:
410
+ return {"ok": False, "error": message, "ts": datetime.now().isoformat()}