ntermqt 0.1.4__py3-none-any.whl → 0.1.6__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.
- nterm/__main__.py +48 -0
- nterm/parser/__init__.py +0 -0
- nterm/parser/api_help_dialog.py +607 -0
- nterm/parser/ntc_download_dialog.py +372 -0
- nterm/parser/tfsm_engine.py +246 -0
- nterm/parser/tfsm_fire.py +237 -0
- nterm/parser/tfsm_fire_tester.py +2329 -0
- nterm/scripting/__init__.py +8 -6
- nterm/scripting/api.py +926 -19
- nterm/scripting/repl.py +406 -0
- nterm/scripting/repl_interactive.py +418 -0
- nterm/session/local_terminal.py +1 -0
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.6.dist-info}/METADATA +7 -5
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.6.dist-info}/RECORD +17 -10
- nterm/examples/basic_terminal.py +0 -415
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.6.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.6.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.4.dist-info → ntermqt-0.1.6.dist-info}/top_level.txt +0 -0
nterm/scripting/repl.py
ADDED
|
@@ -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()}
|