ntermqt 0.1.5__tar.gz → 0.1.6__tar.gz

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.
Files changed (74) hide show
  1. {ntermqt-0.1.5/ntermqt.egg-info → ntermqt-0.1.6}/PKG-INFO +4 -1
  2. {ntermqt-0.1.5 → ntermqt-0.1.6}/README.md +2 -0
  3. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/scripting/__init__.py +8 -6
  4. ntermqt-0.1.6/nterm/scripting/repl.py +406 -0
  5. ntermqt-0.1.6/nterm/scripting/repl_interactive.py +418 -0
  6. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/local_terminal.py +1 -0
  7. {ntermqt-0.1.5 → ntermqt-0.1.6/ntermqt.egg-info}/PKG-INFO +4 -1
  8. {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/SOURCES.txt +2 -0
  9. {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/requires.txt +1 -0
  10. {ntermqt-0.1.5 → ntermqt-0.1.6}/pyproject.toml +2 -1
  11. {ntermqt-0.1.5 → ntermqt-0.1.6}/MANIFEST.in +0 -0
  12. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/__init__.py +0 -0
  13. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/__main__.py +0 -0
  14. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/askpass/__init__.py +0 -0
  15. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/askpass/server.py +0 -0
  16. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/config.py +0 -0
  17. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/connection/__init__.py +0 -0
  18. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/connection/profile.py +0 -0
  19. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/__init__.py +0 -0
  20. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/connect_dialog.py +0 -0
  21. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/editor.py +0 -0
  22. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/io.py +0 -0
  23. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/models.py +0 -0
  24. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/settings.py +0 -0
  25. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/tree.py +0 -0
  26. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/__init__.py +0 -0
  27. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/api_help_dialog.py +0 -0
  28. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/ntc_download_dialog.py +0 -0
  29. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/tfsm_engine.py +0 -0
  30. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/tfsm_fire.py +0 -0
  31. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/tfsm_fire_tester.py +0 -0
  32. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/resources.py +0 -0
  33. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/scripting/api.py +0 -0
  34. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/scripting/cli.py +0 -0
  35. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/__init__.py +0 -0
  36. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/askpass_ssh.py +0 -0
  37. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/base.py +0 -0
  38. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/interactive_ssh.py +0 -0
  39. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/pty_transport.py +0 -0
  40. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/ssh.py +0 -0
  41. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/__init__.py +0 -0
  42. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/bridge.py +0 -0
  43. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.html +0 -0
  44. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.js +0 -0
  45. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
  46. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
  47. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
  48. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.css +0 -0
  49. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.min.js +0 -0
  50. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/widget.py +0 -0
  51. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/__init__.py +0 -0
  52. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/engine.py +0 -0
  53. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/stylesheet.py +0 -0
  54. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/clean.yaml +0 -0
  55. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/default.yaml +0 -0
  56. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/dracula.yaml +0 -0
  57. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_dark.yaml +0 -0
  58. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
  59. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_light.yaml +0 -0
  60. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
  61. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
  62. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_light.yaml +0 -0
  63. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/nord_hybrid.yaml +0 -0
  64. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/__init__.py +0 -0
  65. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/credential_manager.py +0 -0
  66. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/keychain.py +0 -0
  67. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/manager_ui.py +0 -0
  68. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/profile.py +0 -0
  69. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/resolver.py +0 -0
  70. {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/store.py +0 -0
  71. {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/dependency_links.txt +0 -0
  72. {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/entry_points.txt +0 -0
  73. {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/top_level.txt +0 -0
  74. {ntermqt-0.1.5 → ntermqt-0.1.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntermqt
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
5
5
  Author: Scott Peterman
6
6
  License: GPL-3.0
@@ -29,6 +29,7 @@ Requires-Dist: click>=8.0.0
29
29
  Requires-Dist: ipython>=8.0.0
30
30
  Requires-Dist: requests>=2.10.0
31
31
  Requires-Dist: textfsm>=2.0.0
32
+ Requires-Dist: rich>=14.0.0
32
33
  Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
33
34
  Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
34
35
  Provides-Extra: keyring
@@ -110,6 +111,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
110
111
 
111
112
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
112
113
 
114
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
115
+
113
116
  The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
114
117
 
115
118
  ```python
@@ -66,6 +66,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
66
66
 
67
67
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
68
68
 
69
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
70
+
69
71
  The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
70
72
 
71
73
  ```python
@@ -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
  ]
@@ -0,0 +1,406 @@
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
+ # Single active session policy by default
223
+ if self.state.session:
224
+ self._safe_disconnect()
225
+
226
+ try:
227
+ sess = self.state.api.connect(device, credential=cred)
228
+ self.state.session = sess
229
+ self.state.connected_device = sess.device_name
230
+
231
+ return self._ok({
232
+ "type": "connect",
233
+ "device": sess.device_name,
234
+ "hostname": sess.hostname,
235
+ "port": sess.port,
236
+ "platform": sess.platform,
237
+ "prompt": sess.prompt,
238
+ })
239
+ except Exception as e:
240
+ return self._err(f"Connection failed: {e}")
241
+
242
+ if cmd == ":disconnect":
243
+ self._safe_disconnect()
244
+ return self._ok({"type": "disconnect"})
245
+
246
+ if cmd == ":policy":
247
+ if len(parts) < 2:
248
+ return self._ok({"type": "policy", "mode": self.state.policy.mode})
249
+ mode = parts[1].lower()
250
+ if mode not in ["read_only", "ops"]:
251
+ return self._err("Policy must be read_only or ops")
252
+ self.state.policy.mode = mode
253
+ return self._ok({"type": "policy", "mode": mode})
254
+
255
+ if cmd == ":mode":
256
+ if len(parts) < 2:
257
+ return self._ok({
258
+ "type": "mode",
259
+ "mode": self.state.output_mode,
260
+ "platform_hint": self.state.platform_hint,
261
+ })
262
+ mode = parts[1].lower()
263
+ if mode not in ["raw", "parsed"]:
264
+ return self._err("Mode must be 'raw' or 'parsed'")
265
+ self.state.output_mode = mode
266
+ return self._ok({"type": "mode", "mode": mode})
267
+
268
+ if cmd == ":format":
269
+ if len(parts) < 2:
270
+ return self._ok({
271
+ "type": "format",
272
+ "format": self.state.output_format,
273
+ })
274
+ fmt = parts[1].lower()
275
+ if fmt not in ["text", "rich", "json"]:
276
+ return self._err("Format must be 'text', 'rich', or 'json'")
277
+ self.state.output_format = fmt
278
+ return self._ok({"type": "format", "format": fmt})
279
+
280
+ if cmd == ":set_hint":
281
+ if len(parts) < 2:
282
+ return self._err("Usage: :set_hint <platform> (e.g., cisco_ios, arista_eos)")
283
+ platform = parts[1].lower()
284
+ self.state.platform_hint = platform
285
+ return self._ok({"type": "set_hint", "platform_hint": platform})
286
+
287
+ if cmd == ":clear_hint":
288
+ self.state.platform_hint = None
289
+ return self._ok({"type": "clear_hint"})
290
+
291
+ if cmd == ":debug":
292
+ if len(parts) >= 2:
293
+ mode = parts[1].lower()
294
+ if mode in ["on", "true", "1"]:
295
+ self.state.debug_mode = True
296
+ elif mode in ["off", "false", "0"]:
297
+ self.state.debug_mode = False
298
+ else:
299
+ return self._err("Debug mode must be on or off")
300
+ else:
301
+ # Toggle
302
+ self.state.debug_mode = not self.state.debug_mode
303
+ return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
304
+
305
+ if cmd == ":dbinfo":
306
+ try:
307
+ db_info = self.state.api.db_info()
308
+ return self._ok({"type": "dbinfo", "db_info": db_info})
309
+ except Exception as e:
310
+ return self._err(f"Failed to get DB info: {e}")
311
+
312
+ return self._err(f"Unknown REPL command: {cmd}")
313
+
314
+ # -----------------------
315
+ # CLI send path
316
+ # -----------------------
317
+
318
+ def _handle_send(self, cli: str) -> Dict[str, Any]:
319
+ if not self.state.session:
320
+ return self._err("Not connected. Use :connect <device>")
321
+
322
+ if not self.state.policy.is_allowed(cli):
323
+ return self._err(f"Blocked by policy ({self.state.policy.mode}): {cli}")
324
+
325
+ try:
326
+ started = time.time()
327
+
328
+ # Determine if we should parse based on output mode
329
+ should_parse = (self.state.output_mode == "parsed")
330
+
331
+ # Apply platform hint if set (modify session platform temporarily)
332
+ original_platform = self.state.session.platform
333
+ if self.state.platform_hint:
334
+ self.state.session.platform = self.state.platform_hint
335
+
336
+ try:
337
+ res: CommandResult = self.state.api.send(
338
+ self.state.session,
339
+ cli,
340
+ timeout=self.state.policy.max_command_seconds,
341
+ parse=should_parse,
342
+ normalize=True,
343
+ )
344
+ finally:
345
+ # Restore original platform
346
+ if self.state.platform_hint:
347
+ self.state.session.platform = original_platform
348
+
349
+ elapsed = time.time() - started
350
+
351
+ # Clip raw output for safety/transport
352
+ raw = res.raw_output or ""
353
+ if len(raw) > self.state.policy.max_output_chars:
354
+ raw = raw[: self.state.policy.max_output_chars] + "\n...<truncated>..."
355
+
356
+ payload = res.to_dict()
357
+ payload["raw_output"] = raw
358
+ payload["elapsed_seconds"] = round(elapsed, 3)
359
+
360
+ return self._ok({"type": "result", "result": payload})
361
+ except Exception as e:
362
+ return self._err(f"Command execution failed: {e}")
363
+
364
+ def _safe_disconnect(self) -> None:
365
+ if self.state.session:
366
+ try:
367
+ self.state.api.disconnect(self.state.session)
368
+ finally:
369
+ self.state.session = None
370
+ self.connected_device = None
371
+
372
+ def do_unlock(self, password: str) -> Dict[str, Any]:
373
+ """Internal method to perform unlock with password."""
374
+ try:
375
+ ok = self.state.api.unlock(password)
376
+ self.state.vault_unlocked = bool(ok)
377
+ return self._ok({"type": "unlock", "vault_unlocked": self.state.vault_unlocked})
378
+ except Exception as e:
379
+ return self._err(f"Unlock failed: {e}")
380
+
381
+ def _help_text(self) -> str:
382
+ return (
383
+ "Commands:\n"
384
+ " :unlock (prompts for vault password securely)\n"
385
+ " :lock\n"
386
+ " :creds [pattern]\n"
387
+ " :devices [pattern]\n"
388
+ " :connect <device> [--cred name]\n"
389
+ " :disconnect\n"
390
+ " :policy [read_only|ops]\n"
391
+ " :mode [raw|parsed] (control output format, default: parsed)\n"
392
+ " :format [text|rich|json] (parsed mode display format, default: text)\n"
393
+ " :set_hint <platform> (override TextFSM platform, e.g., cisco_ios)\n"
394
+ " :clear_hint (use auto-detected platform)\n"
395
+ " :debug [on|off] (show full result data for troubleshooting)\n"
396
+ " :dbinfo (show TextFSM database status)\n"
397
+ " (anything else runs as CLI on the connected session)\n"
398
+ " :help\n"
399
+ " :exit\n"
400
+ )
401
+
402
+ def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
403
+ return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
404
+
405
+ def _err(self, message: str) -> Dict[str, Any]:
406
+ return {"ok": False, "error": message, "ts": datetime.now().isoformat()}
@@ -0,0 +1,418 @@
1
+ """
2
+ nterm Interactive REPL
3
+
4
+ Launch with: api.repl()
5
+
6
+ Provides a safe, policy-controlled interface to network devices.
7
+ Same interface used by both humans (IPython) and MCP tools.
8
+ """
9
+
10
+ from nterm.scripting.repl import NTermREPL, REPLPolicy
11
+ from .api import NTermAPI
12
+ from typing import Optional
13
+ import sys
14
+ import getpass
15
+
16
+
17
+ def start_repl(api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
18
+ """
19
+ Start interactive REPL in IPython or terminal.
20
+
21
+ Args:
22
+ api: NTermAPI instance (creates default if None)
23
+ policy: REPLPolicy (uses read_only default if None)
24
+
25
+ Examples:
26
+ # Default read-only mode
27
+ api.repl()
28
+
29
+ # Operations mode (allows config changes)
30
+ policy = REPLPolicy(mode="ops")
31
+ api.repl(policy=policy)
32
+
33
+ # Custom policy
34
+ policy = REPLPolicy(
35
+ mode="read_only",
36
+ deny_substrings=["reload", "wr"],
37
+ allow_prefixes=["show", "display"],
38
+ )
39
+ api.repl(policy=policy)
40
+ """
41
+ if api is None:
42
+ from nterm.scripting import api as default_api
43
+ api = default_api
44
+
45
+ repl = NTermREPL(api=api, policy=policy)
46
+
47
+ print()
48
+ print("=" * 60)
49
+ print("nterm REPL - Safe Network Automation Interface")
50
+ print("=" * 60)
51
+ print()
52
+ print(f"Policy: {repl.state.policy.mode}")
53
+ print(f"Output: {repl.state.output_mode} ({repl.state.output_format})")
54
+ print(f"Vault: {'unlocked' if api.vault_unlocked else 'locked'}")
55
+
56
+ # Check TextFSM database health
57
+ try:
58
+ db_info = api.db_info()
59
+ db_size = db_info.get('db_size', 0)
60
+ if not db_info.get('db_exists'):
61
+ print(f"\n⚠️ TextFSM database not found!")
62
+ print(f" Parsing will be unavailable. Use :dbinfo for details.")
63
+ elif db_size == 0:
64
+ print(f"\n⚠️ TextFSM database is empty (0 bytes)!")
65
+ print(f" Parsing will fail. Use :dbinfo for details.")
66
+ elif db_size < 100000:
67
+ print(f"\n⚠️ TextFSM database seems small ({db_info.get('db_size_mb', 0):.1f} MB)")
68
+ print(f" Expected ~0.3 MB. Use :dbinfo to check.")
69
+ except Exception:
70
+ # Don't crash startup if db_info fails
71
+ pass
72
+
73
+ print()
74
+ print("Type :help for commands, :exit to quit")
75
+ print()
76
+
77
+ # Interactive loop
78
+ try:
79
+ while True:
80
+ # Show prompt with mode indicator
81
+ if repl.state.connected_device:
82
+ mode_indicator = "📊" if repl.state.output_mode == "parsed" else "📄"
83
+ hint = f"[{repl.state.platform_hint}]" if repl.state.platform_hint else ""
84
+ prompt = f"{mode_indicator} {repl.state.connected_device}{hint}> "
85
+ else:
86
+ prompt = "nterm> "
87
+
88
+ try:
89
+ line = input(prompt)
90
+ except EOFError:
91
+ # Ctrl+D
92
+ break
93
+ except KeyboardInterrupt:
94
+ # Ctrl+C
95
+ print()
96
+ continue
97
+
98
+ # Handle command
99
+ result = repl.handle_line(line)
100
+
101
+ # Display result
102
+ if not result.get("ok"):
103
+ print(f"Error: {result.get('error')}")
104
+ continue
105
+
106
+ data = result.get("data", {})
107
+ cmd_type = data.get("type")
108
+
109
+ if cmd_type == "exit":
110
+ break
111
+ elif cmd_type == "unlock_prompt":
112
+ # Securely prompt for password
113
+ try:
114
+ password = getpass.getpass("Enter vault password: ")
115
+ unlock_result = repl.do_unlock(password)
116
+ if unlock_result.get("ok"):
117
+ unlock_data = unlock_result.get("data", {})
118
+ if unlock_data.get("vault_unlocked"):
119
+ print("Vault unlocked")
120
+ else:
121
+ print("Unlock failed - incorrect password")
122
+ else:
123
+ print(f"Error: {unlock_result.get('error')}")
124
+ except KeyboardInterrupt:
125
+ print("\nUnlock cancelled")
126
+ continue
127
+ elif cmd_type == "help":
128
+ print(data.get("text", ""))
129
+ elif cmd_type == "unlock":
130
+ status = "unlocked" if data.get("vault_unlocked") else "failed"
131
+ print(f"Vault: {status}")
132
+ elif cmd_type == "lock":
133
+ print("Vault locked")
134
+ elif cmd_type == "credentials":
135
+ creds = data.get("credentials", [])
136
+ if not creds:
137
+ print("No credentials found")
138
+ else:
139
+ print(f"\n{'Name':<20} {'Username':<20} {'Type':<15}")
140
+ print("-" * 55)
141
+ for cred in creds:
142
+ print(f"{cred['name']:<20} {cred.get('username', ''):<20} {cred.get('cred_type', 'ssh'):<15}")
143
+ print()
144
+ elif cmd_type == "devices":
145
+ devices = data.get("devices", [])
146
+ if not devices:
147
+ print("No devices found")
148
+ else:
149
+ print(f"\n{'Name':<20} {'Hostname':<20} {'Folder':<15}")
150
+ print("-" * 55)
151
+ for dev in devices:
152
+ print(f"{dev['name']:<20} {dev['hostname']:<20} {dev.get('folder', ''):<15}")
153
+ print()
154
+ elif cmd_type == "connect":
155
+ print(f"Connected to {data['device']} ({data['hostname']}:{data['port']})")
156
+ print(f"Platform: {data.get('platform', 'unknown')}")
157
+ print(f"Prompt: {data.get('prompt', '')}")
158
+ elif cmd_type == "disconnect":
159
+ print("Disconnected")
160
+ elif cmd_type == "policy":
161
+ print(f"Policy mode: {data.get('mode')}")
162
+ elif cmd_type == "mode":
163
+ mode = data.get('mode')
164
+ hint = data.get('platform_hint')
165
+ if mode:
166
+ print(f"Output mode: {mode}")
167
+ else:
168
+ print(f"Current mode: {mode}")
169
+ if hint:
170
+ print(f"Platform hint: {hint}")
171
+ elif cmd_type == "format":
172
+ fmt = data.get('format')
173
+ print(f"Output format: {fmt}")
174
+ elif cmd_type == "set_hint":
175
+ print(f"Platform hint set to: {data.get('platform_hint')}")
176
+ elif cmd_type == "clear_hint":
177
+ print("Platform hint cleared (using auto-detection)")
178
+ elif cmd_type == "debug":
179
+ status = "ON" if data.get("debug_mode") else "OFF"
180
+ print(f"Debug mode: {status}")
181
+ elif cmd_type == "dbinfo":
182
+ db_info = data.get("db_info", {})
183
+ print("\nTextFSM Database Info:")
184
+ print("=" * 60)
185
+ print(f"Engine Available: {db_info.get('engine_available', False)}")
186
+ print(f"Database Path: {db_info.get('db_path', 'unknown')}")
187
+ print(f"Database Exists: {db_info.get('db_exists', False)}")
188
+
189
+ if db_info.get('db_exists'):
190
+ db_size = db_info.get('db_size', 0)
191
+ db_size_mb = db_info.get('db_size_mb', 0.0)
192
+ print(f"Database Size: {db_size:,} bytes ({db_size_mb:.1f} MB)")
193
+ print(f"Absolute Path: {db_info.get('db_absolute_path', 'unknown')}")
194
+
195
+ # Health checks
196
+ if db_size == 0:
197
+ print("\n⚠️ WARNING: Database file is empty (0 bytes)!")
198
+ print(" Parsing will fail until you download templates.")
199
+ print(" Run: api.download_templates() or use the templates installer.")
200
+ elif db_size < 100000: # Less than 100KB
201
+ print(f"\n⚠️ WARNING: Database seems too small ({db_size_mb:.1f} MB)")
202
+ print(" Expected size is ~0.3 MB. May be corrupted or incomplete.")
203
+ else:
204
+ print("\n✓ Database appears healthy")
205
+ else:
206
+ print("\n❌ ERROR: Database file not found!")
207
+ print(" Run: api.download_templates() to create it.")
208
+
209
+ print()
210
+ elif cmd_type == "result":
211
+ result_data = data.get("result", {})
212
+
213
+ # Debug mode: show full result dict
214
+ if repl.state.debug_mode:
215
+ print("\n[DEBUG - Full Result Dict]")
216
+ print("-" * 60)
217
+ import json
218
+ # Don't print raw_output in debug to avoid clutter
219
+ debug_data = {k: v for k, v in result_data.items() if k != "raw_output"}
220
+ print(json.dumps(debug_data, indent=2))
221
+ print("-" * 60)
222
+
223
+ # Show parsed data if available
224
+ parsed = result_data.get("parsed_data")
225
+ parse_success = result_data.get("parse_success", False)
226
+ platform = result_data.get("platform", "")
227
+
228
+ # Display based on mode and format
229
+ if repl.state.output_mode == "parsed":
230
+ if parsed and parse_success:
231
+ print(f"\n[Parsed with {platform} - format: {repl.state.output_format}]")
232
+ print("-" * 60)
233
+ _display_parsed_result(parsed, repl.state.output_format)
234
+ print()
235
+ elif parse_success and not parsed:
236
+ # Parsing succeeded but returned empty/no data
237
+ print(f"\n[Parsed with {platform} - no structured data]")
238
+ raw = result_data.get("raw_output", "")
239
+ print(raw)
240
+ elif parsed is None and not parse_success:
241
+ # Parsing failed or wasn't attempted
242
+ print(f"\n[Parse failed - showing raw output]")
243
+ raw = result_data.get("raw_output", "")
244
+ print(raw)
245
+ else:
246
+ # Fallback
247
+ raw = result_data.get("raw_output", "")
248
+ print(raw)
249
+ else:
250
+ # Raw mode - just show output
251
+ raw = result_data.get("raw_output", "")
252
+ print(raw)
253
+
254
+ # Show timing
255
+ elapsed = result_data.get("elapsed_seconds", 0)
256
+ print(f"\n[{elapsed}s]")
257
+ elif cmd_type == "noop":
258
+ pass
259
+ else:
260
+ # Unknown type, show raw data
261
+ import json
262
+ print(json.dumps(data, indent=2))
263
+
264
+ finally:
265
+ # Clean up
266
+ if repl.state.session:
267
+ print("\nDisconnecting...")
268
+ repl._safe_disconnect()
269
+ print("\nREPL closed")
270
+
271
+
272
+ def _print_parsed_data(data, max_rows=20):
273
+ """Pretty print parsed data (list of dicts) in text format."""
274
+ if not data:
275
+ print("(empty)")
276
+ return
277
+
278
+ if not isinstance(data, list):
279
+ import json
280
+ print(json.dumps(data, indent=2))
281
+ return
282
+
283
+ # Get all unique keys
284
+ all_keys = set()
285
+ for row in data:
286
+ if isinstance(row, dict):
287
+ all_keys.update(row.keys())
288
+
289
+ keys = sorted(all_keys)
290
+
291
+ if not keys:
292
+ import json
293
+ print(json.dumps(data, indent=2))
294
+ return
295
+
296
+ # Calculate column widths
297
+ col_widths = {}
298
+ for key in keys:
299
+ col_widths[key] = len(key)
300
+
301
+ for row in data[:max_rows]:
302
+ if isinstance(row, dict):
303
+ for key in keys:
304
+ val = str(row.get(key, ""))
305
+ col_widths[key] = max(col_widths[key], len(val))
306
+
307
+ # Cap widths
308
+ for key in keys:
309
+ col_widths[key] = min(col_widths[key], 30)
310
+
311
+ # Print header
312
+ header = " | ".join(key[:col_widths[key]].ljust(col_widths[key]) for key in keys)
313
+ print(header)
314
+ print("-" * len(header))
315
+
316
+ # Print rows
317
+ shown = 0
318
+ for row in data:
319
+ if not isinstance(row, dict):
320
+ continue
321
+ if shown >= max_rows:
322
+ remaining = len(data) - shown
323
+ print(f"... ({remaining} more rows)")
324
+ break
325
+
326
+ values = []
327
+ for key in keys:
328
+ val = str(row.get(key, ""))
329
+ if len(val) > col_widths[key]:
330
+ val = val[:col_widths[key] - 3] + "..."
331
+ values.append(val.ljust(col_widths[key]))
332
+
333
+ print(" | ".join(values))
334
+ shown += 1
335
+
336
+
337
+ def _print_parsed_data_rich(data, max_rows=20):
338
+ """Pretty print parsed data using Rich library."""
339
+ try:
340
+ from rich.console import Console
341
+ from rich.table import Table
342
+ except ImportError:
343
+ print("⚠️ Rich library not available, falling back to text format")
344
+ _print_parsed_data(data, max_rows)
345
+ return
346
+
347
+ if not data:
348
+ print("(empty)")
349
+ return
350
+
351
+ if not isinstance(data, list):
352
+ import json
353
+ print(json.dumps(data, indent=2))
354
+ return
355
+
356
+ # Get all unique keys
357
+ all_keys = set()
358
+ for row in data:
359
+ if isinstance(row, dict):
360
+ all_keys.update(row.keys())
361
+
362
+ keys = sorted(all_keys)
363
+
364
+ if not keys:
365
+ import json
366
+ print(json.dumps(data, indent=2))
367
+ return
368
+
369
+ # Create rich table
370
+ console = Console()
371
+ table = Table(show_header=True, header_style="bold cyan")
372
+
373
+ # Add columns
374
+ for key in keys:
375
+ table.add_column(key, style="white", no_wrap=False, max_width=30)
376
+
377
+ # Add rows
378
+ shown = 0
379
+ for row in data:
380
+ if not isinstance(row, dict):
381
+ continue
382
+ if shown >= max_rows:
383
+ remaining = len(data) - shown
384
+ console.print(f"[yellow]... ({remaining} more rows)[/yellow]")
385
+ break
386
+
387
+ values = [str(row.get(key, "")) for key in keys]
388
+ table.add_row(*values)
389
+ shown += 1
390
+
391
+ console.print(table)
392
+
393
+
394
+ def _print_parsed_data_json(data):
395
+ """Print parsed data as JSON."""
396
+ import json
397
+ print(json.dumps(data, indent=2))
398
+
399
+
400
+ def _display_parsed_result(data, output_format, max_rows=20):
401
+ """Display parsed data in the specified format."""
402
+ if output_format == "json":
403
+ _print_parsed_data_json(data)
404
+ elif output_format == "rich":
405
+ _print_parsed_data_rich(data, max_rows)
406
+ else: # text or fallback
407
+ _print_parsed_data(data, max_rows)
408
+
409
+
410
+ # Convenience function to add to API
411
+ def add_repl_to_api(api_instance):
412
+ """Add repl() method to API instance."""
413
+
414
+ def repl(policy: Optional[REPLPolicy] = None):
415
+ """Start interactive REPL."""
416
+ start_repl(api=api_instance, policy=policy)
417
+
418
+ api_instance.repl = repl
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
20
20
  IPYTHON_STARTUP = '''
21
21
  from nterm.scripting import api
22
22
  print("\\n\\033[1;36mnterm API loaded.\\033[0m")
23
+ print(" api.repl() _ Start CLI/repl")
23
24
  print(" api.devices() - List saved devices")
24
25
  print(" api.search(query) - Search devices")
25
26
  print(" api.credentials() - List credentials (after api.unlock())")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntermqt
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
5
5
  Author: Scott Peterman
6
6
  License: GPL-3.0
@@ -29,6 +29,7 @@ Requires-Dist: click>=8.0.0
29
29
  Requires-Dist: ipython>=8.0.0
30
30
  Requires-Dist: requests>=2.10.0
31
31
  Requires-Dist: textfsm>=2.0.0
32
+ Requires-Dist: rich>=14.0.0
32
33
  Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
33
34
  Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
34
35
  Provides-Extra: keyring
@@ -110,6 +111,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
110
111
 
111
112
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
112
113
 
114
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
115
+
113
116
  The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
114
117
 
115
118
  ```python
@@ -25,6 +25,8 @@ nterm/parser/tfsm_fire_tester.py
25
25
  nterm/scripting/__init__.py
26
26
  nterm/scripting/api.py
27
27
  nterm/scripting/cli.py
28
+ nterm/scripting/repl.py
29
+ nterm/scripting/repl_interactive.py
28
30
  nterm/session/__init__.py
29
31
  nterm/session/askpass_ssh.py
30
32
  nterm/session/base.py
@@ -7,6 +7,7 @@ click>=8.0.0
7
7
  ipython>=8.0.0
8
8
  requests>=2.10.0
9
9
  textfsm>=2.0.0
10
+ rich>=14.0.0
10
11
 
11
12
  [:sys_platform != "win32"]
12
13
  pexpect>=4.8.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ntermqt"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Modern SSH terminal widget for PyQt6 with credential vault and jump host support"
9
9
  readme = "README.md"
10
10
  license = {text = "GPL-3.0"}
@@ -34,6 +34,7 @@ dependencies = [
34
34
  "ipython>=8.0.0",
35
35
  "requests>=2.10.0",
36
36
  "textfsm>=2.0.0",
37
+ "rich>=14.0.0",
37
38
  # Platform-specific PTY support
38
39
  "pexpect>=4.8.0; sys_platform != 'win32'",
39
40
  "pywinpty>=2.0.0; sys_platform == 'win32'",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes